[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
findActiveSystemShortcutConflict,
listActiveSystemBindings,
} from './activeKeyBindings.ts';
import { DEFAULT_KEY_BINDINGS, type KeyBinding } from './models/keyBindings.ts';
const withBindingOverride = (
id: string,
override: Partial<Pick<KeyBinding, 'mac' | 'pc'>>,
): KeyBinding[] => (
DEFAULT_KEY_BINDINGS.map((binding) => (
binding.id === id ? { ...binding, ...override } : binding
))
);
test('disabled scheme exposes no active system bindings', () => {
assert.deepEqual(listActiveSystemBindings('disabled', DEFAULT_KEY_BINDINGS), []);
});
test('disabled scheme does not treat default tab or broadcast keys as occupied', () => {
assert.equal(
findActiveSystemShortcutConflict('Ctrl + 1', 'disabled', DEFAULT_KEY_BINDINGS),
null,
);
assert.equal(
findActiveSystemShortcutConflict('Ctrl + B', 'disabled', DEFAULT_KEY_BINDINGS),
null,
);
assert.equal(
findActiveSystemShortcutConflict('⌘ + 1', 'disabled', DEFAULT_KEY_BINDINGS),
null,
);
});
test('pc scheme reports the tab-switch binding for Ctrl+1', () => {
const conflict = findActiveSystemShortcutConflict('Ctrl + 1', 'pc', DEFAULT_KEY_BINDINGS);
assert.equal(conflict?.id, 'switch-tab-1-9');
});
test('pc scheme reports the broadcast binding for Ctrl+B', () => {
const conflict = findActiveSystemShortcutConflict('Ctrl + B', 'pc', DEFAULT_KEY_BINDINGS);
assert.equal(conflict?.id, 'broadcast');
});
test('mac scheme does not treat Ctrl+1 as a tab shortcut', () => {
assert.equal(
findActiveSystemShortcutConflict('Ctrl + 1', 'mac', DEFAULT_KEY_BINDINGS),
null,
);
});
test('mac scheme reports the tab-switch binding for Cmd+1', () => {
const conflict = findActiveSystemShortcutConflict('⌘ + 1', 'mac', DEFAULT_KEY_BINDINGS);
assert.equal(conflict?.id, 'switch-tab-1-9');
});
test('a Disabled individual binding is not a conflict', () => {
const bindings = withBindingOverride('switch-tab-1-9', { pc: 'Disabled' });
assert.equal(findActiveSystemShortcutConflict('Ctrl + 1', 'pc', bindings), null);
assert.equal(findActiveSystemShortcutConflict('Ctrl + B', 'pc', bindings)?.id, 'broadcast');
});

View File

@@ -0,0 +1,52 @@
import {
type HotkeyScheme,
type KeyBinding,
keyStringToKeyboardEvent,
matchesKeyBinding,
} from './models/keyBindings.ts';
export type ActiveSystemBinding = {
binding: KeyBinding;
key: string;
isMac: boolean;
};
/** App shortcuts that can actually fire for the current scheme. */
export const listActiveSystemBindings = (
hotkeyScheme: HotkeyScheme,
keyBindings: readonly KeyBinding[],
): ActiveSystemBinding[] => {
if (hotkeyScheme === 'disabled') return [];
return keyBindings.flatMap((binding) => {
if (hotkeyScheme === 'mac') {
return binding.mac && binding.mac !== 'Disabled'
? [{ binding, key: binding.mac, isMac: true }]
: [];
}
if (hotkeyScheme === 'pc') {
return binding.pc && binding.pc !== 'Disabled'
? [{ binding, key: binding.pc, isMac: false }]
: [];
}
return [];
});
};
export const findActiveSystemShortcutConflict = (
key: string,
hotkeyScheme: HotkeyScheme,
keyBindings: readonly KeyBinding[],
): KeyBinding | null => {
if (!key) return null;
const event = keyStringToKeyboardEvent(key);
if (!event) return null;
const conflict = listActiveSystemBindings(hotkeyScheme, keyBindings).find((entry) => (
matchesKeyBinding(event, entry.key, entry.isMac)
));
return conflict?.binding ?? null;
};

37
domain/agentActivity.ts Normal file
View File

@@ -0,0 +1,37 @@
export type AgentActivityStatus = 'running' | 'completed' | 'failed';
export type AgentFileChangeKind = 'add' | 'delete' | 'update';
export type AgentActivity =
| {
id: string;
type: 'file_change';
status: Exclude<AgentActivityStatus, 'running'>;
changes: Array<{ path: string; kind: AgentFileChangeKind }>;
}
| {
id: string;
type: 'web_search';
status: Exclude<AgentActivityStatus, 'failed'>;
query: string;
}
| {
id: string;
type: 'plan_update';
status: Exclude<AgentActivityStatus, 'failed'>;
items: Array<{ text: string; completed: boolean }>;
}
| {
id: string;
type: 'warning';
status: 'completed';
message: string;
};
export interface AgentUsage {
inputTokens: number;
cachedInputTokens?: number;
outputTokens: number;
reasoningTokens?: number;
totalTokens: number;
estimated?: boolean;
}

223
domain/agentIcon.ts Normal file
View File

@@ -0,0 +1,223 @@
import {
matchCodingCliProviderFromCommand,
matchCodingCliProviderFromTitle,
} from './codingCliProviderMatch';
export type AgentIconKey =
| 'catty'
| 'copilot'
| 'cursor'
| 'openai'
| 'codex'
| 'claude'
| 'anthropic'
| 'gemini'
| 'google'
| 'ollama'
| 'openrouter'
| 'zed'
| 'atom'
| 'droid'
| 'opencode'
| 'kimi'
| 'codebuddy'
| 'grok'
| 'terminal'
| 'plus';
export type AgentIconVisual = {
src: string;
badgeClassName: string;
imageClassName: string;
};
export const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
catty: {
src: '/ai/agents/catty.svg',
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
copilot: {
src: '/ai/agents/copilot.svg',
badgeClassName: 'border-zinc-300 bg-white',
imageClassName: 'object-contain brightness-0',
},
cursor: {
src: '/ai/agents/cursor.svg',
badgeClassName: 'border-zinc-500/22 bg-zinc-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
openai: {
src: '/ai/providers/openai.svg',
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
codex: {
src: '/ai/agents/codex.svg',
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-95',
},
claude: {
src: '/ai/agents/claude.svg',
badgeClassName: 'border-orange-500/22 bg-orange-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
anthropic: {
src: '/ai/providers/anthropic.svg',
badgeClassName: 'border-orange-500/22 bg-orange-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
gemini: {
src: '/ai/agents/gemini.svg',
badgeClassName: 'border-sky-500/22 bg-sky-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
google: {
src: '/ai/providers/google.svg',
badgeClassName: 'border-sky-500/22 bg-sky-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
ollama: {
src: '/ai/providers/ollama.svg',
badgeClassName: 'border-violet-500/22 bg-violet-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
openrouter: {
src: '/ai/providers/openrouter.svg',
badgeClassName: 'border-fuchsia-500/22 bg-fuchsia-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
zed: {
src: '/ai/agents/zed.svg',
badgeClassName: 'border-cyan-500/22 bg-cyan-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
atom: {
src: '/ai/agents/atom.svg',
badgeClassName: 'border-amber-500/18 bg-amber-500/10',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
droid: {
src: '/ai/agents/droid.svg',
badgeClassName: 'border-orange-500/22 bg-orange-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-95',
},
opencode: {
src: '/ai/agents/opencode.svg',
badgeClassName: 'border-slate-500/22 bg-slate-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
kimi: {
src: '/ai/providers/kimi.svg',
badgeClassName: 'border-zinc-500/22 bg-zinc-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
codebuddy: {
src: '/ai/agents/codebuddy.svg',
badgeClassName: 'border-indigo-500/22 bg-indigo-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
grok: {
src: '/ai/providers/grok.svg',
badgeClassName: 'border-zinc-500/22 bg-zinc-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
terminal: {
src: '/ai/agents/terminal.svg',
badgeClassName: 'border-white/8 bg-white/[0.04]',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
plus: {
src: '/ai/agents/plus.svg',
badgeClassName: 'border-white/8 bg-white/[0.04]',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-85',
},
};
export type AgentIconSource = {
icon?: string;
command?: string;
name?: string;
id?: string;
type?: 'builtin' | 'external';
};
export function normalizeAgentToken(value?: string): string {
return (value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '');
}
export function resolveAgentIconKey(source: AgentIconSource | 'add-more'): AgentIconKey {
if (source === 'add-more') {
return 'plus';
}
if (source.type === 'builtin') {
return 'catty';
}
const commandCandidates = [source.command, source.name, source.id].filter(
(value): value is string => Boolean(value?.trim()),
);
for (const commandLine of commandCandidates) {
const provider = matchCodingCliProviderFromCommand(commandLine);
if (provider) return provider.iconKey;
}
const titleCandidates = [source.name, source.id, source.icon].filter(
(value): value is string => Boolean(value?.trim()),
);
for (const title of titleCandidates) {
const provider = matchCodingCliProviderFromTitle(title);
if (provider) return provider.iconKey;
}
const tokens = [
normalizeAgentToken(source.icon),
normalizeAgentToken(source.command),
normalizeAgentToken(source.name),
normalizeAgentToken(source.id),
].filter(Boolean);
if (tokens.some((token) => token.includes('anthropic'))) {
return 'anthropic';
}
if (
tokens.some(
(token) =>
token.includes('openai') ||
token.includes('chatgpt'),
)
) {
return 'openai';
}
if (
tokens.some(
(token) =>
token.includes('google') ||
token.includes('googlegemini'),
)
) {
return 'google';
}
if (tokens.some((token) => token.includes('ollama'))) {
return 'ollama';
}
if (tokens.some((token) => token.includes('openrouter'))) {
return 'openrouter';
}
if (tokens.some((token) => token.includes('zed'))) {
return 'zed';
}
if (tokens.some((token) => token.includes('factory'))) {
return 'atom';
}
if (tokens.some((token) => token.includes('grok') || token.includes('xai'))) {
return 'grok';
}
return 'terminal';
}
export function getAgentIconVisual(key: AgentIconKey): AgentIconVisual {
return AGENT_ICON_VISUALS[key];
}

View File

@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
aiPanelContextsEqual,
retainStableAiPanelContexts,
type AIPanelContextLike,
type AIPanelTerminalSessionLike,
} from './aiPanelContextsEqual.ts';
const sessionInfo = (
overrides: Partial<AIPanelTerminalSessionLike> = {},
): AIPanelTerminalSessionLike => ({
sessionId: 's1',
hostId: 'h1',
hostname: 'example.test',
label: 'example',
connected: true,
...overrides,
});
const context = (
overrides: Partial<AIPanelContextLike> = {},
): AIPanelContextLike => ({
scopeType: 'terminal',
scopeTargetId: 's1',
scopeHostIds: ['h1'],
scopeLabel: 'example',
terminalSessions: [sessionInfo()],
...overrides,
});
test('aiPanelContextsEqual is true for structurally equal maps with different identity', () => {
const a = new Map([['s1', context()]]);
const b = new Map([['s1', context()]]);
assert.equal(aiPanelContextsEqual(a, b), true);
assert.equal(retainStableAiPanelContexts(a, b), a);
});
test('aiPanelContextsEqual is false when connection status changes', () => {
const a = new Map([['s1', context({ terminalSessions: [sessionInfo({ connected: true })] })]]);
const b = new Map([['s1', context({ terminalSessions: [sessionInfo({ connected: false })] })]]);
assert.equal(aiPanelContextsEqual(a, b), false);
assert.equal(retainStableAiPanelContexts(a, b), b);
});
test('aiPanelContextsEqual is false when port-forward status changes', () => {
const a = new Map([['s1', context({
terminalSessions: [sessionInfo({
activePortForwards: [{ ruleId: 'r1', status: 'active', localPort: 8080 }],
})],
})]]);
const b = new Map([['s1', context({
terminalSessions: [sessionInfo({
activePortForwards: [{ ruleId: 'r1', status: 'connecting', localPort: 8080 }],
})],
})]]);
assert.equal(aiPanelContextsEqual(a, b), false);
});

View File

@@ -0,0 +1,159 @@
/**
* Structural equality for AI side-panel context maps.
* Uses a domain-local shape so domain stays independent of UI modules.
*/
/** Minimal host-chain hop used for AI context equality. */
export type AIPanelHostChainHop = {
hostId: string;
label?: string;
hostname?: string;
};
/** Minimal active port-forward snapshot used for AI context equality. */
export type AIPanelPortForwardLike = {
ruleId: string;
label?: string;
type?: string;
localPort?: number;
status?: string;
};
/** Minimal terminal session info used for AI context equality. */
export type AIPanelTerminalSessionLike = {
sessionId: string;
hostId: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
hostChain?: AIPanelHostChainHop[];
activePortForwards?: AIPanelPortForwardLike[];
};
/** Minimal AI panel context shape used for structural equality. */
export type AIPanelContextLike = {
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeHostIds: string[];
scopeLabel: string;
focusedSessionId?: string;
terminalSessions: AIPanelTerminalSessionLike[];
};
function hostChainEqual(
a: AIPanelTerminalSessionLike['hostChain'] | undefined,
b: AIPanelTerminalSessionLike['hostChain'] | undefined,
): boolean {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (
a[i].hostId !== b[i].hostId
|| a[i].label !== b[i].label
|| a[i].hostname !== b[i].hostname
) {
return false;
}
}
return true;
}
function portForwardsEqual(
a: AIPanelTerminalSessionLike['activePortForwards'] | undefined,
b: AIPanelTerminalSessionLike['activePortForwards'] | undefined,
): boolean {
if (a === b) return true;
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (
a[i].ruleId !== b[i].ruleId
|| a[i].label !== b[i].label
|| a[i].type !== b[i].type
|| a[i].localPort !== b[i].localPort
|| a[i].status !== b[i].status
) {
return false;
}
}
return true;
}
function aiTerminalSessionInfoEqual(
a: AIPanelTerminalSessionLike,
b: AIPanelTerminalSessionLike,
): boolean {
return a.sessionId === b.sessionId
&& a.hostId === b.hostId
&& a.hostname === b.hostname
&& a.label === b.label
&& a.os === b.os
&& a.username === b.username
&& a.protocol === b.protocol
&& a.shellType === b.shellType
&& a.deviceType === b.deviceType
&& a.connected === b.connected
&& hostChainEqual(a.hostChain, b.hostChain)
&& portForwardsEqual(a.activePortForwards, b.activePortForwards);
}
function scopeHostIdsEqual(a: string[], b: string[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
function aiPanelContextEqual(a: AIPanelContextLike, b: AIPanelContextLike): boolean {
if (a === b) return true;
if (a.scopeType !== b.scopeType) return false;
if (a.scopeTargetId !== b.scopeTargetId) return false;
if (a.scopeLabel !== b.scopeLabel) return false;
if ((a.focusedSessionId ?? '') !== (b.focusedSessionId ?? '')) return false;
if (!scopeHostIdsEqual(a.scopeHostIds, b.scopeHostIds)) return false;
if (a.terminalSessions.length !== b.terminalSessions.length) return false;
for (let i = 0; i < a.terminalSessions.length; i += 1) {
if (!aiTerminalSessionInfoEqual(a.terminalSessions[i], b.terminalSessions[i])) {
return false;
}
}
return true;
}
/**
* Structural equal for AI side-panel context maps. Ignores Map identity and
* presentation-only terminal noise by comparing AI-relevant session/host fields.
*/
export function aiPanelContextsEqual(
prev: Map<string, AIPanelContextLike> | null | undefined,
next: Map<string, AIPanelContextLike> | null | undefined,
): boolean {
if (prev === next) return true;
if (!prev || !next) return false;
if (prev.size !== next.size) return false;
for (const [tabId, context] of prev) {
const other = next.get(tabId);
if (!other || !aiPanelContextEqual(context, other)) return false;
}
return true;
}
/**
* Return `next` unless it is structurally equal to `previous`, in which case
* keep the previous Map identity for React memo consumers.
*/
export function retainStableAiPanelContexts<T extends AIPanelContextLike>(
previous: Map<string, T> | null | undefined,
next: Map<string, T>,
): Map<string, T> {
if (previous && aiPanelContextsEqual(previous, next)) {
return previous;
}
return next;
}

View File

@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
aiSessionIdSetEqual,
exactScopeAISessionsEqual,
filterAISessionsForScope,
retainStableAISessionsForScope,
sessionMatchesAIScope,
} from './aiSessionsForScope.ts';
const session = (
id: string,
scopeType: string,
targetId?: string,
) => ({
id,
scope: { type: scopeType, targetId },
});
test('filterAISessionsForScope keeps only matching scope', () => {
const a = session('a', 'terminal', 't1');
const b = session('b', 'terminal', 't2');
const c = session('c', 'workspace', 'w1');
const all = [a, b, c];
assert.deepEqual(filterAISessionsForScope(all, 'terminal', 't1'), [a]);
assert.deepEqual(filterAISessionsForScope(all, 'workspace', 'w1'), [c]);
assert.equal(sessionMatchesAIScope(a, 'terminal', 't1'), true);
assert.equal(sessionMatchesAIScope(a, 'terminal', 't2'), false);
});
test('retainStableAISessionsForScope keeps identity when session refs match', () => {
const a = session('a', 'terminal', 't1');
const prev = [a];
const next = [a];
assert.equal(retainStableAISessionsForScope(prev, next), prev);
const replaced = [session('a', 'terminal', 't1')];
assert.notEqual(retainStableAISessionsForScope(prev, replaced), prev);
});
test('exactScopeAISessionsEqual ignores sibling session object churn', () => {
const a = session('a', 'terminal', 't1');
const b1 = session('b', 'terminal', 't2');
const b2 = session('b', 'terminal', 't2'); // new object, sibling stream
const prev = [a, b1];
const next = [a, b2];
assert.equal(exactScopeAISessionsEqual(prev, next, 'terminal', 't1'), true);
const a2 = session('a', 'terminal', 't1');
assert.equal(exactScopeAISessionsEqual(prev, [a2, b1], 'terminal', 't1'), false);
});
test('exactScopeAISessionsEqual tracks selected cross-scope resumed session', () => {
const exact = session('exact', 'terminal', 't-new');
const history1 = session('hist', 'terminal', 't-old');
const history2 = session('hist', 'terminal', 't-old'); // stream update object
const prev = [exact, history1];
const next = [exact, history2];
// Without selectedSessionId, cross-scope history is ignored (sibling thrash isolation).
assert.equal(exactScopeAISessionsEqual(prev, next, 'terminal', 't-new'), true);
// With selectedSessionId, visible resumed history must re-render on updates.
assert.equal(
exactScopeAISessionsEqual(prev, next, 'terminal', 't-new', 'hist'),
false,
);
assert.equal(
exactScopeAISessionsEqual(prev, prev, 'terminal', 't-new', 'hist'),
true,
);
});
test('aiSessionIdSetEqual detects create/delete without object-identity thrash', () => {
const a1 = session('a', 'terminal', 't1');
const a2 = session('a', 'terminal', 't1');
const b = session('b', 'terminal', 't2');
assert.equal(aiSessionIdSetEqual([a1, b], [a2, b]), true);
assert.equal(aiSessionIdSetEqual([a1, b], [a1]), false);
assert.equal(aiSessionIdSetEqual([a1], [b]), false);
});
test('aiSessionIdSetEqual detects title chrome renames without message thrash', () => {
const a1 = { ...session('a', 'terminal', 't1'), title: 'old', updatedAt: 1 };
const a2 = { ...session('a', 'terminal', 't1'), title: 'old', updatedAt: 1 }; // new object
const a3 = { ...session('a', 'terminal', 't1'), title: 'new', updatedAt: 1 };
const a4 = { ...session('a', 'terminal', 't1'), title: 'old', updatedAt: 2 };
assert.equal(aiSessionIdSetEqual([a1], [a2]), true);
assert.equal(aiSessionIdSetEqual([a1], [a3]), false);
assert.equal(aiSessionIdSetEqual([a1], [a4]), false);
});
test('aiSessionIdSetEqual detects updatedAt chrome without message thrash', () => {
const a1 = { ...session('a', 'terminal', 't1'), title: 'chat', updatedAt: 1 };
const a2 = { ...session('a', 'terminal', 't1'), title: 'chat', updatedAt: 1 }; // new object, same chrome
const a3 = { ...session('a', 'terminal', 't1'), title: 'chat', updatedAt: 2 };
assert.equal(aiSessionIdSetEqual([a1], [a2]), true);
assert.equal(aiSessionIdSetEqual([a1], [a3]), false);
});

View File

@@ -0,0 +1,131 @@
/**
* Exact-scope AI session helpers for multi-panel memo isolation.
*
* History still needs the full sessions list for fuzzy host-match ranking
* (`getScopedHistorySessions`). Stream thrash is blocked by comparing only
* exact-scope session object refs in panel are equal — not by pre-filtering
* the history universe away.
*/
export type AISessionScopeLike = {
type: string;
targetId?: string;
};
export type AISessionLike = {
id: string;
scope: AISessionScopeLike;
/** Optional chrome for history list equality (title renames without message thrash). */
title?: string | null;
/** Optional chrome for history sort / relative-time display (`getScopedHistorySessions`). */
updatedAt?: number;
};
export function buildAIScopeKey(scopeType: string, scopeTargetId?: string): string {
return `${scopeType}:${scopeTargetId ?? ''}`;
}
export function sessionMatchesAIScope(
session: AISessionLike,
scopeType: string,
scopeTargetId?: string,
): boolean {
return session.scope.type === scopeType
&& (session.scope.targetId ?? '') === (scopeTargetId ?? '');
}
export function filterAISessionsForScope<T extends AISessionLike>(
sessions: readonly T[],
scopeType: string,
scopeTargetId?: string,
): T[] {
return sessions.filter((session) => sessionMatchesAIScope(session, scopeType, scopeTargetId));
}
/**
* True when the given session id's object identity is unchanged across arrays
* (or both sides lack that session).
*/
export function aiSessionByIdEqual<T extends AISessionLike>(
prev: readonly T[] | null | undefined,
next: readonly T[] | null | undefined,
sessionId: string | null | undefined,
): boolean {
if (!sessionId) return true;
if (prev === next) return true;
if (!prev || !next) return false;
const prevSession = prev.find((session) => session.id === sessionId);
const nextSession = next.find((session) => session.id === sessionId);
return prevSession === nextSession;
}
/**
* True when both arrays contain the same session ids (order-insensitive) and
* matching history chrome (title + updatedAt). Detects create/delete/rename and
* timestamp/order changes without treating message-body object replacement alone
* as a reason to re-render when chrome is unchanged.
*/
export function aiSessionIdSetEqual<T extends AISessionLike>(
prev: readonly T[] | null | undefined,
next: readonly T[] | null | undefined,
): boolean {
if (prev === next) return true;
if (!prev || !next) return false;
if (prev.length !== next.length) return false;
if (prev.length === 0) return true;
const prevById = new Map(prev.map((session) => [session.id, session]));
for (const session of next) {
const prevSession = prevById.get(session.id);
if (!prevSession) return false;
if ((prevSession.title ?? '') !== (session.title ?? '')) return false;
if ((prevSession.updatedAt ?? 0) !== (session.updatedAt ?? 0)) return false;
}
return true;
}
/**
* True when exact-scope session object identities match (order-insensitive by id).
* Sibling stream updates replace only their own session objects, so other panels
* see the same exact-scope refs and can skip re-render.
*
* When `selectedSessionId` is set (e.g. a history chat resumed under a newer
* terminal whose stored scope still points at an older target), that session is
* also compared by identity so stream updates still re-render the visible panel.
*/
export function exactScopeAISessionsEqual<T extends AISessionLike>(
prev: readonly T[] | null | undefined,
next: readonly T[] | null | undefined,
scopeType: string,
scopeTargetId?: string,
selectedSessionId?: string | null,
): boolean {
if (prev === next) return true;
if (!prev || !next) return false;
if (!aiSessionByIdEqual(prev, next, selectedSessionId)) return false;
const prevExact = filterAISessionsForScope(prev, scopeType, scopeTargetId);
const nextExact = filterAISessionsForScope(next, scopeType, scopeTargetId);
if (prevExact.length !== nextExact.length) return false;
if (prevExact.length === 0) return true;
const prevById = new Map(prevExact.map((session) => [session.id, session]));
for (const session of nextExact) {
if (prevById.get(session.id) !== session) return false;
}
return true;
}
/**
* Keep previous filtered array identity when every matching session ref is the same.
*/
export function retainStableAISessionsForScope<T extends AISessionLike>(
previous: readonly T[] | null | undefined,
next: readonly T[],
): readonly T[] {
if (
previous
&& previous.length === next.length
&& previous.every((session, index) => session === next[index])
) {
return previous;
}
return next;
}

View File

@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveInheritedAIActiveSessionId } from './aiWorkspaceScopeInherit.ts';
test('workspace scope prefers its own active session over member terminals', () => {
assert.equal(
resolveInheritedAIActiveSessionId({
scopeType: 'workspace',
scopeTargetId: 'ws-1',
activeSessionIdMap: {
'workspace:ws-1': 'chat-ws',
'terminal:term-a': 'chat-a',
},
memberTerminalIds: ['term-a', 'term-b'],
preferredTerminalId: 'term-a',
}),
'chat-ws',
);
});
test('workspace scope inherits focused terminal active session after merge', () => {
assert.equal(
resolveInheritedAIActiveSessionId({
scopeType: 'workspace',
scopeTargetId: 'ws-1',
activeSessionIdMap: {
'terminal:term-a': 'chat-a',
'terminal:term-b': 'chat-b',
},
memberTerminalIds: ['term-a', 'term-b'],
preferredTerminalId: 'term-a',
}),
'chat-a',
);
});
test('workspace scope falls back to other member terminals when focused has none', () => {
assert.equal(
resolveInheritedAIActiveSessionId({
scopeType: 'workspace',
scopeTargetId: 'ws-1',
activeSessionIdMap: {
'terminal:term-b': 'chat-b',
},
memberTerminalIds: ['term-a', 'term-b'],
preferredTerminalId: 'term-a',
}),
'chat-b',
);
});
test('inheritance skips member sessions missing from visible history', () => {
assert.equal(
resolveInheritedAIActiveSessionId({
scopeType: 'workspace',
scopeTargetId: 'ws-1',
activeSessionIdMap: {
'terminal:term-a': 'chat-a',
'terminal:term-b': 'chat-b',
},
memberTerminalIds: ['term-a', 'term-b'],
preferredTerminalId: 'term-a',
visibleSessionIds: new Set(['chat-b']),
}),
'chat-b',
);
});
test('terminal scope does not inherit from siblings', () => {
assert.equal(
resolveInheritedAIActiveSessionId({
scopeType: 'terminal',
scopeTargetId: 'term-a',
activeSessionIdMap: {
'terminal:term-b': 'chat-b',
},
memberTerminalIds: ['term-a', 'term-b'],
preferredTerminalId: 'term-a',
}),
null,
);
});

View File

@@ -0,0 +1,50 @@
import { buildAIScopeKey } from './aiSessionsForScope';
/**
* When terminals merge into a workspace, AI panel scope flips from
* `terminal:<id>` to `workspace:<id>`. Prefer the workspace's own active
* chat, otherwise inherit from member terminal scopes (focused first) so
* the chat the user was using survives the merge.
*/
export function resolveInheritedAIActiveSessionId(input: {
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
activeSessionIdMap: Readonly<Record<string, string | null | undefined>>;
memberTerminalIds: readonly string[];
preferredTerminalId?: string | null;
/**
* When provided, inherited ids must appear here (e.g. ranked history).
* Direct workspace-map hits are returned even if absent so callers can
* decide how to recover stale ids.
*/
visibleSessionIds?: ReadonlySet<string>;
}): string | null {
const scopeKey = buildAIScopeKey(input.scopeType, input.scopeTargetId);
const direct = input.activeSessionIdMap[scopeKey];
if (typeof direct === 'string' && direct.length > 0) {
return direct;
}
if (input.scopeType !== 'workspace') {
return null;
}
const preferredOrder: string[] = [];
if (input.preferredTerminalId) {
preferredOrder.push(input.preferredTerminalId);
}
for (const terminalId of input.memberTerminalIds) {
if (terminalId && !preferredOrder.includes(terminalId)) {
preferredOrder.push(terminalId);
}
}
for (const terminalId of preferredOrder) {
const sessionId = input.activeSessionIdMap[buildAIScopeKey('terminal', terminalId)];
if (typeof sessionId !== 'string' || sessionId.length === 0) continue;
if (input.visibleSessionIds && !input.visibleSessionIds.has(sessionId)) continue;
return sessionId;
}
return null;
}

26
domain/appIconVariant.ts Normal file
View File

@@ -0,0 +1,26 @@
export const APP_ICON_VARIANTS = [
'original',
'bright',
'dark',
'colorful',
'high-contrast',
'white-navy',
'white-sky',
'white-rose',
'white-emerald',
'white-amber',
'white-violet',
'rainbow',
] as const;
export type AppIconVariant = (typeof APP_ICON_VARIANTS)[number];
export const DEFAULT_APP_ICON_VARIANT: AppIconVariant = 'original';
export function isValidAppIconVariant(value: unknown): value is AppIconVariant {
return typeof value === 'string' && (APP_ICON_VARIANTS as readonly string[]).includes(value);
}
export function resolveAppIconVariant(value: unknown): AppIconVariant {
return isValidAppIconVariant(value) ? value : DEFAULT_APP_ICON_VARIANT;
}

104
domain/appLock.test.ts Normal file
View File

@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
APP_LOCK_TIMEOUT_OPTIONS_MINUTES,
DEFAULT_APP_LOCK_SETTINGS,
normalizeAppLockSettings,
normalizeAppLockTimeoutMinutes,
} from "./appLock.ts";
test("normalizeAppLockTimeoutMinutes accepts only supported timeout options", () => {
assert.deepEqual(APP_LOCK_TIMEOUT_OPTIONS_MINUTES, [0, 1, 5, 15, 30, 60]);
assert.equal(normalizeAppLockTimeoutMinutes(0), 0);
assert.equal(normalizeAppLockTimeoutMinutes(1), 1);
assert.equal(normalizeAppLockTimeoutMinutes("5"), 5);
assert.equal(normalizeAppLockTimeoutMinutes(60), 60);
assert.equal(normalizeAppLockTimeoutMinutes(2), DEFAULT_APP_LOCK_SETTINGS.timeoutMinutes);
assert.equal(normalizeAppLockTimeoutMinutes(""), DEFAULT_APP_LOCK_SETTINGS.timeoutMinutes);
});
test("normalizeAppLockSettings preserves a valid verifier but clears system unlock when disabled", () => {
const normalized = normalizeAppLockSettings({
enabled: false,
timeoutMinutes: 30,
systemUnlockEnabled: true,
systemUnlockAutoPromptEnabled: true,
passwordVerifier: {
version: 1,
algorithm: "PBKDF2-SHA256",
iterations: 210000,
salt: Buffer.alloc(16, 1).toString("base64"),
hash: Buffer.alloc(32, 2).toString("base64"),
},
});
assert.deepEqual(normalized, {
enabled: false,
timeoutMinutes: 30,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: {
version: 1,
algorithm: "PBKDF2-SHA256",
iterations: 210000,
salt: Buffer.alloc(16, 1).toString("base64"),
hash: Buffer.alloc(32, 2).toString("base64"),
},
});
});
test("normalizeAppLockSettings refuses enabled state without a valid verifier", () => {
assert.deepEqual(
normalizeAppLockSettings({
enabled: true,
timeoutMinutes: 5,
passwordVerifier: {
version: 1,
algorithm: "PBKDF2-SHA256",
iterations: 0,
salt: "",
hash: "",
},
systemUnlockEnabled: true,
systemUnlockAutoPromptEnabled: true,
}),
{
enabled: false,
timeoutMinutes: 5,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: null,
},
);
});
test("normalizeAppLockSettings defaults system unlock and auto prompt off for older settings", () => {
const normalized = normalizeAppLockSettings({
enabled: false,
timeoutMinutes: 15,
passwordVerifier: null,
});
assert.equal(normalized.systemUnlockEnabled, false);
assert.equal(normalized.systemUnlockAutoPromptEnabled, false);
});
test("normalizeAppLockSettings disables auto prompt unless system unlock is enabled", () => {
const normalized = normalizeAppLockSettings({
enabled: false,
timeoutMinutes: 15,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: true,
passwordVerifier: {
version: 1,
algorithm: "PBKDF2-SHA256",
iterations: 210000,
salt: Buffer.alloc(16, 1).toString("base64"),
hash: Buffer.alloc(32, 2).toString("base64"),
},
});
assert.equal(normalized.systemUnlockEnabled, false);
assert.equal(normalized.systemUnlockAutoPromptEnabled, false);
});

101
domain/appLock.ts Normal file
View File

@@ -0,0 +1,101 @@
export const APP_LOCK_TIMEOUT_OPTIONS_MINUTES = [0, 1, 5, 15, 30, 60] as const;
export type AppLockTimeoutMinutes = typeof APP_LOCK_TIMEOUT_OPTIONS_MINUTES[number];
export interface AppLockPasswordVerifier {
version: 1;
algorithm: 'PBKDF2-SHA256';
iterations: number;
salt: string;
hash: string;
}
export interface AppLockSettings {
enabled: boolean;
timeoutMinutes: AppLockTimeoutMinutes;
systemUnlockEnabled: boolean;
systemUnlockAutoPromptEnabled: boolean;
passwordVerifier: AppLockPasswordVerifier | null;
}
export type AppLockSettingsChangeError =
| 'empty-current'
| 'empty-next'
| 'incorrect';
export const DEFAULT_APP_LOCK_SETTINGS: AppLockSettings = {
enabled: false,
timeoutMinutes: 15,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: null,
};
const APP_LOCK_VERIFIER_VERSION = 1;
const APP_LOCK_ALGORITHM = 'PBKDF2-SHA256';
const APP_LOCK_SALT_BYTES = 16;
const APP_LOCK_HASH_BYTES = 32;
function base64ToBytes(value: string): Uint8Array | null {
try {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
}
export function normalizeAppLockTimeoutMinutes(input: unknown): AppLockTimeoutMinutes {
const value = typeof input === 'string' && input.trim() !== '' ? Number(input) : input;
return APP_LOCK_TIMEOUT_OPTIONS_MINUTES.includes(value as AppLockTimeoutMinutes)
? value as AppLockTimeoutMinutes
: DEFAULT_APP_LOCK_SETTINGS.timeoutMinutes;
}
export function normalizeAppLockPasswordVerifier(input: unknown): AppLockPasswordVerifier | null {
if (!isRecord(input)) return null;
if (input.version !== APP_LOCK_VERIFIER_VERSION) return null;
if (input.algorithm !== APP_LOCK_ALGORITHM) return null;
if (typeof input.iterations !== 'number' || !Number.isInteger(input.iterations) || input.iterations < 100000) {
return null;
}
if (typeof input.salt !== 'string' || base64ToBytes(input.salt)?.length !== APP_LOCK_SALT_BYTES) {
return null;
}
if (typeof input.hash !== 'string' || base64ToBytes(input.hash)?.length !== APP_LOCK_HASH_BYTES) {
return null;
}
return {
version: APP_LOCK_VERIFIER_VERSION,
algorithm: APP_LOCK_ALGORITHM,
iterations: input.iterations,
salt: input.salt,
hash: input.hash,
};
}
export function normalizeAppLockSettings(input: unknown): AppLockSettings {
if (!isRecord(input)) return DEFAULT_APP_LOCK_SETTINGS;
const timeoutMinutes = normalizeAppLockTimeoutMinutes(input.timeoutMinutes);
const passwordVerifier = normalizeAppLockPasswordVerifier(input.passwordVerifier);
const enabled = input.enabled === true && passwordVerifier !== null;
const systemUnlockEnabled = input.systemUnlockEnabled === true && enabled;
const systemUnlockAutoPromptEnabled = input.systemUnlockAutoPromptEnabled === true && systemUnlockEnabled;
return {
enabled,
timeoutMinutes,
systemUnlockEnabled,
systemUnlockAutoPromptEnabled,
passwordVerifier,
};
}

View File

@@ -0,0 +1,86 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildAITerminalSessionInfo } from "./buildAITerminalSessionInfo.ts";
import type { Host, TerminalSession } from "../../types";
const baseHost = (overrides: Partial<Host> = {}): Host =>
({
id: "h1",
label: "sw1",
hostname: "10.0.0.1",
username: "admin",
protocol: "ssh",
...overrides,
} as Host);
const baseSession = (overrides: Partial<TerminalSession> = {}): TerminalSession =>
({
id: "s1",
hostId: "h1",
protocol: "ssh",
status: "connected",
...overrides,
} as TerminalSession);
test("keeps explicit network deviceType", () => {
const info = buildAITerminalSessionInfo(
baseSession(),
baseHost({ deviceType: "network" }),
"linux",
);
assert.equal(info.deviceType, "network");
});
test("reports 'network' when the detected distro classifies as a network device (#2367)", () => {
// Huawei VRP is a known network-device vendor id; the user has NOT flipped
// Network Device Mode, so host.deviceType is unset.
const info = buildAITerminalSessionInfo(
baseSession(),
baseHost({ distro: "huawei" }),
"linux",
);
assert.equal(info.deviceType, "network");
});
test("does not force network for a normal linux distro", () => {
const info = buildAITerminalSessionInfo(
baseSession(),
baseHost({ distro: "ubuntu" }),
"linux",
);
assert.notEqual(info.deviceType, "network");
});
test("does not misclassify a distro that merely contains a vendor keyword as a substring", () => {
// Classification is exact-match against the vendor id list, not a substring
// scan: a custom distro string that merely embeds "cisco"/"huawei" must NOT
// be treated as a network device (guards against a false positive that would
// send raw, shell-unwrapped commands to a real POSIX host).
for (const distro of ["cisco-lab-server", "my-huawei-cloud", "cisco linux", "fortinet-vm"]) {
const info = buildAITerminalSessionInfo(
baseSession(),
baseHost({ distro }),
"linux",
);
assert.notEqual(info.deviceType, "network", `distro "${distro}" should not be network`);
}
});
test("suppresses network deviceType for Mosh sessions", () => {
const info = buildAITerminalSessionInfo(
baseSession({ moshEnabled: true }),
baseHost({ distro: "huawei", deviceType: "network" }),
"linux",
);
assert.equal(info.deviceType, undefined);
});
test("suppresses network deviceType for ET sessions", () => {
const info = buildAITerminalSessionInfo(
baseSession({ etEnabled: true }),
baseHost({ deviceType: "network" }),
"linux",
);
assert.equal(info.deviceType, undefined);
});

View File

@@ -0,0 +1,106 @@
import { classifyDistroId, resolveHostOs } from './host';
import type { PortForwardingRule } from './models';
import type { Host, TerminalSession } from '../types';
export type AITerminalSessionInfo = {
sessionId: string;
hostId: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
hostChain?: Array<{ hostId: string; label?: string; hostname?: string }>;
activePortForwards?: Array<{
ruleId: string;
label?: string;
type?: string;
localPort?: number;
status?: string;
}>;
};
function summarizeHostChain(
host: Host | undefined,
allHosts: Host[],
): AITerminalSessionInfo['hostChain'] | undefined {
if (!host?.hostChain?.hostIds?.length) return undefined;
return host.hostChain.hostIds.map((hostId) => {
const jumpHost = allHosts.find((entry) => entry.id === hostId);
return {
hostId,
label: jumpHost?.label,
hostname: jumpHost?.hostname,
};
});
}
export const buildAITerminalSessionInfo = (
session: TerminalSession | undefined,
host: Host | undefined,
localOs: 'linux' | 'macos' | 'windows',
options?: {
allHosts?: Host[];
portForwardingRules?: PortForwardingRule[];
},
): AITerminalSessionInfo => {
const protocol = session?.protocol || host?.protocol;
const isLocalSession = protocol === 'local' || session?.hostId?.startsWith('local-');
const allHosts = options?.allHosts ?? (host ? [host] : []);
const hostChain = summarizeHostChain(host, allHosts);
const activePortForwards = host?.id && options?.portForwardingRules
? options.portForwardingRules
.filter((rule) => rule.hostId === host.id && (rule.status === 'active' || rule.status === 'connecting'))
.map((rule) => ({
ruleId: rule.id,
label: rule.label,
type: rule.type,
localPort: rule.localPort,
status: rule.status,
}))
: undefined;
// Mosh / ET sessions always run over a shell-backed PTY and cannot reach a
// vendor CLI, so network device mode never applies to them.
const isMoshOrEt = Boolean(
session?.moshEnabled || host?.moshEnabled || session?.etEnabled || host?.etEnabled,
);
// Report 'network' when the host is explicitly a network device OR when the
// detected distro/vendor classifies as one (Huawei VRP, Cisco IOS, ...). This
// mirrors the terminal's own gating (Terminal.tsx / systemTarget.ts) so AI
// exec skips shell wrapping (routing to the raw-PTY path) and the system
// prompt gets vendor-CLI guidance even before the user manually flips
// Network Device Mode (#2367).
const isNetworkDevice = host?.deviceType === 'network'
|| classifyDistroId(host?.distro) === 'network-device';
const deviceType = isMoshOrEt
? undefined
: (isNetworkDevice ? 'network' : host?.deviceType);
return {
sessionId: session?.id || '',
hostId: session?.hostId || '',
hostname: host?.hostname || session?.hostname || '',
label: host?.label || session?.hostLabel || '',
os: isLocalSession ? localOs : resolveHostOs(host),
username: host?.username || session?.username,
protocol,
shellType: session?.shellType && session.shellType !== 'unknown' ? session.shellType : undefined,
deviceType,
connected: session?.status === 'connected',
...(hostChain?.length ? { hostChain } : {}),
...(activePortForwards?.length ? { activePortForwards } : {}),
};
};
export type AIPanelContext = {
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeHostIds: string[];
scopeLabel: string;
/** Focused pane in a workspace; used to inherit AI chat after terminal merge. */
focusedSessionId?: string;
terminalSessions: AITerminalSessionInfo[];
};

View File

@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildChatJumpEntries,
chatMessageDomId,
resolveTailCountForJumpTarget,
truncateChatJumpLabel,
} from './chatJumpNav.ts';
test('truncateChatJumpLabel collapses whitespace and ellipsizes', () => {
assert.equal(truncateChatJumpLabel(' hello world '), 'hello world');
assert.equal(
truncateChatJumpLabel('a'.repeat(40), 10),
`${'a'.repeat(7)}...`,
);
assert.equal(truncateChatJumpLabel(' '), '');
});
test('buildChatJumpEntries returns empty until the minimum user-turn threshold', () => {
const messages = [
{ id: 'u1', role: 'user', content: 'one' },
{ id: 'a1', role: 'assistant', content: 'ok' },
{ id: 'u2', role: 'user', content: 'two' },
];
assert.deepEqual(buildChatJumpEntries(messages), []);
assert.equal(
buildChatJumpEntries([
...messages,
{ id: 'u3', role: 'user', content: 'three' },
]).length,
3,
);
});
test('buildChatJumpEntries uses empty label fallback and skips non-user roles', () => {
const entries = buildChatJumpEntries(
[
{ id: 's', role: 'system', content: 'ignore' },
{ id: 'u1', role: 'user', content: ' ' },
{ id: 't', role: 'tool', content: 'tool' },
{ id: 'u2', role: 'user', content: 'second' },
{ id: 'u3', role: 'user', content: 'third' },
],
{ emptyLabel: '(empty)' },
);
assert.deepEqual(entries, [
{ messageId: 'u1', label: '(empty)', index: 1 },
{ messageId: 'u2', label: 'second', index: 2 },
{ messageId: 'u3', label: 'third', index: 3 },
]);
});
test('resolveTailCountForJumpTarget expands only when the target is outside the window', () => {
const visible = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }];
assert.equal(resolveTailCountForJumpTarget(visible, 'd', 2), 2);
assert.equal(resolveTailCountForJumpTarget(visible, 'b', 2), 4);
assert.equal(resolveTailCountForJumpTarget(visible, 'missing', 2), 2);
});
test('resolveTailCountForJumpTarget grows with appends so a pinned target stays mounted', () => {
const before = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }];
const afterJump = resolveTailCountForJumpTarget(before, 'b', 2);
assert.equal(afterJump, 4);
// slice(-4) on a longer list would drop 'b' unless the count is re-resolved.
const afterAppend = [...before, { id: 'f' }, { id: 'g' }];
assert.equal(resolveTailCountForJumpTarget(afterAppend, 'b', afterJump), 6);
});
test('chatMessageDomId prefixes message ids for DOM anchors', () => {
assert.equal(chatMessageDomId('msg-12'), 'ai-chat-msg-msg-12');
});

72
domain/chatJumpNav.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* Pure helpers for AI chat in-session jump navigation (user-turn TOC).
*/
export const CHAT_JUMP_MIN_ENTRIES = 3;
export const CHAT_JUMP_LABEL_MAX = 36;
export type ChatJumpMessage = {
id: string;
role: string;
content?: string;
};
export type ChatJumpEntry = {
messageId: string;
label: string;
/** 1-based index among user turns */
index: number;
};
export function chatMessageDomId(messageId: string): string {
return `ai-chat-msg-${messageId}`;
}
export function truncateChatJumpLabel(text: string, maxLen = CHAT_JUMP_LABEL_MAX): string {
const normalized = text.replace(/\s+/g, ' ').trim();
if (!normalized) return '';
if (normalized.length <= maxLen) return normalized;
const ellipsis = '...';
return `${normalized.slice(0, Math.max(1, maxLen - ellipsis.length)).trimEnd()}${ellipsis}`;
}
export function buildChatJumpEntries(
messages: ReadonlyArray<ChatJumpMessage>,
options?: {
minEntries?: number;
maxLabelLen?: number;
emptyLabel?: string;
},
): ChatJumpEntry[] {
const minEntries = options?.minEntries ?? CHAT_JUMP_MIN_ENTRIES;
const maxLabelLen = options?.maxLabelLen ?? CHAT_JUMP_LABEL_MAX;
const emptyLabel = options?.emptyLabel ?? '...';
const entries: ChatJumpEntry[] = [];
for (const message of messages) {
if (message.role !== 'user') continue;
const label = truncateChatJumpLabel(message.content ?? '', maxLabelLen) || emptyLabel;
entries.push({
messageId: message.id,
label,
index: entries.length + 1,
});
}
return entries.length >= minEntries ? entries : [];
}
/**
* Expand the rendered tail window so a jump target is mounted in the DOM.
* Call again whenever the message list grows while the jump target remains
* active; a one-shot expand at jump time is not enough if the tail is
* `slice(-count)` and later appends would otherwise slide the window forward.
*/
export function resolveTailCountForJumpTarget(
visibleMessages: ReadonlyArray<{ id: string }>,
targetMessageId: string,
currentTailCount: number,
): number {
const targetIndex = visibleMessages.findIndex((message) => message.id === targetMessageId);
if (targetIndex < 0) return currentTailCount;
const requiredTail = visibleMessages.length - targetIndex;
return Math.max(currentTailCount, requiredTail);
}

View File

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
BUILTIN_CLOUD_PROVIDERS,
assertCloudProviderId,
isBuiltinCloudProvider,
isPluginCloudProviderId,
providerConnectionStorageKey,
} from './cloudProviderIds';
describe('cloudProviderIds', () => {
it('recognizes built-in providers', () => {
for (const id of BUILTIN_CLOUD_PROVIDERS) {
assert.equal(isBuiltinCloudProvider(id), true);
assert.equal(isPluginCloudProviderId(id), false);
assert.equal(providerConnectionStorageKey(id), `netcatty_provider_${id}_v1`);
}
});
it('accepts namespaced plugin provider IDs without coercing them to built-ins', () => {
const id = 'com.example.backup.sync';
assert.equal(isPluginCloudProviderId(id), true);
assert.equal(isBuiltinCloudProvider(id), false);
assert.equal(providerConnectionStorageKey(id), `netcatty_provider_plugin_v1:${id}`);
assert.equal(assertCloudProviderId(id), id);
});
it('rejects empty or NUL-containing provider IDs', () => {
assert.throws(() => assertCloudProviderId(''), /invalid/i);
assert.throws(() => assertCloudProviderId('bad\0id'), /invalid/i);
});
});

View File

@@ -0,0 +1,59 @@
/**
* Cloud provider identity helpers.
*
* Built-in providers keep their stable short IDs. Plugin sync Providers use
* namespaced contribution IDs (e.g. com.example.backup.sync). The manager
* boundary accepts both without coercing missing plugins away.
*/
export const BUILTIN_CLOUD_PROVIDERS = [
'github',
'google',
'onedrive',
'webdav',
's3',
] as const;
export type BuiltinCloudProvider = (typeof BUILTIN_CLOUD_PROVIDERS)[number];
/** Built-in short IDs or namespaced plugin contribution IDs. */
export type CloudProviderId = BuiltinCloudProvider | (string & {});
const BUILTIN_SET = new Set<string>(BUILTIN_CLOUD_PROVIDERS);
export function isBuiltinCloudProvider(provider: string): provider is BuiltinCloudProvider {
return BUILTIN_SET.has(provider);
}
/**
* True when the provider ID looks like a namespaced plugin contribution.
* Contribution IDs are reverse-DNS style and always contain a dot.
*/
export function isPluginCloudProviderId(provider: string): boolean {
if (isBuiltinCloudProvider(provider)) return false;
if (typeof provider !== 'string' || provider.length < 3 || provider.length > 256) return false;
if (provider.includes('\0') || provider.includes('/') || provider.includes('\\')) return false;
return provider.includes('.');
}
export function assertCloudProviderId(provider: string): CloudProviderId {
if (typeof provider !== 'string' || provider.length < 1 || provider.length > 256) {
throw new TypeError('Cloud provider ID is invalid');
}
if (provider.includes('\0')) {
throw new TypeError('Cloud provider ID is invalid');
}
return provider;
}
/** localStorage / encrypted local key for a provider connection record. */
export function providerConnectionStorageKey(provider: CloudProviderId): string {
if (isBuiltinCloudProvider(provider)) {
return `netcatty_provider_${provider}_v1`;
}
// Plugin IDs may contain characters unsafe for ad-hoc key templates.
return `netcatty_provider_plugin_v1:${provider}`;
}
/** Registry of dynamic plugin provider IDs that have been connected on this device. */
export const PLUGIN_CLOUD_PROVIDER_REGISTRY_KEY = 'netcatty_plugin_cloud_providers_v1';

View File

@@ -0,0 +1,269 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildCodebuddyElicitationContent,
initialCodebuddyElicitationValues,
parseCodebuddyElicitationFields,
selectedCodebuddyOptionKey,
toggleCodebuddyArrayOption,
validateCodebuddyElicitationValues,
} from './codebuddyElicitationForm';
test('CodeBuddy elicitation options preserve typed enum and const values', () => {
const fields = parseCodebuddyElicitationFields({
type: 'object',
properties: {
retries: {
type: 'integer',
enum: [1, 2],
default: 1,
},
enabled: {
type: 'boolean',
oneOf: [
{ const: true, title: 'Enabled' },
{ const: false, title: 'Disabled' },
],
},
mixed: {
type: 'array',
items: {
enum: [1, '1', false],
},
},
},
});
assert.equal(fields[0].options[1].value, 2);
assert.equal(fields[1].options[1].value, false);
assert.equal(selectedCodebuddyOptionKey(fields[0].options, 1), 'enum:0');
assert.deepEqual(initialCodebuddyElicitationValues(fields), { retries: 1 });
let mixed: unknown[] = [];
mixed = toggleCodebuddyArrayOption(mixed, 1, true);
mixed = toggleCodebuddyArrayOption(mixed, '1', true);
mixed = toggleCodebuddyArrayOption(mixed, false, true);
mixed = toggleCodebuddyArrayOption(mixed, '1', true);
assert.deepEqual(mixed, [1, '1', false]);
assert.deepEqual(buildCodebuddyElicitationContent({
retries: 2,
enabled: false,
mixed,
omitted: undefined,
}), {
retries: 2,
enabled: false,
mixed: [1, '1', false],
});
});
test('CodeBuddy elicitation content excludes stale fields from a replaced schema', () => {
const fields = parseCodebuddyElicitationFields({
type: 'object',
properties: {
current: { type: 'string' },
},
});
assert.deepEqual(buildCodebuddyElicitationContent({
stale: 'must not leak',
current: 'kept',
omitted: undefined,
}, fields), {
current: 'kept',
});
});
test('CodeBuddy elicitation validation enforces numeric and array constraints', () => {
const fields = parseCodebuddyElicitationFields({
type: 'object',
properties: {
retries: {
type: 'integer',
title: 'Retries',
minimum: 10,
maximum: 20,
},
choices: {
type: 'array',
title: 'Choices',
minItems: 2,
maxItems: 2,
items: { enum: ['a', 'b', 'c'] },
},
},
required: ['retries', 'choices'],
});
assert.deepEqual(
validateCodebuddyElicitationValues(fields, {
retries: 1,
choices: ['a'],
}).map(({ fieldId, code, limit }) => ({ fieldId, code, limit })),
[
{ fieldId: 'retries', code: 'minimum', limit: 10 },
{ fieldId: 'choices', code: 'minItems', limit: 2 },
],
);
assert.deepEqual(validateCodebuddyElicitationValues(fields, {
retries: 10.5,
choices: ['a', 'b', 'c'],
}).map(({ fieldId, code, limit }) => ({ fieldId, code, limit })), [
{ fieldId: 'retries', code: 'notInteger', limit: undefined },
{ fieldId: 'choices', code: 'maxItems', limit: 2 },
]);
assert.deepEqual(validateCodebuddyElicitationValues(fields, {
retries: 12,
choices: ['a', 'b'],
}), []);
});
test('CodeBuddy elicitation validation reports notInteger for fractional numeric input', () => {
const fields = parseCodebuddyElicitationFields({
type: 'object',
properties: {
retries: {
type: 'integer',
title: 'Retries',
minimum: 2,
maximum: 5,
},
},
required: ['retries'],
});
const issues = validateCodebuddyElicitationValues(fields, { retries: 3.5 });
assert.equal(issues.length, 1);
assert.equal(issues[0].code, 'notInteger');
assert.equal(issues[0].fieldId, 'retries');
assert.equal(issues[0].fieldTitle, 'Retries');
});
test('CodeBuddy elicitation validation enforces string lengths, formats, and options', () => {
const fields = parseCodebuddyElicitationFields({
type: 'object',
properties: {
name: {
type: 'string',
minLength: 2,
maxLength: 3,
},
email: {
type: 'string',
format: 'email',
},
date: {
type: 'string',
format: 'date',
},
dateTime: {
type: 'string',
format: 'date-time',
},
environment: {
type: 'string',
enum: ['staging', 'production'],
},
optional: {
type: 'string',
default: null,
},
},
required: ['name', 'email', 'date', 'dateTime', 'environment'],
});
assert.deepEqual(initialCodebuddyElicitationValues(fields), {});
assert.deepEqual(
validateCodebuddyElicitationValues(fields, {
name: 'a',
email: 'not-an-email',
date: '2026-02-30',
dateTime: '2026-02-30T12:00:00Z',
environment: 'unknown',
}).map(({ fieldId, code }) => ({ fieldId, code })),
[
{ fieldId: 'name', code: 'minLength' },
{ fieldId: 'email', code: 'format' },
{ fieldId: 'date', code: 'format' },
{ fieldId: 'dateTime', code: 'format' },
{ fieldId: 'environment', code: 'option' },
],
);
assert.deepEqual(validateCodebuddyElicitationValues(fields, {
name: '猫猫',
email: 'cat@example.com',
date: '2026-07-27',
dateTime: '2026-07-27T15:30:00+08:00',
environment: 'staging',
}), []);
});
test('CodeBuddy elicitation validation follows standard email and date-time boundaries', () => {
const fields = parseCodebuddyElicitationFields({
type: 'object',
properties: {
email: {
type: 'string',
format: 'email',
},
dateTime: {
type: 'string',
format: 'date-time',
},
},
required: ['email', 'dateTime'],
});
assert.deepEqual(validateCodebuddyElicitationValues(fields, {
email: 'cat+alerts@example.com',
dateTime: '2026-07-27t15:30:00z',
}), []);
assert.deepEqual(validateCodebuddyElicitationValues(fields, {
email: 'cat@example.com',
dateTime: '1990-12-31T23:59:60Z',
}), []);
assert.deepEqual(
validateCodebuddyElicitationValues(fields, {
email: 'cat@example..com',
dateTime: '2026-07-27T15:30:00Z',
}).map(({ fieldId, code }) => ({ fieldId, code })),
[{ fieldId: 'email', code: 'format' }],
);
});
test('CodeBuddy required fields allow empty values unless size constraints reject them', () => {
const fields = parseCodebuddyElicitationFields({
type: 'object',
properties: {
note: { type: 'string' },
choices: {
type: 'array',
items: { enum: ['a', 'b'] },
},
constrainedNote: {
type: 'string',
minLength: 1,
},
constrainedChoices: {
type: 'array',
minItems: 1,
items: { enum: ['a', 'b'] },
},
},
required: ['note', 'choices', 'constrainedNote', 'constrainedChoices'],
});
assert.deepEqual(
validateCodebuddyElicitationValues(fields, {
note: '',
choices: [],
constrainedNote: '',
constrainedChoices: [],
}).map(({ fieldId, code }) => ({ fieldId, code })),
[
{ fieldId: 'constrainedNote', code: 'minLength' },
{ fieldId: 'constrainedChoices', code: 'minItems' },
],
);
});

View File

@@ -0,0 +1,307 @@
export interface CodebuddyElicitationOption {
key: string;
value: unknown;
label: string;
}
export interface CodebuddyElicitationField {
id: string;
title: string;
description: string;
type: string;
required: boolean;
defaultValue?: unknown;
options: CodebuddyElicitationOption[];
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
minItems?: number;
maxItems?: number;
format?: string;
}
export type CodebuddyElicitationValidationCode =
| 'required'
| 'invalidType'
| 'integer'
| 'notInteger'
| 'minimum'
| 'maximum'
| 'minLength'
| 'maxLength'
| 'minItems'
| 'maxItems'
| 'format'
| 'option';
export interface CodebuddyElicitationValidationIssue {
fieldId: string;
fieldTitle: string;
code: CodebuddyElicitationValidationCode;
limit?: number;
format?: string;
}
const EMAIL_PATTERN =
/^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const DATE_TIME_PATTERN =
/^(\d{4}-\d{2}-\d{2})[Tt ](\d{2}):(\d{2}):(\d{2}(?:\.\d+)?)(?:[Zz]|([+-])(\d{2})(?::?(\d{2}))?)$/;
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function optionalNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function fieldOptions(schema: Record<string, unknown>): CodebuddyElicitationOption[] {
if (Array.isArray(schema.enum)) {
const labels = Array.isArray(schema.enumNames) ? schema.enumNames : [];
return schema.enum.map((value, index) => ({
key: `enum:${index}`,
value,
label: String(labels[index] ?? value),
}));
}
const variants = Array.isArray(schema.oneOf)
? schema.oneOf
: Array.isArray(schema.anyOf)
? schema.anyOf
: [];
return variants.flatMap((variant, index) => {
const option = asRecord(variant);
return !Object.prototype.hasOwnProperty.call(option, 'const')
? []
: [{
key: `variant:${index}`,
value: option.const,
label: String(option.title ?? option.const),
}];
});
}
export function parseCodebuddyElicitationFields(
requestedSchema: unknown,
): CodebuddyElicitationField[] {
const schema = asRecord(requestedSchema);
const properties = asRecord(schema.properties);
const required = new Set(
Array.isArray(schema.required) ? schema.required.map((value) => String(value)) : [],
);
return Object.entries(properties).map(([id, rawField]) => {
const field = asRecord(rawField);
const type = String(field.type || 'string');
return {
id,
title: String(field.title || id),
description: String(field.description || ''),
type,
required: required.has(id),
defaultValue: field.default === null ? undefined : field.default,
options: fieldOptions(type === 'array' ? asRecord(field.items) : field),
minimum: optionalNumber(field.minimum),
maximum: optionalNumber(field.maximum),
minLength: optionalNumber(field.minLength),
maxLength: optionalNumber(field.maxLength),
minItems: optionalNumber(field.minItems),
maxItems: optionalNumber(field.maxItems),
format: typeof field.format === 'string' ? field.format : undefined,
};
});
}
export function initialCodebuddyElicitationValues(
fields: CodebuddyElicitationField[],
): Record<string, unknown> {
return Object.fromEntries(
fields
.filter((field) => field.defaultValue !== undefined)
.map((field) => [field.id, field.defaultValue]),
);
}
export function selectedCodebuddyOptionKey(
options: CodebuddyElicitationOption[],
value: unknown,
): string {
return options.find((option) => Object.is(option.value, value))?.key || '';
}
export function toggleCodebuddyArrayOption(
currentValue: unknown,
optionValue: unknown,
checked: boolean,
): unknown[] {
const selected = Array.isArray(currentValue) ? currentValue : [];
if (checked) {
return selected.some((value) => Object.is(value, optionValue))
? selected
: [...selected, optionValue];
}
return selected.filter((value) => !Object.is(value, optionValue));
}
export function buildCodebuddyElicitationContent(
values: Record<string, unknown>,
fields?: CodebuddyElicitationField[],
): Record<string, unknown> {
const allowedFieldIds = fields
? new Set(fields.map((field) => field.id))
: null;
return Object.fromEntries(
Object.entries(values).filter(
([fieldId, value]) => value !== undefined
&& (!allowedFieldIds || allowedFieldIds.has(fieldId)),
),
);
}
function hasOptionValue(
options: CodebuddyElicitationOption[],
value: unknown,
): boolean {
return options.some((option) => Object.is(option.value, value));
}
function isValidDate(value: string): boolean {
if (!DATE_PATTERN.test(value)) return false;
const date = new Date(`${value}T00:00:00.000Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
function isValidDateTime(value: string): boolean {
const match = DATE_TIME_PATTERN.exec(value);
if (!match || !isValidDate(match[1])) return false;
const hour = Number(match[2]);
const minute = Number(match[3]);
const second = Number(match[4]);
const offsetSign = match[5] === '-' ? -1 : 1;
const offsetHour = match[6] === undefined ? 0 : Number(match[6]);
const offsetMinute = match[7] === undefined ? 0 : Number(match[7]);
if (offsetHour > 23 || offsetMinute > 59) return false;
if (hour <= 23 && minute <= 59 && second < 60) return true;
const utcMinute = minute - offsetMinute * offsetSign;
const utcHour = hour - offsetHour * offsetSign - (utcMinute < 0 ? 1 : 0);
return (utcHour === 23 || utcHour === -1)
&& (utcMinute === 59 || utcMinute === -1)
&& second < 61;
}
function matchesFormat(value: string, format: string | undefined): boolean {
if (!format) return true;
if (format === 'email') {
return EMAIL_PATTERN.test(value);
}
if (format === 'uri') {
try {
return Boolean(new URL(value).protocol);
} catch {
return false;
}
}
if (format === 'date') {
return isValidDate(value);
}
if (format === 'date-time') {
return isValidDateTime(value);
}
return true;
}
function issue(
field: CodebuddyElicitationField,
code: CodebuddyElicitationValidationCode,
details: Pick<CodebuddyElicitationValidationIssue, 'limit' | 'format'> = {},
): CodebuddyElicitationValidationIssue {
return {
fieldId: field.id,
fieldTitle: field.title,
code,
...details,
};
}
function validateField(
field: CodebuddyElicitationField,
value: unknown,
): CodebuddyElicitationValidationIssue | null {
if (value === undefined) {
return field.required ? issue(field, 'required') : null;
}
if (field.type === 'boolean') {
if (typeof value !== 'boolean') return issue(field, 'invalidType');
if (field.options.length > 0 && !hasOptionValue(field.options, value)) {
return issue(field, 'option');
}
return null;
}
if (field.type === 'array') {
if (!Array.isArray(value)) return issue(field, 'invalidType');
if (field.minItems !== undefined && value.length < field.minItems) {
return issue(field, 'minItems', { limit: field.minItems });
}
if (field.maxItems !== undefined && value.length > field.maxItems) {
return issue(field, 'maxItems', { limit: field.maxItems });
}
if (
field.options.length > 0 &&
value.some((selected) => !hasOptionValue(field.options, selected))
) {
return issue(field, 'option');
}
return null;
}
if (field.type === 'number' || field.type === 'integer') {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return issue(field, 'invalidType');
}
if (field.type === 'integer' && !Number.isInteger(value)) {
return issue(field, 'notInteger');
}
if (field.minimum !== undefined && value < field.minimum) {
return issue(field, 'minimum', { limit: field.minimum });
}
if (field.maximum !== undefined && value > field.maximum) {
return issue(field, 'maximum', { limit: field.maximum });
}
if (field.options.length > 0 && !hasOptionValue(field.options, value)) {
return issue(field, 'option');
}
return null;
}
if (typeof value !== 'string') return issue(field, 'invalidType');
const length = [...value].length;
if (field.minLength !== undefined && length < field.minLength) {
return issue(field, 'minLength', { limit: field.minLength });
}
if (field.maxLength !== undefined && length > field.maxLength) {
return issue(field, 'maxLength', { limit: field.maxLength });
}
if (!matchesFormat(value, field.format)) {
return issue(field, 'format', { format: field.format });
}
if (field.options.length > 0 && !hasOptionValue(field.options, value)) {
return issue(field, 'option');
}
return null;
}
export function validateCodebuddyElicitationValues(
fields: CodebuddyElicitationField[],
values: Record<string, unknown>,
): CodebuddyElicitationValidationIssue[] {
return fields.flatMap((field) => {
const validationIssue = validateField(field, values[field.id]);
return validationIssue ? [validationIssue] : [];
});
}

View File

@@ -0,0 +1,178 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createCodingCliOutputScanner,
inferCodingCliProviderFromOutput,
stripTerminalControlSequences,
} from './codingCliOutputDetect';
test('inferCodingCliProviderFromOutput detects Codex startup banner', () => {
assert.equal(
inferCodingCliProviderFromOutput('>_ OpenAI Codex (v0.141.0)\r\nmodel: gpt-5.5'),
'codex',
);
assert.equal(
inferCodingCliProviderFromOutput('OpenAI Codex (v0.141.0)'),
'codex',
);
});
test('inferCodingCliProviderFromOutput detects other CLI banners', () => {
assert.equal(inferCodingCliProviderFromOutput('Welcome to Claude Code'), 'claude');
assert.equal(inferCodingCliProviderFromOutput('GitHub Copilot CLI'), 'copilot');
assert.equal(inferCodingCliProviderFromOutput('Factory Droid ready'), 'droid');
assert.equal(
inferCodingCliProviderFromOutput(
'█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█\n'
+ '█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀\n'
+ '▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀',
),
'opencode',
);
});
test('inferCodingCliProviderFromOutput ignores coding CLI installer and package output', () => {
assert.equal(
inferCodingCliProviderFromOutput('Setting up Claude Code...\n✅ Installation complete!'),
undefined,
);
assert.equal(
inferCodingCliProviderFromOutput(
'npm update codex\nchanged 3 packages in 2s\nopencode@1.2.3\n├── opencode@1.2.3',
),
undefined,
);
assert.equal(
inferCodingCliProviderFromOutput(
'\x1b[90mOpenCode includes free models, to start:\x1b[0m\n'
+ 'cd <project> # Open directory\n'
+ 'opencode # Run command\n'
+ 'For more information visit https://opencode.ai/docs',
),
undefined,
);
assert.equal(
inferCodingCliProviderFromOutput('updated opencode-ai@1.2.3'),
undefined,
);
});
test('createCodingCliOutputScanner ignores split installer output', () => {
const scanner = createCodingCliOutputScanner();
assert.equal(scanner.feed('\x1b[90mSetting up Claude '), undefined);
assert.equal(scanner.feed('Code...\x1b[0m\n✅ Installation complete!'), undefined);
assert.equal(
scanner.feed(
'\n\x1b[90m█▀▀█ █▀▀█ █▀▀█ █▀▀▄ \x1b[0m█▀▀▀ █▀▀█ █▀▀█ █▀▀█\n'
+ '\x1b[90m█░░█ █░░█ █▀▀▀ █░░█ \x1b[0m█░░░ █░░█ █░░█ █▀▀▀\n',
),
undefined,
);
assert.equal(
scanner.feed(
'\x1b[90m▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ \x1b[0m▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀\n'
+ '\x1b[90mOpenCode includes free models, ',
),
undefined,
);
assert.equal(scanner.feed('to start:\nopencode # Run command'), undefined);
});
test('createCodingCliOutputScanner detects the ANSI-colored OpenCode TUI logo across chunks', () => {
const scanner = createCodingCliOutputScanner();
assert.equal(
scanner.feed('\x1b[36m█▀▀█ █▀▀█ █▀▀█ █▀▀▄\x1b[0m █▀▀▀ █▀▀█ █▀▀█ █▀▀█\n'),
undefined,
);
assert.equal(
scanner.feed('\x1b[36m█ █ █ █ █▀▀▀ █ █\x1b[0m █ █ █ █ █ █▀▀▀\n'),
'opencode',
);
assert.equal(
scanner.feed('\x1b[36m▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀\x1b[0m ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀'),
'opencode',
);
});
test('createCodingCliOutputScanner preserves ANSI sequences split across chunks', () => {
const scanner = createCodingCliOutputScanner();
assert.equal(
scanner.feed('\x1b[36m█▀▀█ █▀▀█ █▀▀█ █▀▀▄\x1b[0m █▀▀▀ █▀▀█ █▀▀█ █▀▀█\n'),
undefined,
);
assert.equal(
scanner.feed('\x1b[36m█ █ \x1b['),
undefined,
);
assert.equal(
scanner.feed('0m█ █ █▀▀▀ █ █\x1b[0m █ █ █ █ █ █▀▀▀'),
'opencode',
);
});
test('createCodingCliOutputScanner hides OSC payloads split before BEL or ST terminators', () => {
const belScanner = createCodingCliOutputScanner();
assert.equal(belScanner.feed('\x1b]0;Welcome to Claude Code'), undefined);
assert.equal(belScanner.feed('\x07ordinary output'), undefined);
const stScanner = createCodingCliOutputScanner();
assert.equal(stScanner.feed('\x1b]0;GitHub Copilot CLI'), undefined);
assert.equal(stScanner.feed('\x1b\\ordinary output'), undefined);
});
test('createCodingCliOutputScanner preserves visible output between ST-terminated OSC sequences', () => {
const scanner = createCodingCliOutputScanner();
assert.equal(
scanner.feed('\x1b]0;first title\x1b\\Welcome to Claude '),
undefined,
);
assert.equal(
scanner.feed('Code\x1b]0;second title\x1b\\'),
'claude',
);
});
test('createCodingCliOutputScanner hides provider text in OSC payloads longer than the scan buffer', () => {
const belScanner = createCodingCliOutputScanner();
assert.equal(
belScanner.feed(`\x1b]0;${'x'.repeat(9000)} Welcome to Claude Code`),
undefined,
);
assert.equal(belScanner.feed('\x07ordinary output'), undefined);
const stScanner = createCodingCliOutputScanner();
assert.equal(
stScanner.feed(`\x1b]0;${'x'.repeat(9000)} GitHub Copilot CLI`),
undefined,
);
assert.equal(stScanner.feed('\x1b\\ordinary output'), undefined);
});
test('createCodingCliOutputScanner strips split ESC intermediate sequences inside banners', () => {
const scanner = createCodingCliOutputScanner();
assert.equal(scanner.feed('Welcome to Claude \x1b('), undefined);
assert.equal(scanner.feed('BCode'), 'claude');
});
test('createCodingCliOutputScanner hides split DCS, SOS, PM, and APC payloads', () => {
for (const introducer of ['P', 'X', '^', '_']) {
const scanner = createCodingCliOutputScanner();
assert.equal(
scanner.feed(`\x1b${introducer}Welcome to Claude`),
undefined,
);
assert.equal(scanner.feed(' Code\x1b\\ordinary output'), undefined);
}
});
test('createCodingCliOutputScanner finds providers across chunked output', () => {
const scanner = createCodingCliOutputScanner();
assert.equal(scanner.feed('>_ Open'), undefined);
assert.equal(scanner.feed('AI Codex (v0.141.0)'), 'codex');
assert.equal(scanner.feed('more output'), 'codex');
});
test('stripTerminalControlSequences removes ANSI color codes', () => {
const stripped = stripTerminalControlSequences('\x1b[1mOpenAI Codex\x1b[0m');
assert.equal(stripped, 'OpenAI Codex');
});

View File

@@ -0,0 +1,176 @@
import type { CodingCliProviderId } from './codingCliProviders';
const ESC = String.fromCharCode(0x1b);
const BEL = String.fromCharCode(0x07);
type ControlSequenceMode =
| 'text'
| 'esc'
| 'escIntermediate'
| 'csi'
| 'string'
| 'stringEsc';
function createTerminalControlSequenceStripper() {
let mode: ControlSequenceMode = 'text';
let stringAllowsBel = false;
const feed = (text: string): string => {
let visible = '';
for (const char of text) {
const code = char.charCodeAt(0);
if (mode === 'text') {
if (char === ESC) mode = 'esc';
else visible += char;
} else if (mode === 'esc') {
if (char === '[') {
mode = 'csi';
} else if (char === ']' || char === 'P' || char === 'X' || char === '^' || char === '_') {
mode = 'string';
stringAllowsBel = char === ']';
} else if (code >= 0x20 && code <= 0x2f) {
mode = 'escIntermediate';
} else {
mode = 'text';
// ESC final bytes span 0x30-0x7e. Preserve only invalid bytes rather
// than silently swallowing ordinary output.
if (code < 0x30 || code > 0x7e) visible += char;
}
} else if (mode === 'escIntermediate') {
if (code >= 0x20 && code <= 0x2f) continue;
mode = char === ESC ? 'esc' : 'text';
if (char !== ESC && (code < 0x30 || code > 0x7e)) visible += char;
} else if (mode === 'csi') {
if (code >= 0x40 && code <= 0x7e) mode = 'text';
} else if (mode === 'string') {
if (stringAllowsBel && char === BEL) mode = 'text';
else if (char === ESC) mode = 'stringEsc';
} else if (char === '\\') {
mode = 'text';
} else if (stringAllowsBel && char === BEL) {
mode = 'text';
} else if (char !== ESC) {
mode = 'string';
}
}
return visible;
};
return {
feed,
reset: () => {
mode = 'text';
stringAllowsBel = false;
},
};
}
/** Strip ANSI/OSC sequences so startup banners remain readable. */
export function stripTerminalControlSequences(text: string): string {
return createTerminalControlSequenceStripper().feed(text);
}
type OutputSignature = {
id: CodingCliProviderId;
test: (text: string) => boolean;
};
/**
* Startup banners and prompts emitted by coding CLIs.
* Codex does not put its name in OSC titles by default (openai/codex#18740),
* but always prints an "OpenAI Codex" header when the TUI starts.
*/
const OUTPUT_SIGNATURES: readonly OutputSignature[] = [
{
id: 'codex',
test: (text) => /(?:^|\s)(?:>\s*)?OpenAI Codex(?:\s*\(|$|\s)/i.test(text),
},
{
id: 'claude',
// Match Claude's actual welcome banner, not installer messages such as
// "Setting up Claude Code..." which are ordinary shell output.
test: (text) => /\bWelcome to Claude Code\b/i.test(text) || text.includes('✳'),
},
{
id: 'copilot',
test: (text) => /GitHub Copilot/i.test(text),
},
{
id: 'gemini',
test: (text) => /Gemini CLI/i.test(text),
},
{
id: 'droid',
test: (text) => /Factory Droid/i.test(text) || /Factory\.ai/i.test(text),
},
{
id: 'opencode',
// The installer prints the brand and a shaded ASCII wordmark. The TUI's
// startup logo uses a distinct space-filled third row, so require both
// TUI rows instead of matching ordinary OpenCode text.
test: (text) => (
/█▀▀█\s+█▀▀█\s+█▀▀█\s+█▀▀▄[\s\S]{0,512}█ {2}█\s+█ {2}█\s+█▀▀▀\s+█ {2}█/.test(text)
),
},
{
id: 'kimi',
test: (text) => /\bMoonshot\b/i.test(text) || /\bKimi\b/i.test(text),
},
] as const;
const OUTPUT_SCAN_BUFFER_LIMIT = 8192;
const OUTPUT_SCAN_BYTE_LIMIT = 16384;
export function inferCodingCliProviderFromOutput(text: string): CodingCliProviderId | undefined {
const normalized = stripTerminalControlSequences(text);
if (!normalized.trim()) return undefined;
for (const signature of OUTPUT_SIGNATURES) {
if (signature.test(normalized)) {
return signature.id;
}
}
return undefined;
}
export type CodingCliOutputScanner = {
feed: (chunk: string) => CodingCliProviderId | undefined;
reset: () => void;
isExhausted: () => boolean;
};
/** Rolling buffer scanner for live terminal output chunks. */
export function createCodingCliOutputScanner(): CodingCliOutputScanner {
let visibleBuffer = '';
let bytesFed = 0;
let exhausted = false;
const controlSequenceStripper = createTerminalControlSequenceStripper();
const feed = (chunk: string): CodingCliProviderId | undefined => {
if (!chunk || exhausted) return undefined;
bytesFed += chunk.length;
visibleBuffer = `${visibleBuffer}${controlSequenceStripper.feed(chunk)}`
.slice(-OUTPUT_SCAN_BUFFER_LIMIT);
const providerId = inferCodingCliProviderFromOutput(visibleBuffer);
if (providerId) return providerId;
if (bytesFed >= OUTPUT_SCAN_BYTE_LIMIT) {
exhausted = true;
}
return undefined;
};
const reset = () => {
visibleBuffer = '';
bytesFed = 0;
exhausted = false;
controlSequenceStripper.reset();
};
const isExhausted = () => exhausted;
return { feed, reset, isExhausted };
}

View File

@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getCodingCliCommandBasename,
matchCodingCliProviderFromCommand,
matchCodingCliProviderFromTitle,
resolveSessionCodingCliProvider,
} from './codingCliProviderMatch';
test('getCodingCliCommandBasename extracts executable name from paths', () => {
assert.equal(getCodingCliCommandBasename('/usr/local/bin/claude --resume abc'), 'claude');
assert.equal(getCodingCliCommandBasename('codex.exe'), 'codex');
});
test('matchCodingCliProviderFromCommand resolves known CLIs', () => {
assert.equal(matchCodingCliProviderFromCommand('opencode')?.id, 'opencode');
assert.equal(matchCodingCliProviderFromCommand('droid')?.id, 'droid');
assert.equal(matchCodingCliProviderFromCommand('factory')?.id, 'droid');
assert.equal(matchCodingCliProviderFromCommand('grok')?.id, 'grok');
assert.equal(matchCodingCliProviderFromCommand('C:\\\\Tools\\\\grok.exe')?.id, 'grok');
});
test('matchCodingCliProviderFromTitle detects Claude Code and Codex titles', () => {
assert.equal(
matchCodingCliProviderFromTitle('✳ Claude Code · refactor auth')?.id,
'claude',
);
assert.equal(
matchCodingCliProviderFromTitle('⠋ codex · my-project')?.id,
'codex',
);
assert.equal(
matchCodingCliProviderFromTitle('⠋ Working · netcatty')?.id,
'codex',
);
assert.equal(
matchCodingCliProviderFromTitle('Factory Droid · auth flow')?.id,
'droid',
);
assert.equal(
matchCodingCliProviderFromTitle('android@pixel:~'),
undefined,
);
});
test('resolveSessionCodingCliProvider detects providers from dynamic titles', () => {
assert.equal(
resolveSessionCodingCliProvider(
{ dynamicTitle: 'Claude Code' },
)?.id,
'claude',
);
});
test('resolveSessionCodingCliProvider ignores dynamic title for renamed sessions', () => {
assert.equal(
resolveSessionCodingCliProvider({
customName: 'Prod deploy',
dynamicTitle: 'Claude Code',
}),
undefined,
);
});
test('resolveSessionCodingCliProvider falls back to host startup command', () => {
assert.equal(
resolveSessionCodingCliProvider({}, { startupCommand: 'codex' })?.id,
'codex',
);
});
test('resolveSessionCodingCliProvider prefers sticky provider over launch command', () => {
assert.equal(
resolveSessionCodingCliProvider({
codingCliProviderId: 'codex',
startupCommand: 'droid',
})?.id,
'codex',
);
});
test('resolveSessionCodingCliProvider keeps sticky provider when title is only a project name', () => {
assert.equal(
resolveSessionCodingCliProvider({
codingCliProviderId: 'codex',
dynamicTitle: 'netcatty',
})?.id,
'codex',
);
});

View File

@@ -0,0 +1,129 @@
import { CODING_CLI_PROVIDERS, getCodingCliProvider, type CodingCliProvider } from './codingCliProviders';
import {
inferCodingCliProviderFromTitleSignals,
titleIncludesPhrase,
} from './codingCliTitleParse';
import type { Host, TerminalSession } from '../types';
export type SessionCodingCliSource = Pick<
TerminalSession,
| 'dynamicTitle'
| 'startupCommand'
| 'customName'
| 'hostLabel'
| 'localShell'
| 'localShellName'
| 'codingCliProviderId'
> & {
hostStartupCommand?: Host['startupCommand'];
};
export function getCodingCliCommandBasename(commandLine: string): string {
const trimmed = commandLine.trim();
if (!trimmed) return '';
const firstToken = trimmed.split(/\s+/)[0] ?? '';
const segments = firstToken.split(/[\\/]/);
const basename = (segments.pop() || '').toLowerCase();
return basename.replace(/\.(exe|cmd|bat|ps1)$/i, '');
}
export function matchCodingCliProviderFromCommand(commandLine: string): CodingCliProvider | undefined {
const basename = getCodingCliCommandBasename(commandLine);
if (!basename) return undefined;
return CODING_CLI_PROVIDERS.find((provider) => (
provider.command === basename
|| provider.aliases?.some((alias) => alias === basename)
));
}
export function matchCodingCliProviderFromTitle(title: string): CodingCliProvider | undefined {
const inferredId = inferCodingCliProviderFromTitleSignals(title);
if (inferredId) {
return getCodingCliProvider(inferredId);
}
const normalized = title.toLowerCase();
if (!normalized.trim()) return undefined;
const ranked = [...CODING_CLI_PROVIDERS].sort((left, right) => {
const leftHints = [
...(left.titleHints ?? []),
left.label,
left.command,
...(left.aliases ?? []),
];
const rightHints = [
...(right.titleHints ?? []),
right.label,
right.command,
...(right.aliases ?? []),
];
const leftMax = Math.max(...leftHints.map((hint) => hint.length), 0);
const rightMax = Math.max(...rightHints.map((hint) => hint.length), 0);
return rightMax - leftMax;
});
for (const provider of ranked) {
const hints = [
...(provider.titleHints ?? []),
provider.label,
provider.command,
...(provider.aliases ?? []),
];
if (hints.some((hint) => titleIncludesPhrase(normalized, hint))) {
return provider;
}
}
return undefined;
}
/**
* Resolve the active coding CLI for a terminal session from launch commands
* and shell-reported window titles.
*/
export function resolveCodingCliProviderFromCommandCandidates(
source: Pick<SessionCodingCliSource, 'startupCommand' | 'localShell'>,
host?: Pick<Host, 'startupCommand'>,
): CodingCliProvider | undefined {
const commandCandidates = [
source.startupCommand,
host?.startupCommand,
source.localShell,
].filter((value): value is string => Boolean(value?.trim()));
for (const commandLine of commandCandidates) {
const provider = matchCodingCliProviderFromCommand(commandLine);
if (provider) return provider;
}
return undefined;
}
export function resolveSessionCodingCliProvider(
source: SessionCodingCliSource,
host?: Pick<Host, 'startupCommand'>,
): CodingCliProvider | undefined {
if (source.codingCliProviderId) {
const sticky = getCodingCliProvider(source.codingCliProviderId);
if (sticky) return sticky;
}
const commandProvider = resolveCodingCliProviderFromCommandCandidates(source, host);
if (commandProvider) return commandProvider;
if (!source.customName) {
const dynamicTitle = source.dynamicTitle?.trim();
if (dynamicTitle) {
const provider = matchCodingCliProviderFromTitle(dynamicTitle);
if (provider) return provider;
}
}
if (source.localShellName) {
return matchCodingCliProviderFromTitle(source.localShellName);
}
return undefined;
}

View File

@@ -0,0 +1,112 @@
import type { AgentIconKey } from './agentIcon';
export type CodingCliProviderId =
| 'claude'
| 'codex'
| 'opencode'
| 'gemini'
| 'kimi'
| 'droid'
| 'copilot'
| 'cursor'
| 'codebuddy'
| 'grok';
export type CodingCliProvider = {
id: CodingCliProviderId;
label: string;
/** Primary CLI executable basename, e.g. `claude` or `codex`. */
command: string;
/** Alternate executable names that should resolve to this provider. */
aliases?: string[];
/** Substrings commonly present in OSC window titles for this CLI. */
titleHints?: string[];
iconKey: AgentIconKey;
};
/**
* Built-in coding CLI providers shown on terminal session tabs.
* Command names align with common agent launch binaries.
*/
export const CODING_CLI_PROVIDERS: readonly CodingCliProvider[] = [
{
id: 'claude',
label: 'Claude Code',
command: 'claude',
titleHints: ['claude code', 'claude'],
iconKey: 'claude',
},
{
id: 'codex',
label: 'Codex CLI',
command: 'codex',
titleHints: ['codex', 'chatgpt'],
iconKey: 'codex',
},
{
id: 'opencode',
label: 'OpenCode',
command: 'opencode',
titleHints: ['opencode'],
iconKey: 'opencode',
},
{
id: 'gemini',
label: 'Gemini CLI',
command: 'gemini',
titleHints: ['gemini'],
iconKey: 'gemini',
},
{
id: 'kimi',
label: 'Kimi CLI',
command: 'kimi',
aliases: ['moonshot'],
titleHints: ['kimi', 'moonshot'],
iconKey: 'kimi',
},
{
id: 'droid',
label: 'Droid',
command: 'droid',
aliases: ['factory'],
titleHints: ['droid', 'factory droid', 'factory ai'],
iconKey: 'droid',
},
{
id: 'copilot',
label: 'GitHub Copilot CLI',
command: 'copilot',
titleHints: ['copilot', 'github copilot'],
iconKey: 'copilot',
},
{
id: 'cursor',
label: 'Cursor Agent',
command: 'cursor',
titleHints: ['cursor'],
iconKey: 'cursor',
},
{
id: 'codebuddy',
label: 'CodeBuddy',
command: 'codebuddy',
titleHints: ['codebuddy'],
iconKey: 'codebuddy',
},
{
id: 'grok',
label: 'Grok Build',
command: 'grok',
titleHints: ['grok build', 'grok'],
iconKey: 'grok',
},
] as const;
const PROVIDER_BY_ID = new Map(
CODING_CLI_PROVIDERS.map((provider) => [provider.id, provider] as const),
);
export function getCodingCliProvider(id: CodingCliProviderId): CodingCliProvider | undefined {
return PROVIDER_BY_ID.get(id);
}

View File

@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
inferCodingCliProviderFromTitleSignals,
normalizeCodingCliDynamicTitleForStorage,
normalizeCodingCliTitle,
resolveCodingCliActivityPhase,
shouldClearCodingCliProviderForTitle,
titleHasBrailleSpinner,
titleIncludesPhrase,
} from './codingCliTitleParse';
test('inferCodingCliProviderFromTitleSignals detects Claude and Codex titles', () => {
assert.equal(inferCodingCliProviderFromTitleSignals('✳ Claude Code · refactor auth'), 'claude');
assert.equal(inferCodingCliProviderFromTitleSignals('⠋ codex · my-project'), 'codex');
assert.equal(inferCodingCliProviderFromTitleSignals('⠋ Working · netcatty'), 'codex');
});
test('inferCodingCliProviderFromTitleSignals detects Droid and Factory titles', () => {
assert.equal(inferCodingCliProviderFromTitleSignals('Factory Droid · auth flow'), 'droid');
assert.equal(inferCodingCliProviderFromTitleSignals('droid · session'), 'droid');
});
test('inferCodingCliProviderFromTitleSignals ignores provider names inside longer words', () => {
assert.equal(inferCodingCliProviderFromTitleSignals('android@pixel:~'), undefined);
assert.equal(inferCodingCliProviderFromTitleSignals('myopencodetooling'), undefined);
});
test('resolveCodingCliActivityPhase treats spinner titles as busy', () => {
assert.equal(
resolveCodingCliActivityPhase('⠋ netcatty', 'codex'),
'busy',
);
assert.equal(
resolveCodingCliActivityPhase('netcatty', 'codex'),
'idle',
);
});
test('resolveCodingCliActivityPhase detects waiting states', () => {
assert.equal(
resolveCodingCliActivityPhase('Claude Code · waiting for approval', 'claude'),
'waiting',
);
});
test('normalizeCodingCliTitle strips action-required and dot prefixes', () => {
assert.equal(normalizeCodingCliTitle('[ ! ] Action Required · deploy'), 'deploy');
assert.equal(normalizeCodingCliTitle('··· my task'), 'my task');
assert.equal(normalizeCodingCliTitle('∴ hello'), '∴ hello');
});
test('normalizeCodingCliDynamicTitleForStorage stabilizes spinner-only title changes', () => {
assert.equal(normalizeCodingCliDynamicTitleForStorage('⠋ Droid'), 'Droid');
assert.equal(normalizeCodingCliDynamicTitleForStorage('⠙ Droid'), 'Droid');
assert.equal(normalizeCodingCliDynamicTitleForStorage('[ ! ] Action Required · deploy'), '[ ! ] Action Required · deploy');
});
test('titleIncludesPhrase requires phrase boundaries', () => {
assert.equal(titleIncludesPhrase('Factory Droid · auth flow', 'droid'), true);
assert.equal(titleIncludesPhrase('android@pixel:~', 'droid'), false);
});
test('shouldClearCodingCliProviderForTitle clears on shell titles only', () => {
assert.equal(shouldClearCodingCliProviderForTitle('zsh', 'codex'), true);
assert.equal(shouldClearCodingCliProviderForTitle('user@host:~/repo', 'codex'), true);
assert.equal(shouldClearCodingCliProviderForTitle('user@host:/var/log', 'codex'), true);
assert.equal(shouldClearCodingCliProviderForTitle('host:~/repo', 'codex'), true);
assert.equal(shouldClearCodingCliProviderForTitle('/Users/alice/project', 'codex'), true);
assert.equal(shouldClearCodingCliProviderForTitle('C:\\Users\\alice\\project', 'codex'), true);
assert.equal(shouldClearCodingCliProviderForTitle('netcatty', 'codex'), false);
assert.equal(shouldClearCodingCliProviderForTitle('netcatty: refactor', 'codex'), false);
assert.equal(shouldClearCodingCliProviderForTitle('⠋ Working · netcatty', 'codex'), false);
assert.equal(shouldClearCodingCliProviderForTitle('', 'codex'), true);
});
test('titleHasBrailleSpinner recognizes Codex frames', () => {
assert.equal(titleHasBrailleSpinner('⠇ my-app'), true);
assert.equal(titleHasBrailleSpinner('my-app'), false);
});

View File

@@ -0,0 +1,120 @@
import type { CodingCliProviderId } from './codingCliProviders';
/** Braille dot-spinner frames used by Codex and several other agent TUIs. */
export const CODING_CLI_BRAILLE_SPINNER_FRAMES = [
'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏',
] as const;
const BRAILLE_SPINNER_RE = /^[\s]+/u;
const ACTION_REQUIRED_PREFIX_RE = /^\[\s*[!.]\s*\]\s*(?:Action Required\s*)?/iu;
const LEADING_SEPARATOR_RE = /^[\s·.]+/u;
const CLAUDE_MARKERS = ['claude code', 'claude', 'anthropic'] as const;
const CODEX_STATUS_WORDS = ['working', 'thinking', 'ready', 'waiting'] as const;
const BUSY_STATUS_WORDS = ['working', 'thinking', 'running', 'compacting', 'generating'] as const;
const WAITING_STATUS_WORDS = ['waiting', 'permission', 'approval', 'confirm', 'input required'] as const;
export type CodingCliActivityPhase = 'idle' | 'busy' | 'waiting';
export function normalizeCodingCliTitle(title: string): string {
let normalized = title.trim().replace(BRAILLE_SPINNER_RE, '').trim();
normalized = normalized.replace(ACTION_REQUIRED_PREFIX_RE, '').trim();
normalized = normalized.replace(LEADING_SEPARATOR_RE, '').trim();
return normalized;
}
export function normalizeCodingCliDynamicTitleForStorage(title: string): string {
let normalized = title.trim().replace(BRAILLE_SPINNER_RE, '').trim();
normalized = normalized.replace(LEADING_SEPARATOR_RE, '').trim();
return normalized;
}
export function titleHasBrailleSpinner(title: string): boolean {
return CODING_CLI_BRAILLE_SPINNER_FRAMES.some((frame) => title.includes(frame));
}
export function titleIncludesPhrase(title: string, phrase: string): boolean {
const normalized = title.toLowerCase();
const needle = phrase.toLowerCase().trim();
if (!needle) return false;
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(?:^|[^a-z0-9])${escaped}(?:[^a-z0-9]|$)`, 'i').test(normalized);
}
export function inferCodingCliProviderFromTitleSignals(title: string): CodingCliProviderId | undefined {
const raw = title.trim();
if (!raw) return undefined;
if (titleIncludesPhrase(raw, 'claude code') || raw.includes('✳') || titleIncludesPhrase(raw, 'claude')) {
return 'claude';
}
if (titleIncludesPhrase(raw, 'opencode')) return 'opencode';
if (titleIncludesPhrase(raw, 'codex') || titleIncludesPhrase(raw, 'chatgpt')) return 'codex';
if (titleIncludesPhrase(raw, 'github copilot') || titleIncludesPhrase(raw, 'copilot')) return 'copilot';
if (titleIncludesPhrase(raw, 'codebuddy')) return 'codebuddy';
if (titleIncludesPhrase(raw, 'gemini')) return 'gemini';
if (titleIncludesPhrase(raw, 'moonshot') || titleIncludesPhrase(raw, 'kimi')) return 'kimi';
if (titleIncludesPhrase(raw, 'factory droid') || titleIncludesPhrase(raw, 'factory ai')) return 'droid';
if (titleIncludesPhrase(raw, 'droid')) return 'droid';
if (titleIncludesPhrase(raw, 'cursor agent') || titleIncludesPhrase(raw, 'cursor')) return 'cursor';
const stripped = normalizeCodingCliTitle(raw).toLowerCase();
if (
titleHasBrailleSpinner(raw)
&& CODEX_STATUS_WORDS.some((word) => titleIncludesPhrase(stripped, word))
&& !CLAUDE_MARKERS.some((marker) => titleIncludesPhrase(raw, marker))
) {
return 'codex';
}
return undefined;
}
export function resolveCodingCliActivityPhase(
title: string | undefined,
providerId?: CodingCliProviderId,
): CodingCliActivityPhase {
const raw = title?.trim();
if (!raw || !providerId) return 'idle';
const normalized = normalizeCodingCliTitle(raw).toLowerCase();
if (WAITING_STATUS_WORDS.some((word) => titleIncludesPhrase(normalized, word))) {
return 'waiting';
}
if (titleHasBrailleSpinner(raw) || raw.includes('✳')) {
return 'busy';
}
if (BUSY_STATUS_WORDS.some((word) => titleIncludesPhrase(normalized, word))) {
return 'busy';
}
if (providerId === 'codex' && CODEX_STATUS_WORDS.includes(normalized as typeof CODEX_STATUS_WORDS[number])) {
return normalized === 'ready' ? 'idle' : 'busy';
}
return 'idle';
}
const SHELL_TITLE_RE = /^(?:bash|zsh|fish|pwsh|powershell|sh|nu|xonsh|cmd)(?:\s|$|[(@])/i;
const SHELL_PATH_TITLE_RE = /^(?:(?:[^@\s:]+@)?[^:\s]+:)?(?:~(?:\/|$)|\/|[A-Za-z]:[\\/])/;
/** Whether a shell-reported title no longer reflects an active coding CLI session. */
export function shouldClearCodingCliProviderForTitle(
title: string,
providerId: CodingCliProviderId,
): boolean {
const trimmed = title.trim();
if (!trimmed) return true;
const inferredId = inferCodingCliProviderFromTitleSignals(trimmed);
if (inferredId === providerId) return false;
if (inferredId) return true;
if (SHELL_TITLE_RE.test(trimmed)) return true;
if (SHELL_PATH_TITLE_RE.test(trimmed)) return true;
// Ambiguous titles (e.g. Codex project names) may still be an active agent session.
return false;
}

68
domain/colorContrast.ts Normal file
View File

@@ -0,0 +1,68 @@
type ParsedHslToken = {
hue: number;
saturation: number;
lightness: number;
};
const BLACK_HSL = '0 0% 0%';
const WHITE_HSL = '0 0% 100%';
const parseHslToken = (value: string): ParsedHslToken | null => {
const match = /^\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%\s*$/.exec(value);
if (!match) return null;
const hue = Number(match[1]);
const saturation = Number(match[2]);
const lightness = Number(match[3]);
if (![hue, saturation, lightness].every(Number.isFinite)) return null;
return {
hue: ((hue % 360) + 360) % 360,
saturation: Math.min(100, Math.max(0, saturation)) / 100,
lightness: Math.min(100, Math.max(0, lightness)) / 100,
};
};
const hslToRgb = ({ hue, saturation, lightness }: ParsedHslToken): [number, number, number] => {
if (saturation === 0) return [lightness, lightness, lightness];
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
const huePrime = hue / 60;
const x = chroma * (1 - Math.abs((huePrime % 2) - 1));
const [red, green, blue] =
huePrime < 1 ? [chroma, x, 0] :
huePrime < 2 ? [x, chroma, 0] :
huePrime < 3 ? [0, chroma, x] :
huePrime < 4 ? [0, x, chroma] :
huePrime < 5 ? [x, 0, chroma] :
[chroma, 0, x];
const match = lightness - chroma / 2;
return [red + match, green + match, blue + match];
};
const toLinearSrgb = (channel: number): number => (
channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
);
export const getHslTokenRelativeLuminance = (value: string): number | null => {
const parsed = parseHslToken(value);
if (!parsed) return null;
const [red, green, blue] = hslToRgb(parsed).map(toLinearSrgb);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
};
export const getContrastRatio = (foregroundLuminance: number, backgroundLuminance: number): number => {
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
const darker = Math.min(foregroundLuminance, backgroundLuminance);
return (lighter + 0.05) / (darker + 0.05);
};
export const resolveReadableForegroundForHsl = (
backgroundHsl: string,
fallback: string = WHITE_HSL,
): string => {
const backgroundLuminance = getHslTokenRelativeLuminance(backgroundHsl);
if (backgroundLuminance == null) return fallback;
const blackContrast = getContrastRatio(0, backgroundLuminance);
const whiteContrast = getContrastRatio(1, backgroundLuminance);
return whiteContrast >= blackContrast ? WHITE_HSL : BLACK_HSL;
};

View File

@@ -0,0 +1,23 @@
import commandBlocklistTable from '../lib/commandBlocklist.json';
const LEGACY_DEFAULT_PATTERNS = [
...commandBlocklistTable.common,
...commandBlocklistTable.posixNative,
...commandBlocklistTable.posix,
];
/**
* Add PowerShell defaults only to a complete pre-shell-aware list.
* If any PowerShell default is already present, the list has been upgraded or
* customized and is left as saved.
*/
export function migrateLegacyCommandBlocklist(blocklist: string[]): string[] {
const configured = new Set(blocklist);
if (!LEGACY_DEFAULT_PATTERNS.every((pattern) => configured.has(pattern))) {
return blocklist;
}
if (commandBlocklistTable.powershell.some((pattern) => configured.has(pattern))) {
return blocklist;
}
return [...blocklist, ...commandBlocklistTable.powershell];
}

View File

@@ -0,0 +1,195 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { ConnectionLog } from "./models.ts";
import { selectConnectionLogForTerminalDataCapture } from "./connectionLog.ts";
import {
MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES,
mergeConnectionLogsFromStorage,
mergeTerminalDataIntoLogs,
mergeTerminalDataMapsForStorage,
pruneTerminalDataMapForStorage,
} from "./connectionLogTerminalData.ts";
const baseLog: ConnectionLog = {
id: "log-base",
sessionId: "session-1",
hostId: "host-1",
hostLabel: "Example",
hostname: "example.com",
username: "user",
protocol: "ssh",
startTime: 1000,
localUsername: "local",
localHostname: "machine",
saved: false,
};
test("selectConnectionLogForTerminalDataCapture picks the active log for a normal session exit", () => {
const matchingLog = { ...baseLog, id: "active", startTime: 2000 };
const staleLog = {
...baseLog,
id: "stale",
sessionId: "session-2",
startTime: 3000,
};
assert.equal(
selectConnectionLogForTerminalDataCapture(
[staleLog, matchingLog],
{ sessionId: "session-1", hostname: "example.com" },
)?.id,
"active",
);
});
test("selectConnectionLogForTerminalDataCapture reuses the latest log for repeated captures after reconnect", () => {
const firstCapture = {
...baseLog,
id: "first-capture",
startTime: 2000,
endTime: 2500,
terminalData: "first disconnect",
};
const olderSameSession = {
...baseLog,
id: "older-same-session",
startTime: 1500,
endTime: 1800,
terminalData: "older data",
};
const otherSession = {
...baseLog,
id: "other-session",
sessionId: "session-2",
startTime: 3000,
};
assert.equal(
selectConnectionLogForTerminalDataCapture(
[otherSession, olderSameSession, firstCapture],
{ sessionId: "session-1", hostname: "example.com" },
)?.id,
"first-capture",
);
});
test("selectConnectionLogForTerminalDataCapture does not cross-match localhost logs without sessionId", () => {
const openLocalWithoutSession = {
...baseLog,
id: "open-local",
sessionId: undefined,
hostname: "localhost",
protocol: "local",
startTime: 3000,
};
const targetLocal = {
...baseLog,
id: "target-local",
sessionId: "session-local-a",
hostname: "localhost",
protocol: "local",
startTime: 2000,
};
assert.equal(
selectConnectionLogForTerminalDataCapture(
[openLocalWithoutSession, targetLocal],
{ sessionId: "session-local-b", hostname: "localhost" },
),
undefined,
);
});
test("mergeConnectionLogsFromStorage keeps in-memory terminal replay data", () => {
const memoryLog = {
...baseLog,
id: "memory",
terminalData: "captured output",
};
const storedLog = {
...baseLog,
id: "memory",
endTime: 2000,
};
const merged = mergeConnectionLogsFromStorage(
[memoryLog],
[storedLog],
{},
);
assert.equal(merged[0]?.terminalData, "captured output");
});
test("mergeTerminalDataIntoLogs hydrates unsaved logs from side storage", () => {
const storedLog = { ...baseLog, id: "hydrate" };
const hydrated = mergeTerminalDataIntoLogs([storedLog], {
hydrate: "side-store output",
});
assert.equal(hydrated[0]?.terminalData, "side-store output");
});
test("pruneTerminalDataMapForStorage caps unsaved replay buffers", () => {
const logs: ConnectionLog[] = Array.from({ length: 60 }, (_, index) => ({
...baseLog,
id: `log-${index}`,
startTime: index,
saved: false,
}));
const map = Object.fromEntries(
logs.map((log) => [log.id, `data-${log.id}`]),
);
const pruned = pruneTerminalDataMapForStorage(logs, map);
assert.equal(Object.keys(pruned).length, MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES);
assert.equal(pruned["log-59"], "data-log-59");
assert.equal(pruned["log-0"], undefined);
});
test("mergeTerminalDataMapsForStorage keeps replay data from other windows", () => {
const logs: ConnectionLog[] = [
{ ...baseLog, id: "local", startTime: 2000 },
{ ...baseLog, id: "remote", startTime: 1000 },
];
const merged = mergeTerminalDataMapsForStorage(
logs,
{ remote: "remote-window output", other: "other-window only" },
[{ local: "local-window output" }],
new Set(["local", "remote", "other"]),
);
assert.equal(merged.remote, "remote-window output");
assert.equal(merged.local, "local-window output");
assert.equal(merged.other, "other-window only");
});
test("mergeTerminalDataMapsForStorage prefers fresher local replay data for orphans", () => {
const logs: ConnectionLog[] = [{ ...baseLog, id: "local", startTime: 2000 }];
const merged = mergeTerminalDataMapsForStorage(
logs,
{ other: "stale snapshot" },
[{ other: "fresh local" }],
new Set(["local", "other"]),
);
assert.equal(merged.other, "fresh local");
});
test("mergeTerminalDataMapsForStorage drops deleted log replay buffers", () => {
const logs: ConnectionLog[] = [{ ...baseLog, id: "local", startTime: 2000 }];
const merged = mergeTerminalDataMapsForStorage(
logs,
{ local: "keep", deleted: "drop me" },
[],
new Set(["local"]),
);
assert.equal(merged.local, "keep");
assert.equal(merged.deleted, undefined);
});

30
domain/connectionLog.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { ConnectionLog } from "./models.ts";
interface TerminalDataCaptureTarget {
sessionId: string;
hostname?: string;
}
export const selectConnectionLogForTerminalDataCapture = (
connectionLogs: ConnectionLog[],
target: TerminalDataCaptureTarget,
): ConnectionLog | undefined => {
if (target.sessionId) {
const sessionMatches = connectionLogs
.filter((log) => log.sessionId === target.sessionId)
.sort((a, b) => b.startTime - a.startTime);
const openLog = sessionMatches.find((log) => !log.endTime && !log.terminalData);
if (openLog) return openLog;
return sessionMatches[0];
}
// Legacy logs created without sessionId (e.g. old hotkey local terminals).
return connectionLogs
.filter((log) => {
if (log.endTime || log.terminalData || log.sessionId) return false;
return !!target.hostname && log.hostname === target.hostname;
})
.sort((a, b) => b.startTime - a.startTime)[0];
};

View File

@@ -0,0 +1,115 @@
import type { ConnectionLog } from "./models.ts";
/** Max unsaved connection logs whose terminal replay data we persist separately. */
export const MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES = 50;
export type ConnectionLogTerminalDataMap = Record<string, string>;
export const readTerminalDataFromLog = (log: ConnectionLog): string | undefined =>
log.terminalData;
export const mergeTerminalDataIntoLogs = (
logs: ConnectionLog[],
terminalDataMap: ConnectionLogTerminalDataMap,
): ConnectionLog[] => {
if (logs.length === 0) return logs;
let changed = false;
const next = logs.map((log) => {
if (log.terminalData) return log;
const sideData = terminalDataMap[log.id];
if (!sideData) return log;
changed = true;
return { ...log, terminalData: sideData };
});
return changed ? next : logs;
};
/**
* When another window or a pruned localStorage write reloads connection logs,
* keep in-memory / side-store terminal replay data instead of wiping it.
*/
export const mergeConnectionLogsFromStorage = (
prev: ConnectionLog[],
next: ConnectionLog[],
terminalDataMap: ConnectionLogTerminalDataMap,
): ConnectionLog[] => {
if (next.length === 0) return next;
const prevById = new Map(prev.map((log) => [log.id, log]));
let changed = false;
const merged = next.map((log) => {
const memoryData = readTerminalDataFromLog(prevById.get(log.id) ?? log);
const sideData = terminalDataMap[log.id];
const terminalData = memoryData ?? readTerminalDataFromLog(log) ?? sideData;
if (!terminalData || log.terminalData === terminalData) return log;
changed = true;
return { ...log, terminalData };
});
return changed ? merged : next;
};
export const buildTerminalDataMapFromLogs = (
logs: ConnectionLog[],
): ConnectionLogTerminalDataMap => {
const map: ConnectionLogTerminalDataMap = {};
for (const log of logs) {
const data = readTerminalDataFromLog(log);
if (data) map[log.id] = data;
}
return map;
};
/**
* Keep only unsaved logs' terminal data, capped to the most recent entries.
* Saved logs keep terminalData in the main connection log blob when bookmarked.
*/
export const pruneTerminalDataMapForStorage = (
logs: ConnectionLog[],
map: ConnectionLogTerminalDataMap,
): ConnectionLogTerminalDataMap => {
const unsavedIds = logs
.filter((log) => !log.saved)
.sort((a, b) => b.startTime - a.startTime)
.slice(0, MAX_PERSISTED_UNSAVED_TERMINAL_DATA_ENTRIES)
.map((log) => log.id);
const allowed = new Set(unsavedIds);
for (const log of logs) {
if (log.saved && map[log.id]) {
allowed.add(log.id);
}
}
const next: ConnectionLogTerminalDataMap = {};
for (const id of allowed) {
if (map[id]) next[id] = map[id];
}
return next;
};
/** Fold side-store maps together, then cap to the allowed unsaved/saved set. */
export const mergeTerminalDataMapsForStorage = (
logs: ConnectionLog[],
persistedSnapshot: ConnectionLogTerminalDataMap,
localMaps: ConnectionLogTerminalDataMap[],
persistedLogIds: ReadonlySet<string>,
): ConnectionLogTerminalDataMap => {
const combined: ConnectionLogTerminalDataMap = { ...persistedSnapshot };
for (const map of localMaps) {
for (const [id, data] of Object.entries(map)) {
if (data) combined[id] = data;
}
}
const pruned = pruneTerminalDataMapForStorage(logs, combined);
// Retain replay buffers only for log ids still present in the persisted
// connection-log blob but not yet loaded into this window's React state.
for (const id of persistedLogIds) {
if (!logs.some((log) => log.id === id) && combined[id]) {
pruned[id] = combined[id];
}
}
return pruned;
};

View File

@@ -0,0 +1,98 @@
import type {
Dot,
HybridLogicalClock,
VersionVector,
} from './types';
import { ConvergentSyncInvariantError } from './types';
import { getOwnRecordValue, setOwnRecordValue } from './record';
export function compareStrings(left: string, right: string): number {
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
export function compareDots(left: Dot, right: Dot): number {
const deviceOrder = compareStrings(left.deviceId, right.deviceId);
return deviceOrder !== 0 ? deviceOrder : left.counter - right.counter;
}
export function dotKey(dot: Dot): string {
return `${dot.deviceId}:${dot.counter}`;
}
export function observesDot(vector: VersionVector, dot: Dot): boolean {
return (getOwnRecordValue(vector, dot.deviceId) ?? 0) >= dot.counter;
}
export function mergeVersionVectors(
left: VersionVector,
right: VersionVector,
): VersionVector {
const merged: VersionVector = {};
const deviceIds = new Set([...Object.keys(left), ...Object.keys(right)]);
for (const deviceId of [...deviceIds].sort()) {
const counter = Math.max(
getOwnRecordValue(left, deviceId) ?? 0,
getOwnRecordValue(right, deviceId) ?? 0,
);
if (counter > 0) setOwnRecordValue(merged, deviceId, counter);
}
return merged;
}
/**
* Returns true when `candidate` has observed every write represented by
* `expected`. Extra counters in `candidate` are allowed: they represent a
* remote superset that must be joined and propagated, not a failed write.
*/
export function versionVectorDominates(
candidate: VersionVector,
expected: VersionVector,
): boolean {
return Object.keys(expected).every(
(deviceId) => (getOwnRecordValue(candidate, deviceId) ?? 0)
>= (getOwnRecordValue(expected, deviceId) ?? 0),
);
}
export function versionVectorsEqual(
left: VersionVector,
right: VersionVector,
): boolean {
return versionVectorDominates(left, right) && versionVectorDominates(right, left);
}
export function compareHybridLogicalClocks(
left: HybridLogicalClock,
right: HybridLogicalClock,
): number {
if (left.wallTime !== right.wallTime) return left.wallTime - right.wallTime;
return left.logical - right.logical;
}
export function maxHybridLogicalClock(
left: HybridLogicalClock,
right: HybridLogicalClock,
): HybridLogicalClock {
return compareHybridLogicalClocks(left, right) >= 0
? { ...left }
: { ...right };
}
export function tickHybridLogicalClock(
current: HybridLogicalClock,
now: number,
): HybridLogicalClock {
const safeNow = Number.isFinite(now) ? Math.max(0, Math.floor(now)) : 0;
if (!Number.isSafeInteger(safeNow)) {
throw new ConvergentSyncInvariantError('Hybrid logical clock wall time is out of range');
}
if (safeNow > current.wallTime) {
return { wallTime: safeNow, logical: 0 };
}
if (current.logical >= Number.MAX_SAFE_INTEGER) {
throw new ConvergentSyncInvariantError('Hybrid logical clock counter exhausted');
}
return { wallTime: current.wallTime, logical: current.logical + 1 };
}

View File

@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { dotKey } from './clock.ts';
import {
isConvergentConflictSecret,
resolveConvergentFieldConflict,
} from './conflicts.ts';
import { createConvergentSyncStateFromPayload } from './payload.ts';
import { applyLegacySyncPayload } from './legacy.ts';
import { materializeConvergentSyncState, mergeConvergentSyncStates } from './state.ts';
import type { SyncPayload } from '../sync.ts';
function payload(label: string): SyncPayload {
return {
hosts: [{ id: 'h', label, hostname: 'example.com', port: 22, username: 'root', tags: [], os: 'linux' }],
keys: [], snippets: [], customGroups: [], syncedAt: 0,
};
}
test('field conflict resolution writes a causal value over every candidate', () => {
const basePayload = payload('base');
const base = createConvergentSyncStateFromPayload(basePayload, 'seed', 1);
const left = applyLegacySyncPayload(base, basePayload, payload('left'), 'left', 2);
const right = applyLegacySyncPayload(base, basePayload, payload('right'), 'right', 3);
const merged = mergeConvergentSyncStates(left, right);
const conflict = materializeConvergentSyncState(merged).conflicts.find(
(entry) => entry.address.kind === 'entity-field' && entry.address.field === 'label',
)!;
const selected = conflict.candidates.find((candidate) => candidate.value === 'left')!;
const resolved = resolveConvergentFieldConflict(
merged,
conflict,
dotKey(selected.dot),
'resolver',
4,
);
assert.equal(materializeConvergentSyncState(resolved).conflicts.length, 0);
assert.equal(materializeConvergentSyncState(resolved).collections.hosts[0]?.label, 'left');
});
test('secret conflicts are identified from paths and nested candidate keys', () => {
assert.equal(isConvergentConflictSecret({
address: { kind: 'entity-field', collection: 'keys', entityId: 'k', field: 'privateKey' },
candidates: [],
}), true);
assert.equal(isConvergentConflictSecret({
address: { kind: 'entity-field', collection: 'hosts', entityId: 'h', field: 'proxyConfig' },
candidates: [{
dot: { deviceId: 'a', counter: 1 },
hlc: { wallTime: 1, logical: 0 },
tombstone: false,
value: { password: 'do-not-render' },
selected: true,
}],
}), true);
assert.equal(isConvergentConflictSecret({
address: { kind: 'setting', path: ['ai', 'providers'] },
candidates: [{
dot: { deviceId: 'a', counter: 2 },
hlc: { wallTime: 2, logical: 0 },
tombstone: false,
value: [{ id: 'provider-1', credentials: { apiKey: 'nested-do-not-render' } }],
selected: true,
}],
}), true);
});

View File

@@ -0,0 +1,80 @@
import { dotKey } from './clock';
import { applyConvergentMutations } from './state';
import type {
ConvergentConflictAddress,
ConvergentConflictCandidate,
ConvergentFieldConflict,
ConvergentSyncStateV2,
JsonValue,
RegisterAddress,
} from './types';
export function convergentConflictAddressKey(address: ConvergentConflictAddress): string {
switch (address.kind) {
case 'entity-presence':
case 'entity-position':
return JSON.stringify([address.kind, address.collection, address.entityId]);
case 'entity-field':
return JSON.stringify([address.kind, address.collection, address.entityId, address.field]);
case 'setting':
return JSON.stringify([address.kind, ...address.path]);
case 'setting-structure':
return JSON.stringify([address.kind, ...address.paths]);
case 'string-entry-presence':
case 'string-entry-position':
return JSON.stringify([address.kind, address.collection, address.value]);
}
}
function selectedAddress(
conflict: ConvergentFieldConflict,
candidate: ConvergentConflictCandidate,
): RegisterAddress {
if (conflict.address.kind !== 'setting-structure') return conflict.address;
if (!candidate.settingPath?.length) {
throw new Error('A setting-structure candidate must identify its setting path');
}
return { kind: 'setting', path: candidate.settingPath };
}
export function resolveConvergentFieldConflict(
state: ConvergentSyncStateV2,
conflict: ConvergentFieldConflict,
candidateDot: string,
deviceId: string,
now: number,
): ConvergentSyncStateV2 {
const candidate = conflict.candidates.find((entry) => dotKey(entry.dot) === candidateDot);
if (!candidate) throw new Error('The selected convergent conflict candidate no longer exists');
if (!candidate.tombstone && candidate.value === undefined) {
throw new Error('The selected convergent conflict candidate has no value');
}
return applyConvergentMutations(state, deviceId, [{
kind: 'resolve-register',
address: selectedAddress(conflict, candidate),
...(candidate.tombstone ? { tombstone: true } : { value: candidate.value }),
}], now);
}
const SECRET_SEGMENT = /(?:password|passphrase|privatekey|secret|token|api[_-]?key|access[_-]?key)/i;
function valueContainsSecretField(value: JsonValue | undefined): boolean {
if (!value || typeof value !== 'object') return false;
if (Array.isArray(value)) return value.some((nested) => valueContainsSecretField(nested));
return Object.entries(value).some(([key, nested]) =>
SECRET_SEGMENT.test(key) || valueContainsSecretField(nested));
}
/** Values from secret-bearing registers must never be rendered or logged. */
export function isConvergentConflictSecret(conflict: ConvergentFieldConflict): boolean {
const { address } = conflict;
if (address.kind === 'entity-field' && SECRET_SEGMENT.test(address.field)) return true;
if (address.kind === 'setting' && address.path.some((segment) => SECRET_SEGMENT.test(segment))) {
return true;
}
if (
address.kind === 'setting-structure'
&& address.paths.some((path) => path.some((segment) => SECRET_SEGMENT.test(segment)))
) return true;
return conflict.candidates.some((candidate) => valueContainsSecretField(candidate.value));
}

View File

@@ -0,0 +1,211 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import fc from 'fast-check';
import {
applyConvergentMutations,
createConvergentSyncState,
materializeConvergentSyncState,
mergeConvergentSyncStates,
serializeConvergentSyncState,
type ConvergentMutation,
type JsonValue,
} from './index.ts';
const jsonValueArbitrary: fc.Arbitrary<JsonValue> = fc.oneof(
fc.string({ maxLength: 20 }),
fc.integer({ min: -1_000, max: 1_000 }),
fc.boolean(),
fc.array(fc.integer({ min: 0, max: 20 }), { maxLength: 5 }),
fc.record({ enabled: fc.boolean(), label: fc.string({ maxLength: 10 }) }),
);
const settingPathArbitrary: fc.Arbitrary<string[]> = fc.constantFrom(
'theme',
'terminalRoot',
'fontSize',
'palette',
).map((value) => {
if (value === 'theme') return ['theme'];
if (value === 'terminalRoot') return ['terminal'];
return ['terminal', value];
});
const mutationArbitrary: fc.Arbitrary<ConvergentMutation> = fc.oneof(
fc.record({
kind: fc.constant<'setting-set'>('setting-set'),
path: settingPathArbitrary,
value: jsonValueArbitrary,
}),
fc.record({
kind: fc.constant<'setting-delete'>('setting-delete'),
path: settingPathArbitrary,
}),
fc.record({
kind: fc.constant<'entity-field-set'>('entity-field-set'),
collection: fc.constant('hosts'),
entityId: fc.constantFrom('host-0', 'host-1', 'host-2'),
field: fc.constantFrom('label', 'hostname', 'tags'),
value: jsonValueArbitrary,
}),
fc.record({
kind: fc.constant<'entity-delete'>('entity-delete'),
collection: fc.constant('hosts'),
entityId: fc.constantFrom('host-0', 'host-1', 'host-2'),
}),
fc.record({
kind: fc.constant<'string-entry-add'>('string-entry-add'),
collection: fc.constant('customGroups'),
value: fc.constantFrom('alpha', 'beta', 'gamma'),
position: fc.integer({ min: 0, max: 10 }),
}),
fc.record({
kind: fc.constant<'string-entry-delete'>('string-entry-delete'),
collection: fc.constant('customGroups'),
value: fc.constantFrom('alpha', 'beta', 'gamma'),
}),
);
const mutationListArbitrary = fc.array(mutationArbitrary, { maxLength: 16 });
function replica(deviceId: string, mutations: ConvergentMutation[], time: number) {
return applyConvergentMutations(
createConvergentSyncState(),
deviceId,
mutations,
time,
);
}
test('merge is commutative', () => {
fc.assert(fc.property(
mutationListArbitrary,
mutationListArbitrary,
(leftMutations, rightMutations) => {
const left = replica('device-a', leftMutations, 100);
const right = replica('device-b', rightMutations, 100);
const leftRight = mergeConvergentSyncStates(left, right);
const rightLeft = mergeConvergentSyncStates(right, left);
assert.equal(
serializeConvergentSyncState(leftRight),
serializeConvergentSyncState(rightLeft),
);
assert.deepEqual(
materializeConvergentSyncState(leftRight),
materializeConvergentSyncState(rightLeft),
);
},
), { numRuns: 150 });
});
test('merge is associative', () => {
fc.assert(fc.property(
mutationListArbitrary,
mutationListArbitrary,
mutationListArbitrary,
(aMutations, bMutations, cMutations) => {
const a = replica('device-a', aMutations, 100);
const b = replica('device-b', bMutations, 100);
const c = replica('device-c', cMutations, 100);
const leftGrouped = mergeConvergentSyncStates(
mergeConvergentSyncStates(a, b),
c,
);
const rightGrouped = mergeConvergentSyncStates(
a,
mergeConvergentSyncStates(b, c),
);
assert.equal(
serializeConvergentSyncState(leftGrouped),
serializeConvergentSyncState(rightGrouped),
);
},
), { numRuns: 120 });
});
test('merge is idempotent', () => {
fc.assert(fc.property(mutationListArbitrary, (mutations) => {
const state = replica('device-a', mutations, 100);
assert.equal(
serializeConvergentSyncState(mergeConvergentSyncStates(state, state)),
serializeConvergentSyncState(state),
);
}), { numRuns: 150 });
});
test('causal parent deletion removes every generated descendant', () => {
fc.assert(fc.property(
fc.array(
fc.tuple(
fc.constantFrom('fontSize', 'fontFamily', 'palette', 'cursor'),
jsonValueArbitrary,
),
{ minLength: 1, maxLength: 12 },
),
(leaves) => {
const populated = applyConvergentMutations(
createConvergentSyncState(),
'device-a',
leaves.map(([leaf, value]) => ({
kind: 'setting-set' as const,
path: ['terminal', leaf],
value,
})),
100,
);
const deleted = applyConvergentMutations(populated, 'device-a', [{
kind: 'setting-delete',
path: ['terminal'],
}], 101);
assert.equal(
Object.hasOwn(materializeConvergentSyncState(deleted).settings, 'terminal'),
false,
);
assert.equal(
Object.hasOwn(
materializeConvergentSyncState(
mergeConvergentSyncStates(populated, deleted),
).settings,
'terminal',
),
false,
);
},
), { numRuns: 100 });
});
test('2-20 offline replicas converge across reordering, partitions, and duplicates', () => {
fc.assert(fc.property(
fc.array(mutationListArbitrary, { minLength: 2, maxLength: 20 }),
fc.array(fc.integer(), { minLength: 20, maxLength: 20 }),
(replicaMutations, orderKeys) => {
const replicas = replicaMutations.map((mutations, index) =>
replica(`device-${index.toString().padStart(2, '0')}`, mutations, 100 + index),
);
const baseline = replicas.reduce(mergeConvergentSyncStates);
const order = replicas
.map((state, index) => ({ state, key: orderKeys[index] ?? index }))
.sort((left, right) => left.key - right.key)
.map((item) => item.state);
const reordered = order.reduce(mergeConvergentSyncStates);
const split = Math.max(1, Math.floor(order.length / 2));
const leftPartition = order.slice(0, split).reduce(mergeConvergentSyncStates);
const rightPartition = order.slice(split).reduce(mergeConvergentSyncStates);
const partitioned = mergeConvergentSyncStates(
mergeConvergentSyncStates(leftPartition, leftPartition),
mergeConvergentSyncStates(rightPartition, rightPartition),
);
assert.equal(
serializeConvergentSyncState(reordered),
serializeConvergentSyncState(baseline),
);
assert.equal(
serializeConvergentSyncState(partitioned),
serializeConvergentSyncState(baseline),
);
},
), { numRuns: 60 });
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
export * from './clock';
export * from './conflicts';
export * from './json';
export * from './legacy';
export * from './migration';
export * from './payload';
export * from './record';
export * from './register';
export * from './registerId';
export * from './serialization';
export * from './state';
export * from './types';

View File

@@ -0,0 +1,62 @@
import type { JsonValue } from './types';
export function isJsonValue(value: unknown): value is JsonValue {
if (
value === null
|| typeof value === 'string'
|| typeof value === 'boolean'
) {
return true;
}
if (typeof value === 'number') return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue);
if (!value || typeof value !== 'object') return false;
return Object.values(value).every(isJsonValue);
}
/** Normalize in-memory model values exactly as the encrypted JSON payload does. */
export function normalizeJsonValue(value: unknown): JsonValue {
const serialized = JSON.stringify(value);
if (serialized === undefined) {
throw new TypeError('Value cannot be represented as JSON');
}
const normalized: unknown = JSON.parse(serialized);
if (!isJsonValue(normalized)) {
throw new TypeError('Value cannot be represented as JSON');
}
return normalized;
}
export function cloneJson<T extends JsonValue>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneJson(item)) as T;
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, nested]) => [key, cloneJson(nested)]),
) as T;
}
return value;
}
export function canonicalizeJson<T extends JsonValue>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => canonicalizeJson(item)) as T;
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, canonicalizeJson(value[key])]),
) as T;
}
return value;
}
export function canonicalJsonString(value: JsonValue): string {
return JSON.stringify(canonicalizeJson(value));
}
export function jsonValuesEqual(left: JsonValue, right: JsonValue): boolean {
return canonicalJsonString(left) === canonicalJsonString(right);
}

View File

@@ -0,0 +1,231 @@
import { withHostsSanitizedForSync, type SyncPayload } from '../sync';
import { normalizeJsonValue } from './json';
import { encodeSettingPath } from './serialization';
import { applyConvergentMutations } from './state';
import type {
ConvergentMutation,
ConvergentSyncStateV2,
JsonValue,
} from './types';
import {
CONVERGENT_ENTITY_COLLECTIONS,
CONVERGENT_STRING_COLLECTIONS,
} from './payload';
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function stableValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableValue);
if (isRecord(value)) {
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, stableValue(value[key])]),
);
}
return value;
}
function fingerprint(value: unknown): string {
return JSON.stringify(stableValue(value));
}
function hasDefinedOwnProperty(payload: SyncPayload, property: string): boolean {
const record = payload as unknown as Record<string, unknown>;
return Object.prototype.hasOwnProperty.call(record, property)
&& record[property] !== undefined;
}
/** Preserve fields that an older client did not provide for safety checks. */
export function inheritOmittedLegacySyncFields(
baseline: SyncPayload,
legacy: SyncPayload,
): SyncPayload {
const result = { ...legacy } as SyncPayload;
const resultRecord = result as unknown as Record<string, unknown>;
const baselineRecord = baseline as unknown as Record<string, unknown>;
for (const property of [
...CONVERGENT_ENTITY_COLLECTIONS,
...CONVERGENT_STRING_COLLECTIONS,
'settings',
]) {
if (!hasDefinedOwnProperty(legacy, property)) {
resultRecord[property] = baselineRecord[property];
}
}
return result;
}
function normalizedEntityValue(
collection: string,
id: string,
value: Record<string, unknown>,
): Extract<ConvergentMutation, { kind: 'entity-upsert' }>['value'] {
try {
const normalized = normalizeJsonValue({ ...value, id });
if (!isRecord(normalized)) throw new TypeError('Entity is not an object');
return normalized as Extract<ConvergentMutation, { kind: 'entity-upsert' }>['value'];
} catch {
throw new Error(`${collection}/${id} contains a value that cannot be represented as JSON`);
}
}
function entityId(collection: string, value: Record<string, unknown>): string | undefined {
const id = collection === 'groupConfigs' ? value.path : value.id;
return typeof id === 'string' && id.length > 0 ? id : undefined;
}
function entityMap(payload: SyncPayload, collection: string): Map<string, Record<string, unknown>> {
const values = (payload as unknown as Record<string, unknown>)[collection];
const result = new Map<string, Record<string, unknown>>();
if (!Array.isArray(values)) return result;
for (const value of values) {
if (!isRecord(value)) continue;
const id = entityId(collection, value);
if (id) result.set(id, value);
}
return result;
}
function stringSet(payload: SyncPayload, collection: string): Set<string> {
const values = (payload as unknown as Record<string, unknown>)[collection];
return new Set(Array.isArray(values) ? values.filter((value): value is string => typeof value === 'string') : []);
}
function positionMap(values: Iterable<string>): Map<string, number> {
const positions = new Map<string, number>();
let position = 0;
for (const value of values) {
positions.set(value, position);
position += 1;
}
return positions;
}
function flattenSettings(
value: unknown,
path: string[] = [],
output: Map<string, { path: string[]; value: JsonValue }> = new Map(),
): Map<string, { path: string[]; value: JsonValue }> {
if (isRecord(value) && Object.keys(value).length > 0) {
for (const key of Object.keys(value).sort()) {
flattenSettings(value[key], [...path, key], output);
}
} else if (path.length > 0 && value !== undefined) {
output.set(encodeSettingPath(path), { path, value: value as JsonValue });
}
return output;
}
/** Compare only cloud materialized data; timestamps and reliability metadata are transport details. */
export function cloudSyncPayloadsEqual(left: SyncPayload, right: SyncPayload): boolean {
const project = (payload: SyncPayload) => {
const sanitized = withHostsSanitizedForSync(payload);
return {
...Object.fromEntries(
[...CONVERGENT_ENTITY_COLLECTIONS, ...CONVERGENT_STRING_COLLECTIONS]
.map((key) => [key, (sanitized as unknown as Record<string, unknown>)[key] ?? []]),
),
settings: sanitized.settings ?? {},
// Plugin sidecars are host-owned opaque data on the encrypted blob and
// must participate in migration freshness / equality checks.
pluginSidecars: sanitized.pluginSidecars ?? { version: 1, entries: [] },
};
};
return fingerprint(project(left)) === fingerprint(project(right));
}
/**
* Convert a trusted v1 baseline diff into deterministic CRDT writes. A missing
* or undefined optional top-level collection is treated as "unsupported by
* that client", while an explicitly present empty collection is a real
* deletion.
*/
export function diffLegacySyncPayload(
baseline: SyncPayload,
legacy: SyncPayload,
): ConvergentMutation[] {
const mutations: ConvergentMutation[] = [];
const sanitizedBaseline = withHostsSanitizedForSync(baseline);
const sanitizedLegacy = withHostsSanitizedForSync(legacy);
for (const collection of CONVERGENT_ENTITY_COLLECTIONS) {
if (!hasDefinedOwnProperty(legacy, collection)) continue;
const before = entityMap(sanitizedBaseline, collection);
const after = entityMap(sanitizedLegacy, collection);
const beforePositions = positionMap(before.keys());
const afterPositions = positionMap(after.keys());
const ids = new Set([...before.keys(), ...after.keys()]);
for (const id of [...ids].sort()) {
const previous = before.get(id);
const next = after.get(id);
if (previous && !next) {
mutations.push({ kind: 'entity-delete', collection, entityId: id });
} else if (
next
&& (
!previous
|| fingerprint(previous) !== fingerprint(next)
|| beforePositions.get(id) !== afterPositions.get(id)
)
) {
mutations.push({
kind: 'entity-upsert',
collection,
entityId: id,
value: normalizedEntityValue(collection, id, next),
position: afterPositions.get(id),
});
}
}
}
for (const collection of CONVERGENT_STRING_COLLECTIONS) {
if (!hasDefinedOwnProperty(legacy, collection)) continue;
const before = stringSet(baseline, collection);
const after = stringSet(legacy, collection);
const beforePositions = positionMap(before);
const afterPositions = positionMap(after);
for (const value of [...before].sort()) {
if (!after.has(value)) mutations.push({ kind: 'string-entry-delete', collection, value });
}
for (const value of [...after].sort()) {
if (!before.has(value) || beforePositions.get(value) !== afterPositions.get(value)) {
mutations.push({
kind: 'string-entry-add',
collection,
value,
position: afterPositions.get(value),
});
}
}
}
if (hasDefinedOwnProperty(legacy, 'settings')) {
const before = flattenSettings(baseline.settings);
const after = flattenSettings(legacy.settings);
const paths = new Set([...before.keys(), ...after.keys()]);
for (const encodedPath of [...paths].sort()) {
const previous = before.get(encodedPath);
const next = after.get(encodedPath);
if (previous && !next) {
mutations.push({ kind: 'setting-delete', path: previous.path });
} else if (next && (!previous || fingerprint(previous.value) !== fingerprint(next.value))) {
mutations.push({ kind: 'setting-set', path: next.path, value: next.value });
}
}
}
return mutations;
}
export function applyLegacySyncPayload(
state: ConvergentSyncStateV2,
baseline: SyncPayload,
legacy: SyncPayload,
syntheticDeviceId: string,
now: number,
): ConvergentSyncStateV2 {
return applyConvergentMutations(
state,
syntheticDeviceId,
diffLegacySyncPayload(baseline, legacy),
now,
);
}

View File

@@ -0,0 +1,384 @@
import {
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
hasSyncPayloadEntityData,
type CloudProvider,
type ConvergentMigrationPreview,
type ConvergentProviderMigrationStatus,
type SyncFileMeta,
type SyncPayload,
} from '../sync';
import { detectSuspiciousShrink } from '../syncGuards';
import { mergeSyncPayloads } from '../syncMerge';
import { summarizeSyncChanges } from '../syncReliability';
import { mergeConvergentSyncStates, materializeConvergentSyncState } from './state';
import type { ConvergentSyncStateV2 } from './types';
import {
cloudSyncPayloadsEqual,
applyLegacySyncPayload,
inheritOmittedLegacySyncFields,
} from './legacy';
import {
CONVERGENT_ENTITY_COLLECTIONS,
CONVERGENT_STRING_COLLECTIONS,
createConvergentSyncStateFromPayload,
hydrateConvergentSyncEnvelope,
materializeSyncPayloadFromConvergentState,
withConvergentSyncEnvelope,
} from './payload';
import {
mergePluginSyncSidecars,
mergePluginSyncSidecarsThreeWay,
} from '../pluginSyncSidecar';
/** LWW-union plugin sidecars from every migration input that carries them. */
function mergeMigrationSidecars(
...bundles: Array<SyncPayload['pluginSidecars'] | null | undefined>
): SyncPayload['pluginSidecars'] | undefined {
let entries: NonNullable<SyncPayload['pluginSidecars']>['entries'] = [];
let sawAny = false;
for (const bundle of bundles) {
if (!bundle || !Array.isArray(bundle.entries)) continue;
sawAny = true;
entries = mergePluginSyncSidecars({ local: entries, remote: bundle });
}
return sawAny ? { version: 1, entries } : undefined;
}
/**
* Three-way merge local sidecars against each remote source using that
* source's trusted baseline (falls back to local baseline). Preserves
* explicit local deletions instead of resurrecting them via LWW union.
*/
function mergeMigrationSidecarsWithBaselines(options: {
local: SyncPayload['pluginSidecars'] | null | undefined;
localBaseline: SyncPayload['pluginSidecars'] | null | undefined;
sources: Array<{
remote: SyncPayload['pluginSidecars'] | null | undefined;
baseline: SyncPayload['pluginSidecars'] | null | undefined;
}>;
}): SyncPayload['pluginSidecars'] | undefined {
let entries = Array.isArray(options.local?.entries) ? [...options.local.entries] : [];
const localBase = Array.isArray(options.localBaseline?.entries)
? options.localBaseline.entries
: [];
let sawAny = Array.isArray(options.local?.entries);
for (const source of options.sources) {
if (!source.remote || !Array.isArray(source.remote.entries)) continue;
sawAny = true;
const base = Array.isArray(source.baseline?.entries)
? source.baseline.entries
: localBase;
entries = mergePluginSyncSidecarsThreeWay({
base,
local: entries,
remote: source.remote.entries,
});
}
if (!sawAny) return undefined;
return { version: 1, entries };
}
/*
* A local snapshot with no cloud entities and no trusted base is a fresh
* device, not an untrusted deletion. Settings are intentionally ignored here
* because first-launch defaults must not prevent adoption of an existing v2
* vault. Once a trusted base exists, an empty snapshot remains a real deletion.
*/
function shouldIncludeLegacyLocalSource(
payload: SyncPayload,
trustedBaseline: SyncPayload | null,
): boolean {
return trustedBaseline !== null
|| hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS);
}
export type ConvergentMigrationProviderInput =
| { provider: CloudProvider; status: 'empty' }
| { provider: CloudProvider; status: 'unavailable'; message: string }
| {
provider: CloudProvider;
status: 'ready';
meta: SyncFileMeta;
payload: SyncPayload;
trustedBaseline: SyncPayload | null;
};
export interface ConvergentMigrationPlan {
preview: ConvergentMigrationPreview;
state: ConvergentSyncStateV2 | null;
payload: SyncPayload | null;
}
function runtimeSchema(meta: SyncFileMeta): 1 | 2 | 'future' | 'invalid' {
const value = (meta as { syncSchemaVersion?: unknown }).syncSchemaVersion;
if (value === undefined) return 1;
if (value === 2) return 2;
if (typeof value === 'number' && Number.isInteger(value) && value > 2) return 'future';
return 'invalid';
}
function countSettingsLeaves(value: unknown, root = true): number {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value === undefined ? 0 : 1;
const entries = Object.values(value as Record<string, unknown>);
if (entries.length === 0) return root ? 0 : 1;
return entries.reduce<number>(
(total, child) => total + countSettingsLeaves(child, false),
0,
);
}
function entityCount(payload: SyncPayload, key: string): number {
const value = (payload as unknown as Record<string, unknown>)[key];
return Array.isArray(value) ? value.length : 0;
}
function statusFor(
input: ConvergentMigrationProviderInput,
schemaVersion: ConvergentProviderMigrationStatus['schemaVersion'],
status: ConvergentProviderMigrationStatus['status'],
message?: string,
): ConvergentProviderMigrationStatus {
return {
provider: input.provider,
status,
schemaVersion,
entityCount: input.status === 'ready'
? [...CONVERGENT_ENTITY_COLLECTIONS, ...CONVERGENT_STRING_COLLECTIONS]
.reduce((total, key) => total + entityCount(input.payload, key), 0)
: 0,
hasTrustedBaseline: input.status === 'ready' && input.trustedBaseline !== null,
...(message ? { message } : {}),
};
}
export function planConvergentSyncMigration(options: {
localPayload: SyncPayload;
localTrustedBaseline: SyncPayload | null;
providers: ConvergentMigrationProviderInput[];
deviceId: string;
now: number;
}): ConvergentMigrationPlan {
const providers = [...options.providers].sort((left, right) => left.provider.localeCompare(right.provider));
const blockedReasons: string[] = [];
const providerStatuses: ConvergentProviderMigrationStatus[] = [];
const shrinkFindings: ConvergentMigrationPreview['shrinkFindings'] = [];
const v1Inputs: Extract<ConvergentMigrationProviderInput, { status: 'ready' }>[] = [];
const v2Inputs: Array<Extract<ConvergentMigrationProviderInput, { status: 'ready' }> & { state: ConvergentSyncStateV2 }> = [];
for (const input of providers) {
if (input.status === 'empty') {
providerStatuses.push(statusFor(input, 1, 'empty'));
continue;
}
if (input.status === 'unavailable') {
blockedReasons.push(`${input.provider}: ${input.message}`);
providerStatuses.push(statusFor(input, 'invalid', 'unavailable', input.message));
continue;
}
const schema = runtimeSchema(input.meta);
if (schema === 'future' || schema === 'invalid') {
const message = schema === 'future'
? 'Provider contains a newer sync schema'
: 'Provider contains invalid sync schema metadata';
blockedReasons.push(`${input.provider}: ${message}`);
providerStatuses.push(statusFor(input, schema, 'blocked', message));
continue;
}
if (schema === 1) {
if (input.payload.convergentSync) {
const message = 'Provider envelope does not match its plaintext schema metadata';
blockedReasons.push(`${input.provider}: ${message}`);
providerStatuses.push(statusFor(input, 'invalid', 'blocked', message));
} else {
v1Inputs.push(input);
providerStatuses.push(statusFor(input, 1, 'ready'));
}
continue;
}
try {
if (!input.payload.convergentSync) throw new Error('missing convergent envelope');
const state = hydrateConvergentSyncEnvelope(input.payload.convergentSync, input.payload);
v2Inputs.push({ ...input, state });
providerStatuses.push(statusFor(input, 2, 'ready'));
} catch (error) {
const message = `Damaged convergent envelope: ${error instanceof Error ? error.message : String(error)}`;
blockedReasons.push(`${input.provider}: ${message}`);
providerStatuses.push(statusFor(input, 'invalid', 'blocked', message));
}
}
let state: ConvergentSyncStateV2 | null = null;
let materialized: SyncPayload | null = null;
if (blockedReasons.length === 0 && v2Inputs.length === 0) {
const includeLocalSource = shouldIncludeLegacyLocalSource(
options.localPayload,
options.localTrustedBaseline,
);
const seedFromProvider = !includeLocalSource && v1Inputs.length > 0;
let merged = seedFromProvider ? v1Inputs[0].payload : options.localPayload;
if (seedFromProvider) {
const seed = v1Inputs[0];
const shrink = detectSuspiciousShrink(
seed.payload,
seed.trustedBaseline,
seed.payload,
);
if (shrink.suspicious) {
shrinkFindings.push({ provider: seed.provider, finding: shrink });
blockedReasons.push(`${seed.provider}: legacy migration would remove too many entities`);
}
}
const remainingInputs = seedFromProvider ? v1Inputs.slice(1) : v1Inputs;
for (const input of remainingInputs) {
if (!input.trustedBaseline) {
if (!cloudSyncPayloadsEqual(merged, input.payload)) {
blockedReasons.push(`${input.provider}: no trusted legacy baseline is available`);
}
continue;
}
const result = mergeSyncPayloads(input.trustedBaseline, merged, input.payload);
const changeSummary = summarizeSyncChanges(
input.trustedBaseline,
merged,
input.payload,
);
if (result.hadConflicts || changeSummary.hasConflicts) {
blockedReasons.push(`${input.provider}: legacy smart merge has unresolved conflicts`);
}
const shrink = detectSuspiciousShrink(result.payload, input.trustedBaseline, input.payload);
if (shrink.suspicious) {
shrinkFindings.push({ provider: input.provider, finding: shrink });
blockedReasons.push(`${input.provider}: legacy migration would remove too many entities`);
}
merged = result.payload;
}
if (blockedReasons.length === 0) {
state = createConvergentSyncStateFromPayload(merged, options.deviceId, options.now);
// Prefer the three-way merge result already on `merged` (preserves
// explicit sidecar deletions). Do not re-LWW raw provider bundles —
// that would resurrect entries three-way merge correctly removed.
const migrationSidecars = Object.prototype.hasOwnProperty.call(merged, 'pluginSidecars')
? merged.pluginSidecars
: mergeMigrationSidecars(
options.localPayload.pluginSidecars,
...v1Inputs.map((input) => input.payload.pluginSidecars),
);
materialized = materializeSyncPayloadFromConvergentState(state, {
syncedAt: options.now,
syncMeta: merged.syncMeta,
...(migrationSidecars ? { pluginSidecars: migrationSidecars } : {}),
});
}
} else if (blockedReasons.length === 0) {
state = v2Inputs.map((input) => input.state).reduce(mergeConvergentSyncStates);
// Three-way per provider so local resets are not resurrected from a
// still-stale remote entry during convergent enablement.
const joinedSidecars = mergeMigrationSidecarsWithBaselines({
local: options.localPayload.pluginSidecars,
localBaseline: options.localTrustedBaseline?.pluginSidecars,
sources: [
...v2Inputs.map((input) => ({
remote: input.payload.pluginSidecars,
baseline: input.trustedBaseline?.pluginSidecars,
})),
...v1Inputs.map((input) => ({
remote: input.payload.pluginSidecars,
baseline: input.trustedBaseline?.pluginSidecars,
})),
],
});
const joinedPayload = materializeSyncPayloadFromConvergentState(state, {
syncedAt: options.now,
...(joinedSidecars ? { pluginSidecars: joinedSidecars } : {}),
});
const legacySources: Array<{
id: string;
payload: SyncPayload;
baseline: SyncPayload | null;
now: number;
provider?: CloudProvider;
}> = [
...(shouldIncludeLegacyLocalSource(
options.localPayload,
options.localTrustedBaseline,
) ? [{
id: `legacy-local:${options.deviceId}`,
payload: options.localPayload,
baseline: options.localTrustedBaseline,
now: options.now,
}] : []),
...v1Inputs.map((input) => ({
id: `legacy-provider:${input.provider}:${input.meta.deviceId}`,
payload: input.payload,
baseline: input.trustedBaseline,
now: input.meta.updatedAt,
provider: input.provider,
})),
];
const branches: ConvergentSyncStateV2[] = [];
for (const source of legacySources) {
if (cloudSyncPayloadsEqual(source.payload, joinedPayload)) continue;
if (!source.baseline) {
blockedReasons.push(`${source.id}: no trusted legacy baseline is available`);
continue;
}
const shrink = detectSuspiciousShrink(
inheritOmittedLegacySyncFields(source.baseline, source.payload),
source.baseline,
);
if (shrink.suspicious) {
if (source.provider) {
shrinkFindings.push({ provider: source.provider, finding: shrink });
}
blockedReasons.push(`${source.id}: legacy migration would remove too many entities`);
continue;
}
branches.push(applyLegacySyncPayload(state, source.baseline, source.payload, source.id, source.now));
}
if (blockedReasons.length === 0) {
state = branches.reduce(mergeConvergentSyncStates, state);
// joinedSidecars already unions local + all provider inputs. Re-LWW-ing
// raw sources again cannot add unique entries and can confuse future
// three-way paths that expect the joined set to be final.
materialized = materializeSyncPayloadFromConvergentState(state, {
syncedAt: options.now,
...(joinedSidecars ? { pluginSidecars: joinedSidecars } : {}),
});
}
}
const conflicts = state ? materializeConvergentSyncState(state).conflicts : [];
if (conflicts.length > 0) blockedReasons.push('The convergent state contains unresolved field conflicts');
const canInitialize = blockedReasons.length === 0 && state !== null && materialized !== null;
const payload = canInitialize && state
? withConvergentSyncEnvelope(state, {
syncedAt: options.now,
syncMeta: materialized?.syncMeta,
...(materialized?.pluginSidecars
? { pluginSidecars: materialized.pluginSidecars }
: {}),
})
: null;
const previewPayload = materialized ?? options.localPayload;
const entityCounts = Object.fromEntries(
[...CONVERGENT_ENTITY_COLLECTIONS, ...CONVERGENT_STRING_COLLECTIONS]
.map((key) => [key, entityCount(previewPayload, key)]),
) as ConvergentMigrationPreview['entityCounts'];
return {
preview: {
schemaVersion: 2,
canInitialize,
entityCounts,
settingsLeafCount: countSettingsLeaves(previewPayload.settings),
conflictCount: conflicts.length,
conflicts,
shrinkFindings,
providers: providerStatuses,
oldClientCompatibility: 'materialized-v1-snapshot',
blockedReasons,
},
state: canInitialize ? state : null,
payload,
};
}

View File

@@ -0,0 +1,585 @@
import {
withHostsSanitizedForSync,
type CloudSyncPayloadEntityKey,
type SyncFileMeta,
type SyncPayload,
type SyncReliabilityMeta,
} from '../sync';
import { dotKey } from './clock';
import {
cloneJson,
isJsonValue,
jsonValuesEqual,
normalizeJsonValue,
} from './json';
import { selectRegisterWinner, isTombstoneCandidate } from './register';
import { createEmptyRecord, setOwnRecordValue } from './record';
import {
assertValidConvergentSyncState,
canonicalizeConvergentSyncState,
decodeSettingPath,
} from './serialization';
import {
applyConvergentMutations,
createConvergentSyncState,
materializeConvergentSyncState,
} from './state';
import type {
CollectionPosition,
ConvergentEnvelopeCandidate,
ConvergentEnvelopeCollectionState,
ConvergentEnvelopeEntityState,
ConvergentEnvelopeRegister,
ConvergentEnvelopeStateV2,
ConvergentEnvelopeStringCollectionState,
ConvergentEnvelopeStringEntryState,
ConvergentMutation,
ConvergentSyncEnvelopeV2,
ConvergentSyncStateV2,
JsonObject,
JsonValue,
MultiValueRegister,
RegisterCandidate,
} from './types';
export const CONVERGENT_ENTITY_COLLECTIONS = [
'hosts',
'keys',
'identities',
'proxyProfiles',
'snippets',
'notes',
'portForwardingRules',
'groupConfigs',
] as const satisfies readonly CloudSyncPayloadEntityKey[];
export const CONVERGENT_STRING_COLLECTIONS = [
'customGroups',
'snippetPackages',
'noteGroups',
] as const satisfies readonly CloudSyncPayloadEntityKey[];
type ConvergentEntityCollection = typeof CONVERGENT_ENTITY_COLLECTIONS[number];
type ConvergentStringCollection = typeof CONVERGENT_STRING_COLLECTIONS[number];
const ENTITY_COLLECTION_SET = new Set<string>(CONVERGENT_ENTITY_COLLECTIONS);
const STRING_COLLECTION_SET = new Set<string>(CONVERGENT_STRING_COLLECTIONS);
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function toJsonValue(value: unknown, label: string): JsonValue {
try {
return normalizeJsonValue(value);
} catch {
throw new Error(`${label} contains a value that cannot be represented as JSON`);
}
}
function entityId(collection: ConvergentEntityCollection, value: Record<string, unknown>): string {
const raw = collection === 'groupConfigs' ? value.path : value.id;
if (typeof raw !== 'string' || raw.length === 0) {
throw new Error(`${collection} contains an entity without a stable identifier`);
}
return raw;
}
function entityJson(
collection: ConvergentEntityCollection,
value: Record<string, unknown>,
): JsonObject {
const id = entityId(collection, value);
const json = toJsonValue(value, `${collection}/${id}`);
if (!isRecord(json)) throw new Error(`${collection}/${id} must be a JSON object`);
return {
...json,
id,
} as JsonObject;
}
function payloadEntityValues(
payload: SyncPayload,
collection: ConvergentEntityCollection,
): Record<string, unknown>[] {
const values = payload[collection];
return Array.isArray(values) ? values as unknown as Record<string, unknown>[] : [];
}
function payloadStringValues(
payload: SyncPayload,
collection: ConvergentStringCollection,
): string[] {
const values = payload[collection];
return Array.isArray(values)
? values.filter((value): value is string => typeof value === 'string')
: [];
}
function appendSettingMutations(
value: unknown,
path: string[],
mutations: ConvergentMutation[],
): void {
if (isRecord(value) && Object.keys(value).length > 0) {
for (const key of Object.keys(value).sort()) {
appendSettingMutations(value[key], [...path, key], mutations);
}
return;
}
if (path.length === 0 || value === undefined) return;
mutations.push({
kind: 'setting-set',
path,
value: toJsonValue(value, `settings.${path.join('.')}`),
});
}
export function syncPayloadToConvergentMutations(payload: SyncPayload): ConvergentMutation[] {
const sanitized = withHostsSanitizedForSync(payload);
const mutations: ConvergentMutation[] = [];
for (const collection of CONVERGENT_ENTITY_COLLECTIONS) {
payloadEntityValues(sanitized, collection).forEach((value, position) => {
const id = entityId(collection, value);
mutations.push({
kind: 'entity-upsert',
collection,
entityId: id,
value: entityJson(collection, value),
position,
});
});
}
for (const collection of CONVERGENT_STRING_COLLECTIONS) {
payloadStringValues(sanitized, collection).forEach((value, position) => {
mutations.push({ kind: 'string-entry-add', collection, value, position });
});
}
appendSettingMutations(sanitized.settings, [], mutations);
return mutations;
}
export function createConvergentSyncStateFromPayload(
payload: SyncPayload,
deviceId: string,
now: number,
): ConvergentSyncStateV2 {
return applyConvergentMutations(
createConvergentSyncState(),
deviceId,
syncPayloadToConvergentMutations(payload),
now,
);
}
function requireKnownCollections(state: ConvergentSyncStateV2): void {
for (const collection of Object.keys(state.collections)) {
if (!ENTITY_COLLECTION_SET.has(collection)) {
throw new Error(`Unsupported convergent entity collection: ${collection}`);
}
}
for (const collection of Object.keys(state.stringCollections)) {
if (!STRING_COLLECTION_SET.has(collection)) {
throw new Error(`Unsupported convergent string collection: ${collection}`);
}
}
}
function collectionValues(
collections: Record<string, JsonObject[]>,
collection: ConvergentEntityCollection,
): JsonObject[] {
return collections[collection] ?? [];
}
function typedCollection<T>(
collections: Record<string, JsonObject[]>,
collection: Exclude<ConvergentEntityCollection, 'groupConfigs'>,
): T[] {
return collectionValues(collections, collection) as unknown as T[];
}
export function materializeSyncPayloadFromConvergentState(
state: ConvergentSyncStateV2,
options: {
syncedAt: number;
syncMeta?: SyncReliabilityMeta;
/** Opaque plugin sidecars travel with the encrypted blob outside CRDT fields. */
pluginSidecars?: SyncPayload['pluginSidecars'];
},
): SyncPayload {
requireKnownCollections(state);
const materialized = materializeConvergentSyncState(state);
const groupConfigs = collectionValues(materialized.collections, 'groupConfigs').map((value) => {
const { id: _id, ...groupConfig } = value;
return groupConfig as unknown as import('../models').GroupConfig;
});
const settings = Object.keys(materialized.settings).length > 0
? materialized.settings as unknown as NonNullable<SyncPayload['settings']>
: undefined;
return {
hosts: typedCollection<import('../models').Host>(materialized.collections, 'hosts'),
keys: typedCollection<import('../models').SSHKey>(materialized.collections, 'keys'),
identities: typedCollection<import('../models').Identity>(materialized.collections, 'identities'),
proxyProfiles: typedCollection<import('../models').ProxyProfile>(materialized.collections, 'proxyProfiles'),
snippets: typedCollection<import('../models').Snippet>(materialized.collections, 'snippets'),
customGroups: materialized.stringCollections.customGroups ?? [],
snippetPackages: materialized.stringCollections.snippetPackages ?? [],
notes: typedCollection<import('../models').VaultNote>(materialized.collections, 'notes'),
noteGroups: materialized.stringCollections.noteGroups ?? [],
portForwardingRules: typedCollection<import('../models').PortForwardingRule>(materialized.collections, 'portForwardingRules'),
groupConfigs,
settings,
syncedAt: options.syncedAt,
...(options.syncMeta ? { syncMeta: options.syncMeta } : {}),
// Preserve explicit empty bundles so lifecycle materializations (conflict
// resolve / downgrade) can clear or re-upload sidecars rather than omit
// the field and look like a legacy payload.
...(options.pluginSidecars && Array.isArray(options.pluginSidecars.entries)
? {
pluginSidecars: {
version: 1 as const,
entries: options.pluginSidecars.entries,
},
}
: {}),
};
}
function materializedEntity(
payload: SyncPayload,
collection: string,
id: string,
): Record<string, unknown> | undefined {
if (!ENTITY_COLLECTION_SET.has(collection)) return undefined;
const values = payloadEntityValues(payload, collection as ConvergentEntityCollection);
return values.find((value) => entityId(collection as ConvergentEntityCollection, value) === id);
}
function nestedSetting(payload: SyncPayload, path: string[]): unknown {
let value: unknown = payload.settings;
for (const segment of path) {
if (!isRecord(value) || !Object.prototype.hasOwnProperty.call(value, segment)) return undefined;
value = value[segment];
}
return value;
}
function stableUnknown(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableUnknown);
if (isRecord(value)) {
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, stableUnknown(value[key])]),
);
}
return value;
}
function materializedCloudFingerprint(payload: SyncPayload): string {
return JSON.stringify(stableUnknown({
...Object.fromEntries(
CONVERGENT_ENTITY_COLLECTIONS.map((collection) => [
collection,
payloadEntityValues(payload, collection),
]),
),
...Object.fromEntries(
CONVERGENT_STRING_COLLECTIONS.map((collection) => [
collection,
payloadStringValues(payload, collection),
]),
),
settings: payload.settings ?? {},
}));
}
function assertMaterializedPayloadMatchesState(
state: ConvergentSyncStateV2,
payload: SyncPayload,
): void {
const expected = materializeSyncPayloadFromConvergentState(state, { syncedAt: 0 });
if (materializedCloudFingerprint(expected) !== materializedCloudFingerprint(payload)) {
throw new Error('Convergent envelope does not match its materialized v1 snapshot');
}
}
function compactRegister<T extends JsonValue>(
register: MultiValueRegister<T>,
materializedValue?: unknown,
allowMaterializedValue = false,
): ConvergentEnvelopeRegister<T> {
const winner = selectRegisterWinner(register);
return {
candidates: register.candidates.map((candidate): ConvergentEnvelopeCandidate<T> => {
const base = {
dot: { ...candidate.dot },
context: candidate.context.map((dot) => ({ ...dot })),
hlc: { ...candidate.hlc },
};
if (isTombstoneCandidate(candidate)) return { ...base, tombstone: true };
if (
allowMaterializedValue
&& winner
&& dotKey(candidate.dot) === dotKey(winner.dot)
&& isJsonValue(materializedValue)
&& jsonValuesEqual(candidate.value, materializedValue)
) {
return { ...base, materialized: true };
}
return { ...base, value: cloneJson(candidate.value) };
}),
};
}
export function createConvergentSyncEnvelope(
state: ConvergentSyncStateV2,
materializedPayload: SyncPayload,
): ConvergentSyncEnvelopeV2 {
const canonical = canonicalizeConvergentSyncState(state);
requireKnownCollections(canonical);
assertMaterializedPayloadMatchesState(canonical, materializedPayload);
const collections = createEmptyRecord<ConvergentEnvelopeCollectionState>();
for (const [collectionName, collection] of Object.entries(canonical.collections)) {
const entities = createEmptyRecord<ConvergentEnvelopeEntityState>();
for (const [id, entity] of Object.entries(collection.entities)) {
const materialized = materializedEntity(materializedPayload, collectionName, id);
const fields = createEmptyRecord<ConvergentEnvelopeRegister>();
for (const [field, register] of Object.entries(entity.fields)) {
setOwnRecordValue(fields, field, compactRegister(register, materialized?.[field], true));
}
setOwnRecordValue(entities, id, {
presence: compactRegister(entity.presence),
...(entity.position ? { position: compactRegister(entity.position) } : {}),
fields,
});
}
setOwnRecordValue(collections, collectionName, { entities });
}
const settings = createEmptyRecord<ConvergentEnvelopeRegister>();
for (const [encodedPath, register] of Object.entries(canonical.settings)) {
setOwnRecordValue(settings, encodedPath, compactRegister(
register,
nestedSetting(materializedPayload, decodeSettingPath(encodedPath)),
true,
));
}
const stringCollections = createEmptyRecord<ConvergentEnvelopeStringCollectionState>();
for (const [collectionName, collection] of Object.entries(canonical.stringCollections)) {
const entries = createEmptyRecord<ConvergentEnvelopeStringEntryState>();
for (const [value, entry] of Object.entries(collection.entries)) {
setOwnRecordValue(entries, value, {
presence: compactRegister(entry.presence),
...(entry.position ? { position: compactRegister(entry.position) } : {}),
});
}
setOwnRecordValue(stringCollections, collectionName, { entries });
}
return {
schemaVersion: 2,
encoding: 'materialized-winner-v1',
state: {
vector: Object.fromEntries(Object.entries(canonical.vector)),
dotOrigins: Object.fromEntries(
Object.entries(canonical.dotOrigins).map(([deviceId, origins]) => [deviceId, { ...origins }]),
),
hlc: { ...canonical.hlc },
collections,
settings,
stringCollections,
},
};
}
function hydrateRegister<T extends JsonValue>(
register: ConvergentEnvelopeRegister<T>,
materializedValue: unknown,
label: string,
): MultiValueRegister<T> {
if (!register || !Array.isArray(register.candidates) || register.candidates.length === 0) {
throw new Error(`${label} has no candidates`);
}
return {
candidates: register.candidates.map((candidate, index): RegisterCandidate<T> => {
const candidateLabel = `${label}.candidates[${index}]`;
const base = {
dot: { ...candidate.dot },
context: candidate.context.map((dot) => ({ ...dot })),
hlc: { ...candidate.hlc },
};
if (
candidate.tombstone !== undefined
&& candidate.tombstone !== true
&& candidate.tombstone !== false
) {
throw new Error(`${candidateLabel} has an invalid tombstone marker`);
}
if (
'materialized' in candidate
&& candidate.materialized !== undefined
&& candidate.materialized !== true
) {
throw new Error(`${candidateLabel} has an invalid materialized marker`);
}
if (candidate.tombstone === true) {
if ('materialized' in candidate || 'value' in candidate) {
throw new Error(`${candidateLabel} tombstone contains a value marker`);
}
return { ...base, tombstone: true };
}
if ('materialized' in candidate && candidate.materialized === true) {
if ('value' in candidate || !isJsonValue(materializedValue)) {
throw new Error(`${candidateLabel} cannot reconstruct its materialized value`);
}
return { ...base, value: cloneJson(materializedValue) as T };
}
if (!('value' in candidate) || !isJsonValue(candidate.value)) {
throw new Error(`${candidateLabel} is missing a JSON value`);
}
return { ...base, value: cloneJson(candidate.value) as T };
}),
};
}
function envelopeState(value: unknown): ConvergentEnvelopeStateV2 {
if (!isRecord(value)) throw new Error('Convergent sync envelope state is invalid');
return value as unknown as ConvergentEnvelopeStateV2;
}
export function hydrateConvergentSyncEnvelope(
envelope: ConvergentSyncEnvelopeV2,
materializedPayload: SyncPayload,
): ConvergentSyncStateV2 {
if (
!envelope
|| envelope.schemaVersion !== 2
|| envelope.encoding !== 'materialized-winner-v1'
) {
throw new Error('Unsupported convergent sync envelope');
}
const encoded = envelopeState(envelope.state);
const collections = createEmptyRecord<ConvergentSyncStateV2['collections'][string]>();
for (const [collectionName, collection] of Object.entries(encoded.collections ?? {})) {
if (!ENTITY_COLLECTION_SET.has(collectionName) || !isRecord(collection?.entities)) {
throw new Error(`Unsupported or invalid convergent collection: ${collectionName}`);
}
const entities = createEmptyRecord<ConvergentSyncStateV2['collections'][string]['entities'][string]>();
for (const [id, entity] of Object.entries(collection.entities)) {
if (!isRecord(entity) || !isRecord(entity.fields)) {
throw new Error(`Invalid convergent entity: ${collectionName}/${id}`);
}
const materialized = materializedEntity(materializedPayload, collectionName, id);
const fields = createEmptyRecord<MultiValueRegister>();
for (const [field, register] of Object.entries(entity.fields)) {
setOwnRecordValue(fields, field, hydrateRegister(
register,
materialized?.[field],
`${collectionName}/${id}/${field}`,
));
}
setOwnRecordValue(entities, id, {
presence: hydrateRegister(entity.presence, true, `${collectionName}/${id}/presence`),
...(entity.position
? { position: hydrateRegister<CollectionPosition>(entity.position, undefined, `${collectionName}/${id}/position`) }
: {}),
fields,
});
}
setOwnRecordValue(collections, collectionName, { entities });
}
const settings = createEmptyRecord<MultiValueRegister>();
for (const [path, register] of Object.entries(encoded.settings ?? {})) {
setOwnRecordValue(settings, path, hydrateRegister(
register,
nestedSetting(materializedPayload, decodeSettingPath(path)),
`settings/${path}`,
));
}
const stringCollections = createEmptyRecord<ConvergentSyncStateV2['stringCollections'][string]>();
for (const [collectionName, collection] of Object.entries(encoded.stringCollections ?? {})) {
if (!STRING_COLLECTION_SET.has(collectionName) || !isRecord(collection?.entries)) {
throw new Error(`Unsupported or invalid convergent string collection: ${collectionName}`);
}
const entries = createEmptyRecord<ConvergentSyncStateV2['stringCollections'][string]['entries'][string]>();
for (const [value, entry] of Object.entries(collection.entries)) {
if (!isRecord(entry)) throw new Error(`Invalid convergent string entry: ${collectionName}/${value}`);
setOwnRecordValue(entries, value, {
presence: hydrateRegister(entry.presence, true, `${collectionName}/${value}/presence`),
...(entry.position
? { position: hydrateRegister<CollectionPosition>(entry.position, undefined, `${collectionName}/${value}/position`) }
: {}),
});
}
setOwnRecordValue(stringCollections, collectionName, { entries });
}
const state: ConvergentSyncStateV2 = {
schemaVersion: 2,
vector: encoded.vector,
dotOrigins: encoded.dotOrigins,
hlc: encoded.hlc,
collections,
settings,
stringCollections,
};
assertValidConvergentSyncState(state);
const canonical = canonicalizeConvergentSyncState(state);
assertMaterializedPayloadMatchesState(canonical, materializedPayload);
return canonical;
}
export function withConvergentSyncEnvelope(
state: ConvergentSyncStateV2,
options: {
syncedAt: number;
syncMeta?: SyncReliabilityMeta;
pluginSidecars?: SyncPayload['pluginSidecars'];
},
): SyncPayload {
const payload = materializeSyncPayloadFromConvergentState(state, options);
return {
...payload,
convergentSync: createConvergentSyncEnvelope(state, payload),
};
}
export function validateConvergentSyncPayload(
meta: Pick<SyncFileMeta, 'syncSchemaVersion'>,
payload: SyncPayload,
): ConvergentSyncStateV2 | null {
const schemaVersion = (meta as { syncSchemaVersion?: unknown }).syncSchemaVersion;
if (schemaVersion === undefined) {
if (payload.convergentSync !== undefined) {
throw new Error('Convergent sync envelope is present without schema metadata');
}
return null;
}
if (schemaVersion !== 2) {
throw new Error(`Unsupported sync schema version: ${String(schemaVersion)}`);
}
if (!payload.convergentSync) {
throw new Error('Sync schema v2 payload is missing its convergent envelope');
}
return hydrateConvergentSyncEnvelope(payload.convergentSync, payload);
}
/** Prevent the legacy snapshot writer from silently erasing v2/future metadata. */
export function assertConvergentSyncWriteCompatible(
remoteMeta: Pick<SyncFileMeta, 'syncSchemaVersion'> | null | undefined,
outgoingPayload: SyncPayload,
): void {
if (!remoteMeta) return;
const remoteSchema = (remoteMeta as { syncSchemaVersion?: unknown }).syncSchemaVersion;
if (remoteSchema === undefined) return;
if (remoteSchema !== 2) {
throw new Error(`Cannot overwrite unsupported sync schema version: ${String(remoteSchema)}`);
}
if (!outgoingPayload.convergentSync) {
throw new Error(
'Cloud data uses convergent sync v2. Enable or migrate convergent sync before uploading.',
);
}
}
export function stripConvergentSyncEnvelope(payload: SyncPayload): SyncPayload {
const { convergentSync: _convergentSync, ...legacyPayload } = payload;
return legacyPayload;
}

View File

@@ -0,0 +1,756 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { sanitizeHost } from '../host.ts';
import type { SyncFileMeta, SyncPayload } from '../sync.ts';
import {
applyConvergentMutations,
assertConvergentSyncWriteCompatible,
applyLegacySyncPayload,
cloudSyncPayloadsEqual,
createConvergentSyncEnvelope,
createConvergentSyncStateFromPayload,
diffLegacySyncPayload,
hydrateConvergentSyncEnvelope,
materializeSyncPayloadFromConvergentState,
mergeConvergentSyncStates,
planConvergentSyncMigration,
serializeConvergentSyncState,
validateConvergentSyncPayload,
withConvergentSyncEnvelope,
createConvergentSyncState,
} from './index.ts';
const NOW = 1_700_000_000_000;
function payload(label = 'Production'): SyncPayload {
return {
hosts: [{
id: 'host-1',
label,
hostname: 'example.com',
username: 'root',
tags: ['prod'],
os: 'linux',
password: 'host-secret',
}],
keys: [{
id: 'key-1',
label: 'Deploy key',
type: 'ED25519',
privateKey: 'private-secret',
source: 'imported',
category: 'key',
created: NOW,
}],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: ['prod'],
snippetPackages: [],
notes: [],
noteGroups: [],
portForwardingRules: [],
groupConfigs: [],
settings: {
theme: 'dark',
ai: { providers: [{ id: 'provider-1', apiKey: 'api-secret' }] },
},
syncedAt: NOW,
};
}
function emptyPayload(settings?: SyncPayload['settings']): SyncPayload {
return {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
snippetPackages: [],
notes: [],
noteGroups: [],
portForwardingRules: [],
groupConfigs: [],
settings,
syncedAt: NOW,
};
}
function meta(overrides: Partial<SyncFileMeta> = {}): SyncFileMeta {
return {
version: 1,
updatedAt: NOW,
deviceId: 'remote-device',
appVersion: '1.0.0',
iv: 'iv',
salt: 'salt',
algorithm: 'AES-256-GCM',
kdf: 'PBKDF2',
...overrides,
};
}
test('encrypted envelope omits materialized winner values and hydrates exactly', () => {
const state = createConvergentSyncStateFromPayload(payload(), 'device-a', NOW);
const materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW });
const envelope = createConvergentSyncEnvelope(state, materialized);
const envelopeJson = JSON.stringify(envelope);
assert.equal(envelopeJson.includes('host-secret'), false);
assert.equal(envelopeJson.includes('private-secret'), false);
assert.equal(envelopeJson.includes('api-secret'), false);
assert.match(JSON.stringify(materialized), /private-secret/);
assert.equal(
serializeConvergentSyncState(hydrateConvergentSyncEnvelope(envelope, materialized)),
serializeConvergentSyncState(state),
);
});
test('envelope creation and hydration reject a materialized snapshot that disagrees with state', () => {
const state = createConvergentSyncStateFromPayload(payload('State value'), 'device-a', NOW);
const mismatched = payload('Different snapshot value');
assert.throws(
() => createConvergentSyncEnvelope(state, mismatched),
/does not match its materialized v1 snapshot/,
);
const materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW });
const envelope = createConvergentSyncEnvelope(state, materialized);
const damaged = structuredClone(envelope);
const labelRegister = damaged.state.collections.hosts.entities['host-1'].fields.label;
const selected = labelRegister.candidates.find(
(candidate) => 'materialized' in candidate && candidate.materialized === true,
);
assert.ok(selected);
const damagedCandidate = selected as unknown as { materialized?: true; value?: string };
delete damagedCandidate.materialized;
damagedCandidate.value = 'Envelope-only value';
assert.throws(
() => hydrateConvergentSyncEnvelope(damaged, materialized),
/does not match its materialized v1 snapshot/,
);
});
test('poisoned enc:v1 secrets still round-trip through convergent envelope validation', () => {
// Materialize must preserve device-bound ciphertext that already lives in the
// CRDT + v1 snapshot pair. Stripping here would make decrypt/hydrate reject
// the exact poisoned v2 clouds #2702 needs to recover from.
const completeBlob = Buffer.alloc(19, 0);
Buffer.from('v10', 'utf8').copy(completeBlob, 0);
const ENC = `enc:v1:${completeBlob.toString('base64')}`;
const poisoned = payload();
poisoned.hosts = [{ ...poisoned.hosts[0]!, password: ENC }];
poisoned.keys = [{ ...poisoned.keys[0]!, privateKey: ENC }];
const state = createConvergentSyncStateFromPayload(poisoned, 'device-a', NOW);
const materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW });
assert.equal(materialized.hosts[0]?.password, ENC);
assert.equal(materialized.keys[0]?.privateKey, ENC);
const envelope = createConvergentSyncEnvelope(state, materialized);
assert.equal(
serializeConvergentSyncState(hydrateConvergentSyncEnvelope(envelope, materialized)),
serializeConvergentSyncState(state),
);
});
test('envelope maps preserve prototype-like entity, field, setting, and string identifiers', () => {
const specialObject = JSON.parse('{"id":"__proto__","constructor":"safe"}') as {
id: string;
constructor: string;
};
const state = applyConvergentMutations(createConvergentSyncState(), 'device-a', [
{
kind: 'entity-upsert',
collection: 'hosts',
entityId: '__proto__',
value: specialObject,
position: 0,
},
{ kind: 'setting-set', path: ['__proto__'], value: 'safe-setting' },
{ kind: 'string-entry-add', collection: 'customGroups', value: '__proto__', position: 0 },
], NOW);
const materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW });
const envelope = createConvergentSyncEnvelope(state, materialized);
const hydrated = hydrateConvergentSyncEnvelope(
JSON.parse(JSON.stringify(envelope)),
JSON.parse(JSON.stringify(materialized)),
);
assert.equal(serializeConvergentSyncState(hydrated), serializeConvergentSyncState(state));
});
test('envelope retains concurrent alternatives while the selected winner remains materialized', () => {
const base = createConvergentSyncStateFromPayload(payload('Base'), 'seed', NOW);
const left = applyConvergentMutations(base, 'device-a', [{
kind: 'entity-field-set',
collection: 'hosts',
entityId: 'host-1',
field: 'label',
value: 'Left alternative',
}], NOW + 1);
const right = applyConvergentMutations(base, 'device-z', [{
kind: 'entity-field-set',
collection: 'hosts',
entityId: 'host-1',
field: 'label',
value: 'Right winner',
}], NOW + 1);
const state = mergeConvergentSyncStates(left, right);
const materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW + 1 });
const envelopeJson = JSON.stringify(createConvergentSyncEnvelope(state, materialized));
assert.match(envelopeJson, /Left alternative/);
assert.equal(envelopeJson.includes('Right winner'), false);
});
test('schema validation fails closed for missing, mismatched, future, and damaged envelopes', () => {
const state = createConvergentSyncStateFromPayload(payload(), 'device-a', NOW);
const v2 = withConvergentSyncEnvelope(state, { syncedAt: NOW });
assert.equal(
serializeConvergentSyncState(validateConvergentSyncPayload(meta({ syncSchemaVersion: 2 }), v2)!),
serializeConvergentSyncState(state),
);
assert.throws(
() => validateConvergentSyncPayload(meta(), v2),
/without schema metadata/,
);
assert.throws(
() => validateConvergentSyncPayload(meta({ syncSchemaVersion: 2 }), payload()),
/missing its convergent envelope/,
);
assert.throws(
() => validateConvergentSyncPayload(
{ ...meta(), syncSchemaVersion: 3 } as unknown as SyncFileMeta,
payload(),
),
/Unsupported sync schema version/,
);
const damaged = structuredClone(v2);
damaged.convergentSync!.state.vector['device-a'] = 999;
assert.throws(
() => validateConvergentSyncPayload(meta({ syncSchemaVersion: 2 }), damaged),
/not witnessed|cover every counter/,
);
});
test('legacy writers cannot silently overwrite convergent or future cloud schemas', () => {
const state = createConvergentSyncStateFromPayload(payload(), 'device-a', NOW);
const v2 = withConvergentSyncEnvelope(state, { syncedAt: NOW });
assert.doesNotThrow(() => assertConvergentSyncWriteCompatible(meta(), payload()));
assert.doesNotThrow(() => assertConvergentSyncWriteCompatible(
meta({ syncSchemaVersion: 2 }),
v2,
));
assert.throws(
() => assertConvergentSyncWriteCompatible(meta({ syncSchemaVersion: 2 }), payload()),
/Enable or migrate convergent sync/,
);
assert.throws(
() => assertConvergentSyncWriteCompatible(
{ syncSchemaVersion: 3 } as unknown as SyncFileMeta,
v2,
),
/unsupported sync schema/,
);
});
test('cloudSyncPayloadsEqual ignores lastConnectedAt telemetry', () => {
const left = payload();
const right = {
...payload(),
hosts: [{ ...payload().hosts[0], lastConnectedAt: NOW }],
};
assert.equal(cloudSyncPayloadsEqual(left, right), true);
});
test('createConvergentSyncStateFromPayload does not persist lastConnectedAt', () => {
const withTelemetry = {
...payload(),
hosts: [{ ...payload().hosts[0], lastConnectedAt: NOW }],
};
const state = createConvergentSyncStateFromPayload(withTelemetry, 'device-a', NOW);
const materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW });
assert.equal(materialized.hosts[0].lastConnectedAt, undefined);
assert.equal('lastConnectedAt' in materialized.hosts[0], false);
});
test('diffLegacySyncPayload ignores lastConnectedAt-only host changes', () => {
const baseline = payload();
const legacy = {
...payload(),
hosts: [{ ...payload().hosts[0], lastConnectedAt: NOW + 1 }],
};
assert.deepEqual(diffLegacySyncPayload(baseline, legacy), []);
});
test('trusted legacy diff becomes causal CRDT writes without carrying transport metadata', () => {
const baseline = payload('Before');
const state = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
const legacy = {
...payload('After'),
keys: [],
syncedAt: NOW + 100,
};
const next = applyLegacySyncPayload(
state,
baseline,
legacy,
'legacy:github:remote-device',
NOW + 100,
);
const materialized = materializeSyncPayloadFromConvergentState(next, { syncedAt: NOW + 100 });
assert.equal(materialized.hosts[0].label, 'After');
assert.deepEqual(materialized.keys, []);
assert.equal(cloudSyncPayloadsEqual(materialized, legacy), true);
});
test('payload and legacy conversion normalize undefined fields with JSON semantics', () => {
const baseline = payload('Before');
baseline.hosts = [sanitizeHost({
...baseline.hosts[0],
proxyConfig: {
type: 'http',
host: 'proxy.example.com',
port: 8080,
username: undefined,
},
})];
assert.equal(Object.hasOwn(baseline.hosts[0], 'iconMode'), true);
assert.equal(Object.hasOwn(baseline.hosts[0].proxyConfig!, 'username'), true);
const state = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
const initial = materializeSyncPayloadFromConvergentState(state, { syncedAt: NOW });
assert.equal(Object.hasOwn(initial.hosts[0], 'iconMode'), false);
assert.equal(Object.hasOwn(initial.hosts[0].proxyConfig!, 'username'), false);
const legacy: SyncPayload = {
...baseline,
hosts: [{ ...baseline.hosts[0], label: 'After' }],
syncedAt: NOW + 1,
};
const next = applyLegacySyncPayload(
state,
baseline,
legacy,
'legacy:github:remote-device',
NOW + 1,
);
const materialized = materializeSyncPayloadFromConvergentState(next, { syncedAt: NOW + 1 });
assert.equal(materialized.hosts[0].label, 'After');
assert.equal(Object.hasOwn(materialized.hosts[0], 'iconMode'), false);
assert.equal(Object.hasOwn(materialized.hosts[0].proxyConfig!, 'username'), false);
});
test('trusted legacy diff treats own undefined optional fields as omitted', () => {
const baseline = payload();
baseline.identities = [{
id: 'identity-1',
label: 'Production identity',
username: 'root',
authMethod: 'password',
password: 'identity-secret',
created: NOW,
}];
baseline.noteGroups = ['operations'];
const legacy = {
...baseline,
identities: undefined,
noteGroups: undefined,
settings: undefined,
syncedAt: NOW + 1,
} as unknown as SyncPayload;
assert.deepEqual(diffLegacySyncPayload(baseline, legacy), []);
const state = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
const next = applyLegacySyncPayload(
state,
baseline,
legacy,
'legacy:github:remote-device',
NOW + 1,
);
const materialized = materializeSyncPayloadFromConvergentState(next, { syncedAt: NOW + 1 });
assert.equal(materialized.identities?.[0]?.id, 'identity-1');
assert.deepEqual(materialized.noteGroups, ['operations']);
assert.equal(materialized.settings?.theme, 'dark');
});
test('payload conversion still rejects entities that JSON cannot serialize', () => {
const invalid = payload();
const circular: Record<string, unknown> = {};
circular.self = circular;
(invalid.hosts[0] as unknown as Record<string, unknown>).invalid = circular;
assert.throws(
() => createConvergentSyncStateFromPayload(invalid, 'seed', NOW),
/cannot be represented as JSON/,
);
});
test('trusted legacy diff preserves reorder-only entity and string collection edits', () => {
const baseline = payload();
baseline.hosts.push({
...baseline.hosts[0],
id: 'host-2',
label: 'Staging',
hostname: 'staging.example.com',
});
baseline.customGroups = ['prod', 'staging'];
const state = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
const legacy: SyncPayload = {
...baseline,
hosts: [baseline.hosts[1], baseline.hosts[0]],
customGroups: ['staging', 'prod'],
syncedAt: NOW + 1,
};
const next = applyLegacySyncPayload(
state,
baseline,
legacy,
'legacy:github:remote-device',
NOW + 1,
);
const materialized = materializeSyncPayloadFromConvergentState(next, { syncedAt: NOW + 1 });
assert.deepEqual(materialized.hosts.map((host) => host.id), ['host-2', 'host-1']);
assert.deepEqual(materialized.customGroups, ['staging', 'prod']);
assert.equal(cloudSyncPayloadsEqual(materialized, legacy), true);
});
test('v1-only migration previews and creates a backward-compatible v2 payload', () => {
const local = payload();
const remote = {
...payload(),
snippets: [{ id: 'snippet-1', label: 'List', command: 'ls' }],
};
const plan = planConvergentSyncMigration({
localPayload: local,
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: meta(),
payload: remote,
trustedBaseline: local,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, true);
assert.equal(plan.preview.oldClientCompatibility, 'materialized-v1-snapshot');
assert.equal(plan.payload?.snippets.length, 1);
assert.equal(plan.payload?.convergentSync?.schemaVersion, 2);
});
test('a fresh entity-empty device adopts v1 cloud settings instead of merging local defaults', () => {
const remote = payload('Remote');
remote.settings = { theme: 'dark' };
const plan = planConvergentSyncMigration({
localPayload: emptyPayload({ theme: 'light' }),
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: meta(),
payload: remote,
trustedBaseline: null,
}],
deviceId: 'fresh-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, true);
assert.equal(plan.payload?.hosts[0].label, 'Remote');
assert.equal(plan.payload?.settings?.theme, 'dark');
});
test('v1-only migration blocks divergent provider data without a trusted baseline', () => {
const local = payload('Stale local host');
const remote = emptyPayload();
const plan = planConvergentSyncMigration({
localPayload: local,
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: meta(),
payload: remote,
trustedBaseline: null,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, false);
assert.match(plan.preview.blockedReasons.join(' '), /github: no trusted legacy baseline/);
assert.equal(plan.payload, null);
});
test('v1-only migration accepts matching provider data without a trusted baseline', () => {
const local = payload('Matching host');
const plan = planConvergentSyncMigration({
localPayload: local,
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: meta(),
payload: structuredClone(local),
trustedBaseline: null,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, true);
assert.equal(plan.payload?.hosts[0]?.label, 'Matching host');
});
test('a fresh device still blocks a shrunk v1 provider used as the migration seed', () => {
const baseline = payload('Base');
baseline.hosts = Array.from({ length: 4 }, (_, index) => ({
...baseline.hosts[0],
id: `host-${index + 1}`,
label: `Host ${index + 1}`,
}));
const remote: SyncPayload = {
...baseline,
hosts: baseline.hosts.slice(0, 1),
syncedAt: NOW + 1,
};
const plan = planConvergentSyncMigration({
localPayload: emptyPayload(),
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: meta(),
payload: remote,
trustedBaseline: baseline,
}],
deviceId: 'fresh-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, false);
assert.equal(plan.preview.shrinkFindings[0]?.provider, 'github');
assert.equal(plan.preview.shrinkFindings[0]?.finding.lost, 3);
assert.match(plan.preview.blockedReasons.join(' '), /remove too many entities/);
});
test('migration blocks unresolved v1 conflicts and future provider schemas', () => {
const baseline = payload('Base');
const conflict = planConvergentSyncMigration({
localPayload: payload('Local'),
localTrustedBaseline: baseline,
providers: [{
provider: 'github',
status: 'ready',
meta: meta(),
payload: payload('Remote'),
trustedBaseline: baseline,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(conflict.preview.canInitialize, false);
assert.match(conflict.preview.blockedReasons.join(' '), /unresolved conflicts/);
const future = planConvergentSyncMigration({
localPayload: baseline,
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: { ...meta(), syncSchemaVersion: 3 } as unknown as SyncFileMeta,
payload: baseline,
trustedBaseline: null,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(future.preview.canInitialize, false);
assert.equal(future.preview.providers[0].schemaVersion, 'future');
});
test('joining existing v2 data blocks changed legacy writers without a trusted baseline', () => {
const remoteState = createConvergentSyncStateFromPayload(payload('Remote'), 'remote', NOW);
const remotePayload = withConvergentSyncEnvelope(remoteState, { syncedAt: NOW });
const plan = planConvergentSyncMigration({
localPayload: payload('Unbased local edit'),
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: meta({ syncSchemaVersion: 2 }),
payload: remotePayload,
trustedBaseline: null,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, false);
assert.match(plan.preview.blockedReasons.join(' '), /no trusted legacy baseline/);
});
test('joining existing v2 data blocks a shrunk legacy local source', () => {
const baseline = payload('Remote');
baseline.hosts = Array.from({ length: 4 }, (_, index) => ({
...baseline.hosts[0],
id: `host-${index + 1}`,
label: `Host ${index + 1}`,
}));
const local: SyncPayload = {
...baseline,
hosts: baseline.hosts.slice(0, 1),
syncedAt: NOW + 1,
};
const remoteState = createConvergentSyncStateFromPayload(baseline, 'remote', NOW);
const plan = planConvergentSyncMigration({
localPayload: local,
localTrustedBaseline: baseline,
providers: [{
provider: 'github',
status: 'ready',
meta: meta({ syncSchemaVersion: 2 }),
payload: withConvergentSyncEnvelope(remoteState, { syncedAt: NOW }),
trustedBaseline: null,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, false);
assert.match(plan.preview.blockedReasons.join(' '), /legacy-local:local-device.*remove too many entities/);
});
test('joining existing v2 data blocks a shrunk legacy provider source', () => {
const baseline = payload('Remote');
baseline.hosts = Array.from({ length: 4 }, (_, index) => ({
...baseline.hosts[0],
id: `host-${index + 1}`,
label: `Host ${index + 1}`,
}));
const legacy: SyncPayload = {
...baseline,
hosts: baseline.hosts.slice(0, 1),
syncedAt: NOW + 1,
};
const remoteState = createConvergentSyncStateFromPayload(baseline, 'remote', NOW);
const plan = planConvergentSyncMigration({
localPayload: emptyPayload(),
localTrustedBaseline: null,
providers: [
{
provider: 'github',
status: 'ready',
meta: meta({ syncSchemaVersion: 2 }),
payload: withConvergentSyncEnvelope(remoteState, { syncedAt: NOW }),
trustedBaseline: null,
},
{
provider: 'webdav',
status: 'ready',
meta: meta({ deviceId: 'legacy-device' }),
payload: legacy,
trustedBaseline: baseline,
},
],
deviceId: 'fresh-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, false);
assert.equal(plan.preview.shrinkFindings[0]?.provider, 'webdav');
assert.equal(plan.preview.shrinkFindings[0]?.finding.lost, 3);
});
test('v2 migration shrink checks preserve optional collections omitted by legacy clients', () => {
const baseline = payload('Remote');
baseline.identities = Array.from({ length: 4 }, (_, index) => ({
id: `identity-${index + 1}`,
label: `Identity ${index + 1}`,
username: `user-${index + 1}`,
authMethod: 'password' as const,
created: NOW,
}));
const local = {
...baseline,
identities: undefined,
syncedAt: NOW + 1,
} as unknown as SyncPayload;
const remoteState = createConvergentSyncStateFromPayload(baseline, 'remote', NOW);
const plan = planConvergentSyncMigration({
localPayload: local,
localTrustedBaseline: baseline,
providers: [{
provider: 'github',
status: 'ready',
meta: meta({ syncSchemaVersion: 2 }),
payload: withConvergentSyncEnvelope(remoteState, { syncedAt: NOW }),
trustedBaseline: null,
}],
deviceId: 'local-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, true);
assert.equal(plan.preview.shrinkFindings.length, 0);
assert.equal(plan.payload?.identities?.length, 4);
});
test('a fresh entity-empty device adopts existing v2 data without a trusted baseline', () => {
const remoteState = createConvergentSyncStateFromPayload(payload('Remote'), 'remote', NOW);
const remotePayload = withConvergentSyncEnvelope(remoteState, { syncedAt: NOW });
const plan = planConvergentSyncMigration({
localPayload: emptyPayload({ theme: 'light' }),
localTrustedBaseline: null,
providers: [{
provider: 'github',
status: 'ready',
meta: meta({ syncSchemaVersion: 2 }),
payload: remotePayload,
trustedBaseline: null,
}],
deviceId: 'fresh-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, true);
assert.equal(plan.payload?.hosts[0].label, 'Remote');
assert.equal(plan.payload?.settings?.theme, 'dark');
});
test('an empty local snapshot with a trusted baseline remains a real deletion', () => {
const baseline = payload('Remote');
const remoteState = createConvergentSyncStateFromPayload(baseline, 'remote', NOW);
const remotePayload = withConvergentSyncEnvelope(remoteState, { syncedAt: NOW });
const plan = planConvergentSyncMigration({
localPayload: emptyPayload(),
localTrustedBaseline: baseline,
providers: [{
provider: 'github',
status: 'ready',
meta: meta({ syncSchemaVersion: 2 }),
payload: remotePayload,
trustedBaseline: null,
}],
deviceId: 'legacy-device',
now: NOW + 1,
});
assert.equal(plan.preview.canInitialize, true);
assert.deepEqual(plan.payload?.hosts, []);
});

View File

@@ -0,0 +1,25 @@
export function createEmptyRecord<T>(): Record<string, T> {
return {};
}
export function getOwnRecordValue<T>(
record: Record<string, T>,
key: string,
): T | undefined {
return Object.prototype.hasOwnProperty.call(record, key)
? record[key]
: undefined;
}
export function setOwnRecordValue<T>(
record: Record<string, T>,
key: string,
value: T,
): void {
Object.defineProperty(record, key, {
configurable: true,
enumerable: true,
value,
writable: true,
});
}

View File

@@ -0,0 +1,225 @@
import {
compareDots,
compareHybridLogicalClocks,
compareStrings,
dotKey,
} from './clock';
import { canonicalJsonString, cloneJson } from './json';
import {
ConvergentSyncInvariantError,
type Dot,
type HybridLogicalClock,
type JsonValue,
type MultiValueRegister,
type RegisterCandidate,
} from './types';
export function isTombstoneCandidate(
candidate: RegisterCandidate,
): candidate is Extract<RegisterCandidate, { tombstone: true }> {
return candidate.tombstone === true;
}
export function cloneCandidate<T extends JsonValue>(
candidate: RegisterCandidate<T>,
): RegisterCandidate<T> {
const base = {
dot: {
deviceId: candidate.dot.deviceId,
counter: candidate.dot.counter,
},
context: candidate.context.map((dot) => ({
deviceId: dot.deviceId,
counter: dot.counter,
})),
hlc: {
wallTime: candidate.hlc.wallTime,
logical: candidate.hlc.logical,
},
};
if (isTombstoneCandidate(candidate)) {
return { ...base, tombstone: true };
}
return { ...base, value: cloneJson(candidate.value) };
}
export function createRegisterCandidate<T extends JsonValue>(options: {
dot: Dot;
context: Dot[];
hlc: HybridLogicalClock;
value?: T;
tombstone?: boolean;
}): RegisterCandidate<T> {
const base = {
dot: {
deviceId: options.dot.deviceId,
counter: options.dot.counter,
},
context: options.context.map((dot) => ({
deviceId: dot.deviceId,
counter: dot.counter,
})),
hlc: {
wallTime: options.hlc.wallTime,
logical: options.hlc.logical,
},
};
if (options.tombstone) {
if (options.value !== undefined) {
throw new ConvergentSyncInvariantError('A tombstone candidate cannot contain a value');
}
return { ...base, tombstone: true };
}
if (options.value === undefined) {
throw new ConvergentSyncInvariantError('A non-tombstone candidate requires a value');
}
return { ...base, value: cloneJson(options.value) };
}
function canonicalContext(context: Dot[]): string {
return JSON.stringify(
context
.map((dot) => ({ deviceId: dot.deviceId, counter: dot.counter }))
.sort(compareDots),
);
}
function candidateFingerprint(candidate: RegisterCandidate): string {
return JSON.stringify({
dot: {
deviceId: candidate.dot.deviceId,
counter: candidate.dot.counter,
},
context: canonicalContext(candidate.context),
hlc: {
wallTime: candidate.hlc.wallTime,
logical: candidate.hlc.logical,
},
tombstone: isTombstoneCandidate(candidate),
value: isTombstoneCandidate(candidate)
? undefined
: canonicalJsonString(candidate.value),
});
}
function assertEquivalentCandidates(
left: RegisterCandidate,
right: RegisterCandidate,
): void {
if (candidateFingerprint(left) !== candidateFingerprint(right)) {
throw new ConvergentSyncInvariantError(
`Dot ${dotKey(left.dot)} has conflicting candidate payloads`,
);
}
}
function candidateDominates(
winner: RegisterCandidate,
candidate: RegisterCandidate,
): boolean {
return winner.context.some((dot) => dotKey(dot) === dotKey(candidate.dot));
}
export function registerCausalContext(
register: MultiValueRegister | undefined,
): Dot[] {
const context = new Map<string, Dot>();
const observe = (dot: Dot) => {
context.set(dotKey(dot), {
deviceId: dot.deviceId,
counter: dot.counter,
});
};
for (const candidate of register?.candidates ?? []) {
candidate.context.forEach(observe);
observe(candidate.dot);
}
return [...context.values()].sort(compareDots);
}
export function compareRegisterCandidates(
left: RegisterCandidate,
right: RegisterCandidate,
): number {
const leftTombstone = isTombstoneCandidate(left);
const rightTombstone = isTombstoneCandidate(right);
if (leftTombstone !== rightTombstone) return leftTombstone ? -1 : 1;
const clockOrder = compareHybridLogicalClocks(left.hlc, right.hlc);
if (clockOrder !== 0) return clockOrder;
const deviceOrder = compareStrings(left.dot.deviceId, right.dot.deviceId);
if (deviceOrder !== 0) return deviceOrder;
return left.dot.counter - right.dot.counter;
}
export function compareCandidatesByDot(
left: RegisterCandidate,
right: RegisterCandidate,
): number {
return compareDots(left.dot, right.dot);
}
export function selectRegisterWinner<T extends JsonValue>(
register: MultiValueRegister<T> | undefined,
): RegisterCandidate<T> | undefined {
if (!register || register.candidates.length === 0) return undefined;
return register.candidates.reduce((winner, candidate) =>
compareRegisterCandidates(candidate, winner) > 0 ? candidate : winner,
);
}
export function mergeMultiValueRegisters<T extends JsonValue>(
left: MultiValueRegister<T> | undefined,
right: MultiValueRegister<T> | undefined,
): MultiValueRegister<T> | undefined {
const leftCandidates = left?.candidates ?? [];
const rightCandidates = right?.candidates ?? [];
const leftContext = registerCausalContext(left);
const rightContext = registerCausalContext(right);
const leftByDot = new Map(leftCandidates.map((candidate) => [dotKey(candidate.dot), candidate]));
const rightByDot = new Map(rightCandidates.map((candidate) => [dotKey(candidate.dot), candidate]));
const candidates: RegisterCandidate<T>[] = [];
for (const key of new Set([...leftByDot.keys(), ...rightByDot.keys()])) {
const leftCandidate = leftByDot.get(key);
const rightCandidate = rightByDot.get(key);
if (leftCandidate && rightCandidate) {
assertEquivalentCandidates(leftCandidate, rightCandidate);
candidates.push(cloneCandidate(leftCandidate));
} else if (
leftCandidate
&& !rightContext.some((dot) => dotKey(dot) === dotKey(leftCandidate.dot))
) {
candidates.push(cloneCandidate(leftCandidate));
} else if (
rightCandidate
&& !leftContext.some((dot) => dotKey(dot) === dotKey(rightCandidate.dot))
) {
candidates.push(cloneCandidate(rightCandidate));
}
}
const maximal = candidates.filter((candidate, index) =>
!candidates.some((other, otherIndex) =>
index !== otherIndex && candidateDominates(other, candidate),
),
);
if (maximal.length === 0) return undefined;
maximal.sort(compareCandidatesByDot);
return { candidates: maximal };
}
export function registerHasConflict(register: MultiValueRegister): boolean {
if (register.candidates.length < 2) return false;
const distinctValues = new Set(
register.candidates.map((candidate) =>
isTombstoneCandidate(candidate)
? '<tombstone>'
: canonicalJsonString(candidate.value),
),
);
return distinctValues.size > 1;
}

View File

@@ -0,0 +1,24 @@
import type { RegisterAddress } from './types';
/** Collision-free identity persisted for causal-origin validation. */
export function registerId(address: RegisterAddress): string {
switch (address.kind) {
case 'entity-presence':
return JSON.stringify([address.kind, address.collection, address.entityId]);
case 'entity-position':
return JSON.stringify([address.kind, address.collection, address.entityId]);
case 'entity-field':
return JSON.stringify([
address.kind,
address.collection,
address.entityId,
address.field,
]);
case 'setting':
return JSON.stringify([address.kind, ...address.path]);
case 'string-entry-presence':
return JSON.stringify([address.kind, address.collection, address.value]);
case 'string-entry-position':
return JSON.stringify([address.kind, address.collection, address.value]);
}
}

View File

@@ -0,0 +1,555 @@
import {
compareCandidatesByDot,
isTombstoneCandidate,
} from './register';
import { compareDots, compareHybridLogicalClocks, dotKey } from './clock';
import { canonicalizeJson, cloneJson, isJsonValue } from './json';
import { getOwnRecordValue } from './record';
import { registerId } from './registerId';
import {
ConvergentSyncInvariantError,
type ConvergentCollectionState,
type ConvergentEntityState,
type ConvergentStringCollectionState,
type ConvergentStringEntryState,
type ConvergentSyncStateV2,
type Dot,
type DotOriginIndex,
type JsonValue,
type MultiValueRegister,
type RegisterCandidate,
type VersionVector,
} from './types';
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function assertNonNegativeInteger(value: unknown, label: string): asserts value is number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
throw new ConvergentSyncInvariantError(`${label} must be a non-negative integer`);
}
}
function assertPositiveInteger(value: unknown, label: string): asserts value is number {
if (!Number.isSafeInteger(value) || (value as number) <= 0) {
throw new ConvergentSyncInvariantError(`${label} must be a positive integer`);
}
}
function assertNonEmptyKey(value: string, label: string): void {
if (value.length === 0) {
throw new ConvergentSyncInvariantError(`${label} must not be empty`);
}
}
function assertVersionVector(value: unknown, label: string): asserts value is VersionVector {
if (!isRecord(value)) {
throw new ConvergentSyncInvariantError(`${label} must be an object`);
}
for (const [deviceId, counter] of Object.entries(value)) {
assertNonEmptyKey(deviceId, `${label} device ID`);
assertPositiveInteger(counter, `${label}.${deviceId}`);
}
}
function assertDotOrigins(
value: unknown,
vector: VersionVector,
): asserts value is DotOriginIndex {
if (!isRecord(value)) {
throw new ConvergentSyncInvariantError('dotOrigins must be an object');
}
for (const [deviceId, origins] of Object.entries(value)) {
assertNonEmptyKey(deviceId, 'dotOrigins device ID');
if (!isRecord(origins)) {
throw new ConvergentSyncInvariantError(`dotOrigins.${deviceId} must be an object`);
}
const vectorCounter = getOwnRecordValue(vector, deviceId);
if (!vectorCounter || Object.keys(origins).length !== vectorCounter) {
throw new ConvergentSyncInvariantError(
`dotOrigins.${deviceId} must cover every counter in the state vector`,
);
}
for (let counter = 1; counter <= vectorCounter; counter += 1) {
const origin = getOwnRecordValue(origins, String(counter));
if (typeof origin !== 'string' || origin.length === 0) {
throw new ConvergentSyncInvariantError(
`dotOrigins.${deviceId}.${counter} must contain a register identity`,
);
}
}
}
for (const deviceId of Object.keys(vector)) {
if (!Object.hasOwn(value, deviceId)) {
throw new ConvergentSyncInvariantError(
`dotOrigins.${deviceId} must cover every counter in the state vector`,
);
}
}
}
function assertClock(value: unknown, label: string): void {
if (!isRecord(value)) {
throw new ConvergentSyncInvariantError(`${label} must be an object`);
}
assertNonNegativeInteger(value.wallTime, `${label}.wallTime`);
assertNonNegativeInteger(value.logical, `${label}.logical`);
}
function assertCoveredDot(
value: unknown,
state: ConvergentSyncStateV2,
label: string,
): asserts value is Dot {
if (!isRecord(value)) {
throw new ConvergentSyncInvariantError(`${label} must be a dot`);
}
if (typeof value.deviceId !== 'string' || value.deviceId.length === 0) {
throw new ConvergentSyncInvariantError(`${label}.deviceId must not be empty`);
}
assertPositiveInteger(value.counter, `${label}.counter`);
if ((getOwnRecordValue(state.vector, value.deviceId) ?? 0) < value.counter) {
throw new ConvergentSyncInvariantError(`${label} is not covered by the state vector`);
}
}
function recordVectorWitness(
witnessedDots: Map<string, Set<number>>,
dot: Dot,
): void {
const counters = witnessedDots.get(dot.deviceId) ?? new Set<number>();
counters.add(dot.counter);
witnessedDots.set(dot.deviceId, counters);
}
interface DotLocation {
candidateLabel: string;
registerIdentity: string;
}
interface ContextReference {
key: string;
label: string;
registerIdentity: string;
}
function assertDotOrigin(
state: ConvergentSyncStateV2,
dot: Dot,
expectedRegisterId: string,
label: string,
): void {
const deviceOrigins = getOwnRecordValue(state.dotOrigins, dot.deviceId);
const origin = deviceOrigins
? getOwnRecordValue(deviceOrigins, String(dot.counter))
: undefined;
if (origin !== expectedRegisterId) {
throw new ConvergentSyncInvariantError(
`${label} is assigned to a different register origin`,
);
}
}
function assertVectorIsExactlyWitnessed(
vector: VersionVector,
witnessedDots: Map<string, Set<number>>,
): void {
for (const [deviceId, counter] of Object.entries(vector)) {
const counters = witnessedDots.get(deviceId);
if (!counters || counters.size !== counter || !counters.has(counter)) {
throw new ConvergentSyncInvariantError(
`vector.${deviceId} is not witnessed by retained candidate dots and contexts`,
);
}
}
}
function assertCandidate(
value: unknown,
state: ConvergentSyncStateV2,
label: string,
registerIdentity: string,
globalDots: Map<string, DotLocation>,
witnessedDots: Map<string, Set<number>>,
contextReferences: ContextReference[],
): asserts value is RegisterCandidate {
if (!isRecord(value)) {
throw new ConvergentSyncInvariantError(`${label} must contain a dot`);
}
const candidateDot = value.dot;
assertCoveredDot(candidateDot, state, `${label}.dot`);
assertDotOrigin(state, candidateDot, registerIdentity, `${label}.dot`);
if (!Array.isArray(value.context)) {
throw new ConvergentSyncInvariantError(`${label}.context must be an array of dots`);
}
const contextKeys = new Set<string>();
value.context.forEach((contextDot, index) => {
const contextLabel = `${label}.context[${index}]`;
assertCoveredDot(contextDot, state, contextLabel);
assertDotOrigin(state, contextDot, registerIdentity, contextLabel);
const contextKey = dotKey(contextDot);
if (contextKeys.has(contextKey)) {
throw new ConvergentSyncInvariantError(`${label}.context contains duplicate dot ${contextKey}`);
}
if (contextKey === dotKey(candidateDot)) {
throw new ConvergentSyncInvariantError(`${label}.context must not contain its own dot`);
}
if (
contextDot.deviceId === candidateDot.deviceId
&& contextDot.counter >= candidateDot.counter
) {
throw new ConvergentSyncInvariantError(`${contextLabel} must precede its own device dot`);
}
contextKeys.add(contextKey);
contextReferences.push({ key: contextKey, label: contextLabel, registerIdentity });
recordVectorWitness(witnessedDots, contextDot);
});
const deviceId = candidateDot.deviceId;
assertClock(value.hlc, `${label}.hlc`);
const candidateClock = value.hlc as { wallTime: number; logical: number };
if (compareHybridLogicalClocks(candidateClock, state.hlc) > 0) {
throw new ConvergentSyncInvariantError(`${label}.hlc exceeds the state clock`);
}
if (
value.tombstone !== undefined
&& value.tombstone !== false
&& value.tombstone !== true
) {
throw new ConvergentSyncInvariantError(`${label}.tombstone must be a boolean`);
}
const tombstone = value.tombstone === true;
if (!tombstone && !isJsonValue(value.value)) {
throw new ConvergentSyncInvariantError(`${label}.value must be valid JSON`);
}
if (tombstone && Object.prototype.hasOwnProperty.call(value, 'value')) {
throw new ConvergentSyncInvariantError(`${label} tombstones must not contain a value`);
}
const key = dotKey({ deviceId, counter: candidateDot.counter });
const previousLocation = globalDots.get(key);
if (previousLocation) {
throw new ConvergentSyncInvariantError(
`Dot ${key} is reused by ${previousLocation.candidateLabel} and ${label}`,
);
}
globalDots.set(key, { candidateLabel: label, registerIdentity });
recordVectorWitness(witnessedDots, candidateDot);
}
function assertRegister(
value: unknown,
state: ConvergentSyncStateV2,
label: string,
registerIdentity: string,
globalDots: Map<string, DotLocation>,
witnessedDots: Map<string, Set<number>>,
contextReferences: ContextReference[],
valueValidator?: (candidate: RegisterCandidate, label: string) => void,
): asserts value is MultiValueRegister {
if (!isRecord(value) || !Array.isArray(value.candidates) || value.candidates.length === 0) {
throw new ConvergentSyncInvariantError(`${label} must contain at least one candidate`);
}
value.candidates.forEach((candidate, index) => {
const candidateLabel = `${label}.candidates[${index}]`;
assertCandidate(
candidate,
state,
candidateLabel,
registerIdentity,
globalDots,
witnessedDots,
contextReferences,
);
valueValidator?.(candidate, candidateLabel);
});
}
function assertPresenceCandidate(candidate: RegisterCandidate, label: string): void {
if (!isTombstoneCandidate(candidate) && candidate.value !== true) {
throw new ConvergentSyncInvariantError(`${label} presence values must be true`);
}
}
function assertPositionCandidate(candidate: RegisterCandidate, label: string): void {
if (
!isTombstoneCandidate(candidate)
&& typeof candidate.value !== 'string'
&& typeof candidate.value !== 'number'
) {
throw new ConvergentSyncInvariantError(`${label} position must be a string or number`);
}
}
export function encodeSettingPath(path: string[]): string {
if (path.length === 0 || path.some((segment) => segment.length === 0)) {
throw new ConvergentSyncInvariantError('Setting paths require non-empty segments');
}
return `/${path.map((segment) => segment.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}`;
}
export function decodeSettingPath(encoded: string): string[] {
if (!encoded.startsWith('/') || encoded.length === 1) {
throw new ConvergentSyncInvariantError(`Invalid encoded setting path: ${encoded}`);
}
const path = encoded.slice(1).split('/').map((segment) =>
segment.replaceAll('~1', '/').replaceAll('~0', '~'),
);
if (encodeSettingPath(path) !== encoded) {
throw new ConvergentSyncInvariantError(`Non-canonical setting path: ${encoded}`);
}
return path;
}
export function assertValidConvergentSyncState(
value: unknown,
): asserts value is ConvergentSyncStateV2 {
if (!isRecord(value) || value.schemaVersion !== 2) {
throw new ConvergentSyncInvariantError('Expected convergent sync schema version 2');
}
assertVersionVector(value.vector, 'vector');
assertDotOrigins(value.dotOrigins, value.vector);
assertClock(value.hlc, 'hlc');
if (!isRecord(value.collections) || !isRecord(value.settings) || !isRecord(value.stringCollections)) {
throw new ConvergentSyncInvariantError('Collections, settings, and stringCollections must be objects');
}
const state = value as unknown as ConvergentSyncStateV2;
const globalDots = new Map<string, DotLocation>();
const witnessedDots = new Map<string, Set<number>>();
const contextReferences: ContextReference[] = [];
for (const [collectionName, collection] of Object.entries(state.collections)) {
assertNonEmptyKey(collectionName, 'Collection name');
if (!isRecord(collection) || !isRecord(collection.entities)) {
throw new ConvergentSyncInvariantError(`Collection ${collectionName} must contain entities`);
}
for (const [entityId, entity] of Object.entries(collection.entities)) {
assertNonEmptyKey(entityId, `Entity ID in ${collectionName}`);
if (!isRecord(entity) || !isRecord(entity.fields)) {
throw new ConvergentSyncInvariantError(`Entity ${collectionName}/${entityId} is invalid`);
}
const entityLabel = `collections.${collectionName}.${entityId}`;
assertRegister(
entity.presence,
state,
`${entityLabel}.presence`,
registerId({ kind: 'entity-presence', collection: collectionName, entityId }),
globalDots,
witnessedDots,
contextReferences,
assertPresenceCandidate,
);
if (entity.position !== undefined) {
assertRegister(
entity.position,
state,
`${entityLabel}.position`,
registerId({ kind: 'entity-position', collection: collectionName, entityId }),
globalDots,
witnessedDots,
contextReferences,
assertPositionCandidate,
);
}
for (const [field, register] of Object.entries(entity.fields)) {
assertNonEmptyKey(field, `${entityLabel} field`);
if (field === 'id') {
throw new ConvergentSyncInvariantError(`${entityLabel} must not store structural ID as a field`);
}
assertRegister(
register,
state,
`${entityLabel}.fields.${field}`,
registerId({ kind: 'entity-field', collection: collectionName, entityId, field }),
globalDots,
witnessedDots,
contextReferences,
);
}
}
}
for (const [encodedPath, register] of Object.entries(state.settings)) {
decodeSettingPath(encodedPath);
assertRegister(
register,
state,
`settings.${encodedPath}`,
registerId({ kind: 'setting', path: decodeSettingPath(encodedPath) }),
globalDots,
witnessedDots,
contextReferences,
);
}
for (const [collectionName, collection] of Object.entries(state.stringCollections)) {
assertNonEmptyKey(collectionName, 'String collection name');
if (!isRecord(collection) || !isRecord(collection.entries)) {
throw new ConvergentSyncInvariantError(`String collection ${collectionName} must contain entries`);
}
for (const [entryValue, entry] of Object.entries(collection.entries)) {
assertNonEmptyKey(entryValue, `Entry value in ${collectionName}`);
if (!isRecord(entry)) {
throw new ConvergentSyncInvariantError(`String entry ${collectionName}/${entryValue} is invalid`);
}
const entryLabel = `stringCollections.${collectionName}.${entryValue}`;
assertRegister(
entry.presence,
state,
`${entryLabel}.presence`,
registerId({
kind: 'string-entry-presence',
collection: collectionName,
value: entryValue,
}),
globalDots,
witnessedDots,
contextReferences,
assertPresenceCandidate,
);
if (entry.position !== undefined) {
assertRegister(
entry.position,
state,
`${entryLabel}.position`,
registerId({
kind: 'string-entry-position',
collection: collectionName,
value: entryValue,
}),
globalDots,
witnessedDots,
contextReferences,
assertPositionCandidate,
);
}
}
}
for (const reference of contextReferences) {
const retainedLocation = globalDots.get(reference.key);
if (retainedLocation) {
const location = retainedLocation.registerIdentity === reference.registerIdentity
? 'the same register'
: 'another register';
throw new ConvergentSyncInvariantError(
`${reference.label} references candidate dot ${reference.key} retained in ${location}`,
);
}
}
assertVectorIsExactlyWitnessed(state.vector, witnessedDots);
}
function sortRecord<T>(record: Record<string, T>, clone: (value: T) => T): Record<string, T> {
return Object.fromEntries(
Object.keys(record).sort().map((key) => [key, clone(record[key])]),
);
}
function canonicalCandidate<T extends JsonValue>(
candidate: RegisterCandidate<T>,
): RegisterCandidate<T> {
const base = {
dot: {
deviceId: candidate.dot.deviceId,
counter: candidate.dot.counter,
},
context: candidate.context
.map((dot) => ({
deviceId: dot.deviceId,
counter: dot.counter,
}))
.sort(compareDots),
hlc: {
wallTime: candidate.hlc.wallTime,
logical: candidate.hlc.logical,
},
};
if (isTombstoneCandidate(candidate)) return { ...base, tombstone: true };
return { ...base, value: canonicalizeJson(cloneJson(candidate.value)) };
}
function canonicalRegister<T extends JsonValue>(
register: MultiValueRegister<T>,
): MultiValueRegister<T> {
return {
candidates: register.candidates
.map(canonicalCandidate)
.sort(compareCandidatesByDot),
};
}
function canonicalEntity(entity: ConvergentEntityState): ConvergentEntityState {
return {
presence: canonicalRegister(entity.presence),
...(entity.position ? { position: canonicalRegister(entity.position) } : {}),
fields: sortRecord(entity.fields, canonicalRegister),
};
}
function canonicalCollection(collection: ConvergentCollectionState): ConvergentCollectionState {
return { entities: sortRecord(collection.entities, canonicalEntity) };
}
function canonicalStringEntry(entry: ConvergentStringEntryState): ConvergentStringEntryState {
return {
presence: canonicalRegister(entry.presence),
...(entry.position ? { position: canonicalRegister(entry.position) } : {}),
};
}
function canonicalStringCollection(
collection: ConvergentStringCollectionState,
): ConvergentStringCollectionState {
return { entries: sortRecord(collection.entries, canonicalStringEntry) };
}
function canonicalDotOrigins(origins: DotOriginIndex): DotOriginIndex {
return Object.fromEntries(
Object.keys(origins).sort().map((deviceId) => [
deviceId,
Object.fromEntries(
Object.entries(origins[deviceId])
.sort(([left], [right]) => Number(left) - Number(right)),
),
]),
);
}
export function canonicalizeConvergentSyncState(
state: ConvergentSyncStateV2,
): ConvergentSyncStateV2 {
assertValidConvergentSyncState(state);
return {
schemaVersion: 2,
vector: sortRecord(state.vector, (counter) => counter),
dotOrigins: canonicalDotOrigins(state.dotOrigins),
hlc: {
wallTime: state.hlc.wallTime,
logical: state.hlc.logical,
},
collections: sortRecord(state.collections, canonicalCollection),
settings: sortRecord(state.settings, canonicalRegister),
stringCollections: sortRecord(state.stringCollections, canonicalStringCollection),
};
}
export function serializeConvergentSyncState(state: ConvergentSyncStateV2): string {
return JSON.stringify(canonicalizeConvergentSyncState(state));
}
export function hydrateConvergentSyncState(serialized: string): ConvergentSyncStateV2 {
let parsed: unknown;
try {
parsed = JSON.parse(serialized) as unknown;
} catch (error) {
throw new ConvergentSyncInvariantError(
`Invalid convergent sync JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
assertValidConvergentSyncState(parsed);
return canonicalizeConvergentSyncState(parsed);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,261 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue =
| JsonPrimitive
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonObject = { [key: string]: JsonValue };
export interface VersionVector {
[deviceId: string]: number;
}
export interface DotOriginIndex {
[deviceId: string]: Record<string, string>;
}
export interface Dot {
deviceId: string;
counter: number;
}
export interface HybridLogicalClock {
wallTime: number;
logical: number;
}
interface RegisterCandidateBase {
dot: Dot;
/** Dots observed in this register before this candidate was written. */
context: Dot[];
hlc: HybridLogicalClock;
}
export interface RegisterValueCandidate<T extends JsonValue = JsonValue>
extends RegisterCandidateBase {
value: T;
tombstone?: false;
}
export interface RegisterTombstoneCandidate extends RegisterCandidateBase {
tombstone: true;
}
export type RegisterCandidate<T extends JsonValue = JsonValue> =
| RegisterValueCandidate<T>
| RegisterTombstoneCandidate;
export interface MultiValueRegister<T extends JsonValue = JsonValue> {
candidates: RegisterCandidate<T>[];
}
export type CollectionPosition = string | number;
export interface ConvergentEntityState {
presence: MultiValueRegister<boolean>;
position?: MultiValueRegister<CollectionPosition>;
fields: Record<string, MultiValueRegister>;
}
export interface ConvergentCollectionState {
entities: Record<string, ConvergentEntityState>;
}
export interface ConvergentStringEntryState {
presence: MultiValueRegister<boolean>;
position?: MultiValueRegister<CollectionPosition>;
}
export interface ConvergentStringCollectionState {
entries: Record<string, ConvergentStringEntryState>;
}
/**
* Pure CRDT state. The encrypted protocol envelope is introduced separately;
* this type deliberately contains no provider, persistence, or UI concerns.
*/
export interface ConvergentSyncStateV2 {
schemaVersion: 2;
vector: VersionVector;
/** Register identity for every allocated device counter. */
dotOrigins: DotOriginIndex;
hlc: HybridLogicalClock;
collections: Record<string, ConvergentCollectionState>;
settings: Record<string, MultiValueRegister>;
stringCollections: Record<string, ConvergentStringCollectionState>;
}
/**
* A register candidate stored in the encrypted cloud envelope. Winner values
* that already exist in the adjacent materialized v1 snapshot may be omitted
* and reconstructed during hydration. Structural values (presence and
* position) remain inline so the envelope is self-describing.
*/
export type ConvergentEnvelopeCandidate<T extends JsonValue = JsonValue> =
| RegisterTombstoneCandidate
| (Omit<RegisterValueCandidate<T>, 'value'> & {
value?: T;
materialized?: true;
});
export interface ConvergentEnvelopeRegister<T extends JsonValue = JsonValue> {
candidates: ConvergentEnvelopeCandidate<T>[];
}
export interface ConvergentEnvelopeEntityState {
presence: ConvergentEnvelopeRegister<boolean>;
position?: ConvergentEnvelopeRegister<CollectionPosition>;
fields: Record<string, ConvergentEnvelopeRegister>;
}
export interface ConvergentEnvelopeCollectionState {
entities: Record<string, ConvergentEnvelopeEntityState>;
}
export interface ConvergentEnvelopeStringEntryState {
presence: ConvergentEnvelopeRegister<boolean>;
position?: ConvergentEnvelopeRegister<CollectionPosition>;
}
export interface ConvergentEnvelopeStringCollectionState {
entries: Record<string, ConvergentEnvelopeStringEntryState>;
}
export interface ConvergentEnvelopeStateV2 {
vector: VersionVector;
dotOrigins: DotOriginIndex;
hlc: HybridLogicalClock;
collections: Record<string, ConvergentEnvelopeCollectionState>;
settings: Record<string, ConvergentEnvelopeRegister>;
stringCollections: Record<string, ConvergentEnvelopeStringCollectionState>;
}
/**
* Stored inside the AES-256-GCM encrypted SyncPayload. Plaintext metadata only
* advertises `syncSchemaVersion: 2`; candidate values never leave ciphertext.
*/
export interface ConvergentSyncEnvelopeV2 {
schemaVersion: 2;
encoding: 'materialized-winner-v1';
state: ConvergentEnvelopeStateV2;
}
export type RegisterAddress =
| {
kind: 'entity-presence';
collection: string;
entityId: string;
}
| {
kind: 'entity-position';
collection: string;
entityId: string;
}
| {
kind: 'entity-field';
collection: string;
entityId: string;
field: string;
}
| {
kind: 'setting';
path: string[];
}
| {
kind: 'string-entry-presence';
collection: string;
value: string;
}
| {
kind: 'string-entry-position';
collection: string;
value: string;
};
export type ConvergentConflictAddress = RegisterAddress | {
kind: 'setting-structure';
paths: string[][];
};
export type ConvergentMutation =
| {
kind: 'entity-upsert';
collection: string;
entityId: string;
value: JsonObject;
position?: CollectionPosition;
}
| {
kind: 'entity-field-set';
collection: string;
entityId: string;
field: string;
value: JsonValue;
}
| {
kind: 'entity-field-delete';
collection: string;
entityId: string;
field: string;
}
| {
kind: 'entity-delete';
collection: string;
entityId: string;
}
| {
kind: 'setting-set';
path: string[];
value: JsonValue;
}
| {
kind: 'setting-delete';
path: string[];
}
| {
kind: 'string-entry-add';
collection: string;
value: string;
position?: CollectionPosition;
}
| {
kind: 'string-entry-delete';
collection: string;
value: string;
}
| {
kind: 'resolve-register';
address: RegisterAddress;
value?: JsonValue;
tombstone?: boolean;
};
export interface ConvergentConflictCandidate {
dot: Dot;
hlc: HybridLogicalClock;
tombstone: boolean;
value?: JsonValue;
/** Present when candidates from multiple setting leaf paths conflict. */
settingPath?: string[];
selected: boolean;
}
export interface ConvergentFieldConflict {
address: ConvergentConflictAddress;
candidates: ConvergentConflictCandidate[];
}
export interface MaterializedConvergentSyncState {
collections: Record<string, JsonObject[]>;
settings: JsonObject;
stringCollections: Record<string, string[]>;
conflicts: ConvergentFieldConflict[];
}
export class ConvergentSyncInvariantError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConvergentSyncInvariantError';
}
}

182
domain/credentials.test.ts Normal file
View File

@@ -0,0 +1,182 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
findSyncPayloadEncryptedCredentialPaths,
healPoisonedSecretsForMerge,
isEncryptedCredentialPlaceholder,
isVaultStoredKeySource,
needsVaultStoredKeyHydration,
stripSyncPayloadEncryptedCredentials,
} from "./credentials.ts";
import type { SyncPayload } from "./sync.ts";
const completeBlob = Buffer.alloc(19, 0);
Buffer.from("v10", "utf8").copy(completeBlob, 0);
const ENC = `enc:v1:${completeBlob.toString("base64")}`;
function samplePayload(overrides: Partial<SyncPayload> = {}): SyncPayload {
return {
hosts: [
{
id: "h1",
label: "prod",
hostname: "prod.example",
username: "root",
password: ENC,
port: 22,
os: "linux",
group: "",
tags: [],
protocol: "ssh",
},
],
keys: [
{
id: "k1",
label: "key",
type: "ED25519",
privateKey: ENC,
source: "imported",
category: "key",
created: 1,
},
],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
...overrides,
};
}
test("isEncryptedCredentialPlaceholder detects complete v10 device-bound ciphertext", () => {
assert.equal(isEncryptedCredentialPlaceholder(ENC), true);
});
test("isEncryptedCredentialPlaceholder rejects intermediate v10 lengths that are neither CBC nor GCM", () => {
const body = Buffer.alloc(24, 0);
Buffer.from("v10", "utf8").copy(body, 0);
assert.equal(isEncryptedCredentialPlaceholder(`enc:v1:${body.toString("base64")}`), false);
});
test("isEncryptedCredentialPlaceholder detects real Windows DPAPI base64 prefixes", () => {
const body = Buffer.from([
0x01, 0x00, 0x00, 0x00,
0xd0, 0x8c, 0x9d, 0xdf, 0x01, 0x15, 0xd1, 0x11,
0x8c, 0x7a, 0x00, 0xc0, 0x4f, 0xc2, 0x97, 0xeb,
0xaa,
]);
const encoded = body.toString("base64");
assert.equal(encoded.startsWith("AQAAANCMnd8"), true);
assert.equal(isEncryptedCredentialPlaceholder(`enc:v1:${encoded}`), true);
});
test("isEncryptedCredentialPlaceholder rejects header-only enc:v1 payloads", () => {
assert.equal(isEncryptedCredentialPlaceholder("enc:v1:djEw"), false);
});
test("needsVaultStoredKeyHydration waits for imported or generated ciphertext and empty keys", () => {
assert.equal(isVaultStoredKeySource("imported"), true);
assert.equal(isVaultStoredKeySource("generated"), true);
assert.equal(isVaultStoredKeySource("reference"), false);
assert.equal(needsVaultStoredKeyHydration({
source: "imported",
privateKey: ENC,
}), true);
assert.equal(needsVaultStoredKeyHydration({
source: "generated",
privateKey: "",
}), true);
assert.equal(needsVaultStoredKeyHydration({
source: "imported",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----",
}), false);
assert.equal(needsVaultStoredKeyHydration({
source: "reference",
privateKey: ENC,
}), false);
});
test("findSyncPayloadEncryptedCredentialPaths reports host and key secrets", () => {
const paths = findSyncPayloadEncryptedCredentialPaths(samplePayload());
assert.deepEqual(paths, ["hosts[0].password", "keys[0].privateKey"]);
});
test("stripSyncPayloadEncryptedCredentials clears device-bound placeholders for recovery", () => {
const stripped = stripSyncPayloadEncryptedCredentials(samplePayload());
assert.equal(stripped.hosts[0]?.password, undefined);
assert.equal(stripped.keys[0]?.privateKey, "");
assert.equal(findSyncPayloadEncryptedCredentialPaths(stripped).length, 0);
});
test("healPoisonedSecretsForMerge keeps usable preferred passwords over poisoned enc:v1", () => {
const poisoned = samplePayload();
const preferred = samplePayload({
hosts: [{
...samplePayload().hosts[0]!,
password: "preferred-secret",
}],
keys: [{
...samplePayload().keys[0]!,
privateKey: "PREFERRED_PRIVATE_KEY",
}],
});
const fallback = samplePayload({
hosts: [{
...samplePayload().hosts[0]!,
password: "base-secret",
}],
});
const healed = healPoisonedSecretsForMerge(poisoned, preferred, fallback);
assert.equal(healed.hosts[0]?.password, "preferred-secret");
assert.equal(healed.keys[0]?.privateKey, "PREFERRED_PRIVATE_KEY");
});
test("healPoisonedSecretsForMerge heals local poison from remote then base", () => {
const local = samplePayload();
const remote = samplePayload({
hosts: [{
...samplePayload().hosts[0]!,
password: "remote-secret",
}],
keys: [{
...samplePayload().keys[0]!,
privateKey: ENC,
}],
});
const base = samplePayload({
keys: [{
...samplePayload().keys[0]!,
privateKey: "BASE_PRIVATE_KEY",
}],
});
const healed = healPoisonedSecretsForMerge(local, remote, base);
assert.equal(healed.hosts[0]?.password, "remote-secret");
assert.equal(healed.keys[0]?.privateKey, "BASE_PRIVATE_KEY");
});
test("healPoisonedSecretsForMerge preserves explicit preferred credential deletions", () => {
const poisoned = samplePayload({
hosts: [{
...samplePayload().hosts[0]!,
label: "renamed-on-poisoned-device",
password: ENC,
}],
});
const preferred = samplePayload({
hosts: [{
...samplePayload().hosts[0]!,
label: "renamed-on-poisoned-device",
password: undefined,
}],
});
const fallback = samplePayload({
hosts: [{
...samplePayload().hosts[0]!,
password: "base-secret",
}],
});
const healed = healPoisonedSecretsForMerge(poisoned, preferred, fallback);
assert.equal(healed.hosts[0]?.password, undefined);
assert.equal(healed.hosts[0]?.label, "renamed-on-poisoned-device");
});

448
domain/credentials.ts Normal file
View File

@@ -0,0 +1,448 @@
import type { SyncPayload } from "./sync";
const CREDENTIAL_ENCRYPTION_PREFIX = "enc:v1:";
/**
* Base64 pattern: only allows A-Z, a-z, 0-9, +, / and trailing = padding.
*/
const BASE64_RE = /^[A-Za-z0-9+/]+=*$/;
/**
* Chromium/Electron safeStorage ciphertext carries known platform headers:
* - macOS/Linux: plaintext bytes start with "v10" or "v11"
* - Windows (legacy DPAPI blob): leading bytes are 0x01 0x00 0x00 0x00
*
* Detect headers on *decoded* bytes. A four-byte DPAPI version alone is not
* enough — real blobs continue with provider GUID
* {df9d8cd0-1501-11d1-8c7a-00c04fc297eb} (base64 `AQAAANCMnd8...`).
*
* We require a known header AND a complete-enough decoded blob. v10/v11 CBC
* blobs are at least header(3) + one AES block(16) = 19 bytes. Header-only
* base64 such as `enc:v1:djEw` must not be treated as ciphertext.
*
* Keep in sync with electron/bridges/credentialBridge.cjs.
*
* References:
* - components/os_crypt/sync/os_crypt_mac.mm (kObfuscationPrefixV10 = "v10")
* - components/os_crypt/sync/os_crypt_linux.cc (kObfuscationPrefixV10/V11)
* - components/os_crypt/sync/os_crypt_win.cc (DPAPI legacy path)
*/
const V10_HEADER = [0x76, 0x31, 0x30] as const; // "v10"
const V11_HEADER = [0x76, 0x31, 0x31] as const; // "v11"
// Version (4) + provider GUID {df9d8cd0-1501-11d1-8c7a-00c04fc297eb} (16).
const DPAPI_BLOB_PREFIX = [
0x01, 0x00, 0x00, 0x00,
0xd0, 0x8c, 0x9d, 0xdf, 0x01, 0x15, 0xd1, 0x11,
0x8c, 0x7a, 0x00, 0xc0, 0x4f, 0xc2, 0x97, 0xeb,
] as const;
/** Minimum decoded sizes for complete Chromium OSCrypt blobs. */
const MIN_V10_V11_CIPHERTEXT_BYTES = 19; // CBC: header(3) + one AES block(16)
const MIN_V10_V11_GCM_CIPHERTEXT_BYTES = 31; // header(3) + nonce(12) + tag(16)
const MIN_DPAPI_CIPHERTEXT_BYTES = DPAPI_BLOB_PREFIX.length + 1;
/**
* Renderer-safe base64 decode. Avoids Node `Buffer` which is unavailable
* in Electron windows with `nodeIntegration: false`.
*/
const decodeBase64Bytes = (payload: string): Uint8Array | null => {
try {
if (typeof atob === "function") {
const binary = atob(payload);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
out[i] = binary.charCodeAt(i);
}
return out;
}
} catch {
// fall through
}
// Node / test environments without atob.
if (typeof Buffer !== "undefined") {
try {
return new Uint8Array(Buffer.from(payload, "base64"));
} catch {
return null;
}
}
return null;
};
const startsWithBytes = (
decoded: Uint8Array,
prefix: readonly number[],
): boolean => {
if (decoded.byteLength < prefix.length) return false;
return prefix.every((byte, index) => decoded[index] === byte);
};
/** CBC is header(3) + 16-byte blocks; GCM is at least header+nonce+tag (31). */
const isValidV10V11CiphertextLength = (byteLength: number): boolean => {
if (byteLength >= MIN_V10_V11_GCM_CIPHERTEXT_BYTES) return true;
return byteLength >= MIN_V10_V11_CIPHERTEXT_BYTES
&& (byteLength - 3) % 16 === 0;
};
export const isEncryptedCredentialPlaceholder = (
value: string | undefined | null,
): value is string => {
if (typeof value !== "string" || !value.startsWith(CREDENTIAL_ENCRYPTION_PREFIX)) {
return false;
}
const payload = value.slice(CREDENTIAL_ENCRYPTION_PREFIX.length);
if (!payload || !BASE64_RE.test(payload)) return false;
const decoded = decodeBase64Bytes(payload);
if (!decoded) return false;
if (startsWithBytes(decoded, V10_HEADER) || startsWithBytes(decoded, V11_HEADER)) {
return isValidV10V11CiphertextLength(decoded.byteLength);
}
if (startsWithBytes(decoded, DPAPI_BLOB_PREFIX)) {
return decoded.byteLength >= MIN_DPAPI_CIPHERTEXT_BYTES;
}
return false;
};
/**
* Strip enc:v1: placeholders from a single credential value.
* Used at the terminal connection boundary to avoid sending encrypted
* placeholders as actual passwords to SSH/Telnet servers.
*/
export const sanitizeCredentialValue = (
value: string | undefined,
): string | undefined => {
if (isEncryptedCredentialPlaceholder(value)) return undefined;
return value;
};
export const isVaultStoredKeySource = (
source: string | undefined,
): source is "imported" | "generated" =>
source === "imported" || source === "generated";
/**
* Imported/generated keys store private material in the vault. Empty or
* still-encrypted privateKey means hydration has not finished (or failed).
*/
export const needsVaultStoredKeyHydration = (
key?: { source?: string; privateKey?: string } | null,
): boolean => {
if (!key || !isVaultStoredKeySource(key.source)) return false;
return !key.privateKey || isEncryptedCredentialPlaceholder(key.privateKey);
};
/**
* Scan a sync payload for any fields that still carry device-bound
* enc:v1: ciphertext. Returns the dotted paths of offending fields.
* Used as a pre-upload guard to prevent pushing un-decryptable data.
*/
export const findSyncPayloadEncryptedCredentialPaths = (
payload: SyncPayload,
): string[] => {
const issues: string[] = [];
payload.hosts.forEach((host, index) => {
if (isEncryptedCredentialPlaceholder(host.password)) {
issues.push(`hosts[${index}].password`);
}
if (isEncryptedCredentialPlaceholder(host.telnetPassword)) {
issues.push(`hosts[${index}].telnetPassword`);
}
if (isEncryptedCredentialPlaceholder(host.proxyConfig?.password)) {
issues.push(`hosts[${index}].proxyConfig.password`);
}
});
payload.keys.forEach((key, index) => {
if (isEncryptedCredentialPlaceholder(key.privateKey)) {
issues.push(`keys[${index}].privateKey`);
}
if (isEncryptedCredentialPlaceholder(key.passphrase)) {
issues.push(`keys[${index}].passphrase`);
}
});
payload.identities?.forEach((identity, index) => {
if (isEncryptedCredentialPlaceholder(identity.password)) {
issues.push(`identities[${index}].password`);
}
});
payload.proxyProfiles?.forEach((profile, index) => {
if (isEncryptedCredentialPlaceholder(profile.config.password)) {
issues.push(`proxyProfiles[${index}].config.password`);
}
});
payload.groupConfigs?.forEach((config, index) => {
if (isEncryptedCredentialPlaceholder(config.password)) {
issues.push(`groupConfigs[${index}].password`);
}
if (isEncryptedCredentialPlaceholder(config.telnetPassword)) {
issues.push(`groupConfigs[${index}].telnetPassword`);
}
if (isEncryptedCredentialPlaceholder(config.proxyConfig?.password)) {
issues.push(`groupConfigs[${index}].proxyConfig.password`);
}
});
return issues;
};
/**
* Clear device-bound enc:v1 placeholders from a portable sync payload.
*
* Cloud / backup payloads must carry plaintext secrets (protected by the
* master key envelope). If a previous bug uploaded undecryptable local
* ciphertext, stripping placeholders lets download restore a usable vault
* shell so the user can re-enter credentials instead of looping forever.
*/
export const stripSyncPayloadEncryptedCredentials = (
payload: SyncPayload,
): SyncPayload => {
const hosts = payload.hosts.map((host) => {
const next = { ...host };
if (isEncryptedCredentialPlaceholder(next.password)) delete next.password;
if (isEncryptedCredentialPlaceholder(next.telnetPassword)) delete next.telnetPassword;
if (next.proxyConfig && isEncryptedCredentialPlaceholder(next.proxyConfig.password)) {
const { password: _removed, ...proxyRest } = next.proxyConfig;
next.proxyConfig = proxyRest;
}
return next;
});
const keys = payload.keys.map((key) => {
const next = { ...key };
if (isEncryptedCredentialPlaceholder(next.privateKey)) next.privateKey = "";
if (isEncryptedCredentialPlaceholder(next.passphrase)) delete next.passphrase;
return next;
});
const identities = payload.identities?.map((identity) => {
if (!isEncryptedCredentialPlaceholder(identity.password)) return identity;
const next = { ...identity };
delete next.password;
return next;
});
const proxyProfiles = payload.proxyProfiles?.map((profile) => {
if (!isEncryptedCredentialPlaceholder(profile.config.password)) return profile;
const { password: _removed, ...configRest } = profile.config;
return { ...profile, config: configRest };
});
const groupConfigs = payload.groupConfigs?.map((config) => {
const next = { ...config };
if (isEncryptedCredentialPlaceholder(next.password)) delete next.password;
if (isEncryptedCredentialPlaceholder(next.telnetPassword)) delete next.telnetPassword;
if (next.proxyConfig && isEncryptedCredentialPlaceholder(next.proxyConfig.password)) {
const { password: _removed, ...proxyRest } = next.proxyConfig;
next.proxyConfig = proxyRest;
}
return next;
});
return {
...payload,
hosts,
keys,
identities: identities ?? payload.identities,
proxyProfiles: proxyProfiles ?? payload.proxyProfiles,
groupConfigs: groupConfigs ?? payload.groupConfigs,
};
};
const usableCredential = (value: string | undefined): string | undefined => {
if (typeof value !== "string" || value.length === 0) return undefined;
if (isEncryptedCredentialPlaceholder(value)) return undefined;
return value;
};
/**
* Resolve a poisoned secret against preferred then fallback.
*
* If the preferred *entity* exists, an empty/missing preferred secret is an
* explicit deletion and must win over fallback/base. Only when preferred is
* also poisoned (or the preferred entity is absent) may we revive from base.
*/
const healPoisonedCredential = (
preferredEntity: unknown,
preferredValue: string | undefined,
fallbackValue: string | undefined,
): string | undefined => {
if (preferredEntity) {
if (isEncryptedCredentialPlaceholder(preferredValue)) {
return usableCredential(fallbackValue);
}
return usableCredential(preferredValue);
}
return usableCredential(fallbackValue);
};
/**
* Before three-way merge, replace device-bound enc:v1 secrets on `poisoned`
* with usable values from `preferred` then `fallback`. Used for both remote
* and local sides so a poisoned field cannot win the entity-level merge and
* delete a still-usable secret from the other side / base.
*
* Explicit preferred-side deletions (empty/absent secrets on a present entity)
* are authoritative and are not revived from base.
*/
export const healPoisonedSecretsForMerge = (
poisoned: SyncPayload,
preferred: SyncPayload,
fallback: SyncPayload | null | undefined,
): SyncPayload => {
const preferredHosts = new Map(preferred.hosts.map((host) => [host.id, host]));
const fallbackHosts = new Map((fallback?.hosts ?? []).map((host) => [host.id, host]));
const hosts = poisoned.hosts.map((host) => {
const preferredHost = preferredHosts.get(host.id);
const fallbackHost = fallbackHosts.get(host.id);
const next = { ...host };
if (isEncryptedCredentialPlaceholder(next.password)) {
const healed = healPoisonedCredential(
preferredHost,
preferredHost?.password,
fallbackHost?.password,
);
if (healed !== undefined) next.password = healed;
else delete next.password;
}
if (isEncryptedCredentialPlaceholder(next.telnetPassword)) {
const healed = healPoisonedCredential(
preferredHost,
preferredHost?.telnetPassword,
fallbackHost?.telnetPassword,
);
if (healed !== undefined) next.telnetPassword = healed;
else delete next.telnetPassword;
}
if (next.proxyConfig && isEncryptedCredentialPlaceholder(next.proxyConfig.password)) {
const healed = healPoisonedCredential(
preferredHost,
preferredHost?.proxyConfig?.password,
fallbackHost?.proxyConfig?.password,
);
if (healed !== undefined) {
next.proxyConfig = { ...next.proxyConfig, password: healed };
} else {
const { password: _removed, ...proxyRest } = next.proxyConfig;
next.proxyConfig = proxyRest;
}
}
return next;
});
const preferredKeys = new Map(preferred.keys.map((key) => [key.id, key]));
const fallbackKeys = new Map((fallback?.keys ?? []).map((key) => [key.id, key]));
const keys = poisoned.keys.map((key) => {
const preferredKey = preferredKeys.get(key.id);
const fallbackKey = fallbackKeys.get(key.id);
const next = { ...key };
if (isEncryptedCredentialPlaceholder(next.privateKey)) {
next.privateKey = healPoisonedCredential(
preferredKey,
preferredKey?.privateKey,
fallbackKey?.privateKey,
) ?? "";
}
if (isEncryptedCredentialPlaceholder(next.passphrase)) {
const healed = healPoisonedCredential(
preferredKey,
preferredKey?.passphrase,
fallbackKey?.passphrase,
);
if (healed !== undefined) next.passphrase = healed;
else delete next.passphrase;
}
return next;
});
const preferredIdentities = new Map((preferred.identities ?? []).map((identity) => [identity.id, identity]));
const fallbackIdentities = new Map((fallback?.identities ?? []).map((identity) => [identity.id, identity]));
const identities = poisoned.identities?.map((identity) => {
if (!isEncryptedCredentialPlaceholder(identity.password)) return identity;
const preferredIdentity = preferredIdentities.get(identity.id);
const healed = healPoisonedCredential(
preferredIdentity,
preferredIdentity?.password,
fallbackIdentities.get(identity.id)?.password,
);
if (healed !== undefined) return { ...identity, password: healed };
const next = { ...identity };
delete next.password;
return next;
});
const preferredProfiles = new Map((preferred.proxyProfiles ?? []).map((profile) => [profile.id, profile]));
const fallbackProfiles = new Map((fallback?.proxyProfiles ?? []).map((profile) => [profile.id, profile]));
const proxyProfiles = poisoned.proxyProfiles?.map((profile) => {
if (!isEncryptedCredentialPlaceholder(profile.config.password)) return profile;
const preferredProfile = preferredProfiles.get(profile.id);
const healed = healPoisonedCredential(
preferredProfile,
preferredProfile?.config.password,
fallbackProfiles.get(profile.id)?.config.password,
);
if (healed !== undefined) {
return { ...profile, config: { ...profile.config, password: healed } };
}
const { password: _removed, ...configRest } = profile.config;
return { ...profile, config: configRest };
});
const preferredGroupConfigs = new Map((preferred.groupConfigs ?? []).map((config) => [config.path, config]));
const fallbackGroupConfigs = new Map((fallback?.groupConfigs ?? []).map((config) => [config.path, config]));
const groupConfigs = poisoned.groupConfigs?.map((config) => {
const preferredConfig = preferredGroupConfigs.get(config.path);
const fallbackConfig = fallbackGroupConfigs.get(config.path);
const next = { ...config };
let changed = false;
if (isEncryptedCredentialPlaceholder(next.password)) {
const healed = healPoisonedCredential(
preferredConfig,
preferredConfig?.password,
fallbackConfig?.password,
);
if (healed !== undefined) next.password = healed;
else delete next.password;
changed = true;
}
if (isEncryptedCredentialPlaceholder(next.telnetPassword)) {
const healed = healPoisonedCredential(
preferredConfig,
preferredConfig?.telnetPassword,
fallbackConfig?.telnetPassword,
);
if (healed !== undefined) next.telnetPassword = healed;
else delete next.telnetPassword;
changed = true;
}
if (next.proxyConfig && isEncryptedCredentialPlaceholder(next.proxyConfig.password)) {
const healed = healPoisonedCredential(
preferredConfig,
preferredConfig?.proxyConfig?.password,
fallbackConfig?.proxyConfig?.password,
);
if (healed !== undefined) {
next.proxyConfig = { ...next.proxyConfig, password: healed };
} else {
const { password: _removed, ...proxyRest } = next.proxyConfig;
next.proxyConfig = proxyRest;
}
changed = true;
}
return changed ? next : config;
});
return {
...poisoned,
hosts,
keys,
identities: identities ?? poisoned.identities,
proxyProfiles: proxyProfiles ?? poisoned.proxyProfiles,
groupConfigs: groupConfigs ?? poisoned.groupConfigs,
};
};

View File

@@ -0,0 +1,22 @@
/**
* Shared complete enc:v1 fixtures for tests.
*
* Production detectors require a full platform-shaped safeStorage blob
* (v10/v11 CBC >= 19 bytes, or DPAPI >= 20). Short strings like
* `enc:v1:djEwAAAA` are no longer treated as ciphertext.
*/
export const MIN_V10_TEST_CIPHERTEXT_BYTES = 19;
export function makeEncryptedCredentialPlaceholder(seed = "fixture"): string {
const body = Buffer.alloc(MIN_V10_TEST_CIPHERTEXT_BYTES, 0);
Buffer.from("v10", "utf8").copy(body, 0);
Buffer.from(String(seed).slice(0, 16), "utf8").copy(body, 3);
return `enc:v1:${body.toString("base64")}`;
}
/** Stable complete placeholder used by most auth/SFTP/proxy tests. */
export const ENCRYPTED_CREDENTIAL_PLACEHOLDER = makeEncryptedCredentialPlaceholder("test");
/** Alternate complete placeholder (historically `enc:v1:djEwYWJj`). */
export const ENCRYPTED_CREDENTIAL_PLACEHOLDER_ABC = makeEncryptedCredentialPlaceholder("abc");

View File

@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
CURSOR_LINE_HIGHLIGHT_BLEND,
ensureCursorLineHighlightContrast,
resolveCursorLineHighlightBackground,
} from './cursorLineHighlight.ts';
test('resolveCursorLineHighlightBackground mixes the selection with the theme background', () => {
const color = resolveCursorLineHighlightBackground({
background: '#0d1117',
foreground: '#c9d1d9',
selection: '#264f78',
});
assert.equal(color, '#1b334c');
});
test('resolveCursorLineHighlightBackground falls back to foreground when selection is invalid', () => {
const withSelection = resolveCursorLineHighlightBackground({
background: '#000000',
foreground: '#ffffff',
selection: '#808080',
});
const withoutSelection = resolveCursorLineHighlightBackground({
background: '#000000',
foreground: '#ffffff',
selection: 'not-a-color',
});
assert.equal(withSelection, '#464646');
assert.equal(withoutSelection, '#707070');
});
test('resolveCursorLineHighlightBackground expands short hex and strips alpha', () => {
const short = resolveCursorLineHighlightBackground({
background: '#000',
foreground: '#fff',
selection: '#88888888',
});
assert.equal(short, '#4b4b4b');
});
test('CURSOR_LINE_HIGHLIGHT_BLEND stays a visible fraction', () => {
assert.ok(CURSOR_LINE_HIGHLIGHT_BLEND > 0 && CURSOR_LINE_HIGHLIGHT_BLEND < 1);
});
test('resolveCursorLineHighlightBackground keeps white text readable', () => {
const color = resolveCursorLineHighlightBackground({
background: '#0d1117',
foreground: '#ffffff',
selection: '#ffffff',
});
const channel = Number.parseInt(color.slice(1, 3), 16) / 255;
const luminance = channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
assert.ok((1.05) / (luminance + 0.05) >= 4.5);
});
test('resolveCursorLineHighlightBackground repairs a low-contrast light theme', () => {
assert.equal(
resolveCursorLineHighlightBackground({
background: '#f0f0f0',
foreground: '#888888',
selection: '#ffffff',
}),
'#000000',
);
});
test('ensureCursorLineHighlightContrast protects keyword blue', () => {
assert.equal(
ensureCursorLineHighlightContrast('#1b334c', ['#3b82f6']),
'#000000',
);
});
test('resolveCursorLineHighlightBackground keeps Tokyo Night Light visible', () => {
assert.notEqual(
resolveCursorLineHighlightBackground({
background: '#e1e2e7',
foreground: '#3760bf',
selection: '#abc7d4',
}),
'#e1e2e7',
);
});

View File

@@ -0,0 +1,127 @@
/** Pure helpers for the terminal cursor-line highlight (WindTerm-style). */
export type CursorLineHighlightColors = {
background: string;
foreground: string;
selection: string;
};
const HEX_RGB_RE = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
type Rgb = { r: number; g: number; b: number };
const MIN_CURSOR_LINE_CONTRAST = 4.5;
const parseHexRgb = (value: string): Rgb | null => {
const match = value.trim().match(HEX_RGB_RE);
if (!match) return null;
let hex = match[1];
if (hex.length === 3) {
hex = hex.split('').map((ch) => ch + ch).join('');
} else if (hex.length === 8) {
hex = hex.slice(0, 6);
}
const r = Number.parseInt(hex.slice(0, 2), 16);
const g = Number.parseInt(hex.slice(2, 4), 16);
const b = Number.parseInt(hex.slice(4, 6), 16);
if (![r, g, b].every((channel) => Number.isFinite(channel))) return null;
return { r, g, b };
};
const relativeLuminance = ({ r, g, b }: Rgb): number => {
const linearize = (channel: number) => {
const normalized = channel / 255;
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
};
const contrastRatio = (left: Rgb, right: Rgb): number => {
const brighter = Math.max(relativeLuminance(left), relativeLuminance(right));
const darker = Math.min(relativeLuminance(left), relativeLuminance(right));
return (brighter + 0.05) / (darker + 0.05);
};
const mixRgb = (base: Rgb, accent: Rgb, amount: number): Rgb => ({
r: Math.round(base.r + (accent.r - base.r) * amount),
g: Math.round(base.g + (accent.g - base.g) * amount),
b: Math.round(base.b + (accent.b - base.b) * amount),
});
const toHex = ({ r, g, b }: Rgb): string =>
`#${[r, g, b].map((channel) => channel.toString(16).padStart(2, '0')).join('')}`;
/** Keep the cursor-row background readable against other foreground decorations. */
export const ensureCursorLineHighlightContrast = (
backgroundColor: string,
foregroundColors: readonly string[],
): string => {
const background = parseHexRgb(backgroundColor);
const foregrounds = foregroundColors
.map(parseHexRgb)
.filter((color): color is Rgb => color !== null);
if (!background || foregrounds.length === 0) return backgroundColor;
const contrastScore = (candidate: Rgb) =>
Math.min(...foregrounds.map((foreground) => contrastRatio(candidate, foreground)));
const baseScore = contrastScore(background);
if (baseScore >= MIN_CURSOR_LINE_CONTRAST) return backgroundColor;
const black = { r: 0, g: 0, b: 0 };
const white = { r: 255, g: 255, b: 255 };
const blackScore = contrastScore(black);
const whiteScore = contrastScore(white);
if (blackScore >= MIN_CURSOR_LINE_CONTRAST && blackScore >= whiteScore) return '#000000';
if (whiteScore >= MIN_CURSOR_LINE_CONTRAST) return '#ffffff';
return blackScore >= Math.max(baseScore, whiteScore) ? '#000000' : '#ffffff';
};
/** Strength of the theme accent mixed into the terminal background. */
export const CURSOR_LINE_HIGHLIGHT_BLEND = 0.55;
/**
* Resolve an opaque background for the cursor line decoration.
* Prefers selection over foreground so the highlight stays theme-aligned.
* The renderer applies this only to cells with the default background, which
* keeps ANSI-colored cells and the terminal's text foreground untouched.
*/
export const resolveCursorLineHighlightBackground = (
colors: CursorLineHighlightColors,
): string => {
const background = parseHexRgb(colors.background) ?? { r: 13, g: 17, b: 23 };
const backgroundLuminance =
background.r * 0.299 + background.g * 0.587 + background.b * 0.114;
const overlay =
parseHexRgb(colors.selection) ??
parseHexRgb(colors.foreground) ??
(backgroundLuminance >= 128
? { r: 0, g: 0, b: 0 }
: { r: 255, g: 255, b: 255 });
const foreground =
parseHexRgb(colors.foreground) ??
(backgroundLuminance >= 128
? { r: 0, g: 0, b: 0 }
: { r: 255, g: 255, b: 255 });
const contrastSafeAccents = [overlay, { r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 }];
const baseContrast = contrastRatio(background, foreground);
let mixed = background;
let bestContrast = baseContrast;
for (const [accentIndex, accent] of contrastSafeAccents.entries()) {
const maxAmount = accentIndex === 0 ? CURSOR_LINE_HIGHLIGHT_BLEND : 1;
for (let step = 20; step >= 0; step -= 1) {
const amount = maxAmount * (step / 20);
const candidate = mixRgb(background, accent, amount);
const candidateContrast = contrastRatio(candidate, foreground);
if (candidateContrast > bestContrast) {
mixed = candidate;
bestContrast = candidateContrast;
}
if (step > 0 && candidateContrast >= MIN_CURSOR_LINE_CONTRAST) {
mixed = candidate;
return toHex(mixed);
}
}
}
return toHex(mixed);
};

View File

@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
areCustomKeyBindingsEqual,
nextCustomKeyBindingsSyncVersion,
parseCustomKeyBindingsStorageRecord,
resetCustomKeyBinding,
serializeCustomKeyBindingsStorageRecord,
shouldApplyIncomingCustomKeyBindingsRecord,
updateCustomKeyBinding,
} from './customKeyBindings.ts';
test('parses legacy stored custom key bindings without sync metadata', () => {
const parsed = parseCustomKeyBindingsStorageRecord('{"open":{"mac":"Cmd+K"}}');
assert.deepEqual(parsed, {
version: 0,
origin: 'legacy',
bindings: {
open: { mac: 'Cmd+K' },
},
});
});
test('round-trips versioned stored custom key bindings', () => {
const raw = serializeCustomKeyBindingsStorageRecord({
version: 42,
origin: 'window-b',
bindings: {
open: { pc: 'Ctrl+K' },
},
});
assert.deepEqual(parseCustomKeyBindingsStorageRecord(raw), {
version: 42,
origin: 'window-b',
bindings: {
open: { pc: 'Ctrl+K' },
},
});
});
test('parses plain IPC custom key binding sync payloads', () => {
const parsed = parseCustomKeyBindingsStorageRecord({
version: 7,
origin: 'window-a',
bindings: {
open: { pc: 'Ctrl+K' },
},
});
assert.deepEqual(parsed, {
version: 7,
origin: 'window-a',
bindings: {
open: { pc: 'Ctrl+K' },
},
});
});
test('next sync version is monotonic even within the same millisecond', () => {
assert.equal(nextCustomKeyBindingsSyncVersion(100, 90), 101);
assert.equal(nextCustomKeyBindingsSyncVersion(100, 150), 150);
});
test('newer incoming records apply and older ones are ignored', () => {
assert.equal(
shouldApplyIncomingCustomKeyBindingsRecord(
{ version: 10, origin: 'window-a' },
{ version: 11, origin: 'window-b' },
),
true,
);
assert.equal(
shouldApplyIncomingCustomKeyBindingsRecord(
{ version: 10, origin: 'window-a' },
{ version: 10, origin: 'window-a' },
),
false,
);
assert.equal(
shouldApplyIncomingCustomKeyBindingsRecord(
{ version: 10, origin: 'window-b' },
{ version: 10, origin: 'window-a' },
),
false,
);
});
test('same-version updates converge by origin tie-breaker', () => {
assert.equal(
shouldApplyIncomingCustomKeyBindingsRecord(
{ version: 10, origin: 'window-a' },
{ version: 10, origin: 'window-b' },
),
true,
);
});
test('update custom key binding keeps other bindings intact', () => {
const prev = {
open: { mac: 'Cmd+K' },
close: { pc: 'Ctrl+W' },
};
const next = updateCustomKeyBinding(prev, 'open', 'pc', 'Ctrl+K');
assert.deepEqual(next, {
open: { mac: 'Cmd+K', pc: 'Ctrl+K' },
close: { pc: 'Ctrl+W' },
});
assert.equal(areCustomKeyBindingsEqual(prev, {
open: { mac: 'Cmd+K' },
close: { pc: 'Ctrl+W' },
}), true);
});
test('resetting one side of a shortcut does not mutate the previous bindings', () => {
const prev = {
open: { mac: 'Cmd+K', pc: 'Ctrl+K' },
};
const next = resetCustomKeyBinding(prev, 'open', 'mac');
assert.deepEqual(next, {
open: { pc: 'Ctrl+K' },
});
assert.deepEqual(prev, {
open: { mac: 'Cmd+K', pc: 'Ctrl+K' },
});
});
test('resetting the last side removes the binding entry entirely', () => {
const next = resetCustomKeyBinding({
open: { mac: 'Cmd+K' },
}, 'open', 'mac');
assert.deepEqual(next, {});
});

133
domain/customKeyBindings.ts Normal file
View File

@@ -0,0 +1,133 @@
import { CustomKeyBindings } from './models';
const SYNC_VERSION_FIELD = '__netcattySyncVersion';
const SYNC_ORIGIN_FIELD = '__netcattySyncOrigin';
export interface CustomKeyBindingsStorageRecord {
bindings: CustomKeyBindings;
version: number;
origin: string;
}
export const serializeCustomKeyBindings = (bindings: CustomKeyBindings): string =>
JSON.stringify(bindings);
export const areCustomKeyBindingsEqual = (a: CustomKeyBindings, b: CustomKeyBindings): boolean =>
serializeCustomKeyBindings(a) === serializeCustomKeyBindings(b);
export const parseCustomKeyBindingsStorageRecord = (
value: unknown,
): CustomKeyBindingsStorageRecord | null => {
let candidate = value;
if (typeof candidate === 'string') {
try {
candidate = JSON.parse(candidate);
} catch {
return null;
}
}
if (!candidate || typeof candidate !== 'object') {
return null;
}
const record = candidate as Record<string, unknown>;
if (
typeof record.version === 'number' &&
typeof record.origin === 'string' &&
record.bindings &&
typeof record.bindings === 'object'
) {
return {
version: record.version,
origin: record.origin,
bindings: record.bindings as CustomKeyBindings,
};
}
if (
typeof record[SYNC_VERSION_FIELD] === 'number' &&
typeof record[SYNC_ORIGIN_FIELD] === 'string' &&
record.bindings &&
typeof record.bindings === 'object'
) {
return {
version: record[SYNC_VERSION_FIELD] as number,
origin: record[SYNC_ORIGIN_FIELD] as string,
bindings: record.bindings as CustomKeyBindings,
};
}
return {
version: 0,
origin: 'legacy',
bindings: candidate as CustomKeyBindings,
};
};
export const serializeCustomKeyBindingsStorageRecord = (
record: CustomKeyBindingsStorageRecord,
): string =>
JSON.stringify({
[SYNC_VERSION_FIELD]: record.version,
[SYNC_ORIGIN_FIELD]: record.origin,
bindings: record.bindings,
});
export const nextCustomKeyBindingsSyncVersion = (
currentVersion: number,
now: number = Date.now(),
): number => Math.max(now, currentVersion + 1);
export const shouldApplyIncomingCustomKeyBindingsRecord = (
current: Pick<CustomKeyBindingsStorageRecord, 'version' | 'origin'>,
incoming: Pick<CustomKeyBindingsStorageRecord, 'version' | 'origin'>,
): boolean => {
if (incoming.version !== current.version) {
return incoming.version > current.version;
}
return incoming.origin > current.origin;
};
export const updateCustomKeyBinding = (
bindings: CustomKeyBindings,
bindingId: string,
scheme: 'mac' | 'pc',
newKey: string,
): CustomKeyBindings => ({
...bindings,
[bindingId]: {
...bindings[bindingId],
[scheme]: newKey,
},
});
export const resetCustomKeyBinding = (
bindings: CustomKeyBindings,
bindingId: string,
scheme?: 'mac' | 'pc',
): CustomKeyBindings => {
if (!scheme) {
const { [bindingId]: _removed, ...rest } = bindings;
return rest;
}
const existing = bindings[bindingId];
if (!existing) {
return bindings;
}
const nextBinding = { ...existing };
delete nextBinding[scheme];
if (Object.keys(nextBinding).length === 0) {
const { [bindingId]: _removed, ...rest } = bindings;
return rest;
}
return {
...bindings,
[bindingId]: nextBinding,
};
};

View File

@@ -0,0 +1,71 @@
/**
* Shared encrypted-object storage surface for built-in cloud adapters and
* plugin sync Providers. Netcatty always encrypts before write and decrypts
* after read; implementations only handle already-encrypted bytes.
*/
export interface EncryptedObjectAccount {
id: string;
email?: string;
name?: string;
avatarUrl?: string;
}
export interface EncryptedObjectStorageCapabilities {
revisions: boolean;
conditionalWrites: boolean;
atomicReplacement: boolean;
maxObjectBytes?: number;
maxObjects?: number;
}
export interface EncryptedObjectReadResult {
found: boolean;
key: string;
bytes: Uint8Array | null;
revision?: string;
contentType?: string;
}
export interface EncryptedObjectWriteResult {
created: boolean;
revision?: string;
}
export interface EncryptedObjectDeleteResult {
deleted: boolean;
}
export interface EncryptedObjectWriteOptions {
/** When set, the write is conditional on the current remote revision. `null` means the object must not exist. */
expectedRevision?: string | null;
signal?: AbortSignal;
}
export interface EncryptedObjectDeleteOptions {
expectedRevision?: string;
signal?: AbortSignal;
}
/**
* Provider-agnostic encrypted object store. Plugins and WebDAV both implement
* this shape so CloudSyncManager can encrypt→write / read→decrypt without
* special-casing storage backends.
*/
export interface EncryptedObjectStorage {
readonly providerId: string;
connect(configuration?: unknown, options?: { signal?: AbortSignal }): Promise<{ account: EncryptedObjectAccount }>;
disconnect(options?: { signal?: AbortSignal }): Promise<void>;
getAccount(options?: { signal?: AbortSignal }): Promise<EncryptedObjectAccount | null>;
getCapabilities(options?: { signal?: AbortSignal }): Promise<EncryptedObjectStorageCapabilities>;
readObject(key: string, options?: { signal?: AbortSignal; preferStream?: boolean }): Promise<EncryptedObjectReadResult>;
writeObject(
key: string,
bytes: Uint8Array,
options?: EncryptedObjectWriteOptions & { preferStream?: boolean },
): Promise<EncryptedObjectWriteResult>;
deleteObject(key: string, options?: EncryptedObjectDeleteOptions): Promise<EncryptedObjectDeleteResult>;
}
/** Default object key used when adapting the legacy single-file CloudAdapter path. */
export const DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY = 'netcatty-vault.json';

View File

@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, isSavedVaultHost, splitHostsUpdateByEphemeral } from "./ephemeralHosts";
import type { Host } from "./models";
const makeHost = (id: string, overrides: Partial<Host> = {}): Host => ({
id,
label: id,
hostname: `${id}.example.com`,
username: "root",
group: "",
tags: [],
os: "linux",
...overrides,
});
test("splitHostsUpdateByEphemeral separates ephemeral hosts from vault hosts", () => {
const vaultHost = makeHost("vault-1");
const ephemeralHost = makeHost("ephemeral-1", { password: "secret" });
const { vaultHosts, ephemeralHosts } = splitHostsUpdateByEphemeral(
[vaultHost, ephemeralHost],
new Set(["ephemeral-1"]),
);
assert.deepEqual(vaultHosts, [vaultHost]);
assert.deepEqual(ephemeralHosts, [ephemeralHost]);
});
test("splitHostsUpdateByEphemeral passes everything through when no ephemeral ids", () => {
const hosts = [makeHost("a"), makeHost("b")];
const { vaultHosts, ephemeralHosts } = splitHostsUpdateByEphemeral(hosts, new Set());
assert.deepEqual(vaultHosts, hosts);
assert.deepEqual(ephemeralHosts, []);
});
test("applyEphemeralHostsUpdate replaces matching hosts and keeps the rest", () => {
const original = [
makeHost("a", { password: "one-time" }),
makeHost("b", { password: "other" }),
];
const updated = makeHost("a", { password: "one-time", sftpFollowTerminalCwd: true });
const next = applyEphemeralHostsUpdate(original, [updated]);
assert.equal(next.length, 2);
assert.equal(next[0], updated);
assert.equal(next[1], original[1]);
});
test("applyEphemeralHostsUpdate returns previous array when nothing updated", () => {
const original = [makeHost("a")];
assert.equal(applyEphemeralHostsUpdate(original, []), original);
});
test("isSavedVaultHost is false for missing or ephemeral hosts", () => {
assert.equal(isSavedVaultHost(makeHost("a")), true);
assert.equal(isSavedVaultHost(makeHost("a", { ephemeral: true })), false);
assert.equal(isSavedVaultHost(null), false);
assert.equal(isSavedVaultHost(undefined), false);
});
test("temporary host detection reaches AI without changing other hosts or reviving closed sessions", async () => {
const { buildAITerminalSessionInfo } = await import('./buildAITerminalSessionInfo');
const other = makeHost('other');
for (const [distro, os] of [['ubuntu', 'linux'], ['darwin', 'macos'], ['windows', 'windows']]) {
const target = makeHost('quick-connect', { ephemeral: true });
const next = applyEphemeralHostDistroUpdate([target, other], target.id, distro);
assert.equal(buildAITerminalSessionInfo(undefined, next[0], 'macos').os, os);
assert.equal(next[0].ephemeral, true);
assert.equal(next[1], other);
assert.equal(applyEphemeralHostDistroUpdate(next, target.id, distro), next);
const closed = [other];
assert.equal(applyEphemeralHostDistroUpdate(closed, target.id, distro), closed);
}
});

47
domain/ephemeralHosts.ts Normal file
View File

@@ -0,0 +1,47 @@
import { normalizeDistroId } from "./host";
import type { Host } from "./models";
/**
* True when the host entry represents a persisted vault host. Ephemeral
* hosts (password deep links) live in the terminal host list but must not
* be treated as saved hosts for persistence-routing decisions.
*/
export const isSavedVaultHost = (host: Host | null | undefined): boolean =>
Boolean(host) && host?.ephemeral !== true;
export interface EphemeralHostsUpdateSplit {
vaultHosts: Host[];
ephemeralHosts: Host[];
}
export const splitHostsUpdateByEphemeral = (
nextHosts: Host[],
ephemeralHostIds: ReadonlySet<string>,
): EphemeralHostsUpdateSplit => {
const vaultHosts: Host[] = [];
const ephemeralHosts: Host[] = [];
for (const host of nextHosts) {
if (ephemeralHostIds.has(host.id)) {
ephemeralHosts.push(host);
} else {
vaultHosts.push(host);
}
}
return { vaultHosts, ephemeralHosts };
};
export const applyEphemeralHostsUpdate = (
previous: Host[],
updated: Host[],
): Host[] => {
if (updated.length === 0) return previous;
const updatedById = new Map(updated.map((host) => [host.id, host]));
return previous.map((host) => updatedById.get(host.id) ?? host);
};
/** Detection updates stay in memory and never create a host after its session closes. */
export const applyEphemeralHostDistroUpdate = (previous: Host[], hostId: string, distro: string): Host[] => {
const normalized = normalizeDistroId(distro);
if (!previous.some((host) => host.id === hostId && host.distro !== normalized)) return previous;
return previous.map((host) => host.id === hostId ? { ...host, distro: normalized } : host);
};

View File

@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
appendHostFromWorkspaceDrop,
FOCUS_SIDEBAR_HOST_DRAG_TYPE,
FOCUS_SIDEBAR_SESSION_DRAG_TYPE,
readHostIdFromDataTransfer,
resolveFocusSidebarDragKind,
} from './focusSidebarHostDrop.ts';
test('resolveFocusSidebarDragKind prefers in-flight session reorder', () => {
assert.equal(
resolveFocusSidebarDragKind({
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
activeSessionDragId: 'session-1',
}),
'session-reorder',
);
});
test('resolveFocusSidebarDragKind detects session mime even without local state', () => {
assert.equal(
resolveFocusSidebarDragKind({
types: [FOCUS_SIDEBAR_SESSION_DRAG_TYPE],
activeSessionDragId: null,
}),
'session-reorder',
);
});
test('resolveFocusSidebarDragKind accepts vault or host-tree host drags', () => {
assert.equal(
resolveFocusSidebarDragKind({
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
activeSessionDragId: null,
}),
'host-append',
);
});
test('resolveFocusSidebarDragKind ignores unrelated drag payloads', () => {
assert.equal(
resolveFocusSidebarDragKind({
types: ['text/plain', 'tab-reorder-id'],
activeSessionDragId: null,
}),
null,
);
});
test('readHostIdFromDataTransfer returns trimmed host id', () => {
assert.equal(
readHostIdFromDataTransfer((type) => (type === FOCUS_SIDEBAR_HOST_DRAG_TYPE ? ' host-42 ' : '')),
'host-42',
);
});
test('readHostIdFromDataTransfer returns null when host id is missing', () => {
assert.equal(readHostIdFromDataTransfer(() => ''), null);
assert.equal(readHostIdFromDataTransfer(() => ' '), null);
});
test('appendHostFromWorkspaceDrop appends the dropped host to the target workspace once', () => {
const calls: Array<[string, string]> = [];
assert.equal(appendHostFromWorkspaceDrop({
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
getData: (type) => (type === FOCUS_SIDEBAR_HOST_DRAG_TYPE ? 'host-42' : ''),
workspaceId: 'workspace-7',
onAppendHostToWorkspace: (workspaceId, hostId) => calls.push([workspaceId, hostId]),
}), true);
assert.deepEqual(calls, [['workspace-7', 'host-42']]);
});
test('appendHostFromWorkspaceDrop leaves session and invalid drags alone', () => {
const calls: Array<[string, string]> = [];
const onAppendHostToWorkspace = (workspaceId: string, hostId: string) => {
calls.push([workspaceId, hostId]);
};
assert.equal(appendHostFromWorkspaceDrop({
types: [FOCUS_SIDEBAR_SESSION_DRAG_TYPE, FOCUS_SIDEBAR_HOST_DRAG_TYPE],
getData: (type) => (type === FOCUS_SIDEBAR_HOST_DRAG_TYPE ? 'host-42' : 'session-1'),
workspaceId: 'workspace-7',
onAppendHostToWorkspace,
}), false);
assert.equal(appendHostFromWorkspaceDrop({
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
getData: () => ' ',
workspaceId: 'workspace-7',
onAppendHostToWorkspace,
}), false);
assert.deepEqual(calls, []);
});

View File

@@ -0,0 +1,55 @@
export const FOCUS_SIDEBAR_HOST_DRAG_TYPE = 'host-id';
export const FOCUS_SIDEBAR_SESSION_DRAG_TYPE = 'workspace-focus-session-id';
export type FocusSidebarDragKind = 'session-reorder' | 'host-append' | null;
export function dataTransferHasType(
types: ArrayLike<string> | readonly string[],
type: string,
): boolean {
return Array.from(types as ArrayLike<string>).includes(type);
}
/**
* Decide how the focus-mode workspace sidebar should treat an in-progress drag.
* Session reorder wins when this sidebar started the drag, so a host mime that
* happens to be present cannot steal reorder gestures.
*/
export function resolveFocusSidebarDragKind(input: {
types: ArrayLike<string> | readonly string[];
activeSessionDragId?: string | null;
}): FocusSidebarDragKind {
if (
input.activeSessionDragId
|| dataTransferHasType(input.types, FOCUS_SIDEBAR_SESSION_DRAG_TYPE)
) {
return 'session-reorder';
}
if (dataTransferHasType(input.types, FOCUS_SIDEBAR_HOST_DRAG_TYPE)) {
return 'host-append';
}
return null;
}
export function readHostIdFromDataTransfer(
getData: (type: string) => string,
): string | null {
const hostId = getData(FOCUS_SIDEBAR_HOST_DRAG_TYPE)?.trim();
return hostId || null;
}
export function appendHostFromWorkspaceDrop(input: {
types: ArrayLike<string> | readonly string[];
getData: (type: string) => string;
workspaceId: string;
activeSessionDragId?: string | null;
onAppendHostToWorkspace: (workspaceId: string, hostId: string) => void;
}): boolean {
if (resolveFocusSidebarDragKind(input) !== 'host-append') return false;
const hostId = readHostIdFromDataTransfer(input.getData);
if (!hostId) return false;
input.onAppendHostToWorkspace(input.workspaceId, hostId);
return true;
}

View File

@@ -0,0 +1,120 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
mergeGlobalHistoryOnAppend,
removeGlobalHistoryEntry,
sanitizeGlobalHistoryEntries,
shouldRemoveAutocompleteHistoryEntry,
shouldRecordGlobalHistoryCommand,
toGlobalHistoryDisplayEntries,
} from './globalHistory.ts';
import { NETCATTY_AI_HISTORY_MARKER } from './remoteHistory.ts';
import { buildDockerExecShellCommand, buildDockerLogsCommand } from './systemManager/dockerShell.ts';
import { buildTmuxAttachCommand } from './systemManager/tmuxShell.ts';
import type { ShellHistoryEntry } from './models';
const baseEntry = (
overrides: Partial<ShellHistoryEntry> & Pick<ShellHistoryEntry, 'command'>,
): ShellHistoryEntry => ({
id: overrides.id ?? 'id-1',
command: overrides.command,
hostId: overrides.hostId ?? 'host-1',
hostLabel: overrides.hostLabel ?? 'srv',
sessionId: overrides.sessionId ?? 'sess-1',
timestamp: overrides.timestamp ?? 1000,
});
test('shouldRecordGlobalHistoryCommand: rejects empty and AI marker commands', () => {
assert.equal(shouldRecordGlobalHistoryCommand(''), false);
assert.equal(shouldRecordGlobalHistoryCommand(' '), false);
assert.equal(
shouldRecordGlobalHistoryCommand(`echo ${NETCATTY_AI_HISTORY_MARKER}foo`),
false,
);
assert.equal(shouldRecordGlobalHistoryCommand('ls -la'), true);
});
test('shouldRecordGlobalHistoryCommand: rejects Netcatty managed Docker and tmux startup commands', () => {
assert.equal(shouldRecordGlobalHistoryCommand(buildDockerExecShellCommand('587abcdef123')), false);
assert.equal(shouldRecordGlobalHistoryCommand(buildDockerLogsCommand('587abcdef123')), false);
assert.equal(shouldRecordGlobalHistoryCommand(buildTmuxAttachCommand('my-session')), false);
assert.equal(shouldRecordGlobalHistoryCommand(buildTmuxAttachCommand('my-session', 2)), false);
assert.equal(shouldRecordGlobalHistoryCommand('docker ps -a'), true);
assert.equal(shouldRecordGlobalHistoryCommand('docker logs -f 587abcdef123'), true);
assert.equal(shouldRecordGlobalHistoryCommand('docker exec -it 587abcdef123 bash'), true);
assert.equal(shouldRecordGlobalHistoryCommand('tmux attach -t my-session'), true);
});
test('mergeGlobalHistoryOnAppend: trims and prepends a new command', () => {
const next = mergeGlobalHistoryOnAppend([], {
command: ' pwd ',
hostId: 'h1',
hostLabel: 'Host',
sessionId: 's1',
});
assert.equal(next.length, 1);
assert.equal(next[0].command, 'pwd');
});
test('sanitizeGlobalHistoryEntries: removes persisted Netcatty managed startup commands', () => {
const entries = [
baseEntry({ id: 'a', command: buildDockerLogsCommand('587abcdef123') }),
baseEntry({ id: 'b', command: 'docker ps -a' }),
baseEntry({ id: 'c', command: buildTmuxAttachCommand('my-session') }),
];
const out = sanitizeGlobalHistoryEntries(entries);
assert.deepEqual(
out.map((entry) => entry.command),
['docker ps -a'],
);
});
test('mergeGlobalHistoryOnAppend: bumps timestamp for consecutive duplicate', () => {
const prev = [baseEntry({ id: 'a', command: 'ls', timestamp: 1000 })];
const next = mergeGlobalHistoryOnAppend(prev, {
command: 'ls',
hostId: 'h2',
hostLabel: 'Other',
sessionId: 's2',
});
assert.equal(next.length, 1);
assert.equal(next[0].id, 'a');
assert.equal(next[0].hostLabel, 'Other');
assert.ok(next[0].timestamp > 1000);
});
test('toGlobalHistoryDisplayEntries: maps host labels', () => {
const out = toGlobalHistoryDisplayEntries([
baseEntry({ command: 'htop', hostLabel: 'prod' }),
]);
assert.deepEqual(out, [
{ id: 'id-1', command: 'htop', hostId: 'host-1', timestamp: 1000, hostLabel: 'prod' },
]);
});
test('removeGlobalHistoryEntry: removes only the requested record', () => {
const entries = [
baseEntry({ id: 'first', command: 'bad-command' }),
baseEntry({ id: 'second', command: 'keep-command' }),
];
assert.deepEqual(
removeGlobalHistoryEntry(entries, 'first').map((entry) => entry.id),
['second'],
);
assert.equal(removeGlobalHistoryEntry(entries, 'missing'), entries);
});
test('shouldRemoveAutocompleteHistoryEntry: keeps autocomplete while a duplicate row remains', () => {
const entries = [
baseEntry({ id: 'first', command: 'bad-command' }),
baseEntry({ id: 'second', command: 'bad-command' }),
baseEntry({ id: 'other-host', command: 'bad-command', hostId: 'host-2' }),
];
assert.equal(shouldRemoveAutocompleteHistoryEntry(entries, 'first'), false);
assert.equal(shouldRemoveAutocompleteHistoryEntry(entries, 'second'), false);
assert.equal(shouldRemoveAutocompleteHistoryEntry(entries, 'other-host'), true);
assert.equal(shouldRemoveAutocompleteHistoryEntry(entries, 'missing'), false);
});

105
domain/globalHistory.ts Normal file
View File

@@ -0,0 +1,105 @@
import type { ShellHistoryEntry } from './models';
import {
isNetcattyAiHistoryCommand,
isNetcattyManagedStartupHistoryCommand,
} from './remoteHistory';
const makeId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `gh-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
/** True when a typed command should be stored in global (local) shell history. */
export function shouldRecordGlobalHistoryCommand(command: string): boolean {
const cmd = command.trim();
if (!cmd) return false;
if (isNetcattyAiHistoryCommand(cmd)) return false;
if (isNetcattyManagedStartupHistoryCommand(cmd)) return false;
return true;
}
export function sanitizeGlobalHistoryEntries(
entries: ShellHistoryEntry[],
): ShellHistoryEntry[] {
return entries.filter((entry) => shouldRecordGlobalHistoryCommand(entry.command));
}
/** Remove one persisted global history record by its stable id. */
export function removeGlobalHistoryEntry(
entries: ShellHistoryEntry[],
entryId: string,
): ShellHistoryEntry[] {
if (!entries.some((entry) => entry.id === entryId)) return entries;
return entries.filter((entry) => entry.id !== entryId);
}
/** True when deleting a global row should also remove its autocomplete entry. */
export function shouldRemoveAutocompleteHistoryEntry(
entries: ShellHistoryEntry[],
entryId: string,
): boolean {
const entry = entries.find((candidate) => candidate.id === entryId);
if (!entry) return false;
return !entries.some((candidate) => (
candidate.id !== entryId
&& candidate.command === entry.command
&& candidate.hostId === entry.hostId
));
}
/**
* Append one command to global history: trim, drop noise, and de-dupe the most
* recent identical command by bumping its timestamp instead of adding a row.
*/
export function mergeGlobalHistoryOnAppend(
prev: ShellHistoryEntry[],
entry: Omit<ShellHistoryEntry, 'id' | 'timestamp'>,
max = 1000,
): ShellHistoryEntry[] {
const cmd = entry.command.trim();
if (!shouldRecordGlobalHistoryCommand(cmd)) return prev;
const normalized = { ...entry, command: cmd };
if (prev[0]?.command === cmd) {
return [
{
...prev[0],
timestamp: Date.now(),
hostId: normalized.hostId,
hostLabel: normalized.hostLabel,
sessionId: normalized.sessionId,
},
...prev.slice(1),
].slice(0, max);
}
const newEntry: ShellHistoryEntry = {
...normalized,
id: makeId(),
timestamp: Date.now(),
};
return [newEntry, ...prev].slice(0, max);
}
export interface GlobalHistoryDisplayEntry {
id: string;
command: string;
hostId: string;
timestamp: number;
hostLabel?: string;
}
/** Map persisted shell history rows into a panel-friendly list (newest first). */
export function toGlobalHistoryDisplayEntries(
entries: ShellHistoryEntry[],
): GlobalHistoryDisplayEntry[] {
return sanitizeGlobalHistoryEntries(entries).map((entry) => ({
id: entry.id,
command: entry.command,
hostId: entry.hostId,
timestamp: entry.timestamp,
hostLabel: entry.hostLabel,
}));
}

583
domain/groupConfig.test.ts Normal file
View File

@@ -0,0 +1,583 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyGroupDefaults, resolveGroupDefaults, sanitizeGroupConfig } from "./groupConfig.ts";
import { resolveTelnetPassword, resolveTelnetUsername } from "./host.ts";
import type { GroupConfig, Host } from "./models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
...overrides,
});
test("applyGroupDefaults lets a host proxy profile override a group custom proxy", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 3128 },
};
const result = applyGroupDefaults(host({ proxyProfileId: "proxy-1" }), groupDefaults);
assert.equal(result.proxyProfileId, "proxy-1");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults lets a host custom proxy override a group proxy profile", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyProfileId: "group-proxy",
};
const customProxy = { type: "socks5" as const, host: "host-proxy.example.com", port: 1080 };
const result = applyGroupDefaults(host({ proxyConfig: customProxy }), groupDefaults);
assert.equal(result.proxyProfileId, undefined);
assert.deepEqual(result.proxyConfig, customProxy);
});
test("applyGroupDefaults inherits group device type when host does not set one", () => {
const result = applyGroupDefaults(host(), { deviceType: "network" });
assert.equal(result.deviceType, "network");
});
test("applyGroupDefaults lets host device type override group device type", () => {
const result = applyGroupDefaults(host({ deviceType: "general" }), { deviceType: "network" });
assert.equal(result.deviceType, "general");
});
test("applyGroupDefaults inherits startup command run mode", () => {
const result = applyGroupDefaults(host(), { startupCommandRunMode: "paste" });
assert.equal(result.startupCommandRunMode, "paste");
});
test("resolveGroupDefaults lets child group device type override parent device type", () => {
const resolved = resolveGroupDefaults("prod/access", [
{
path: "prod",
deviceType: "network",
},
{
path: "prod/access",
deviceType: "general",
},
]);
assert.equal(resolved.deviceType, "general");
});
test("resolveGroupDefaults treats saved and custom proxies as one inherited setting", () => {
const resolved = resolveGroupDefaults("prod/api", [
{
path: "prod",
proxyConfig: { type: "http", host: "parent-proxy.example.com", port: 3128 },
},
{
path: "prod/api",
proxyProfileId: "child-proxy",
},
]);
assert.equal(resolved.proxyProfileId, "child-proxy");
assert.equal(resolved.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile instead of using group proxy", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyProfileId: "group-proxy",
};
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
groupDefaults,
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile when no group fallback exists", () => {
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
{},
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile instead of using group custom proxy", () => {
const groupProxy = { type: "http" as const, host: "group-proxy.example.com", port: 3128 };
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
{ proxyConfig: groupProxy },
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("resolveGroupDefaults keeps a missing group proxy marker when there is no fallback", () => {
const resolved = resolveGroupDefaults(
"prod",
[{ path: "prod", proxyProfileId: "missing-proxy" }],
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(resolved.proxyProfileId, "missing-proxy");
});
test("applyGroupDefaults inherits a missing group proxy marker so connect paths can fail", () => {
const result = applyGroupDefaults(
host({ group: "prod" }),
{ proxyProfileId: "missing-proxy" },
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("resolveGroupDefaults keeps missing child proxy profiles instead of using parent proxy", () => {
const resolved = resolveGroupDefaults(
"prod/api",
[
{
path: "prod",
proxyConfig: { type: "http", host: "parent-proxy.example.com", port: 3128 },
},
{
path: "prod/api",
proxyProfileId: "missing-proxy",
},
],
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(resolved.proxyProfileId, "missing-proxy");
assert.equal(resolved.proxyConfig, undefined);
});
test("applyGroupDefaults preserves explicitly cleared telnet credentials", () => {
const result = applyGroupDefaults(
host({
username: "ssh-user",
password: "ssh-password",
telnetUsername: "",
telnetPassword: "",
}),
{
telnetUsername: "group-telnet-user",
telnetPassword: "group-telnet-password",
},
);
assert.equal(result.telnetUsername, "");
assert.equal(result.telnetPassword, "");
assert.equal(resolveTelnetUsername(result), "");
assert.equal(resolveTelnetPassword(result), "");
});
test("applyGroupDefaults still inherits telnet credentials when host fields are unset", () => {
const result = applyGroupDefaults(
host({
username: "ssh-user",
password: "ssh-password",
}),
{
telnetUsername: "group-telnet-user",
telnetPassword: "group-telnet-password",
},
);
assert.equal(result.telnetUsername, "group-telnet-user");
assert.equal(result.telnetPassword, "group-telnet-password");
assert.equal(resolveTelnetUsername(result), "group-telnet-user");
assert.equal(resolveTelnetPassword(result), "group-telnet-password");
});
test("applyGroupDefaults inherits a reusable Telnet identity from the group", () => {
const result = applyGroupDefaults(
host({ telnetIdentityId: undefined }),
{ telnetIdentityId: "group-telnet-identity" },
);
assert.equal(result.telnetIdentityId, "group-telnet-identity");
});
test("applyGroupDefaults preserves an explicitly cleared Telnet identity", () => {
const result = applyGroupDefaults(
host({ telnetIdentityId: "" }),
{ telnetIdentityId: "group-telnet-identity" },
);
assert.equal(result.telnetIdentityId, "");
});
test("resolveGroupDefaults lets child manual SSH credentials replace a parent identity", () => {
const resolved = resolveGroupDefaults("prod/manual", [
{ path: "prod", identityId: "parent-identity", username: "parent-user" },
{ path: "prod/manual", username: "child-user", password: "child-password" },
]);
assert.equal(resolved.identityId, undefined);
assert.equal(resolved.username, "child-user");
assert.equal(resolved.password, "child-password");
});
test("resolveGroupDefaults lets child manual Telnet credentials replace a parent identity", () => {
const resolved = resolveGroupDefaults("prod/manual", [
{ path: "prod", telnetIdentityId: "parent-identity" },
{ path: "prod/manual", telnetUsername: "child-user", telnetPassword: "child-password" },
]);
assert.equal(resolved.telnetIdentityId, undefined);
assert.equal(resolved.telnetUsername, "child-user");
assert.equal(resolved.telnetPassword, "child-password");
});
test("resolveGroupDefaults clears a parent key identity bundle for a child password opt-out", () => {
const resolved = resolveGroupDefaults("prod/manual", [
{
path: "prod",
identityId: "parent-identity",
username: "parent-user",
authMethod: "key",
identityFileId: "parent-key",
},
{
path: "prod/manual",
identityId: "",
username: "child-user",
password: "child-password",
authMethod: "password",
},
]);
assert.equal(resolved.identityId, "");
assert.equal(resolved.username, "child-user");
assert.equal(resolved.password, "child-password");
assert.equal(resolved.authMethod, "password");
assert.equal(resolved.identityFileId, undefined);
});
test("resolveGroupDefaults clears parent identity credentials for an empty child marker", () => {
const resolved = resolveGroupDefaults("prod/manual", [
{
path: "prod",
identityId: "parent-identity",
username: "parent-user",
authMethod: "key",
telnetIdentityId: "parent-telnet-identity",
telnetUsername: "parent-telnet-user",
},
{
path: "prod/manual",
identityId: "",
telnetIdentityId: "",
},
]);
assert.equal(resolved.identityId, "");
assert.equal(resolved.username, undefined);
assert.equal(resolved.authMethod, undefined);
assert.equal(resolved.telnetIdentityId, "");
assert.equal(resolved.telnetUsername, undefined);
});
test("applyGroupDefaults keeps host manual SSH credentials instead of a group identity", () => {
const result = applyGroupDefaults(
host({ username: "host-user", password: "host-password" }),
{
identityId: "group-identity",
username: "group-user",
password: "group-password",
savePassword: false,
authMethod: "key",
identityFileId: "group-key",
identityFilePaths: ["~/.ssh/group-key"],
},
);
assert.equal(result.identityId, undefined);
assert.equal(result.username, "host-user");
assert.equal(result.password, "host-password");
assert.equal(result.savePassword, undefined);
assert.equal(result.authMethod, undefined);
assert.equal(result.identityFileId, undefined);
assert.equal(result.identityFilePaths, undefined);
});
test("applyGroupDefaults lets a host password inherit a manual group username", () => {
const result = applyGroupDefaults(
host({ username: "", password: "host-password" }),
{ username: "group-user" },
);
assert.equal(result.identityId, undefined);
assert.equal(result.username, "group-user");
assert.equal(result.password, "host-password");
});
test("applyGroupDefaults lets an empty host identity inherit manual group credentials", () => {
const result = applyGroupDefaults(
host({ identityId: "", username: "", authMethod: undefined }),
{
username: "group-user",
password: "group-password",
authMethod: "password",
},
);
assert.equal(result.identityId, "");
assert.equal(result.username, "group-user");
assert.equal(result.password, "group-password");
assert.equal(result.authMethod, "password");
});
test("applyGroupDefaults does not bypass a host no-save choice with a group identity", () => {
const result = applyGroupDefaults(
host({ username: "", password: undefined, savePassword: false }),
{
identityId: "group-identity",
username: "group-user",
password: "group-password",
authMethod: "password",
},
);
assert.equal(result.identityId, undefined);
assert.equal(result.username, "");
assert.equal(result.password, undefined);
assert.equal(result.savePassword, false);
assert.equal(result.authMethod, undefined);
});
test("applyGroupDefaults does not inherit a group password after a host clears it", () => {
const result = applyGroupDefaults(
host({ username: "", password: undefined, savePassword: false }),
{
username: "group-user",
password: "group-password",
authMethod: "password",
},
);
assert.equal(result.password, undefined);
assert.equal(result.savePassword, false);
assert.equal(result.authMethod, "password");
});
test("applyGroupDefaults keeps host manual Telnet credentials instead of a group identity", () => {
const result = applyGroupDefaults(
host({ telnetUsername: "host-user", telnetPassword: "host-password" }),
{ telnetIdentityId: "group-identity" },
);
assert.equal(result.telnetIdentityId, undefined);
assert.equal(result.telnetUsername, "host-user");
assert.equal(result.telnetPassword, "host-password");
});
test("applyGroupDefaults preserves imported primary Telnet credentials", () => {
const result = applyGroupDefaults(
host({
protocol: "telnet",
username: "operator",
password: "host-password",
telnetIdentityId: undefined,
}),
{ telnetIdentityId: "group-telnet-identity" },
);
assert.equal(result.telnetIdentityId, undefined);
assert.equal(resolveTelnetUsername(result), "operator");
assert.equal(resolveTelnetPassword(result), "host-password");
});
test("applyGroupDefaults lets a default primary Telnet host inherit a group identity", () => {
const result = applyGroupDefaults(
host({
protocol: "telnet",
username: "root",
password: undefined,
telnetIdentityId: undefined,
}),
{ telnetIdentityId: "group-telnet-identity" },
);
assert.equal(result.telnetIdentityId, "group-telnet-identity");
});
test("applyGroupDefaults preserves explicit empty identityId instead of inheriting group identity", () => {
const result = applyGroupDefaults(
host({ identityId: "" }),
{ identityId: "group-identity" },
);
assert.equal(result.identityId, "");
});
test("applyGroupDefaults inherits group identityId when host only has default SSH fields", () => {
const result = applyGroupDefaults(
host({ identityId: undefined, authMethod: "password" }),
{ identityId: "group-identity" },
);
assert.equal(result.identityId, "group-identity");
assert.equal(result.username, "root");
});
test("applyGroupDefaults keeps explicit host auth modes instead of inheriting a group identity", () => {
for (const authMethod of ["auto", "password"] as const) {
const result = applyGroupDefaults(
host({ authMethod, authPolicyVersion: 1 }),
{ identityId: "group-identity" },
);
assert.equal(result.authMethod, authMethod);
assert.equal(result.identityId, undefined);
}
});
test("applyGroupDefaults preserves a custom username instead of inheriting a group identity", () => {
const result = applyGroupDefaults(
host({ identityId: undefined, username: "ubuntu", authMethod: "password" }),
{
identityId: "group-identity",
username: "group-user",
password: "group-password",
authMethod: "password",
},
);
assert.equal(result.identityId, undefined);
assert.equal(result.username, "ubuntu");
assert.equal(result.password, undefined);
assert.equal(result.authMethod, "password");
});
test("applyGroupDefaults treats an explicit empty identity as a host opt-out", () => {
const result = applyGroupDefaults(
host({ identityId: "", username: "host-user", authMethod: "password" }),
{ identityId: "group-identity", username: "group-user" },
);
assert.equal(result.identityId, "");
assert.equal(result.username, "host-user");
});
test("applyGroupDefaults continues to inherit empty ssh username from the group", () => {
const result = applyGroupDefaults(
host({
username: "",
}),
{
username: "group-ssh-user",
},
);
assert.equal(result.username, "group-ssh-user");
});
test("sanitizeGroupConfig migrates a deprecated fontFamily and clears the override flag", () => {
// Regression guard for codex P2 review on PR #940: groups saved with
// pingfang-sc / microsoft-yahei / comic-sans-ms must shed the
// override so member hosts inherit the global default instead of
// silently falling through to fonts[0] under an enabled override.
const before: GroupConfig = {
path: "team",
fontFamily: "pingfang-sc",
fontFamilyOverride: true,
};
const after = sanitizeGroupConfig(before);
assert.equal(after.fontFamily, undefined);
assert.equal(after.fontFamilyOverride, false);
});
test("sanitizeGroupConfig keeps a still-valid fontFamily untouched", () => {
const before: GroupConfig = {
path: "team",
fontFamily: "jetbrains-mono",
fontFamilyOverride: true,
};
const after = sanitizeGroupConfig(before);
assert.equal(after.fontFamily, "jetbrains-mono");
assert.equal(after.fontFamilyOverride, true);
});
test("sanitizeGroupConfig preserves legacy group passwords as password-only", () => {
const after = sanitizeGroupConfig({
path: "team",
password: "group-secret",
});
assert.equal(after.authMethod, "password");
});
test("sanitizeGroupConfig preserves an explicit automatic password fallback", () => {
const after = sanitizeGroupConfig({
path: "team",
authMethod: "auto",
password: "group-secret",
});
assert.equal(after.authMethod, "auto");
});
test("sanitizeGroupConfig does not replace a selected identity with password-only", () => {
const after = sanitizeGroupConfig({
path: "team",
identityId: "identity-1",
password: "stale-secret",
});
assert.equal(after.authMethod, undefined);
});
test("sanitizeGroupConfig treats an empty inherited identity marker as cleared", () => {
const after = sanitizeGroupConfig({
path: "team/child",
identityId: "",
password: "child-secret",
});
assert.equal(after.identityId, "");
assert.equal(after.authMethod, "password");
});
test("applyGroupDefaults inherits skipEcdsaHostKey from the group when host has no value", () => {
const result = applyGroupDefaults(host(), { skipEcdsaHostKey: true });
assert.equal(result.skipEcdsaHostKey, true);
});
test("applyGroupDefaults keeps host-level skipEcdsaHostKey instead of group default", () => {
const result = applyGroupDefaults(
host({ skipEcdsaHostKey: false }),
{ skipEcdsaHostKey: true },
);
assert.equal(result.skipEcdsaHostKey, false);
});
test("applyGroupDefaults inherits algorithm overrides from the group", () => {
const overrides = { serverHostKey: ["ssh-rsa", "ssh-dss"] };
const result = applyGroupDefaults(host(), { algorithms: overrides });
assert.deepEqual(result.algorithms, overrides);
});
test("applyGroupDefaults keeps host algorithm overrides instead of inheriting", () => {
const hostOverrides = { kex: ["curve25519-sha256"] };
const groupOverrides = { kex: ["diffie-hellman-group14-sha256"] };
const result = applyGroupDefaults(
host({ algorithms: hostOverrides }),
{ algorithms: groupOverrides },
);
assert.deepEqual(result.algorithms, hostOverrides);
});

238
domain/groupConfig.ts Normal file
View File

@@ -0,0 +1,238 @@
import type { GroupConfig, Host } from './models';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
/**
* Migrate deprecated primary-font ids out of a GroupConfig's
* font-override fields. Symmetrical to sanitizeHost; both run on load
* to keep the same proportional-font protection working for group
* defaults too.
*/
export function sanitizeGroupConfig(config: GroupConfig): GroupConfig {
const migrated = migrateDeprecatedFontOverride(config);
const hasLegacyPasswordOnlyCredentials = migrated.authMethod === undefined
&& Boolean(migrated.password?.length)
&& !migrated.identityId
&& !migrated.identityFileId
&& !migrated.identityFilePaths?.length;
return hasLegacyPasswordOnlyCredentials
? { ...migrated, authMethod: 'password' }
: migrated;
}
export interface ApplyGroupDefaultsOptions {
validProxyProfileIds?: ReadonlySet<string>;
}
export const hasManualGroupSshCredentials = (config: Partial<GroupConfig>): boolean => [
config.username,
config.password,
config.savePassword,
config.authMethod,
config.identityFileId,
config.identityFilePaths,
].some((value) => value !== undefined);
export const hasManualGroupTelnetCredentials = (config: Partial<GroupConfig>): boolean => [
config.telnetUsername,
config.telnetPassword,
].some((value) => value !== undefined);
const hasUsableProxyProfileId = (
proxyProfileId: string | undefined,
options?: ApplyGroupDefaultsOptions,
): boolean => {
if (!proxyProfileId) return false;
return !options?.validProxyProfileIds || options.validProxyProfileIds.has(proxyProfileId);
};
/**
* Resolve merged group defaults by walking the ancestor chain.
* For group "A/B/C", merges configs from A, A/B, A/B/C (child overrides parent).
*/
export function resolveGroupDefaults(
groupPath: string,
groupConfigs: GroupConfig[],
options?: ApplyGroupDefaultsOptions,
): Partial<GroupConfig> {
const configMap = new Map(groupConfigs.map((c) => [c.path, c]));
const parts = groupPath.split('/').filter(Boolean);
const merged: Record<string, unknown> = {};
for (let i = 0; i < parts.length; i++) {
const ancestorPath = parts.slice(0, i + 1).join('/');
const config = configMap.get(ancestorPath);
if (config) {
const hasSshIdentitySetting = config.identityId !== undefined;
const hasManualSshCredentials = hasManualGroupSshCredentials(config);
if (hasSshIdentitySetting) {
delete merged.username;
delete merged.password;
delete merged.savePassword;
delete merged.authMethod;
delete merged.identityFileId;
delete merged.identityFilePaths;
} else if (!hasSshIdentitySetting && hasManualSshCredentials) {
const replacesInheritedIdentity = Boolean(merged.identityId);
delete merged.identityId;
if (replacesInheritedIdentity) {
delete merged.username;
delete merged.password;
delete merged.savePassword;
delete merged.authMethod;
delete merged.identityFileId;
delete merged.identityFilePaths;
}
}
const hasTelnetIdentitySetting = config.telnetIdentityId !== undefined;
const hasManualTelnetCredentials = hasManualGroupTelnetCredentials(config);
if (hasTelnetIdentitySetting) {
delete merged.telnetUsername;
delete merged.telnetPassword;
} else if (!hasTelnetIdentitySetting && hasManualTelnetCredentials) {
const replacesInheritedIdentity = Boolean(merged.telnetIdentityId);
delete merged.telnetIdentityId;
if (replacesInheritedIdentity) {
delete merged.telnetUsername;
delete merged.telnetPassword;
}
}
for (const [key, value] of Object.entries(config)) {
if (
key === 'proxyProfileId' &&
typeof value === 'string' &&
options?.validProxyProfileIds &&
!options.validProxyProfileIds.has(value)
) {
delete merged.proxyConfig;
}
if (
(key === 'theme' && config.themeOverride === false) ||
(key === 'fontFamily' && config.fontFamilyOverride === false) ||
(key === 'fontSize' && config.fontSizeOverride === false) ||
(key === 'fontWeight' && config.fontWeightOverride === false)
) {
continue;
}
if (key !== 'path' && value !== undefined) {
if (key === 'proxyProfileId') {
delete merged.proxyConfig;
}
if (key === 'proxyConfig') {
delete merged.proxyProfileId;
}
merged[key] = value;
}
}
if (config.themeOverride === false) {
delete merged.themeOverride;
}
if (config.fontFamilyOverride === false) {
delete merged.fontFamilyOverride;
}
if (config.fontSizeOverride === false) {
delete merged.fontSizeOverride;
}
if (config.fontWeightOverride === false) {
delete merged.fontWeightOverride;
}
}
}
return merged as Partial<GroupConfig>;
}
const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
'username', 'password', 'savePassword', 'authMethod', 'identityId', 'identityFileId', 'identityFilePaths',
'port', 'protocol', 'deviceType', 'agentForwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand', 'startupCommandRunMode',
'legacyAlgorithms', 'skipEcdsaHostKey', 'algorithms',
'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
'etEnabled', 'etPort',
'telnetEnabled', 'telnetPort', 'telnetIdentityId', 'telnetUsername', 'telnetPassword',
'theme', 'themeOverride', 'fontFamily', 'fontFamilyOverride', 'fontSize', 'fontSizeOverride', 'fontWeight', 'fontWeightOverride',
'backspaceBehavior',
];
const EMPTY_STRING_OVERRIDES_GROUP_DEFAULT = new Set<keyof GroupConfig>([
'telnetUsername',
'telnetPassword',
'telnetIdentityId',
// Empty-string host identityId = explicitly no identity (auth-retry save, #1956); do not re-inherit group identity.
'identityId',
]);
const SSH_CREDENTIAL_KEYS = new Set<keyof GroupConfig>([
'username',
'password',
'savePassword',
'authMethod',
'identityId',
'identityFileId',
'identityFilePaths',
]);
/**
* Apply group defaults to a host. Only fills in fields the host doesn't already have.
* Returns a new host object — does NOT mutate the original.
*/
export function applyGroupDefaults(
host: Host,
groupDefaults: Partial<GroupConfig>,
options?: ApplyGroupDefaultsOptions,
): Host {
const effective = { ...host };
const hostHasUsableProxyProfile = hasUsableProxyProfileId(host.proxyProfileId, options);
const hostUsername = host.username?.trim();
const hostHasManualSshCredentials = !host.identityId && Boolean(
(hostUsername && hostUsername !== 'root') ||
(host.authPolicyVersion === 1 && host.authMethod !== undefined) ||
host.password !== undefined ||
host.savePassword === false ||
host.identityFileId ||
host.identityFilePaths?.length,
);
const shouldSkipGroupSshCredentialBundle = Boolean(host.identityId) || (
Boolean(groupDefaults.identityId) &&
(host.identityId === '' || hostHasManualSshCredentials)
);
const primaryTelnetHasManualSharedCredentials = host.protocol === 'telnet' && Boolean(
(hostUsername && hostUsername !== 'root') ||
host.password !== undefined ||
host.savePassword === false
);
const hostHasManualTelnetCredentials = !host.telnetIdentityId && (
host.telnetUsername !== undefined ||
host.telnetPassword !== undefined ||
primaryTelnetHasManualSharedCredentials
);
for (const key of INHERITABLE_KEYS) {
if (shouldSkipGroupSshCredentialBundle && SSH_CREDENTIAL_KEYS.has(key)) continue;
if (key === 'password' && effective.savePassword === false) continue;
if (key === 'telnetIdentityId' && hostHasManualTelnetCredentials) continue;
if (key === 'proxyProfileId') {
if (host.proxyConfig !== undefined || !groupDefaults.proxyProfileId) continue;
}
if (key === 'proxyConfig' && (host.proxyProfileId !== undefined || hostHasUsableProxyProfile)) continue;
const hostValue = (effective as unknown as Record<string, unknown>)[key];
const groupValue = (groupDefaults as unknown as Record<string, unknown>)[key];
const emptyStringIsOverride = EMPTY_STRING_OVERRIDES_GROUP_DEFAULT.has(key);
const shouldInherit =
hostValue === undefined ||
hostValue === null ||
(hostValue === '' && !emptyStringIsOverride);
if (shouldInherit && groupValue !== undefined) {
(effective as unknown as Record<string, unknown>)[key] = groupValue;
}
}
return effective;
}
export function resolveGroupTerminalThemeId(
groupDefaults: Partial<GroupConfig> | undefined,
fallbackThemeId: string,
): string {
if (!groupDefaults) return fallbackThemeId;
return groupDefaults.theme || fallbackThemeId;
}

729
domain/host.test.ts Normal file
View File

@@ -0,0 +1,729 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { Host } from "./models.ts";
import {
classifyDistroId,
detectVendorFromSshVersion,
getHostAddressForClipboard,
hostsEqualForIdentityReuse,
migrateHostsFromLegacyLineTimestamps,
normalizeDistroId,
normalizePrimaryTelnetState,
preserveConcurrentHostLineTimestampUpdate,
resolveHostKeepalive,
resolveTelnetPort,
resolveTelnetPassword,
resolveTelnetUsername,
sanitizeHost,
shouldProbeSessionCwd,
shouldSuggestNetworkDeviceMode,
upsertHostById,
} from "./host.ts";
const makeHost = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Primary Host",
hostname: "127.0.0.1",
port: 22,
username: "root",
authMethod: "password",
tags: [],
os: "linux",
createdAt: 1,
protocol: "ssh",
...overrides,
});
test("upsertHostById updates an existing host in place", () => {
const existing = makeHost();
const updated = makeHost({ label: "Updated Host" });
assert.deepEqual(upsertHostById([existing], updated), [updated]);
});
test("shouldSuggestNetworkDeviceMode offers the switch for an auto-detected vendor", () => {
assert.equal(
shouldSuggestNetworkDeviceMode({ host: makeHost(), detectedDistro: "huawei", alreadyHandled: false }),
true,
);
});
test("shouldSuggestNetworkDeviceMode stays silent once already enabled", () => {
assert.equal(
shouldSuggestNetworkDeviceMode({
host: makeHost({ deviceType: "network" }),
detectedDistro: "cisco",
alreadyHandled: false,
}),
false,
);
});
test("shouldSuggestNetworkDeviceMode stays silent after it was already handled", () => {
assert.equal(
shouldSuggestNetworkDeviceMode({ host: makeHost(), detectedDistro: "cisco", alreadyHandled: true }),
false,
);
});
test("shouldSuggestNetworkDeviceMode ignores ordinary linux distros", () => {
assert.equal(
shouldSuggestNetworkDeviceMode({ host: makeHost(), detectedDistro: "ubuntu", alreadyHandled: false }),
false,
);
});
test("shouldSuggestNetworkDeviceMode does not fire on a substring-only vendor keyword", () => {
assert.equal(
shouldSuggestNetworkDeviceMode({ host: makeHost(), detectedDistro: "cisco-lab-server", alreadyHandled: false }),
false,
);
});
test("shouldSuggestNetworkDeviceMode still fires for a plain SSH session", () => {
assert.equal(
shouldSuggestNetworkDeviceMode({
host: makeHost(),
detectedDistro: "cisco",
alreadyHandled: false,
effectiveProtocol: "ssh",
}),
true,
);
});
test("shouldSuggestNetworkDeviceMode stays silent for non-SSH sessions (mosh/et/serial/telnet)", () => {
for (const effectiveProtocol of ["mosh", "et", "serial", "telnet", "local"]) {
assert.equal(
shouldSuggestNetworkDeviceMode({
host: makeHost(),
detectedDistro: "cisco",
alreadyHandled: false,
effectiveProtocol,
}),
false,
`expected no suggestion for ${effectiveProtocol}`,
);
}
});
test("upsertHostById appends a duplicated host with a fresh id", () => {
const existing = makeHost({
id: "serial-original",
label: "Serial Config",
protocol: "serial",
hostname: "/dev/ttyUSB0",
port: 115200,
serialConfig: {
path: "/dev/ttyUSB0",
baudRate: 115200,
dataBits: 8,
stopBits: 1,
parity: "none",
flowControl: "none",
localEcho: false,
lineMode: false,
},
});
const duplicate = makeHost({
...existing,
id: "serial-duplicate",
label: "Serial Config (copy)",
});
assert.deepEqual(upsertHostById([existing], duplicate), [existing, duplicate]);
});
test("telnet credential helpers preserve explicitly cleared values", () => {
const host = makeHost({
username: "ssh-user",
password: "ssh-password",
telnetUsername: "",
telnetPassword: "",
});
assert.equal(resolveTelnetUsername(host), "");
assert.equal(resolveTelnetPassword(host), "");
});
test("telnet credential helpers fall back only when telnet fields are unset", () => {
const host = makeHost({
username: " ssh-user ",
password: "ssh-password",
telnetUsername: undefined,
telnetPassword: undefined,
});
assert.equal(resolveTelnetUsername(host), "ssh-user");
assert.equal(resolveTelnetPassword(host), "ssh-password");
});
test("normalizePrimaryTelnetState enables primary telnet without materializing a port", () => {
const result = normalizePrimaryTelnetState(makeHost({
protocol: "telnet",
telnetEnabled: false,
telnetPort: undefined,
port: undefined,
}));
assert.equal(result.telnetEnabled, true);
assert.equal(result.telnetPort, undefined);
assert.equal(result.port, undefined);
});
test("normalizePrimaryTelnetState leaves optional telnet hosts unchanged", () => {
const result = normalizePrimaryTelnetState(makeHost({
protocol: "ssh",
telnetEnabled: false,
telnetPort: undefined,
}));
assert.equal(result.telnetEnabled, false);
assert.equal(result.telnetPort, undefined);
});
test("migrateHostsFromLegacyLineTimestamps preserves the old global opt-in", () => {
const host = makeHost();
assert.deepEqual(migrateHostsFromLegacyLineTimestamps([host], true), [
{ ...host, showLineTimestamps: true },
]);
});
test("migrateHostsFromLegacyLineTimestamps does not override explicit host choices", () => {
const enabled = makeHost({ id: "enabled", showLineTimestamps: true });
const disabled = makeHost({ id: "disabled", showLineTimestamps: false });
assert.deepEqual(migrateHostsFromLegacyLineTimestamps([enabled, disabled], true), [enabled, disabled]);
});
test("migrateHostsFromLegacyLineTimestamps fills only missing host choices", () => {
const inherited = makeHost({ id: "inherited" });
const disabled = makeHost({ id: "disabled", showLineTimestamps: false });
assert.deepEqual(migrateHostsFromLegacyLineTimestamps([inherited, disabled], true), [
{ ...inherited, showLineTimestamps: true },
disabled,
]);
});
test("sanitizeHost preserves valid custom host icon fields", () => {
const sanitized = sanitizeHost(makeHost({
iconMode: "custom",
iconId: "database",
iconColor: "blue",
}));
assert.equal(sanitized.iconMode, "custom");
assert.equal(sanitized.iconId, "database");
assert.equal(sanitized.iconColor, "blue");
});
test("sanitizeHost preserves valid unavailable plugin connection configuration", () => {
const providerId = "com.example.transport.connection";
const sanitized = sanitizeHost(makeHost({
protocol: `plugin:${providerId}`,
pluginConnection: {
providerId,
configuration: { endpoint: "example", options: [1, 2] },
credentialId: "credential-reference-1234",
},
}));
assert.deepEqual(sanitized.pluginConnection, {
providerId,
configuration: { endpoint: "example", options: [1, 2] },
credentialId: "credential-reference-1234",
});
});
test("sanitizeHost removes hidden built-in credentials and transport state from plugin hosts", () => {
const providerId = "com.example.transport.connection";
const sanitized = sanitizeHost(makeHost({
protocol: `plugin:${providerId}`,
pluginConnection: {
providerId,
configuration: { endpoint: "example" },
credentialId: "credential-reference-1234",
},
port: 22,
identityId: "identity-1",
identityFileId: "key-1",
identityFilePaths: ["~/.ssh/id_work"],
password: "saved-secret",
savePassword: true,
authMethod: "key",
authPolicyVersion: 1,
requiresMfa: true,
useSshAgent: true,
identityAgent: "~/.ssh/agent.sock",
identitiesOnly: true,
addKeysToAgent: "confirm",
useKeychain: true,
agentForwarding: true,
x11Forwarding: true,
proxyProfileId: "proxy-1",
proxyConfig: { type: "http", host: "proxy.example", port: 8080 },
hostChain: { type: "hosts", hostIds: ["jump-1"] },
moshEnabled: true,
moshServerPath: "/usr/bin/mosh-server",
etEnabled: true,
etPort: 2022,
telnetEnabled: true,
telnetPort: 23,
telnetIdentityId: "telnet-identity",
telnetUsername: "legacy-user",
telnetPassword: "legacy-secret",
sftpSudo: true,
legacyAlgorithms: true,
skipEcdsaHostKey: true,
algorithms: { kex: ["diffie-hellman-group14-sha1"] },
keepaliveOverride: true,
keepaliveInterval: 10,
keepaliveCountMax: 3,
sshTcpConnectTimeoutSeconds: 15,
sshAuthReadyTimeoutSeconds: 20,
}));
assert.deepEqual(sanitized.pluginConnection, {
providerId,
configuration: { endpoint: "example" },
credentialId: "credential-reference-1234",
});
for (const field of [
"port", "identityId", "identityFileId", "identityFilePaths", "password", "savePassword",
"authMethod", "authPolicyVersion", "requiresMfa", "useSshAgent", "identityAgent",
"identitiesOnly", "addKeysToAgent", "useKeychain", "agentForwarding", "x11Forwarding",
"proxyProfileId", "proxyConfig", "hostChain", "moshEnabled", "moshServerPath", "etEnabled",
"etPort", "telnetEnabled", "telnetPort", "telnetIdentityId", "telnetUsername",
"telnetPassword", "sftpSudo", "legacyAlgorithms", "skipEcdsaHostKey", "algorithms",
"keepaliveOverride", "keepaliveInterval", "keepaliveCountMax", "sshTcpConnectTimeoutSeconds",
"sshAuthReadyTimeoutSeconds",
]) {
assert.equal(field in sanitized, false, `${field} should be removed`);
}
});
test("sanitizeHost keeps legacy empty-password hosts on automatic authentication", () => {
const sanitized = sanitizeHost(makeHost({
password: undefined,
authMethod: "password",
authPolicyVersion: undefined,
}));
assert.equal(sanitized.authMethod, undefined);
assert.equal(sanitized.authPolicyVersion, 1);
});
test("sanitizeHost keeps legacy agent and key hosts on their prior authentication path", () => {
for (const legacySettings of [
{ useSshAgent: true, password: "saved-secret" },
{ identityFilePaths: ["~/.ssh/id_work"], password: "saved-secret" },
]) {
const sanitized = sanitizeHost(makeHost({
authMethod: "password",
authPolicyVersion: undefined,
...legacySettings,
}));
assert.equal(sanitized.authMethod, undefined);
assert.equal(sanitized.authPolicyVersion, 1);
}
});
test("sanitizeHost preserves an explicit password-only choice", () => {
const sanitized = sanitizeHost(makeHost({
password: undefined,
authMethod: "password",
authPolicyVersion: 1,
}));
assert.equal(sanitized.authMethod, "password");
assert.equal(sanitized.authPolicyVersion, 1);
});
test("sanitizeHost preserves a legacy no-save password-only choice", () => {
const sanitized = sanitizeHost(makeHost({
password: undefined,
savePassword: false,
authMethod: "password",
authPolicyVersion: undefined,
}));
assert.equal(sanitized.authMethod, "password");
assert.equal(sanitized.savePassword, false);
assert.equal(sanitized.authPolicyVersion, 1);
});
test("sanitizeHost keeps legacy no-save agent and key hosts on their prior authentication path", () => {
for (const legacySettings of [
{ useSshAgent: true },
{ identityFileId: "selected-key" },
{ identityFilePaths: ["~/.ssh/id_work"] },
]) {
const sanitized = sanitizeHost(makeHost({
password: undefined,
savePassword: false,
authMethod: "password",
authPolicyVersion: undefined,
...legacySettings,
}));
assert.equal(sanitized.authMethod, undefined);
assert.equal(sanitized.authPolicyVersion, 1);
}
});
test("sanitizeHost preserves a legacy whitespace-only password", () => {
const sanitized = sanitizeHost(makeHost({
password: " ",
authMethod: "password",
authPolicyVersion: undefined,
}));
assert.equal(sanitized.authMethod, "password");
assert.equal(sanitized.password, " ");
assert.equal(sanitized.authPolicyVersion, 1);
});
test("sanitizeHost preserves an inferred legacy saved password", () => {
const sanitized = sanitizeHost(makeHost({
password: "saved-secret",
authMethod: undefined,
authPolicyVersion: undefined,
}));
assert.equal(sanitized.authMethod, "password");
assert.equal(sanitized.authPolicyVersion, 1);
});
test("sanitizeHost preserves inferred legacy identity file paths", () => {
const sanitized = sanitizeHost(makeHost({
password: "fallback-secret",
authMethod: undefined,
authPolicyVersion: undefined,
identityFilePaths: ["~/.ssh/id_work"],
}));
assert.equal(sanitized.authMethod, "key");
assert.equal(sanitized.authPolicyVersion, 1);
});
test("sanitizeHost preserves automatic host icon color fields", () => {
const sanitized = sanitizeHost(makeHost({
iconMode: "auto",
iconColor: "violet",
}));
assert.equal(sanitized.iconMode, "auto");
assert.equal(sanitized.iconId, undefined);
assert.equal(sanitized.iconColor, "violet");
});
test("sanitizeHost removes invalid custom host icon fields", () => {
const sanitized = sanitizeHost(makeHost({
iconMode: "custom",
iconId: "bad",
iconColor: "blue",
} as unknown as Partial<Host>));
assert.equal(sanitized.iconMode, undefined);
assert.equal(sanitized.iconId, undefined);
assert.equal(sanitized.iconColor, undefined);
});
test("sanitizeHost trims leading whitespace before extracting the hostname", () => {
const sanitized = sanitizeHost(makeHost({
hostname: " 127.0.0.1",
}));
assert.equal(sanitized.hostname, "127.0.0.1");
});
test("sanitizeHost preserves valid per-host SSH timeouts and removes invalid values", () => {
const sanitized = sanitizeHost(makeHost({
sshTcpConnectTimeoutSeconds: 45.4,
sshAuthReadyTimeoutSeconds: 3601,
}));
assert.equal(sanitized.sshTcpConnectTimeoutSeconds, 45);
assert.equal(sanitized.sshAuthReadyTimeoutSeconds, undefined);
});
test("preserves a concurrent terminal timestamp toggle when host details did not edit it", () => {
const openedHost = makeHost({ showLineTimestamps: false });
const latestHost = makeHost({ showLineTimestamps: true });
const draft = makeHost({ label: "Edited label", showLineTimestamps: false });
assert.deepEqual(
preserveConcurrentHostLineTimestampUpdate({ draft, openedHost, latestHost }),
{ ...draft, showLineTimestamps: true },
);
});
test("keeps host details timestamp value when the details form edits it", () => {
const openedHost = makeHost({ showLineTimestamps: false });
const latestHost = makeHost({ showLineTimestamps: false });
const draft = makeHost({ showLineTimestamps: true });
assert.equal(
preserveConcurrentHostLineTimestampUpdate({ draft, openedHost, latestHost }).showLineTimestamps,
true,
);
});
test("preserves a concurrent SFTP follow-terminal-directory toggle when host details did not edit it", () => {
const openedHost = makeHost({ sftpFollowTerminalCwd: undefined });
const latestHost = makeHost({ sftpFollowTerminalCwd: true });
const draft = makeHost({ label: "Edited label", sftpFollowTerminalCwd: undefined });
assert.deepEqual(
preserveConcurrentHostLineTimestampUpdate({ draft, openedHost, latestHost }),
{ ...draft, sftpFollowTerminalCwd: true },
);
});
test("keeps host details SFTP follow-terminal-directory value when the details form edits it", () => {
const openedHost = makeHost({ sftpFollowTerminalCwd: false });
const latestHost = makeHost({ sftpFollowTerminalCwd: false });
const draft = makeHost({ sftpFollowTerminalCwd: true });
assert.equal(
preserveConcurrentHostLineTimestampUpdate({ draft, openedHost, latestHost }).sftpFollowTerminalCwd,
true,
);
});
test("normalizePrimaryTelnetState preserves an explicit telnet port", () => {
const result = normalizePrimaryTelnetState(makeHost({
protocol: "telnet",
telnetEnabled: false,
telnetPort: 2325,
}));
assert.equal(result.telnetEnabled, true);
assert.equal(result.telnetPort, 2325);
});
test("resolveTelnetPort ignores ssh ports for optional telnet", () => {
assert.equal(resolveTelnetPort(makeHost({
protocol: "ssh",
port: 2222,
telnetPort: undefined,
})), 23);
});
test("resolveTelnetPort uses primary telnet port fallback", () => {
assert.equal(resolveTelnetPort(makeHost({
protocol: "telnet",
port: 2325,
telnetPort: undefined,
})), 2325);
});
test("sanitizeHost migrates a deprecated fontFamily and clears the override flag", () => {
// Regression guard for codex P2 review on PR #940: hosts saved with
// pingfang-sc / microsoft-yahei / comic-sans-ms in fontFamily must
// have the override dropped so they fall back to the global default
// instead of silently rendering the wrong font while still claiming
// an override is active.
const before = makeHost({
fontFamily: "comic-sans-ms",
fontFamilyOverride: true,
});
const after = sanitizeHost(before);
assert.equal(after.fontFamily, undefined);
assert.equal(after.fontFamilyOverride, false);
});
test("sanitizeHost keeps a still-valid fontFamily untouched", () => {
const before = makeHost({
fontFamily: "fira-code",
fontFamilyOverride: true,
});
const after = sanitizeHost(before);
assert.equal(after.fontFamily, "fira-code");
assert.equal(after.fontFamilyOverride, true);
});
test("detectVendorFromSshVersion recognizes legacy Huawei VRP dash banner", () => {
assert.equal(detectVendorFromSshVersion("-"), "huawei");
assert.equal(detectVendorFromSshVersion("SSH-2.0--"), "huawei");
});
test("detectVendorFromSshVersion recognizes Ruijie RGOS banner", () => {
assert.equal(detectVendorFromSshVersion("RGOS_SSH"), "ruijie");
assert.equal(detectVendorFromSshVersion("SSH-2.0-RGOS_SSH"), "ruijie");
});
test("detectVendorFromSshVersion recognizes H3C and Comware banners", () => {
assert.equal(detectVendorFromSshVersion("H3C-Comware-7.1.064"), "h3c");
assert.equal(detectVendorFromSshVersion("SSH-2.0-Comware-7.1.064"), "h3c");
assert.equal(detectVendorFromSshVersion("SSH-2.0-3Com OS"), "h3c");
});
test("detectVendorFromSshVersion maps mpSSH banners to HPE iLO", () => {
assert.equal(detectVendorFromSshVersion("SSH-2.0-mpSSH_0.2.1"), "hpe");
});
test("normalizeDistroId maps Alibaba Cloud Linux os-release ID to alinux", () => {
// /etc/os-release ID="alinux" — the canonical signal from Alibaba Cloud
// Linux 3 (issue #1200). Regression guard: 'alinux'.includes('linux') is
// true, so without a dedicated branch this would fall through to the
// generic 'linux' icon (the bug the issue reports).
assert.equal(normalizeDistroId("alinux"), "alinux");
assert.notEqual(normalizeDistroId("alinux"), "linux");
});
test("normalizeDistroId maps legacy Aliyun Linux IDs to alinux", () => {
// Older releases branded the distro as "Aliyun Linux" with ID=aliyun.
assert.equal(normalizeDistroId("aliyun"), "alinux");
});
test("normalizeDistroId matches Alibaba Cloud Linux PRETTY_NAME/NAME fallback", () => {
// When ID is absent the detector falls back to NAME / PRETTY_NAME text.
assert.equal(normalizeDistroId("Alibaba Cloud Linux"), "alinux");
assert.equal(
normalizeDistroId("Alibaba Cloud Linux 3.2104 U13.1 (OpenAnolis Edition)"),
"alinux",
);
});
test("normalizeDistroId maps openEuler before the generic Linux fallback", () => {
assert.equal(normalizeDistroId("openeuler"), "openeuler");
assert.equal(normalizeDistroId("openEuler"), "openeuler");
assert.notEqual(normalizeDistroId("openeuler"), "linux");
});
test("normalizeDistroId maps Darwin and macOS labels to macos", () => {
assert.equal(normalizeDistroId("Darwin"), "macos");
assert.equal(normalizeDistroId("Darwin Kernel Version 24.5.0"), "macos");
assert.equal(normalizeDistroId("macOS"), "macos");
assert.equal(normalizeDistroId("Mac OS X"), "macos");
});
test("normalizeDistroId maps FreeBSD uname output to freebsd", () => {
assert.equal(normalizeDistroId("FreeBSD"), "freebsd");
assert.equal(
normalizeDistroId("FreeBSD host.example.com 14.3-RELEASE-p1 GENERIC amd64"),
"freebsd",
);
});
test("classifyDistroId limits Linux-like runtime support to implemented POSIX platforms", () => {
assert.equal(classifyDistroId("macos"), "linux-like");
assert.equal(classifyDistroId("Darwin"), "linux-like");
assert.equal(classifyDistroId("freebsd"), "other");
});
test("shouldProbeSessionCwd allows the probe on a plain Linux host", () => {
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "OpenSSH_9.6" }),
true,
);
});
test("shouldProbeSessionCwd skips the probe on an already-classified network device", () => {
// Reconnect / manual deviceType='network': host.distro already says network.
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: true, remoteSshVersion: "OpenSSH_9.6" }),
false,
);
});
test("shouldProbeSessionCwd skips the probe when the SSH banner reveals a network vendor", () => {
// First connect to a brand-new Huawei VRP: host.distro not persisted yet, so
// isNetworkDevice is still false — the banner is the only signal (#1043).
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "-" }),
false,
);
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "SSH-1.99--" }),
false,
);
});
const GLOBAL_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
test("resolveHostKeepalive falls back to global when override is not set", () => {
const host = makeHost();
assert.deepEqual(
resolveHostKeepalive(host, GLOBAL_KEEPALIVE),
{ interval: 30, countMax: 10, source: "global" },
);
});
test("resolveHostKeepalive falls back to global when override is explicitly false", () => {
const host = makeHost({
keepaliveOverride: false,
keepaliveInterval: 0,
keepaliveCountMax: 3,
});
// Override flag is the gate; the host's stored values stay parked and
// unused so toggling the flag back on later restores them.
assert.deepEqual(
resolveHostKeepalive(host, GLOBAL_KEEPALIVE),
{ interval: 30, countMax: 10, source: "global" },
);
});
test("resolveHostKeepalive uses host values when override is true", () => {
const host = makeHost({
keepaliveOverride: true,
keepaliveInterval: 0,
keepaliveCountMax: 3,
});
assert.deepEqual(
resolveHostKeepalive(host, GLOBAL_KEEPALIVE),
{ interval: 0, countMax: 3, source: "host" },
);
});
test("resolveHostKeepalive lets each field fall back independently", () => {
// Override on, but only `interval` set on the host: inherit global countMax.
assert.deepEqual(
resolveHostKeepalive(
makeHost({ keepaliveOverride: true, keepaliveInterval: 5 }),
GLOBAL_KEEPALIVE,
),
{ interval: 5, countMax: 10, source: "host" },
);
// Override on, but only countMax set: inherit global interval.
assert.deepEqual(
resolveHostKeepalive(
makeHost({ keepaliveOverride: true, keepaliveCountMax: 50 }),
GLOBAL_KEEPALIVE,
),
{ interval: 30, countMax: 50, source: "host" },
);
});
test("hostsEqualForIdentityReuse is true for shallow-identical field values", () => {
const a = makeHost({ showLineTimestamps: true });
const b = { ...a };
assert.equal(hostsEqualForIdentityReuse(a, b), true);
assert.equal(hostsEqualForIdentityReuse(a, makeHost({ showLineTimestamps: false })), false);
});
test("getHostAddressForClipboard returns the trimmed hostname for vault one-click copy", () => {
assert.equal(getHostAddressForClipboard(makeHost({ hostname: " 10.0.0.12 " })), "10.0.0.12");
assert.equal(getHostAddressForClipboard(makeHost({ hostname: "db.internal" })), "db.internal");
assert.equal(getHostAddressForClipboard({ hostname: "" }), "");
});
test("getHostAddressForClipboard omits synthesized plugin hostnames", () => {
assert.equal(
getHostAddressForClipboard(
makeHost({
protocol: "plugin:com.example.transport.connection",
hostname: "My Plugin Label",
pluginConnection: {
providerId: "com.example.transport.connection",
configuration: { endpoint: "opaque-target" },
},
}),
),
"",
);
});

519
domain/host.ts Normal file
View File

@@ -0,0 +1,519 @@
import { Host, Snippet, TerminalSettings } from './models';
import type { HostOperatingSystem, HostOsSelection } from './models/connection';
import { sanitizeHostIconFields } from './hostIcon';
import { migrateHostConnectScriptIds } from './hostConnectScripts.ts';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
import { sanitizeOptionalSshTimeoutSeconds } from './sshConnectionTimeouts.ts';
import {
isPluginHostProtocol,
sanitizePluginConnection,
stripBuiltInConnectionFieldsForPluginHost,
} from './pluginConnection.ts';
export type HostLabelRenameResult =
| { ok: true; changed: true; hosts: Host[] }
| { ok: true; changed: false; reason: 'unchanged' | 'missing'; hosts: Host[] }
| { ok: false; changed: false; reason: 'required'; hosts: Host[] };
export function applyHostLabelRename(
hosts: Host[],
hostId: string,
rawLabel: string,
): HostLabelRenameResult {
const nextLabel = rawLabel.trim();
if (!nextLabel) {
return { ok: false, changed: false, reason: 'required', hosts };
}
let found = false;
let changed = false;
const nextHosts = hosts.map((host) => {
if (host.id !== hostId) return host;
found = true;
if (host.label === nextLabel) return host;
changed = true;
return { ...host, label: nextLabel };
});
if (!found) return { ok: true, changed: false, reason: 'missing', hosts };
if (!changed) return { ok: true, changed: false, reason: 'unchanged', hosts };
return { ok: true, changed: true, hosts: nextHosts };
}
export const LINUX_DISTRO_OPTIONS = [
'linux',
'ubuntu',
'debian',
'centos',
'rocky',
'fedora',
'arch',
'alpine',
'amazon',
'opensuse',
'redhat',
'almalinux',
'oracle',
'kali',
'alinux',
'openeuler',
] as const;
export const POSIX_PLATFORM_OPTIONS = [
'macos',
'freebsd',
] as const;
// Only platforms backed by the current stats and system-management commands
// belong here. Other POSIX platforms may still be valid icon choices.
const LINUX_LIKE_RUNTIME_PLATFORM_OPTIONS = [
'macos',
] as const;
/**
* Known network-device vendor IDs that Netcatty can detect from the SSH
* server identification string. When a host is classified as one of these,
* features that assume a POSIX shell (e.g. the periodic server stats poll)
* are disabled, because the stats command would either be rejected outright
* or generate one AAA session log per poll on the remote device.
*/
export const NETWORK_DEVICE_OPTIONS = [
'cisco',
'juniper',
'huawei',
'h3c',
'hpe',
'mikrotik',
'fortinet',
'paloalto',
'zyxel',
'ruijie',
] as const;
export type NetworkDeviceVendor = typeof NETWORK_DEVICE_OPTIONS[number];
export const normalizeDistroId = (value?: string) => {
const v = (value || '').toLowerCase().trim();
if (!v) return '';
if (
v === 'darwin' ||
v === 'macos' ||
v === 'mac os' ||
v === 'mac os x' ||
v.includes('darwin kernel') ||
v.includes('macos') ||
v.includes('mac os')
) {
return 'macos';
}
if (v === 'windows' || v === 'win32' || /^openssh_for_windows(?:_|$)/.test(v)) return 'windows';
if (v.includes('freebsd')) return 'freebsd';
if (v.includes('ubuntu')) return 'ubuntu';
if (v.includes('debian')) return 'debian';
if (v.includes('centos')) return 'centos';
if (v.includes('rocky')) return 'rocky';
if (v.includes('fedora')) return 'fedora';
if (v.includes('arch') || v.includes('manjaro')) return 'arch';
if (v.includes('alpine')) return 'alpine';
if (v.includes('amzn') || v.includes('amazon') || v.includes('aws')) return 'amazon';
if (v.includes('opensuse') || v.includes('suse') || v.includes('sles')) return 'opensuse';
if (v.includes('red hat') || v.includes('redhat') || v.includes('rhel')) return 'redhat';
if (v.includes('almalinux')) return 'almalinux';
if (v.includes('oracle')) return 'oracle';
if (v.includes('kali')) return 'kali';
if (v.includes('openeuler') || v.includes('open euler')) return 'openeuler';
// Alibaba Cloud Linux: os-release ID is `alinux` (older branding: Aliyun
// Linux / `aliyun`). Must come before the generic `linux` fallback because
// 'alinux'.includes('linux') is true and would otherwise resolve to 'linux'.
if (v.includes('alinux') || v.includes('aliyun') || v.includes('alibaba cloud')) {
return 'alinux';
}
// Network device vendor IDs may arrive here after detection — preserve them.
if ((NETWORK_DEVICE_OPTIONS as readonly string[]).includes(v)) return v;
if (v === 'linux' || v.includes('linux')) return 'linux';
return '';
};
/**
* Parse the SSH server identification string (the `software` portion of
* `SSH-<protocol>-<software>\r\n`, exposed by ssh2 as `conn._remoteVer`)
* and return a normalized network-device vendor ID, or '' if the ident
* does not match a known vendor.
*
* Matching patterns are sourced from the Nmap nmap-service-probes
* database (match lines beginning with `match ssh m|^SSH-`), cross-
* referenced with the ssh-audit project's software.py and vendor docs.
* Only rules whose pattern can be reproduced from those sources are
* included here.
*
* Empty string means "use the fallback linux/macos detection path" —
* that is what happens for OpenSSH, Dropbear, JUNOS, Cisco NX-OS, and
* Arista EOS, all of which either are POSIX systems or present as
* plain `OpenSSH_*` with no distinct vendor marker.
*/
export const detectVendorFromSshVersion = (softwareVersion?: string): '' | NetworkDeviceVendor => {
const s = (softwareVersion || '').trim().replace(/^SSH-(?:2\.0|1\.99)-/i, '');
if (!s) return '';
// Cisco family — IOS, IOS XA, Wireless LAN Controller
if (/^Cisco[-_]/i.test(s)) return 'cisco';
if (/^CiscoIOS_/i.test(s)) return 'cisco';
if (/^CISCO_WLC\b/.test(s)) return 'cisco';
// Note: `IPSSH-*` is used by both Cisco and 3Com devices (per Nmap
// `match ssh m|^SSH-([\d.]+)-IPSSH-([\d.]+)\|`), so we cannot map it
// to a specific vendor icon from the banner alone. Users who want a
// custom icon for such devices can set one via the Host Details
// manual distro override. The stats-polling gate is still handled
// correctly via `host.deviceType === 'network'`.
// Juniper NetScreen firewall (JUNOS itself uses OpenSSH and is caught
// by the fallback failure-counter path in useServerStats).
if (/^NetScreen\b/.test(s)) return 'juniper';
// Huawei VRP and related products
if (s === '-') return 'huawei';
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
if (/^VRP-/i.test(s)) return 'huawei';
// H3C / 3Com Comware switches
if (/^H3C[-_\s]/i.test(s)) return 'h3c';
if (/^Comware-/i.test(s)) return 'h3c';
if (/^3Com\s*OS/i.test(s)) return 'h3c';
// HPE iLO
if (/^mpSSH_/i.test(s)) return 'hpe';
// MikroTik RouterOS
if (/^ROSSSH\b/.test(s)) return 'mikrotik';
// Fortinet FortiOS / FortiGate
if (/^FortiSSH_/i.test(s)) return 'fortinet';
// Palo Alto Networks PAN-OS
if (/^PaloAltoNetworks[_-]/i.test(s)) return 'paloalto';
// ZyXEL ZyWALL
if (/^Zyxel\s*SSH/i.test(s)) return 'zyxel';
// Ruijie RGOS
if (/^RGOS_SSH\b/i.test(s)) return 'ruijie';
return '';
};
/**
* Classify a distro/vendor ID into a high-level device class. Features that
* assume a POSIX shell (periodic stats polling, /etc/os-release probing, etc.)
* should only run when this returns `linux-like`.
*/
export type DeviceClass = 'linux-like' | 'network-device' | 'other';
export const classifyDistroId = (distroId?: string): DeviceClass => {
const v = normalizeDistroId(distroId) || (distroId || '').toLowerCase().trim();
if (!v) return 'other';
if ((NETWORK_DEVICE_OPTIONS as readonly string[]).includes(v)) return 'network-device';
if ((LINUX_DISTRO_OPTIONS as readonly string[]).includes(v)) return 'linux-like';
if ((LINUX_LIKE_RUNTIME_PLATFORM_OPTIONS as readonly string[]).includes(v)) return 'linux-like';
return 'other';
};
export const HOST_OS_SELECTIONS = ['auto', 'linux', 'windows', 'macos', 'freebsd', 'unknown'] as const;
export function getHostOsSelection(host?: Pick<Host, 'os' | 'osOverride'> | null): HostOsSelection {
if (host?.osOverride && (HOST_OS_SELECTIONS as readonly string[]).includes(host.osOverride)) {
return host.osOverride;
}
return host?.os === 'windows' || host?.os === 'macos' ? host.os : 'auto';
}
/** Resolve system facts separately from cosmetic manualDistro/icon choices. */
export function resolveHostOs(
host?: Pick<Host, 'os' | 'osOverride' | 'distro' | 'deviceType' | 'protocol'> | null,
): HostOperatingSystem {
if (host?.protocol === 'local') return host.os;
const selection = getHostOsSelection(host);
if (selection !== 'auto') return selection;
if (host?.deviceType === 'network' || classifyDistroId(host?.distro) === 'network-device') return 'unknown';
const distro = normalizeDistroId(host?.distro);
if (distro === 'windows' || distro === 'macos' || distro === 'freebsd') return distro;
if ((LINUX_DISTRO_OPTIONS as readonly string[]).includes(distro)) return 'linux';
return 'unknown';
}
/**
* Decide whether to offer the "enable Network Device Mode" suggestion after
* distro/vendor detection. We only nag once per host, and never when the user
* has already turned the mode on. The detected value must classify as a
* network-device vendor (exact match — see `classifyDistroId`), so a normal
* Linux distro whose name merely embeds a vendor keyword never triggers it.
*/
export const shouldSuggestNetworkDeviceMode = (opts: {
host?: Pick<Host, 'deviceType'> | null;
detectedDistro?: string;
alreadyHandled?: boolean;
/**
* Effective session protocol (mosh/et folded in). The suggestion — and its
* host-level `deviceType: 'network'` write — must match the Host Details
* "Network Device Mode" toggle, which is only exposed for plain SSH sessions
* (not Mosh/ET/serial/telnet/local). Offering it elsewhere would let the user
* persist a hidden host-level mode that surprises later SSH reconnects (#2367).
*/
effectiveProtocol?: string;
}): boolean => {
if (!opts.host) return false;
if (opts.host.deviceType === 'network') return false;
if (opts.alreadyHandled) return false;
if (opts.effectiveProtocol !== undefined && opts.effectiveProtocol !== 'ssh') {
return false;
}
return classifyDistroId(opts.detectedDistro) === 'network-device';
};
/**
* Decide whether it is safe to run the post-connect `pwd` probe that
* discovers the session's working directory. The probe opens an extra exec
* channel running a POSIX-shell script; strict network-device CLIs such as
* Huawei VRP respond by closing the whole SSH session (#1043), so it must be
* skipped for them.
*
* `isNetworkDevice` covers hosts we already classified (a reconnect, or an
* explicit `deviceType: 'network'`). On a brand-new host that field is not
* populated yet, so we also inspect the SSH server identification banner —
* captured for free at handshake — which identifies most vendors directly.
*/
export const shouldProbeSessionCwd = (opts: {
isNetworkDevice: boolean;
remoteSshVersion?: string;
}): boolean =>
!opts.isNetworkDevice && !detectVendorFromSshVersion(opts.remoteSshVersion);
export const getEffectiveHostDistro = (
host?: Pick<Host, 'distro' | 'manualDistro' | 'distroMode'> | null,
) => {
if (!host) return '';
const detected = normalizeDistroId(host.distro);
const manual = normalizeDistroId(host.manualDistro);
if (host.distroMode === 'manual') return manual || detected;
if (host.distroMode === 'auto') return detected;
return detected;
};
/** Format hostname:port for display, wrapping IPv6 addresses in brackets. */
export const formatHostPort = (hostname: string, port?: number | null): string => {
if (port == null) return hostname;
const isIPv6 = hostname.includes(':') && !hostname.startsWith('[');
const display = isIPv6 ? `[${hostname}]` : hostname;
return `${display}:${port}`;
};
/** Hostname/IP text for one-click clipboard copy from the vault host list. */
export const getHostAddressForClipboard = (
host: Pick<Host, 'hostname' | 'protocol'>,
): string => {
// Plugin endpoints live in pluginConnection.configuration; hostname may be a label/provider id.
if (isPluginHostProtocol(host.protocol)) return '';
return String(host.hostname ?? '').trim();
};
export const resolveTelnetUsername = (
host: Pick<Host, 'telnetUsername' | 'username'>,
): string | undefined =>
host.telnetUsername !== undefined
? host.telnetUsername.trim()
: host.username?.trim();
export const resolveTelnetPassword = (
host: Pick<Host, 'telnetPassword' | 'password'>,
): string | undefined =>
host.telnetPassword !== undefined
? host.telnetPassword
: host.password;
export const resolveTelnetPort = (
host: Pick<Host, 'protocol' | 'telnetPort' | 'port'>,
): number => {
if (host.telnetPort !== undefined && host.telnetPort !== null) return host.telnetPort;
if (host.protocol === 'telnet' && host.port !== undefined && host.port !== null) {
return host.port;
}
return 23;
};
export const normalizePrimaryTelnetState = (host: Host): Host =>
host.protocol === 'telnet' && !host.telnetEnabled
? { ...host, telnetEnabled: true }
: host;
export const migrateHostsFromLegacyLineTimestamps = (
hosts: Host[],
legacyEnabled: boolean,
): Host[] => {
if (!legacyEnabled) return hosts;
let changed = false;
const migrated = hosts.map((host) => {
if (host.showLineTimestamps !== undefined) return host;
changed = true;
return { ...host, showLineTimestamps: true };
});
return changed ? migrated : hosts;
};
export const preserveConcurrentHostLineTimestampUpdate = ({
draft,
openedHost,
latestHost,
}: {
draft: Host;
openedHost?: Host | null;
latestHost?: Host | null;
}): Host => {
if (!openedHost || !latestHost) return draft;
if (draft.id !== openedHost.id || draft.id !== latestHost.id) return draft;
let next = draft;
if (
draft.showLineTimestamps === openedHost.showLineTimestamps &&
latestHost.showLineTimestamps !== openedHost.showLineTimestamps
) {
next = { ...next, showLineTimestamps: latestHost.showLineTimestamps };
}
if (
draft.sftpFollowTerminalCwd === openedHost.sftpFollowTerminalCwd &&
latestHost.sftpFollowTerminalCwd !== openedHost.sftpFollowTerminalCwd
) {
next = { ...next, sftpFollowTerminalCwd: latestHost.sftpFollowTerminalCwd };
}
return next;
};
export const upsertHostById = (hosts: Host[], host: Host): Host[] => {
const hostExists = hosts.some((entry) => entry.id === host.id);
return hostExists
? hosts.map((entry) => (entry.id === host.id ? host : entry))
: [...hosts, host];
};
export interface ResolvedKeepalive {
interval: number; // Seconds; 0 = disabled
countMax: number; // Unanswered keepalives before declaring dead
source: 'host' | 'global';
}
/**
* Decide which SSH keepalive values to apply to a connection. A host can opt
* into its own values via `keepaliveOverride === true` — useful when a
* specific device (older router / switch / NOKIA / ALCATEL SSH stack) doesn't
* reply to keepalive@openssh.com and the global aggressive setting would
* cause the session to be declared dead after a handful of unanswered probes.
* When the override is off (the default), the host inherits the global
* TerminalSettings values which are tuned for cloud / NAT'd hosts.
*
* Each field falls back independently: a host can override only the interval
* while still inheriting the global countMax, and vice versa.
*/
export const resolveHostKeepalive = (
host: Pick<Host, 'keepaliveOverride' | 'keepaliveInterval' | 'keepaliveCountMax'>,
globalSettings: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>,
): ResolvedKeepalive => {
const globalInterval = globalSettings.keepaliveInterval;
const globalCountMax = globalSettings.keepaliveCountMax;
if (host.keepaliveOverride !== true) {
return { interval: globalInterval, countMax: globalCountMax, source: 'global' };
}
return {
interval: host.keepaliveInterval ?? globalInterval,
countMax: host.keepaliveCountMax ?? globalCountMax,
source: 'host',
};
};
/**
* True when two host records are interchangeable for React identity reuse.
* Uses shallow key comparison (nested objects compared by reference).
*/
export const hostsEqualForIdentityReuse = (a: Host, b: Host): boolean => {
if (a === b) return true;
const aKeys = Object.keys(a) as (keyof Host)[];
const bKeys = Object.keys(b) as (keyof Host)[];
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (a[key] !== b[key]) return false;
}
for (const key of bKeys) {
if (a[key] !== b[key]) return false;
}
return true;
};
export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
const cleanHostname = (host.hostname || '').trim().split(/\s+/)[0];
const cleanDistro = normalizeDistroId(host.distro);
const cleanManualDistro = normalizeDistroId(host.manualDistro);
const cleanDistroMode =
host.distroMode === 'manual'
? 'manual'
: host.distroMode === 'auto'
? 'auto'
: undefined;
const cleanHostIcon = sanitizeHostIconFields(host);
const osSelection = getHostOsSelection(host);
const migrated = migrateDeprecatedFontOverride(host);
// Before explicit per-host authentication modes existed, new hosts were
// persisted with authMethod="password" even though an empty password still
// allowed the ambient agent and local keys. Preserve that behavior once on
// load; versioned records represent a deliberate Password-only selection.
const isLegacyPasswordDefault = migrated.authPolicyVersion !== 1
&& migrated.authMethod === 'password'
&& (
migrated.useSshAgent === true
|| Boolean(migrated.identityFileId)
|| Boolean(migrated.identityFilePaths?.length)
|| (migrated.savePassword !== false && !migrated.password?.length)
);
const inferredLegacyAuthMethod = migrated.authPolicyVersion !== 1
&& migrated.authMethod === undefined
&& migrated.useSshAgent !== true
? migrated.identityFilePaths?.length
? 'key'
: !migrated.identityFileId && migrated.password?.length
? 'password'
: undefined
: undefined;
const cleanNotes = host.notes?.trim() || undefined;
const connectScriptIds = host.connectScriptIds ?? (
snippets.length > 0
? migrateHostConnectScriptIds(host, snippets)
: host.loginScriptId
? [host.loginScriptId]
: undefined
);
const pluginConnection = sanitizePluginConnection(host.pluginConnection, host.protocol);
const sanitized: Host = {
...migrated,
authMethod: isLegacyPasswordDefault
? undefined
: migrated.authMethod ?? inferredLegacyAuthMethod,
authPolicyVersion: 1,
hostname: cleanHostname,
osOverride: osSelection,
os: osSelection === 'linux' || osSelection === 'windows' || osSelection === 'macos' ? osSelection : host.os,
distro: cleanDistro,
distroMode: cleanDistroMode,
manualDistro: cleanManualDistro || undefined,
iconMode: undefined,
iconId: undefined,
iconColorMode: undefined,
iconColor: undefined,
iconColorCustom: undefined,
...cleanHostIcon,
notes: cleanNotes,
sshTcpConnectTimeoutSeconds: sanitizeOptionalSshTimeoutSeconds(
host.sshTcpConnectTimeoutSeconds,
),
sshAuthReadyTimeoutSeconds: sanitizeOptionalSshTimeoutSeconds(
host.sshAuthReadyTimeoutSeconds,
),
connectScriptIds: connectScriptIds && connectScriptIds.length > 0 ? connectScriptIds : undefined,
pluginConnection,
};
return stripBuiltInConnectionFieldsForPluginHost(sanitized);
};

View File

@@ -0,0 +1,212 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
DEFAULT_HOST_CLICK_BEHAVIOR,
hostCardFocusClassName,
isHostClickFocusSelected,
isHostClickBehavior,
resolveGroupActivateAction,
resolveHostActivateAction,
shouldClearHostFocusOnBackgroundClick,
} from './hostClickBehavior';
test('default host click behavior is connect (legacy single-click)', () => {
assert.equal(DEFAULT_HOST_CLICK_BEHAVIOR, 'connect');
});
test('isHostClickBehavior accepts only known values', () => {
assert.equal(isHostClickBehavior('connect'), true);
assert.equal(isHostClickBehavior('select'), true);
assert.equal(isHostClickBehavior('double'), false);
assert.equal(isHostClickBehavior(null), false);
});
test('resolveHostActivateAction: multi-select always toggles', () => {
assert.equal(
resolveHostActivateAction({
behavior: 'select',
isMultiSelectMode: true,
focusedHostId: 'a',
hostId: 'a',
}),
'toggle-multi',
);
assert.equal(
resolveHostActivateAction({
behavior: 'connect',
isMultiSelectMode: true,
focusedHostId: null,
hostId: 'a',
}),
'toggle-multi',
);
});
test('resolveHostActivateAction: connect mode always connects', () => {
assert.equal(
resolveHostActivateAction({
behavior: 'connect',
isMultiSelectMode: false,
focusedHostId: null,
hostId: 'a',
}),
'connect',
);
assert.equal(
resolveHostActivateAction({
behavior: 'connect',
isMultiSelectMode: false,
focusedHostId: 'a',
hostId: 'a',
}),
'connect',
);
});
test('resolveHostActivateAction: select mode focuses then connects', () => {
assert.equal(
resolveHostActivateAction({
behavior: 'select',
isMultiSelectMode: false,
focusedHostId: null,
hostId: 'a',
}),
'select',
);
assert.equal(
resolveHostActivateAction({
behavior: 'select',
isMultiSelectMode: false,
focusedHostId: 'b',
hostId: 'a',
}),
'select',
);
assert.equal(
resolveHostActivateAction({
behavior: 'select',
isMultiSelectMode: false,
focusedHostId: 'a',
hostId: 'a',
}),
'connect',
);
});
test('isHostClickFocusSelected only shows a selection in select-before-connect mode', () => {
assert.equal(
isHostClickFocusSelected({
behavior: 'connect',
isMultiSelectMode: false,
focusedHostId: 'a',
hostId: 'a',
}),
false,
);
assert.equal(
isHostClickFocusSelected({
behavior: 'select',
isMultiSelectMode: false,
focusedHostId: 'a',
hostId: 'a',
}),
true,
);
assert.equal(
isHostClickFocusSelected({
behavior: 'select',
isMultiSelectMode: true,
focusedHostId: 'a',
hostId: 'a',
}),
false,
);
});
test('resolveGroupActivateAction: select mode focuses then opens', () => {
assert.equal(
resolveGroupActivateAction({
behavior: 'connect',
focusedGroupPath: null,
groupPath: 'prod',
}),
'open',
);
assert.equal(
resolveGroupActivateAction({
behavior: 'select',
focusedGroupPath: null,
groupPath: 'prod',
}),
'select',
);
assert.equal(
resolveGroupActivateAction({
behavior: 'select',
focusedGroupPath: 'prod',
groupPath: 'prod',
}),
'open',
);
});
test('background clicks clear focus only in select-before-connect mode', () => {
assert.equal(
shouldClearHostFocusOnBackgroundClick({
behavior: 'select',
isMultiSelectMode: false,
clickedWithinHostList: true,
clickedHostOrGroup: false,
}),
true,
);
assert.equal(
shouldClearHostFocusOnBackgroundClick({
behavior: 'select',
isMultiSelectMode: false,
clickedWithinHostList: true,
clickedHostOrGroup: true,
}),
false,
);
assert.equal(
shouldClearHostFocusOnBackgroundClick({
behavior: 'select',
isMultiSelectMode: true,
clickedWithinHostList: true,
clickedHostOrGroup: false,
}),
false,
);
assert.equal(
shouldClearHostFocusOnBackgroundClick({
behavior: 'connect',
isMultiSelectMode: false,
clickedWithinHostList: true,
clickedHostOrGroup: false,
}),
false,
);
assert.equal(
shouldClearHostFocusOnBackgroundClick({
behavior: 'select',
isMultiSelectMode: false,
clickedWithinHostList: false,
clickedHostOrGroup: false,
}),
false,
);
});
test('hostCardFocusClassName: grid recolors border; list uses hover-like fill', () => {
assert.equal(hostCardFocusClassName('grid', false), '');
assert.equal(hostCardFocusClassName('list', false), '');
const grid = hostCardFocusClassName('grid', true);
assert.match(grid, /border-primary/);
assert.doesNotMatch(grid, /bg-/);
const list = hostCardFocusClassName('list', true);
assert.match(list, /bg-secondary/);
assert.doesNotMatch(list, /border/);
assert.doesNotMatch(list, /ring-/);
});

View File

@@ -0,0 +1,80 @@
/**
* Vault host/group click activation.
*
* - `connect` (default): single click immediately connects / opens
* - `select`: first click focuses; click the focused item again to activate
*/
export type HostClickBehavior = 'connect' | 'select';
export const DEFAULT_HOST_CLICK_BEHAVIOR: HostClickBehavior = 'connect';
export function isHostClickBehavior(value: unknown): value is HostClickBehavior {
return value === 'connect' || value === 'select';
}
export function resolveHostActivateAction(input: {
behavior: HostClickBehavior;
isMultiSelectMode: boolean;
focusedHostId: string | null | undefined;
hostId: string;
}): 'connect' | 'select' | 'toggle-multi' {
if (input.isMultiSelectMode) return 'toggle-multi';
if (input.behavior === 'connect') return 'connect';
if (input.focusedHostId === input.hostId) return 'connect';
return 'select';
}
export function isHostClickFocusSelected(input: {
behavior: HostClickBehavior;
isMultiSelectMode: boolean;
focusedHostId: string | null | undefined;
hostId: string;
}): boolean {
return (
input.behavior === 'select'
&& !input.isMultiSelectMode
&& input.focusedHostId === input.hostId
);
}
export function resolveGroupActivateAction(input: {
behavior: HostClickBehavior;
focusedGroupPath: string | null | undefined;
groupPath: string;
}): 'open' | 'select' {
if (input.behavior === 'connect') return 'open';
if (input.focusedGroupPath === input.groupPath) return 'open';
return 'select';
}
export function shouldClearHostFocusOnBackgroundClick(input: {
behavior: HostClickBehavior;
isMultiSelectMode: boolean;
clickedWithinHostList: boolean;
clickedHostOrGroup: boolean;
}): boolean {
return (
input.behavior === 'select' &&
!input.isMultiSelectMode &&
input.clickedWithinHostList &&
!input.clickedHostOrGroup
);
}
/**
* Focus styles for vault host/group cards.
* - Grid: recolor existing soft-card border to accent.
* - List/tree: hover-like background fill only (no border).
*/
export function hostCardFocusClassName(
viewMode: 'grid' | 'list' | 'tree',
isFocused: boolean,
): string {
if (!isFocused) return '';
// Grid soft-card already draws a border; force accent color (beat .soft-card).
if (viewMode === 'grid') {
return '!border-primary';
}
// List/tree: same glass fill as hover — no border.
return 'bg-secondary/60';
}

View File

@@ -0,0 +1,370 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Host, Snippet } from './models';
import {
appendHostConnectScript,
ensureHostConnectScriptIds,
getEditableHostConnectScriptIds,
getGlobalConnectScripts,
getHostConnectScriptIds,
hasHostConnectAutomation,
migrateHostConnectScriptIds,
reorderHostConnectScript,
removeHostConnectScript,
resolveConnectScriptsForHost,
shouldMarkConnectAutomationConsumed,
shouldUseFreshSshConnectionForAutomation,
syncHostsForSnippetTargetChange,
syncSnippetsForHostConnectQueueSave,
} from './hostConnectScripts.ts';
const host: Host = {
id: 'host-a',
label: 'A',
hostname: 'a.example',
username: 'root',
os: 'linux',
protocol: 'ssh',
tags: [],
};
const script = (overrides: Partial<Snippet>): Snippet => ({
id: 's-default',
label: 'default',
command: 'nct.log("x");',
kind: 'script',
trigger: 'onConnect',
...overrides,
});
test('migrateHostConnectScriptIds prefers loginScriptId then linked onConnect scripts', () => {
const snippets = [
script({ id: 'login', targets: ['host-a'], order: 1000 }),
script({ id: 'linked', targets: ['host-a'], order: 2000 }),
script({ id: 'other', targets: ['host-a'], order: 3000 }),
];
const migrated = migrateHostConnectScriptIds({ ...host, loginScriptId: 'login' }, snippets);
assert.deepEqual(migrated, ['login', 'linked', 'other']);
});
test('resolveConnectScriptsForHost runs globals before host queue and dedupes', () => {
const snippets = [
script({ id: 'global', targetsAllHosts: true, order: 1000, label: 'Global' }),
script({ id: 'host-only', targets: ['host-a'], order: 2000, label: 'Host' }),
script({ id: 'both', targetsAllHosts: true, targets: ['host-a'], order: 3000, label: 'Both' }),
];
const resolved = resolveConnectScriptsForHost(
{ ...host, connectScriptIds: ['both', 'host-only'] },
snippets,
);
assert.deepEqual(resolved.map((item) => item.id), ['global', 'both', 'host-only']);
});
test('resolveConnectScriptsForHost dynamically inserts matching group scripts', () => {
const snippets = [
script({ id: 'global', targetsAllHosts: true, order: 1000 }),
script({ id: 'group', targetGroups: ['Production'], order: 2000 }),
script({ id: 'host-only', targets: ['host-a'], order: 3000 }),
];
const groupedHost = { ...host, group: 'Production/Web', connectScriptIds: ['host-only'] };
assert.deepEqual(
resolveConnectScriptsForHost(groupedHost, snippets).map((item) => item.id),
['global', 'group', 'host-only'],
);
assert.deepEqual(
resolveConnectScriptsForHost({ ...groupedHost, group: 'Staging' }, snippets).map((item) => item.id),
['global', 'host-only'],
);
});
test('group scripts stay dynamic instead of being materialized into a new host queue', () => {
const snippets = [script({ id: 'group', targetGroups: ['Production'] })];
const groupedHost = { ...host, group: 'Production' };
assert.deepEqual(migrateHostConnectScriptIds(groupedHost, snippets), []);
assert.deepEqual(resolveConnectScriptsForHost(groupedHost, snippets).map((item) => item.id), ['group']);
});
test('hasHostConnectAutomation covers host, global, and unresolved connect scripts', () => {
assert.equal(
hasHostConnectAutomation(
{ ...host, connectScriptIds: ['host-script'] },
[script({ id: 'host-script', targets: ['host-a'] })],
),
true,
);
assert.equal(
hasHostConnectAutomation(host, [script({ id: 'global', targetsAllHosts: true })]),
true,
);
assert.equal(
hasHostConnectAutomation({ ...host, loginScriptId: 'not-loaded-yet' }, []),
true,
);
assert.equal(hasHostConnectAutomation(host, []), false);
});
test('fresh SSH automation policy is conservative before vault hydration and for pending scripts', () => {
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [],
vaultInitialized: false,
}), true);
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [],
vaultInitialized: true,
hasPendingScript: true,
}), true);
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [],
vaultInitialized: true,
}), false);
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [script({ id: 'global', targetsAllHosts: true })],
vaultInitialized: true,
connectAutomationConsumed: true,
}), false);
assert.equal(shouldUseFreshSshConnectionForAutomation({
host,
snippets: [script({ id: 'global', targetsAllHosts: true })],
vaultInitialized: true,
hasPendingScript: true,
connectAutomationConsumed: true,
}), true);
});
test('empty hydrated vault finalizes the current connection automation decision', () => {
assert.equal(shouldMarkConnectAutomationConsumed({
allConnectScriptsDone: true,
vaultInitialized: true,
hasUnresolvedBindings: false,
}), true);
assert.equal(shouldMarkConnectAutomationConsumed({
allConnectScriptsDone: true,
vaultInitialized: false,
hasUnresolvedBindings: false,
}), false);
});
test('append updates host connectScriptIds order', () => {
const snippets = [
script({ id: 'a', targets: ['host-a'] }),
script({ id: 'b', targets: ['host-a'] }),
];
let next = appendHostConnectScript(host, 'a', snippets);
next = appendHostConnectScript(next, 'b', snippets);
assert.deepEqual(getHostConnectScriptIds(next, snippets), ['a', 'b']);
});
test('append keeps default manual scripts in the editable host queue', () => {
const snippets = [
script({ id: 'reset', label: 'reset-password', trigger: 'manual' }),
script({ id: 'teest', label: 'teest', trigger: 'manual' }),
];
let next = appendHostConnectScript(host, 'reset', snippets);
assert.deepEqual(getEditableHostConnectScriptIds(next, snippets), ['reset']);
// Runtime still ignores non-onConnect until host save promotes the trigger.
assert.deepEqual(getHostConnectScriptIds(next, snippets), []);
next = appendHostConnectScript(next, 'teest', snippets);
assert.deepEqual(getEditableHostConnectScriptIds(next, snippets), ['reset', 'teest']);
assert.deepEqual(next.connectScriptIds, ['reset', 'teest']);
});
test('ensureHostConnectScriptIds preserves pending manual queue entries while editing', () => {
const snippets = [script({ id: 'reset', trigger: 'manual' })];
const draft = { ...host, connectScriptIds: ['reset', 'missing'] };
const ensured = ensureHostConnectScriptIds(draft, snippets);
assert.deepEqual(ensured.connectScriptIds, ['reset']);
assert.deepEqual(getEditableHostConnectScriptIds(ensured, snippets), ['reset']);
assert.deepEqual(getHostConnectScriptIds(ensured, snippets), []);
});
test('syncSnippetsForHostConnectQueueSave promotes already-persisted manual queue entries', () => {
const snippets = [
script({ id: 'reset', trigger: 'manual', targets: [] }),
script({ id: 'ready', trigger: 'onConnect', targets: ['host-a'] }),
];
const { snippets: next, changed, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
snippets,
'host-a',
['reset', 'ready'],
['reset', 'ready'],
);
assert.equal(changed, true);
assert.equal(next.find((item) => item.id === 'reset')?.trigger, 'onConnect');
assert.deepEqual(next.find((item) => item.id === 'reset')?.targets, ['host-a']);
assert.equal(next.find((item) => item.id === 'ready'), snippets[1]);
assert.deepEqual(connectScriptIds, ['reset', 'ready']);
});
test('syncSnippetsForHostConnectQueueSave leaves global onConnect scripts untouched', () => {
const global = script({ id: 'both', targetsAllHosts: true, targets: ['host-a'] });
const { snippets: next, changed, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
[global],
'host-a',
['both'],
['both'],
);
assert.equal(changed, false);
assert.equal(next[0], global);
assert.equal(next[0].targetsAllHosts, true);
assert.deepEqual(connectScriptIds, ['both']);
});
test('syncSnippetsForHostConnectQueueSave promotes global manual without clearing targetsAllHosts', () => {
const globalManual = script({
id: 'everywhere',
trigger: 'manual',
targetsAllHosts: true,
});
const { snippets: next, changed, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
[globalManual],
'host-a',
['everywhere'],
['everywhere'],
);
assert.equal(changed, true);
assert.equal(next[0].trigger, 'onConnect');
assert.equal(next[0].targetsAllHosts, true);
assert.deepEqual(connectScriptIds, ['everywhere']);
});
test('syncSnippetsForHostConnectQueueSave does not re-promote concurrently demoted scripts', () => {
const baseline = [script({ id: 'run', trigger: 'onConnect', targets: ['host-a'] })];
const demoted = [script({ id: 'run', trigger: 'manual', targets: ['host-a'] })];
const { snippets: next, changed, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
demoted,
'host-a',
['run'],
['run'],
{ baselineSnippets: baseline },
);
assert.equal(changed, true);
assert.equal(next[0].trigger, 'manual');
assert.deepEqual(connectScriptIds, []);
});
test('syncSnippetsForHostConnectQueueSave preserves concurrent non-onConnect trigger edits', () => {
const baseline = [script({ id: 'run', trigger: 'manual', targets: ['host-a'] })];
const retargeted = [script({ id: 'run', trigger: 'onOutput', triggerPattern: 'ERR', targets: ['host-a'] })];
const { snippets: next, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
retargeted,
'host-a',
['run'],
['run'],
{ baselineSnippets: baseline },
);
assert.equal(next[0].trigger, 'onOutput');
assert.equal(next[0].triggerPattern, 'ERR');
assert.deepEqual(connectScriptIds, []);
});
test('syncSnippetsForHostConnectQueueSave preserves concurrent target removals', () => {
const baseline = [script({ id: 'run', trigger: 'onConnect', targets: ['host-a', 'host-b'] })];
const unlinked = [script({ id: 'run', trigger: 'onConnect', targets: ['host-b'] })];
const { snippets: next, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
unlinked,
'host-a',
['run'],
['run'],
{ baselineSnippets: baseline },
);
assert.deepEqual(next[0].targets, ['host-b']);
assert.deepEqual(connectScriptIds, []);
});
test('syncSnippetsForHostConnectQueueSave preserves concurrent target removals for manual scripts', () => {
const baseline = [script({ id: 'run', trigger: 'manual', targets: ['host-a', 'host-b'] })];
const unlinked = [script({ id: 'run', trigger: 'manual', targets: ['host-b'] })];
const { snippets: next, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
unlinked,
'host-a',
['run'],
['run'],
{ baselineSnippets: baseline },
);
assert.equal(next[0].trigger, 'manual');
assert.deepEqual(next[0].targets, ['host-b']);
assert.deepEqual(connectScriptIds, []);
});
test('syncSnippetsForHostConnectQueueSave syncs other targeted hosts after promote', () => {
const hostB: Host = {
id: 'host-b',
label: 'B',
hostname: 'b.example',
username: 'root',
os: 'linux',
protocol: 'ssh',
tags: [],
connectScriptIds: [],
};
const snippets = [
script({ id: 'shared', trigger: 'manual', targets: ['host-b'] }),
];
const { snippets: next, hosts, changed } = syncSnippetsForHostConnectQueueSave(
snippets,
'host-a',
[],
['shared'],
{ hosts: [host, hostB] },
);
assert.equal(changed, true);
assert.equal(next[0].trigger, 'onConnect');
assert.deepEqual(next[0].targets, ['host-b', 'host-a']);
assert.deepEqual(hosts?.find((item) => item.id === 'host-b')?.connectScriptIds, ['shared']);
});
test('syncHostsForSnippetTargetChange appends and removes queue entries', () => {
const snippets = [script({ id: 'run', targets: ['host-a'], trigger: 'onConnect' })];
const hosts = syncHostsForSnippetTargetChange(
[host],
script({ id: 'run', targets: ['host-a'], trigger: 'onConnect' }),
[],
snippets,
);
assert.deepEqual(hosts[0].connectScriptIds, ['run']);
const removed = syncHostsForSnippetTargetChange(
hosts,
script({ id: 'run', targets: [], trigger: 'onConnect' }),
['host-a'],
snippets,
);
assert.deepEqual(removed[0].connectScriptIds, []);
});
test('getGlobalConnectScripts sorts by order', () => {
const snippets = [
script({ id: 'z', targetsAllHosts: true, order: 2000, label: 'Z' }),
script({ id: 'a', targetsAllHosts: true, order: 1000, label: 'A' }),
];
assert.deepEqual(getGlobalConnectScripts(snippets).map((item) => item.id), ['a', 'z']);
});
test('reorderHostConnectScript moves item before or after target', () => {
const snippets = [
script({ id: 'a', targets: ['host-a'] }),
script({ id: 'b', targets: ['host-a'] }),
script({ id: 'c', targets: ['host-a'] }),
];
const base = { ...host, connectScriptIds: ['a', 'b', 'c'] };
const movedAfter = reorderHostConnectScript(base, 'a', 'c', 'after', snippets);
assert.deepEqual(getHostConnectScriptIds(movedAfter, snippets), ['b', 'c', 'a']);
const movedBefore = reorderHostConnectScript(base, 'c', 'a', 'before', snippets);
assert.deepEqual(getHostConnectScriptIds(movedBefore, snippets), ['c', 'a', 'b']);
});
test('removeHostConnectScript clears empty queue', () => {
const snippets = [script({ id: 'only', targets: ['host-a'] })];
const updated = removeHostConnectScript(
{ ...host, connectScriptIds: ['only'] },
'only',
snippets,
);
assert.deepEqual(updated.connectScriptIds, []);
});

View File

@@ -0,0 +1,456 @@
import type { Host, Snippet } from './models';
import { isScriptSnippet } from './snippetScript.ts';
import {
getScriptsLinkedToHost,
linkHostToScript,
snippetTargetsHostExplicitly,
snippetTargetsHostGroup,
unlinkHostFromScripts,
} from './snippetTargets.ts';
import { sortByVaultOrder } from './vaultOrder.ts';
function isOnConnectScript(snippet: Snippet): boolean {
return isScriptSnippet(snippet) && snippet.trigger === 'onConnect' && Boolean(snippet.id);
}
function scriptById(snippets: Snippet[], scriptId: string): Snippet | undefined {
return snippets.find((snippet) => snippet.id === scriptId && isScriptSnippet(snippet));
}
function pruneConnectScriptIds(ids: string[], snippets: Snippet[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const id of ids) {
if (!id || seen.has(id)) continue;
const snippet = scriptById(snippets, id);
if (!snippet || !isOnConnectScript(snippet)) continue;
seen.add(id);
result.push(id);
}
return result;
}
/**
* Draft/edit queue prune: keep any existing script IDs.
* Host save promotes non-onConnect scripts via prepareSnippetForHostConnectQueue.
*/
function pruneEditableConnectScriptIds(ids: string[], snippets: Snippet[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const id of ids) {
if (!id || seen.has(id)) continue;
const snippet = scriptById(snippets, id);
if (!snippet) continue;
seen.add(id);
result.push(id);
}
return result;
}
/** Global onConnect scripts (targetsAllHosts), sorted by vault order. */
export function getGlobalConnectScripts(snippets: Snippet[]): Snippet[] {
return sortByVaultOrder(
snippets.filter(
(snippet) => isOnConnectScript(snippet) && Boolean(snippet.targetsAllHosts),
),
);
}
/** Derive initial connectScriptIds from legacy host + snippet bindings. */
export function migrateHostConnectScriptIds(host: Host, snippets: Snippet[]): string[] {
const ordered: string[] = [];
const seen = new Set<string>();
const push = (scriptId?: string) => {
if (!scriptId || seen.has(scriptId)) return;
const snippet = scriptById(snippets, scriptId);
if (!snippet || !isOnConnectScript(snippet)) return;
if (!snippetTargetsHostExplicitly(snippet, host.id) && !snippet.targetsAllHosts) return;
seen.add(scriptId);
ordered.push(scriptId);
};
push(host.loginScriptId);
for (const snippet of getScriptsLinkedToHost(snippets, host.id)) {
if (snippet.trigger === 'onConnect') {
push(snippet.id);
}
}
for (const snippet of sortByVaultOrder(snippets)) {
if (!isOnConnectScript(snippet)) continue;
if (snippet.targetsAllHosts) continue;
if (!snippetTargetsHostExplicitly(snippet, host.id)) continue;
push(snippet.id);
}
return ordered;
}
/** Effective ordered script IDs for a host (lazy migrate + prune). */
export function getHostConnectScriptIds(host: Host, snippets: Snippet[]): string[] {
if (host.connectScriptIds !== undefined) {
return pruneConnectScriptIds(host.connectScriptIds, snippets);
}
return migrateHostConnectScriptIds(host, snippets);
}
/**
* Host-details draft queue: includes scripts pending promote-to-onConnect on save.
* Runtime connect still uses getHostConnectScriptIds (onConnect only).
*/
export function getEditableHostConnectScriptIds(host: Host, snippets: Snippet[]): string[] {
if (host.connectScriptIds !== undefined) {
return pruneEditableConnectScriptIds(host.connectScriptIds, snippets);
}
return migrateHostConnectScriptIds(host, snippets);
}
export function ensureHostConnectScriptIds(host: Host, snippets: Snippet[]): Host {
if (host.connectScriptIds !== undefined) {
const pruned = pruneEditableConnectScriptIds(host.connectScriptIds, snippets);
if (pruned.length === host.connectScriptIds.length
&& pruned.every((id, index) => id === host.connectScriptIds![index])) {
return host;
}
return { ...host, connectScriptIds: pruned };
}
const migrated = migrateHostConnectScriptIds(host, snippets);
return migrated.length > 0 ? { ...host, connectScriptIds: migrated } : host;
}
/** True when host references connect scripts that are not present in snippets yet. */
export function hasUnresolvedConnectScriptBindings(host: Host, snippets: Snippet[]): boolean {
const candidateIds = new Set<string>();
if (host.loginScriptId) candidateIds.add(host.loginScriptId);
for (const id of host.connectScriptIds ?? []) {
if (id) candidateIds.add(id);
}
for (const id of candidateIds) {
if (!snippets.some((snippet) => snippet.id === id)) {
return true;
}
}
return false;
}
/** Group-scoped onConnect scripts inherited dynamically from the host's current group. */
export function getGroupConnectScriptsForHost(host: Host, snippets: Snippet[]): Snippet[] {
return sortByVaultOrder(
snippets.filter(
(snippet) => isOnConnectScript(snippet)
&& !snippet.targetsAllHosts
&& snippetTargetsHostGroup(snippet, host),
),
);
}
/** Resolve full onConnect run list: global, group-inherited, then host queue. */
export function resolveConnectScriptsForHost(host: Host, snippets: Snippet[]): Snippet[] {
const hostIds = getHostConnectScriptIds(host, snippets);
const hostIdSet = new Set(hostIds);
const globals = getGlobalConnectScripts(snippets).filter(
(snippet) => snippet.id && !hostIdSet.has(snippet.id),
);
const inheritedIds = new Set(globals.map((snippet) => snippet.id));
const groupScripts = getGroupConnectScriptsForHost(host, snippets).filter(
(snippet) => snippet.id && !hostIdSet.has(snippet.id) && !inheritedIds.has(snippet.id),
);
const hostScripts = hostIds
.map((id) => scriptById(snippets, id))
.filter((snippet): snippet is Snippet => Boolean(snippet));
return [...globals, ...groupScripts, ...hostScripts];
}
/**
* Whether connecting this host can run automation that depends on the initial
* login output. Missing referenced scripts still count: vault hydration or a
* later sync may restore them after the terminal has already started.
*/
export function hasHostConnectAutomation(host: Host, snippets: Snippet[]): boolean {
return hasUnresolvedConnectScriptBindings(host, snippets)
|| resolveConnectScriptsForHost(host, snippets).length > 0;
}
export function shouldUseFreshSshConnectionForAutomation(options: {
host: Host;
snippets: Snippet[];
vaultInitialized: boolean;
hasPendingScript?: boolean;
connectAutomationConsumed?: boolean;
}): boolean {
return options.hasPendingScript === true
|| (
options.connectAutomationConsumed !== true
&& (
!options.vaultInitialized
|| hasHostConnectAutomation(options.host, options.snippets)
)
);
}
/**
* Mark the current connection's automation decision as final once the vault
* has hydrated, even when it hydrated to an empty script list. This prevents a
* later sync from starting a newly arrived global script against a connection
* whose initial login output may already have been skipped.
*/
export function shouldMarkConnectAutomationConsumed(options: {
allConnectScriptsDone: boolean;
vaultInitialized: boolean;
hasUnresolvedBindings: boolean;
}): boolean {
return options.allConnectScriptsDone
&& options.vaultInitialized
&& !options.hasUnresolvedBindings;
}
export function appendHostConnectScript(host: Host, scriptId: string, snippets: Snippet[]): Host {
const snippet = scriptById(snippets, scriptId);
if (!snippet) return host;
const current = getEditableHostConnectScriptIds(host, snippets);
if (current.includes(scriptId)) {
return { ...host, connectScriptIds: current };
}
return { ...host, connectScriptIds: [...current, scriptId] };
}
export function removeHostConnectScript(host: Host, scriptId: string, snippets: Snippet[]): Host {
const current = getEditableHostConnectScriptIds(host, snippets);
const next = current.filter((id) => id !== scriptId);
return { ...host, connectScriptIds: next };
}
export function reorderHostConnectScript(
host: Host,
draggedScriptId: string,
targetScriptId: string,
position: 'before' | 'after',
snippets: Snippet[],
): Host {
if (draggedScriptId === targetScriptId) return host;
const current = [...getEditableHostConnectScriptIds(host, snippets)];
const fromIndex = current.indexOf(draggedScriptId);
const targetIndex = current.indexOf(targetScriptId);
if (fromIndex === -1 || targetIndex === -1) return host;
current.splice(fromIndex, 1);
let insertIndex = current.indexOf(targetScriptId);
if (insertIndex === -1) return host;
if (position === 'after') insertIndex += 1;
current.splice(insertIndex, 0, draggedScriptId);
return { ...host, connectScriptIds: current };
}
export function prepareSnippetForHostConnectQueue(snippet: Snippet, hostId: string): Snippet {
if (!isScriptSnippet(snippet)) return snippet;
if (snippet.targetsAllHosts) {
return snippet.trigger === 'onConnect'
? snippet
: { ...snippet, trigger: 'onConnect' };
}
return {
...linkHostToScript(snippet, hostId),
trigger: 'onConnect',
};
}
function connectQueueSnippetNeedsPromote(snippet: Snippet, hostId: string): boolean {
if (!isScriptSnippet(snippet)) return false;
if (snippet.trigger !== 'onConnect') return true;
if (snippet.targetsAllHosts) return false;
return !snippetTargetsHostExplicitly(snippet, hostId);
}
function snippetTargetsEqual(left: Snippet, right: Snippet): boolean {
if (Boolean(left.targetsAllHosts) !== Boolean(right.targetsAllHosts)) return false;
const leftTargets = left.targets ?? [];
const rightTargets = right.targets ?? [];
if (leftTargets.length !== rightTargets.length) return false;
if (!leftTargets.every((id, index) => id === rightTargets[index])) return false;
const leftGroups = left.targetGroups ?? [];
const rightGroups = right.targetGroups ?? [];
if (leftGroups.length !== rightGroups.length) return false;
return leftGroups.every((path, index) => path === rightGroups[index]);
}
/**
* After promoting a script to onConnect, ensure every remaining target host with an
* explicit connectScriptIds queue includes it. Hosts without an explicit queue still
* pick the script up via migrateHostConnectScriptIds.
*/
export function ensureTargetHostsHaveConnectScript(
hosts: Host[],
snippet: Snippet,
snippets: Snippet[],
excludeHostId?: string,
): Host[] {
if (!snippet.id || !isScriptSnippet(snippet) || snippet.trigger !== 'onConnect') return hosts;
if (snippet.targetsAllHosts) return hosts;
const targetIds = new Set(snippet.targets ?? []);
if (targetIds.size === 0) return hosts;
let changed = false;
const nextHosts = hosts.map((host) => {
if (host.id === excludeHostId) return host;
if (!targetIds.has(host.id)) return host;
if (host.connectScriptIds === undefined) return host;
const updated = appendHostConnectScript(host, snippet.id!, snippets);
if (updated !== host) changed = true;
return updated;
});
return changed ? nextHosts : hosts;
}
export type SyncHostConnectQueueSaveOptions = {
/** Snippets snapshot from when the host editor opened (detect concurrent demotion). */
baselineSnippets?: Snippet[];
/** When provided, promote also syncs other hosts that remain in targets. */
hosts?: Host[];
};
/**
* Sync script metadata when saving a host connect queue.
* Promotes draft/manual queue entries, preserves global onConnect scripts,
* drops concurrently demoted entries, and optionally syncs peer host queues.
*/
export function syncSnippetsForHostConnectQueueSave(
snippets: Snippet[],
hostId: string,
previousQueueIds: string[],
nextQueueIds: string[],
options: SyncHostConnectQueueSaveOptions = {},
): {
snippets: Snippet[];
hosts: Host[];
connectScriptIds: string[];
changed: boolean;
} {
const previousSet = new Set(previousQueueIds);
const baseline = options.baselineSnippets ?? snippets;
let nextSnippets = snippets;
let nextHosts = options.hosts ?? [];
let changed = false;
const retainedIds: string[] = [];
const demotedDropIds = new Set<string>();
for (const scriptId of nextQueueIds) {
const item = nextSnippets.find((entry) => entry.id === scriptId && isScriptSnippet(entry));
if (!item) continue;
if (!connectQueueSnippetNeedsPromote(item, hostId)) {
retainedIds.push(scriptId);
continue;
}
const newlyAdded = !previousSet.has(scriptId);
const baselineItem = baseline.find((entry) => entry.id === scriptId);
const baselineTrigger = baselineItem && isScriptSnippet(baselineItem)
? baselineItem.trigger
: undefined;
if (!newlyAdded && baselineTrigger === 'onConnect' && item.trigger !== 'onConnect') {
// Concurrent demotion while the editor stayed open: keep demotion, drop stale queue id.
demotedDropIds.add(scriptId);
continue;
}
if (
!newlyAdded
&& baselineItem
&& isScriptSnippet(baselineItem)
&& (Boolean(baselineItem.targetsAllHosts) || snippetTargetsHostExplicitly(baselineItem, hostId))
&& !(Boolean(item.targetsAllHosts) || snippetTargetsHostExplicitly(item, hostId))
) {
// Concurrent target removal for this host: do not re-link on save.
demotedDropIds.add(scriptId);
continue;
}
if (
!newlyAdded
&& baselineTrigger
&& baselineTrigger !== 'onConnect'
&& item.trigger !== baselineTrigger
) {
// Concurrent non-onConnect trigger edit (e.g. manual -> onOutput): do not overwrite.
demotedDropIds.add(scriptId);
continue;
}
const prepared = prepareSnippetForHostConnectQueue(item, hostId);
if (
prepared.trigger !== item.trigger
|| !snippetTargetsEqual(prepared, item)
) {
nextSnippets = nextSnippets.map((entry) => (entry.id === scriptId ? prepared : entry));
changed = true;
if (options.hosts) {
const syncedHosts = ensureTargetHostsHaveConnectScript(
nextHosts,
prepared,
nextSnippets,
hostId,
);
if (syncedHosts !== nextHosts) {
nextHosts = syncedHosts;
changed = true;
}
}
}
retainedIds.push(scriptId);
}
for (const scriptId of previousQueueIds) {
if (retainedIds.includes(scriptId)) continue;
if (demotedDropIds.has(scriptId)) continue;
const unlinked = unlinkHostFromScripts(nextSnippets, hostId, scriptId);
if (unlinked !== nextSnippets) {
nextSnippets = unlinked;
changed = true;
}
}
if (
retainedIds.length !== nextQueueIds.length
|| retainedIds.some((id, index) => id !== nextQueueIds[index])
) {
changed = true;
}
return {
snippets: nextSnippets,
hosts: nextHosts,
connectScriptIds: retainedIds,
changed,
};
}
export function syncHostsForSnippetTargetChange(
hosts: Host[],
snippet: Snippet,
prevTargetIds: string[] | undefined,
snippets: Snippet[],
): Host[] {
if (!isScriptSnippet(snippet) || snippet.trigger !== 'onConnect' || !snippet.id) {
return hosts;
}
if (snippet.targetsAllHosts) {
return hosts.map((host) => removeHostConnectScript(host, snippet.id!, snippets));
}
const prev = new Set(prevTargetIds ?? []);
const next = new Set(snippet.targets ?? []);
const added = [...next].filter((id) => !prev.has(id));
const removed = [...prev].filter((id) => !next.has(id));
if (added.length === 0 && removed.length === 0) return hosts;
return hosts.map((host) => {
let updated = host;
if (added.includes(host.id)) {
updated = appendHostConnectScript(updated, snippet.id!, snippets);
}
if (removed.includes(host.id)) {
updated = removeHostConnectScript(updated, snippet.id!, snippets);
}
return updated;
});
}

5
domain/hostDisplay.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { Host } from './models';
export function hostDisplayTitle(host: Pick<Host, 'label' | 'hostname'>): string {
return host.label?.trim() || host.hostname;
}

View File

@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
remapSnippetTargetGroupPaths,
removeSnippetTargetGroupPaths,
} from './hostGroupPathMutations.ts';
import type { Snippet } from './models';
const snippets: Snippet[] = [{
id: 'script-a',
label: 'A',
command: 'echo a',
kind: 'script',
targetGroups: ['Production', 'Production/Web', 'Staging'],
}];
test('remapSnippetTargetGroupPaths follows group rename and descendants', () => {
const next = remapSnippetTargetGroupPaths(snippets, 'Production', 'Platform');
assert.deepEqual(next[0].targetGroups, ['Platform', 'Platform/Web', 'Staging']);
});
test('removeSnippetTargetGroupPaths removes a deleted group subtree', () => {
const next = removeSnippetTargetGroupPaths(snippets, ['Production']);
assert.deepEqual(next[0].targetGroups, ['Staging']);
});
test('removeSnippetTargetGroupPaths preserves an explicit empty scope', () => {
const next = removeSnippetTargetGroupPaths([
{ ...snippets[0], targetGroups: ['Production'] },
], ['Production']);
assert.deepEqual(next[0].targetGroups, []);
});
test('remapSnippetTargetGroupPaths deduplicates rename collisions', () => {
const next = remapSnippetTargetGroupPaths([
{ ...snippets[0], targetGroups: ['Platform', 'Production'] },
], 'Production', 'Platform');
assert.deepEqual(next[0].targetGroups, ['Platform']);
});
test('group path mutations normalize imported legacy paths', () => {
const legacy = [{ ...snippets[0], targetGroups: [' Production\\Web ', 'Production//Web'] }];
const renamed = remapSnippetTargetGroupPaths(legacy, 'Production/Web', 'Platform/Web');
assert.deepEqual(renamed[0].targetGroups, ['Platform/Web']);
const removed = removeSnippetTargetGroupPaths(legacy, ['Production/Web']);
assert.deepEqual(removed[0].targetGroups, []);
});

View File

@@ -0,0 +1,183 @@
import type { Host, ManagedSource, Snippet } from '../types';
export function normalizeGroupTargetPath(value: string): string {
return value
.replace(/\\/g, '/')
.split('/')
.map((part) => part.trim())
.filter(Boolean)
.join('/');
}
export function normalizeGroupTargetPaths(values: Iterable<string>): string[] {
return Array.from(new Set(
[...values].map(normalizeGroupTargetPath).filter(Boolean),
));
}
function replaceGroupPathPrefix(path: string, sourcePath: string, nextPath: string): string {
if (path === sourcePath) return nextPath;
if (path.startsWith(`${sourcePath}/`)) return nextPath + path.slice(sourcePath.length);
return path;
}
export function remapSnippetTargetGroupPaths(
snippets: Snippet[],
sourceValue: string,
nextValue: string,
): Snippet[] {
const sourcePath = normalizeGroupTargetPath(sourceValue);
const nextPath = normalizeGroupTargetPath(nextValue);
if (!sourcePath || !nextPath || sourcePath === nextPath) return snippets;
let changed = false;
const nextSnippets = snippets.map((snippet) => {
if (!snippet.targetGroups?.length) return snippet;
const targetGroups = normalizeGroupTargetPaths(
snippet.targetGroups.map((path) => replaceGroupPathPrefix(
normalizeGroupTargetPath(path), sourcePath, nextPath,
)),
);
if (
targetGroups.length === snippet.targetGroups.length
&& targetGroups.every((path, index) => path === snippet.targetGroups?.[index])
) return snippet;
changed = true;
return { ...snippet, targetGroups };
});
return changed ? nextSnippets : snippets;
}
export function removeSnippetTargetGroupPaths(
snippets: Snippet[],
removedValues: Iterable<string>,
): Snippet[] {
const removedPaths = [...removedValues]
.map(normalizeGroupTargetPath)
.filter(Boolean);
if (removedPaths.length === 0) return snippets;
const isRemoved = (path: string) => removedPaths.some(
(removed) => path === removed || path.startsWith(`${removed}/`),
);
let changed = false;
const nextSnippets = snippets.map((snippet) => {
if (snippet.targetGroups === undefined) return snippet;
const targetGroups = normalizeGroupTargetPaths(snippet.targetGroups).filter(
(path) => !isRemoved(path),
);
if (
targetGroups.length === snippet.targetGroups.length
&& targetGroups.every((path, index) => path === snippet.targetGroups?.[index])
) return snippet;
changed = true;
// An absent scope keeps the legacy onOutput "current session" behavior.
// Preserve an explicit empty list when the last selected group disappears
// so the script is disabled instead of silently widening to every host.
return { ...snippet, targetGroups };
});
return changed ? nextSnippets : snippets;
}
export function groupDisplayName(groupPath: string): string {
return groupPath.split('/').filter(Boolean).pop() ?? groupPath;
}
export function computeRenamedGroupPath(renameTargetPath: string, nextName: string): string {
const segments = renameTargetPath.split('/').filter(Boolean);
const parent = segments.slice(0, -1).join('/');
return parent ? `${parent}/${nextName}` : nextName;
}
export function allocateUnnamedGroupPath(
customGroups: string[],
parentPath: string | null,
baseName: string,
): { name: string; path: string } {
let name = baseName;
let counter = 2;
while (true) {
const path = parentPath ? `${parentPath}/${name}` : name;
if (!customGroups.includes(path)) {
return { name, path };
}
name = `${baseName} ${counter}`;
counter += 1;
}
}
export function ensureAncestorPathsExpanded(
groupPath: string,
ensurePathExpanded: (path: string) => void,
) {
const segments = groupPath.split('/').filter(Boolean);
for (let i = 1; i <= segments.length; i++) {
ensurePathExpanded(segments.slice(0, i).join('/'));
}
}
export type GroupPathRenameResult =
| {
ok: true;
nextPath: string;
updatedGroups: string[];
updatedHosts: Host[];
updatedManagedSources: ManagedSource[];
}
| { ok: false; error: 'required' | 'invalidChars' | 'duplicatePath' | 'unchanged' };
export function applyGroupPathRename(params: {
renameTargetPath: string;
nextName: string;
customGroups: string[];
hosts: Host[];
managedSources: ManagedSource[];
}): GroupPathRenameResult {
const trimmed = params.nextName.trim();
if (!trimmed) {
return { ok: false, error: 'required' };
}
if (trimmed.includes('/') || trimmed.includes('\\')) {
return { ok: false, error: 'invalidChars' };
}
const nextPath = computeRenamedGroupPath(params.renameTargetPath, trimmed);
if (nextPath === params.renameTargetPath) {
return { ok: false, error: 'unchanged' };
}
if (params.customGroups.includes(nextPath)) {
return { ok: false, error: 'duplicatePath' };
}
const { renameTargetPath, customGroups, hosts, managedSources } = params;
const updatedGroups = customGroups.map((groupPath) => {
if (groupPath === renameTargetPath) return nextPath;
if (groupPath.startsWith(`${renameTargetPath}/`)) {
return nextPath + groupPath.slice(renameTargetPath.length);
}
return groupPath;
});
const updatedHosts = hosts.map((host) => {
const group = host.group || '';
if (group === renameTargetPath) return { ...host, group: nextPath };
if (group.startsWith(`${renameTargetPath}/`)) {
return { ...host, group: nextPath + group.slice(renameTargetPath.length) };
}
return host;
});
const updatedManagedSources = managedSources.map((source) => {
if (source.groupName === renameTargetPath) return { ...source, groupName: nextPath };
if (source.groupName.startsWith(`${renameTargetPath}/`)) {
return { ...source, groupName: nextPath + source.groupName.slice(renameTargetPath.length) };
}
return source;
});
return {
ok: true,
nextPath,
updatedGroups: Array.from(new Set(updatedGroups)),
updatedHosts,
updatedManagedSources,
};
}

View File

@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { buildHostGroupTree } from './hostGroupTree';
import type { Host } from '../types';
const host = (id: string, label: string, group?: string): Host => ({
id,
label,
hostname: `${id}.example.com`,
username: 'root',
port: 22,
group,
tags: [],
os: 'linux',
});
describe('buildHostGroupTree', () => {
it('groups hosts and keeps ungrouped hosts separate', () => {
const { groupTree, ungroupedHosts } = buildHostGroupTree(
[
host('1', 'web-1', 'prod/web'),
host('2', 'db-1', 'prod/db'),
host('3', 'local'),
],
['prod/web'],
);
assert.equal(groupTree.length, 1);
assert.equal(groupTree[0].name, 'prod');
assert.equal(ungroupedHosts.length, 1);
assert.equal(ungroupedHosts[0].id, '3');
});
});

98
domain/hostGroupTree.ts Normal file
View File

@@ -0,0 +1,98 @@
import type { GroupConfig, GroupNode, Host } from '../types';
import { sortByVaultOrder, sortVaultStringsByOrder } from './vaultOrder';
function countAllHostsInNode(node: GroupNode): number {
let count = node.hosts.length;
for (const child of Object.values(node.children)) {
count += countAllHostsInNode(child);
}
node.totalHostCount = count;
return count;
}
export function buildHostGroupTree(
hosts: Host[],
customGroups: string[],
groupConfigs: GroupConfig[] = [],
): { groupTree: GroupNode[]; ungroupedHosts: Host[] } {
const groupOrderByPath = new Map(
groupConfigs
.filter((config) => typeof config.order === 'number' && Number.isFinite(config.order))
.map((config) => [config.path, config.order as number]),
);
const orderedCustomGroups = sortVaultStringsByOrder(customGroups, groupOrderByPath);
const sortGroupNodesBySavedOrder = (nodes: GroupNode[]) => {
const originalIndex = new Map(nodes.map((node, index) => [node.path, index]));
return [...nodes].sort((a, b) => {
const orderA = groupOrderByPath.get(a.path);
const orderB = groupOrderByPath.get(b.path);
const hasOrderA = typeof orderA === 'number' && Number.isFinite(orderA);
const hasOrderB = typeof orderB === 'number' && Number.isFinite(orderB);
if (hasOrderA && hasOrderB && orderA !== orderB) return orderA - orderB;
if (hasOrderA) return -1;
if (hasOrderB) return 1;
return (originalIndex.get(a.path) ?? 0) - (originalIndex.get(b.path) ?? 0);
});
};
const sortChildrenBySavedOrder = (node: GroupNode) => {
const sortedChildren = sortGroupNodesBySavedOrder(Object.values(node.children));
node.children = Object.fromEntries(sortedChildren.map((child) => [child.name, child]));
sortedChildren.forEach(sortChildrenBySavedOrder);
};
const root: Record<string, GroupNode> = {};
const insertPath = (path: string, host?: Host) => {
const parts = path.split('/').filter(Boolean);
let currentLevel = root;
let currentPath = '';
parts.forEach((part, index) => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
if (!currentLevel[part]) {
currentLevel[part] = {
name: part,
path: currentPath,
children: {},
hosts: [],
};
}
if (host && index === parts.length - 1) {
currentLevel[part].hosts.push(host);
}
currentLevel = currentLevel[part].children;
});
};
orderedCustomGroups.forEach((path) => insertPath(path));
const ungroupedHosts: Host[] = [];
for (const host of hosts) {
const group = host.group?.trim();
if (group) {
insertPath(group, host);
} else {
ungroupedHosts.push(host);
}
}
Object.values(root).forEach(countAllHostsInNode);
const groupTree = sortGroupNodesBySavedOrder(Object.values(root));
groupTree.forEach(sortChildrenBySavedOrder);
const orderedUngroupedHosts = sortByVaultOrder(ungroupedHosts);
return { groupTree, ungroupedHosts: orderedUngroupedHosts };
}
export function groupNodeContainsHost(node: GroupNode, hostId: string | null | undefined): boolean {
if (!hostId) return false;
if (node.hosts.some((host) => host.id === hostId)) return true;
return Object.values(node.children).some((child) => groupNodeContainsHost(child, hostId));
}
export function collectGroupTreePaths(nodes: GroupNode[]): string[] {
const paths: string[] = [];
const walk = (node: GroupNode) => {
if (node.path) paths.push(node.path);
for (const child of Object.values(node.children)) {
walk(child);
}
};
for (const node of nodes) walk(node);
return paths;
}

View File

@@ -0,0 +1,125 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildHostGroupTree } from './hostGroupTree.ts';
import { flattenHostGroupTree, hostTreeFlatRowKey } from './hostGroupTreeFlat.ts';
import type { Host } from '../types';
const host = (id: string, label: string, group?: string): Host => ({
id,
label,
hostname: `${id}.example.com`,
username: 'root',
port: 22,
group,
tags: [],
os: 'linux',
});
test('flattenHostGroupTree emits group rows before visible children in saved order', () => {
const { groupTree, ungroupedHosts } = buildHostGroupTree(
[
host('1', 'web-1', 'prod/web'),
host('2', 'db-1', 'prod/db'),
host('3', 'local'),
],
['prod/web'],
);
const expanded = new Set(['prod', 'prod/web', 'prod/db']);
const rows = flattenHostGroupTree({
groupNodes: groupTree,
ungroupedHosts,
expandedPaths: expanded,
searchActive: false,
});
assert.deepEqual(
rows.map((row) => (row.kind === 'group' ? `g:${row.node.path}` : `h:${row.host.id}`)),
['g:prod', 'g:prod/web', 'h:1', 'g:prod/db', 'h:2', 'h:3'],
);
});
test('flattenHostGroupTree uses saved group order when provided', () => {
const { groupTree, ungroupedHosts } = buildHostGroupTree(
[
host('1', 'web-1', 'prod/web'),
host('2', 'db-1', 'prod/db'),
],
['prod/web', 'prod/db'],
[
{ path: 'prod/db', order: 1000 },
{ path: 'prod/web', order: 2000 },
],
);
const rows = flattenHostGroupTree({
groupNodes: groupTree,
ungroupedHosts,
expandedPaths: new Set(['prod', 'prod/web', 'prod/db']),
searchActive: false,
});
assert.deepEqual(
rows.map((row) => (row.kind === 'group' ? `g:${row.node.path}` : `h:${row.host.id}`)),
['g:prod', 'g:prod/db', 'h:2', 'g:prod/web', 'h:1'],
);
});
test('flattenHostGroupTree uses saved group order for host-only groups', () => {
const { groupTree, ungroupedHosts } = buildHostGroupTree(
[
host('1', 'web-1', 'prod/web'),
host('2', 'db-1', 'prod/db'),
],
[],
[
{ path: 'prod/db', order: 1000 },
{ path: 'prod/web', order: 2000 },
],
);
const rows = flattenHostGroupTree({
groupNodes: groupTree,
ungroupedHosts,
expandedPaths: new Set(['prod', 'prod/web', 'prod/db']),
searchActive: false,
});
assert.deepEqual(
rows.map((row) => (row.kind === 'group' ? `g:${row.node.path}` : `h:${row.host.id}`)),
['g:prod', 'g:prod/db', 'h:2', 'g:prod/web', 'h:1'],
);
});
test('flattenHostGroupTree hides collapsed subtrees', () => {
const { groupTree, ungroupedHosts } = buildHostGroupTree(
[host('1', 'web-1', 'prod/web')],
[],
);
const rows = flattenHostGroupTree({
groupNodes: groupTree,
ungroupedHosts,
expandedPaths: new Set(['prod']),
searchActive: false,
});
assert.deepEqual(rows.map(hostTreeFlatRowKey), ['g:prod', 'g:prod/web']);
});
test('flattenHostGroupTree expands all rows while searching', () => {
const { groupTree, ungroupedHosts } = buildHostGroupTree(
[host('1', 'web-1', 'prod/web')],
[],
);
const rows = flattenHostGroupTree({
groupNodes: groupTree,
ungroupedHosts,
expandedPaths: new Set(),
searchActive: true,
});
assert.deepEqual(rows.map(hostTreeFlatRowKey), ['g:prod', 'g:prod/web', 'h:1']);
});

View File

@@ -0,0 +1,56 @@
import type { GroupNode, Host } from '../types';
import { sortByVaultOrder } from './vaultOrder';
export type HostTreeFlatRow =
| { kind: 'group'; node: GroupNode; depth: number }
| { kind: 'host'; host: Host; depth: number };
export function hostTreeFlatRowKey(row: HostTreeFlatRow): string {
return row.kind === 'group' ? `g:${row.node.path}` : `h:${row.host.id}`;
}
export function flattenHostGroupTree(params: {
groupNodes: GroupNode[];
ungroupedHosts: Host[];
expandedPaths: Set<string>;
searchActive: boolean;
}): HostTreeFlatRow[] {
const rows: HostTreeFlatRow[] = [];
const walkGroup = (node: GroupNode, depth: number) => {
rows.push({ kind: 'group', node, depth });
const isExpanded = params.searchActive || params.expandedPaths.has(node.path);
if (!isExpanded) return;
const sortedHosts = sortByVaultOrder(node.hosts);
for (const host of sortedHosts) {
rows.push({ kind: 'host', host, depth: depth + 1 });
}
const childNodes = Object.values(node.children) as GroupNode[];
for (const child of childNodes) {
walkGroup(child, depth + 1);
}
};
for (const node of params.groupNodes) {
walkGroup(node, 0);
}
const sortedUngrouped = sortByVaultOrder(params.ungroupedHosts);
for (const host of sortedUngrouped) {
rows.push({ kind: 'host', host, depth: 0 });
}
return rows;
}
export function hostTreeFlatRowContainsHost(row: HostTreeFlatRow, hostId: string | null | undefined): boolean {
if (!hostId) return false;
if (row.kind === 'host') return row.host.id === hostId;
return row.node.hosts.some((host) => host.id === hostId)
|| Object.values(row.node.children).some((child) => {
const childRow: HostTreeFlatRow = { kind: 'group', node: child as GroupNode, depth: 0 };
return hostTreeFlatRowContainsHost(childRow, hostId);
});
}

121
domain/hostIcon.test.ts Normal file
View File

@@ -0,0 +1,121 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DEFAULT_HOST_ICON_COLOR,
DEFAULT_HOST_ICON_ID,
HOST_ICON_COLORS,
HOST_ICON_DEFAULT_COLORS,
clearHostIconAppearance,
isHostIconColorId,
isHostIconCustomColor,
isHostIconId,
normalizeHostIconSelection,
resolveHostIconAppearance,
resolveHostIconColorAppearance,
resolveHostIconDefaultColorHex,
sanitizeHostIconFields,
} from "./hostIcon.ts";
test("resolveHostIconAppearance returns null for automatic hosts", () => {
assert.equal(resolveHostIconAppearance({}), null);
assert.equal(resolveHostIconAppearance({ iconMode: "auto", iconId: "database", iconColor: "blue" }), null);
});
test("automatic hosts may keep a custom palette color without a custom icon", () => {
assert.deepEqual(sanitizeHostIconFields({ iconMode: "auto", iconColor: "violet" }), {
iconMode: "auto",
iconColorMode: "manual",
iconColor: "violet",
});
});
test("explicit automatic color ignores stale stored color fields", () => {
assert.equal(
resolveHostIconColorAppearance({ iconColorMode: "auto", iconColor: "violet", iconColorCustom: "#12ABEF" }),
null,
);
assert.deepEqual(
sanitizeHostIconFields({ iconMode: "auto", iconColorMode: "auto", iconColor: "violet", iconColorCustom: "#12ABEF" }),
{},
);
assert.deepEqual(
resolveHostIconAppearance({ iconMode: "custom", iconId: "database", iconColorMode: "auto", iconColor: "violet" }),
{ iconId: "database", colorHex: "#0891B2" },
);
});
test("resolveHostIconAppearance returns validated custom icon and color", () => {
assert.deepEqual(
resolveHostIconAppearance({ iconMode: "custom", iconId: "database", iconColor: "blue" }),
{ iconId: "database", colorId: "blue", colorHex: "#2563EB" },
);
});
test("resolveHostIconAppearance ignores invalid custom data", () => {
assert.equal(
resolveHostIconAppearance({ iconMode: "custom", iconId: "bad", iconColor: "blue" } as unknown as Parameters<typeof resolveHostIconAppearance>[0]),
null,
);
assert.deepEqual(resolveHostIconAppearance({ iconMode: "custom", iconId: "server", iconColor: "#123456" } as unknown as Parameters<typeof resolveHostIconAppearance>[0]), {
iconId: "server",
colorHex: "#2563EB",
});
});
test("custom type icons use varied default colors", () => {
assert.equal(HOST_ICON_DEFAULT_COLORS.server, "blue");
assert.equal(HOST_ICON_DEFAULT_COLORS.database, "cyan");
assert.equal(resolveHostIconDefaultColorHex("database"), "#0891B2");
assert.deepEqual(resolveHostIconAppearance({ iconMode: "custom", iconId: "database" }), {
iconId: "database",
colorHex: "#0891B2",
});
});
test("normalizeHostIconSelection creates a complete UI custom selection", () => {
assert.deepEqual(normalizeHostIconSelection({ iconMode: "custom" }), {
iconMode: "custom",
iconId: DEFAULT_HOST_ICON_ID,
});
});
test("custom hex colors are accepted only through the custom color field", () => {
assert.equal(isHostIconCustomColor("#12ABef"), true);
assert.equal(isHostIconCustomColor("12ABef"), false);
assert.deepEqual(
resolveHostIconColorAppearance({ iconColorMode: "manual", iconColorCustom: "#12ABEF" }),
{ colorHex: "#12ABEF" },
);
assert.deepEqual(
sanitizeHostIconFields({ iconMode: "auto", iconColorMode: "manual", iconColorCustom: "#12ABEF" }),
{ iconMode: "auto", iconColorMode: "manual", iconColorCustom: "#12ABEF" },
);
});
test("sanitizeHostIconFields clears incomplete or invalid stored custom data", () => {
assert.deepEqual(sanitizeHostIconFields({ iconMode: "custom" }), {});
assert.deepEqual(
sanitizeHostIconFields({ iconMode: "custom", iconId: "bad", iconColor: "blue" } as unknown as Parameters<typeof sanitizeHostIconFields>[0]),
{},
);
});
test("clearHostIconAppearance removes custom icon fields", () => {
assert.deepEqual(
clearHostIconAppearance({ iconMode: "custom", iconId: "database", iconColorMode: "manual", iconColor: "blue", iconColorCustom: "#123456", label: "DB" }),
{ label: "DB" },
);
});
test("host icon validators accept only curated IDs and color IDs", () => {
assert.equal(isHostIconId("server"), true);
assert.equal(isHostIconId("globe"), true);
assert.equal(isHostIconId("server-cog"), true);
assert.equal(isHostIconId("uploaded-file"), false);
assert.equal(isHostIconColorId(HOST_ICON_COLORS[0].id), true);
assert.equal(isHostIconColorId("violet"), true);
assert.equal(HOST_ICON_COLORS.length, 16);
assert.equal(isHostIconColorId("#2563EB"), false);
assert.equal(DEFAULT_HOST_ICON_COLOR, "blue");
});

187
domain/hostIcon.ts Normal file
View File

@@ -0,0 +1,187 @@
import type { Host, HostIconColorId, HostIconColorMode, HostIconId, HostIconMode } from "./models";
export const DEFAULT_HOST_ICON_ID: HostIconId = "server";
export const DEFAULT_HOST_ICON_COLOR: HostIconColorId = "blue";
export const HOST_ICON_IDS = [
"server",
"terminal",
"database",
"cloud",
"router",
"shield",
"code",
"box",
"globe",
"cpu",
"hard-drive",
"network",
"wifi",
"lock",
"key",
"monitor",
"container",
"activity",
"zap",
"server-cog",
] as const satisfies readonly HostIconId[];
export const HOST_ICON_COLORS = [
{ id: "blue", hex: "#2563EB" },
{ id: "green", hex: "#16A34A" },
{ id: "red", hex: "#DC2626" },
{ id: "amber", hex: "#B45309" },
{ id: "purple", hex: "#9333EA" },
{ id: "cyan", hex: "#0891B2" },
{ id: "orange", hex: "#EA580C" },
{ id: "slate", hex: "#475569" },
{ id: "violet", hex: "#7C3AED" },
{ id: "pink", hex: "#DB2777" },
{ id: "rose", hex: "#E11D48" },
{ id: "lime", hex: "#65A30D" },
{ id: "teal", hex: "#0D9488" },
{ id: "sky", hex: "#0284C7" },
{ id: "indigo", hex: "#4F46E5" },
{ id: "zinc", hex: "#52525B" },
] as const satisfies readonly { id: HostIconColorId; hex: string }[];
export const HOST_ICON_DEFAULT_COLORS = {
"server": "blue",
"terminal": "slate",
"database": "cyan",
"cloud": "sky",
"router": "orange",
"shield": "green",
"code": "violet",
"box": "amber",
"globe": "teal",
"cpu": "indigo",
"hard-drive": "zinc",
"network": "lime",
"wifi": "purple",
"lock": "rose",
"key": "amber",
"monitor": "sky",
"container": "teal",
"activity": "red",
"zap": "orange",
"server-cog": "slate",
} as const satisfies Record<HostIconId, HostIconColorId>;
export type HostIconAppearance = {
iconId: HostIconId;
colorId?: HostIconColorId;
colorHex: string;
};
export type HostIconColorAppearance = {
colorId?: HostIconColorId;
colorHex: string;
};
export const isHostIconMode = (value: unknown): value is HostIconMode =>
value === "auto" || value === "custom";
export const isHostIconId = (value: unknown): value is HostIconId =>
typeof value === "string" && (HOST_ICON_IDS as readonly string[]).includes(value);
export const isHostIconColorId = (value: unknown): value is HostIconColorId =>
typeof value === "string" && HOST_ICON_COLORS.some((color) => color.id === value);
export const isHostIconColorMode = (value: unknown): value is HostIconColorMode =>
value === "auto" || value === "manual";
export const isHostIconCustomColor = (value: unknown): value is string =>
typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value);
const resolveColorHex = (colorId: HostIconColorId): string =>
HOST_ICON_COLORS.find((color) => color.id === colorId)?.hex || HOST_ICON_COLORS[0].hex;
export const resolveHostIconDefaultColorHex = (iconId: HostIconId): string =>
resolveColorHex(HOST_ICON_DEFAULT_COLORS[iconId] || DEFAULT_HOST_ICON_COLOR);
export const resolveHostIconColorAppearance = (
host: Partial<Pick<Host, "iconColorMode" | "iconColor" | "iconColorCustom">>,
): HostIconColorAppearance | null => {
const manualColor = host.iconColorMode === "manual" ||
(host.iconColorMode !== "auto" && (isHostIconColorId(host.iconColor) || isHostIconCustomColor(host.iconColorCustom)));
if (!manualColor) return null;
if (isHostIconCustomColor(host.iconColorCustom)) {
return {
colorHex: host.iconColorCustom,
};
}
return {
colorId: isHostIconColorId(host.iconColor) ? host.iconColor : DEFAULT_HOST_ICON_COLOR,
colorHex: resolveColorHex(isHostIconColorId(host.iconColor) ? host.iconColor : DEFAULT_HOST_ICON_COLOR),
};
};
export const resolveHostIconAppearance = (
host: Partial<Pick<Host, "iconMode" | "iconId" | "iconColorMode" | "iconColor" | "iconColorCustom">>,
): HostIconAppearance | null => {
if (host.iconMode !== "custom") return null;
if (!isHostIconId(host.iconId)) return null;
const color = resolveHostIconColorAppearance(host);
return {
iconId: host.iconId,
...(color?.colorId ? { colorId: color.colorId } : {}),
colorHex: color?.colorHex || resolveHostIconDefaultColorHex(host.iconId),
};
};
export const normalizeHostIconSelection = <T extends Partial<Pick<Host, "iconMode" | "iconId" | "iconColorMode" | "iconColor" | "iconColorCustom">>>(
host: T,
): Pick<Host, "iconMode" | "iconId" | "iconColorMode" | "iconColor" | "iconColorCustom"> => {
const iconColorMode = host.iconColorMode === "manual" ||
(host.iconColorMode !== "auto" && (isHostIconColorId(host.iconColor) || isHostIconCustomColor(host.iconColorCustom)))
? "manual"
: undefined;
const iconColor = iconColorMode === "manual" && isHostIconColorId(host.iconColor) ? host.iconColor : undefined;
const iconColorCustom = iconColorMode === "manual" && isHostIconCustomColor(host.iconColorCustom) ? host.iconColorCustom : undefined;
const colorFields = {
...(iconColorMode ? { iconColorMode } : {}),
...(iconColor ? { iconColor } : {}),
...(iconColorCustom ? { iconColorCustom } : {}),
};
if (host.iconMode !== "custom") {
return iconColorMode ? { iconMode: "auto", ...colorFields } : {};
}
const iconId = isHostIconId(host.iconId) ? host.iconId : DEFAULT_HOST_ICON_ID;
return { iconMode: "custom", iconId, ...colorFields };
};
export const sanitizeHostIconFields = <T extends Partial<Pick<Host, "iconMode" | "iconId" | "iconColorMode" | "iconColor" | "iconColorCustom">>>(
host: T,
): Pick<Host, "iconMode" | "iconId" | "iconColorMode" | "iconColor" | "iconColorCustom"> => {
const iconColorMode = host.iconColorMode === "manual" ||
(host.iconColorMode !== "auto" && (isHostIconColorId(host.iconColor) || isHostIconCustomColor(host.iconColorCustom)))
? "manual"
: undefined;
const iconColor = iconColorMode === "manual" && isHostIconColorId(host.iconColor) ? host.iconColor : undefined;
const iconColorCustom = iconColorMode === "manual" && isHostIconCustomColor(host.iconColorCustom) ? host.iconColorCustom : undefined;
const colorFields = {
...(iconColorMode ? { iconColorMode } : {}),
...(iconColor ? { iconColor } : {}),
...(iconColorCustom ? { iconColorCustom } : {}),
};
if (host.iconMode !== "custom") {
return iconColorMode ? { iconMode: "auto", ...colorFields } : {};
}
if (!isHostIconId(host.iconId)) return {};
return { iconMode: "custom", iconId: host.iconId, ...colorFields };
};
export const clearHostIconAppearance = <T extends Record<string, unknown>>(
host: T,
): Omit<T, "iconMode" | "iconId" | "iconColorMode" | "iconColor" | "iconColorCustom"> => {
const {
iconMode: _iconMode,
iconId: _iconId,
iconColorMode: _iconColorMode,
iconColor: _iconColor,
iconColorCustom: _iconColorCustom,
...rest
} = host;
return rest;
};

33
domain/hostKey.ts Normal file
View File

@@ -0,0 +1,33 @@
/** Shared host-key verification payload used across terminal / SFTP / port-forward. */
export type HostKeyInfo = {
hostname: string;
port: number;
keyType: string;
fingerprint: string;
publicKey?: string;
status?: "unknown" | "changed";
knownHostId?: string;
knownFingerprint?: string;
};
export type HostKeyVerificationRequest = {
hostname: string;
port?: number;
keyType: string;
fingerprint: string;
publicKey?: string;
status?: "unknown" | "changed";
knownHostId?: string;
knownFingerprint?: string;
};
export const toHostKeyInfo = (request: HostKeyVerificationRequest): HostKeyInfo => ({
hostname: request.hostname,
port: request.port ?? 22,
keyType: request.keyType,
fingerprint: request.fingerprint,
publicKey: request.publicKey,
status: request.status,
knownHostId: request.knownHostId,
knownFingerprint: request.knownFingerprint,
});

64
domain/hostSystem.test.ts Normal file
View File

@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getHostOsSelection, resolveHostOs, sanitizeHost } from './host';
import { buildAITerminalSessionInfo } from './buildAITerminalSessionInfo';
import { applyVaultHostUpdate, buildVaultHostFromDraft } from './vaultHostCreate';
import type { Host } from './models';
const host = (changes: Partial<Host> = {}): Host => ({
id: 'host', label: 'Host', hostname: 'example.test', username: 'user', tags: [], os: 'linux', ...changes,
});
test('legacy Linux defaults are not evidence; Windows and macOS corrections survive loading', () => {
for (const os of ['linux', 'windows', 'macos'] as const) {
const restored = sanitizeHost(host({ os }));
assert.equal(getHostOsSelection(restored), os === 'linux' ? 'auto' : os);
assert.equal(resolveHostOs(restored), os === 'linux' ? 'unknown' : os);
assert.deepEqual(sanitizeHost(restored), restored);
}
});
test('detected facts drive AI and ignore cosmetic icon overrides', () => {
for (const [distro, expected] of [['ubuntu','linux'],['windows','windows'],['darwin','macos'],['freebsd','freebsd'],['cisco','unknown']]) {
const target = host({ distro, manualDistro: 'macos', distroMode: 'manual' });
assert.equal(resolveHostOs(target), expected);
assert.equal(buildAITerminalSessionInfo(undefined, target, 'macos').os, expected);
}
});
test('explicit corrections survive detection and can return to automatic', () => {
const target = host({ os: 'windows', osOverride: 'linux', distro: 'windows' });
assert.equal(resolveHostOs(target), 'linux');
assert.equal(resolveHostOs({ ...target, osOverride: 'auto' }), 'windows');
assert.equal(resolveHostOs({ ...target, osOverride: 'unknown' }), 'unknown');
});
test('network device protection is independent of a chosen operating system', () => {
const target = host({ deviceType: 'network', distro: 'ubuntu' });
assert.equal(resolveHostOs(target), 'unknown');
const info = buildAITerminalSessionInfo(undefined, {...target, osOverride: 'linux'}, 'macos');
assert.equal(info.os, 'linux');
assert.equal(info.deviceType, 'network');
});
test('local sessions report the actual local OS rather than a saved default', () => {
assert.equal(buildAITerminalSessionInfo(undefined, host({protocol:'local'}), 'windows').os, 'windows');
});
test('vault create and update share explicit selection and auto reset', () => {
const created = buildVaultHostFromDraft({hostname:'example.test',username:'user',os:'freebsd'});
assert.equal(created.ok, true);
if (!created.ok) return;
assert.equal(resolveHostOs(created.host), 'freebsd');
const updated = applyVaultHostUpdate([created.host], [], created.host.id, {os:'auto'});
assert.equal(updated.ok, true);
if (!updated.ok) return;
assert.equal(resolveHostOs(updated.updatedHost), 'unknown');
});
test('manual selections retain compatible OS values for older readers', () => {
assert.equal(sanitizeHost(host({osOverride:'windows'})).os, 'windows');
const restored = sanitizeHost(host({os:'windows',osOverride:'auto'}));
assert.equal(getHostOsSelection(restored), 'auto');
assert.equal(resolveHostOs(restored), 'unknown');
});

View File

@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DEFAULT_HTTP_NETWORK_PROXY,
areHttpNetworkProxySettingsEqual,
buildElectronProxyConfig,
buildNodeProxyEnv,
normalizeHttpNetworkProxySettings,
type HttpNetworkProxySettings,
} from './httpNetworkProxy.ts';
test('normalizeHttpNetworkProxySettings defaults to system mode', () => {
assert.deepEqual(normalizeHttpNetworkProxySettings(undefined), DEFAULT_HTTP_NETWORK_PROXY);
assert.deepEqual(normalizeHttpNetworkProxySettings(null), DEFAULT_HTTP_NETWORK_PROXY);
assert.equal(DEFAULT_HTTP_NETWORK_PROXY.mode, 'system');
});
test('normalizeHttpNetworkProxySettings accepts direct and custom modes', () => {
assert.deepEqual(normalizeHttpNetworkProxySettings({ mode: 'direct' }), {
mode: 'direct',
url: '',
bypass: '<local>',
});
assert.deepEqual(
normalizeHttpNetworkProxySettings({
mode: 'custom',
url: ' http://127.0.0.1:7890 ',
bypass: ' localhost, 127.0.0.1 ',
}),
{
mode: 'custom',
url: 'http://127.0.0.1:7890',
bypass: 'localhost, 127.0.0.1',
},
);
});
test('normalizeHttpNetworkProxySettings keeps custom draft when url is empty', () => {
assert.deepEqual(normalizeHttpNetworkProxySettings({ mode: 'custom', url: ' ' }), {
mode: 'custom',
url: '',
bypass: '<local>',
});
});
test('normalizeHttpNetworkProxySettings strips proxy credentials from URL', () => {
assert.deepEqual(
normalizeHttpNetworkProxySettings({
mode: 'custom',
url: 'http://user:secret@127.0.0.1:7890',
bypass: '<local>',
}),
{
mode: 'custom',
url: 'http://127.0.0.1:7890',
bypass: '<local>',
},
);
});
test('normalizeHttpNetworkProxySettings strips credentials from incomplete draft URLs', () => {
assert.deepEqual(
normalizeHttpNetworkProxySettings({
mode: 'custom',
url: 'http://user:secret@',
bypass: '<local>',
}),
{
mode: 'custom',
url: 'http://',
bypass: '<local>',
},
);
assert.equal(
normalizeHttpNetworkProxySettings({
mode: 'custom',
url: 'socks4://user:secret@',
bypass: '<local>',
}).url,
'socks4://',
);
assert.equal(
normalizeHttpNetworkProxySettings({
mode: 'custom',
url: 'user:secret@proxy.example:8080',
bypass: '<local>',
}).url,
'proxy.example:8080',
);
});
test('normalizeHttpNetworkProxySettings preserves trailing colon while typing a port', () => {
assert.equal(
normalizeHttpNetworkProxySettings({
mode: 'custom',
url: 'http://127.0.0.1:',
bypass: '<local>',
}).url,
'http://127.0.0.1:',
);
});
test('areHttpNetworkProxySettingsEqual compares mode/url/bypass', () => {
const a = { mode: 'custom' as const, url: 'http://127.0.0.1:7890', bypass: '<local>' };
assert.equal(areHttpNetworkProxySettingsEqual(a, { ...a }), true);
assert.equal(areHttpNetworkProxySettingsEqual(a, { ...a, url: 'http://127.0.0.1:1' }), false);
});
test('buildElectronProxyConfig maps modes to session.setProxy payloads', () => {
assert.deepEqual(buildElectronProxyConfig({ mode: 'system', url: '', bypass: '<local>' }), {
mode: 'system',
});
assert.deepEqual(buildElectronProxyConfig({ mode: 'direct', url: '', bypass: '<local>' }), {
mode: 'direct',
});
const custom: HttpNetworkProxySettings = {
mode: 'custom',
url: 'http://proxy.example:8080',
bypass: 'localhost,*.internal',
};
assert.deepEqual(buildElectronProxyConfig(custom), {
mode: 'fixed_servers',
proxyRules: 'http://proxy.example:8080',
proxyBypassRules: 'localhost,*.internal',
});
});
test('buildNodeProxyEnv mirrors custom proxy into HTTP(S)_PROXY', () => {
assert.deepEqual(buildNodeProxyEnv({ mode: 'direct', url: '', bypass: '<local>' }), {
HTTP_PROXY: '',
HTTPS_PROXY: '',
NO_PROXY: '',
http_proxy: '',
https_proxy: '',
no_proxy: '',
});
assert.deepEqual(
buildNodeProxyEnv({
mode: 'custom',
url: 'http://proxy.example:8080',
bypass: 'localhost,127.0.0.1',
}),
{
HTTP_PROXY: 'http://proxy.example:8080',
HTTPS_PROXY: 'http://proxy.example:8080',
NO_PROXY: 'localhost,127.0.0.1',
http_proxy: 'http://proxy.example:8080',
https_proxy: 'http://proxy.example:8080',
no_proxy: 'localhost,127.0.0.1',
},
);
// System mode leaves Node env alone — Chromium resolveProxy handles it.
assert.equal(buildNodeProxyEnv({ mode: 'system', url: '', bypass: '<local>' }), null);
});

142
domain/httpNetworkProxy.ts Normal file
View File

@@ -0,0 +1,142 @@
/**
* App-level HTTP(S) network proxy settings.
*
* Distinct from SSH ProxyJump / ProxyCommand profiles in the vault.
* Used by cloud sync (Google Drive / OneDrive / GitHub / WebDAV / S3),
* AI provider fetches, and other Chromium/Node outbound HTTPS traffic.
*/
export type HttpNetworkProxyMode = 'system' | 'direct' | 'custom';
export interface HttpNetworkProxySettings {
mode: HttpNetworkProxyMode;
/** Custom proxy URL, e.g. http://127.0.0.1:7890 or socks5://127.0.0.1:1080 */
url: string;
/** Comma-separated bypass hosts; Chromium also accepts `<local>`. */
bypass: string;
}
export const DEFAULT_HTTP_NETWORK_PROXY: HttpNetworkProxySettings = {
mode: 'system',
url: '',
bypass: '<local>',
};
const VALID_MODES = new Set<HttpNetworkProxyMode>(['system', 'direct', 'custom']);
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
/**
* Strip userinfo from proxy URLs without rewriting the rest of the string.
* Electron `proxyRules` does not support credentials, and we must not persist
* proxy passwords in localStorage. Avoid `new URL()` round-trips so incomplete
* drafts like `http://127.0.0.1:` keep the trailing colon while the user types
* a port.
*/
export function sanitizeProxyUrl(proxyUrl: string): string {
const trimmed = asTrimmedString(proxyUrl);
if (!trimmed) return '';
// Strip userinfo for both scheme URLs (`http://user:pass@host`) and
// scheme-less drafts (`user:pass@host:8080`). Electron proxyRules does not
// support credentials and we must not persist proxy passwords.
return trimmed.replace(/^([a-z][a-z0-9+.-]*:\/\/)?([^/?#]*@)/i, '$1');
}
export function normalizeHttpNetworkProxySettings(
raw: unknown,
): HttpNetworkProxySettings {
if (!raw || typeof raw !== 'object') {
return { ...DEFAULT_HTTP_NETWORK_PROXY };
}
const record = raw as Record<string, unknown>;
const modeRaw = asTrimmedString(record.mode);
const mode: HttpNetworkProxyMode = VALID_MODES.has(modeRaw as HttpNetworkProxyMode)
? (modeRaw as HttpNetworkProxyMode)
: 'system';
const url = sanitizeProxyUrl(asTrimmedString(record.url));
const bypass = asTrimmedString(record.bypass) || DEFAULT_HTTP_NETWORK_PROXY.bypass;
if (mode === 'system') {
return { mode: 'system', url: '', bypass: DEFAULT_HTTP_NETWORK_PROXY.bypass };
}
if (mode === 'direct') {
return { mode: 'direct', url: '', bypass: DEFAULT_HTTP_NETWORK_PROXY.bypass };
}
// Allow custom mode with an empty URL so the settings UI can show the
// URL field before the user has typed anything. Electron apply paths
// treat empty custom as system until a URL is present.
return { mode: 'custom', url, bypass };
}
export function areHttpNetworkProxySettingsEqual(
a: HttpNetworkProxySettings,
b: HttpNetworkProxySettings,
): boolean {
return a.mode === b.mode && a.url === b.url && a.bypass === b.bypass;
}
/** Payload for Electron `session.setProxy`. */
export function buildElectronProxyConfig(
settings: HttpNetworkProxySettings,
): { mode: 'system' } | { mode: 'direct' } | {
mode: 'fixed_servers';
proxyRules: string;
proxyBypassRules: string;
} {
if (settings.mode === 'direct') return { mode: 'direct' };
if (settings.mode === 'custom') {
return {
mode: 'fixed_servers',
proxyRules: settings.url,
proxyBypassRules: settings.bypass || DEFAULT_HTTP_NETWORK_PROXY.bypass,
};
}
return { mode: 'system' };
}
export type NodeProxyEnv = {
HTTP_PROXY: string;
HTTPS_PROXY: string;
NO_PROXY: string;
http_proxy: string;
https_proxy: string;
no_proxy: string;
};
/**
* Env vars for Node `http`/`https`/`webdav`/`aws-sdk` stacks that honor
* HTTP(S)_PROXY. Returns `null` for system mode so callers leave process.env
* alone (Chromium `net.fetch` still uses OS proxy via session.setProxy).
*/
export function buildNodeProxyEnv(
settings: HttpNetworkProxySettings,
): NodeProxyEnv | null {
if (settings.mode === 'system') return null;
if (settings.mode === 'direct') {
return {
HTTP_PROXY: '',
HTTPS_PROXY: '',
NO_PROXY: '',
http_proxy: '',
https_proxy: '',
no_proxy: '',
};
}
const url = settings.url;
const bypass = settings.bypass || '';
return {
HTTP_PROXY: url,
HTTPS_PROXY: url,
NO_PROXY: bypass,
http_proxy: url,
https_proxy: url,
no_proxy: bypass,
};
}

View File

@@ -0,0 +1,184 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
resolveSupersededImeInputEvent,
shouldAdoptExternalImeControlledValue,
shouldCommitImeControlledChange,
} from "./imeControlledInput.ts";
test("does not commit controlled changes while an IME composition session is open", () => {
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: true,
nativeEventIsComposing: true,
}),
false,
);
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: true,
nativeEventIsComposing: false,
}),
false,
);
});
test("does not commit when the native event still reports composing", () => {
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: false,
nativeEventIsComposing: true,
}),
false,
);
});
test("commits ordinary keystrokes outside composition", () => {
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: false,
nativeEventIsComposing: false,
}),
true,
);
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: false,
}),
true,
);
});
test("does not commit when composition was externally superseded", () => {
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: false,
nativeEventIsComposing: false,
compositionExternallySuperseded: true,
}),
false,
);
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: true,
nativeEventIsComposing: false,
compositionExternallySuperseded: true,
}),
false,
);
});
test("adopts external value into draft only when not composing and values differ", () => {
assert.equal(
shouldAdoptExternalImeControlledValue({
isComposingSession: false,
draftValue: "sou",
externalValue: "",
}),
true,
);
assert.equal(
shouldAdoptExternalImeControlledValue({
isComposingSession: true,
draftValue: "sou",
externalValue: "",
}),
false,
);
assert.equal(
shouldAdoptExternalImeControlledValue({
isComposingSession: false,
draftValue: "搜",
externalValue: "搜",
}),
false,
);
});
test("adopts external navigation-clear mid-composition when compose-start baseline is provided", () => {
// Parent cleared filter for different-directory navigation while IME was open.
assert.equal(
shouldAdoptExternalImeControlledValue({
isComposingSession: true,
draftValue: "sou",
externalValue: "",
valueAtComposeStart: "old",
}),
true,
);
// External value still matches the compose-start baseline - keep draft for IME.
assert.equal(
shouldAdoptExternalImeControlledValue({
isComposingSession: true,
draftValue: "sou",
externalValue: "old",
valueAtComposeStart: "old",
}),
false,
);
// Draft already matches external after a prior adopt - no-op.
assert.equal(
shouldAdoptExternalImeControlledValue({
isComposingSession: true,
draftValue: "",
externalValue: "",
valueAtComposeStart: "old",
}),
false,
);
});
test("suppresses post-composition onChange after external supersede and clears the latch once ended", () => {
// Mid-composition after navigation clear: ignore event, keep latch armed.
assert.deepEqual(
resolveSupersededImeInputEvent({
compositionExternallySuperseded: true,
isComposingSession: true,
nativeEventIsComposing: true,
}),
{ ignoreEventValue: true, clearSupersedeLatch: false },
);
// Post-compositionend follow-up change (composing=false): ignore once and clear.
assert.deepEqual(
resolveSupersededImeInputEvent({
compositionExternallySuperseded: true,
isComposingSession: false,
nativeEventIsComposing: false,
}),
{ ignoreEventValue: true, clearSupersedeLatch: true },
);
// No supersede: ordinary path.
assert.deepEqual(
resolveSupersededImeInputEvent({
compositionExternallySuperseded: false,
isComposingSession: false,
nativeEventIsComposing: false,
}),
{ ignoreEventValue: false, clearSupersedeLatch: false },
);
});
test("ordinary commits remain blocked only while the supersede latch is armed", () => {
// Documents the stuck-latch failure mode: if compositionend arms the latch and
// no post-composition onChange clears it, shouldCommitImeControlledChange stays
// false for ordinary keystrokes. UI must clear via onChange or a deferred fallback.
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: false,
nativeEventIsComposing: false,
compositionExternallySuperseded: true,
}),
false,
);
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: false,
nativeEventIsComposing: false,
compositionExternallySuperseded: false,
}),
true,
);
});

View File

@@ -0,0 +1,72 @@
/**
* Pure helpers for controlled text inputs that must not fight CJK IME composition.
*
* Deferred parent updates (e.g. startTransition) against `value={external}` reset
* the DOM mid-composition and break Windows IMEs (candidate dismiss / pinyin echo).
*/
export function shouldCommitImeControlledChange(input: {
isComposingSession: boolean;
nativeEventIsComposing?: boolean;
/**
* When true, the open (or just-ended) composition was discarded by an external
* value change (e.g. navigation cleared the filter). Suppress commits so a
* post-compositionend `onChange` cannot reassert the stale composed draft.
*/
compositionExternallySuperseded?: boolean;
}): boolean {
if (input.compositionExternallySuperseded) return false;
return !input.isComposingSession && input.nativeEventIsComposing !== true;
}
export function shouldAdoptExternalImeControlledValue(input: {
isComposingSession: boolean;
draftValue: string;
externalValue: string;
/**
* Committed external value captured at compositionstart. When provided during
* an open composition, an external change relative to this baseline (e.g.
* different-directory navigation setting filter to "") supersedes the draft
* so compositionend cannot re-commit stale text over the navigation clear.
*/
valueAtComposeStart?: string;
}): boolean {
if (input.draftValue === input.externalValue) return false;
if (!input.isComposingSession) return true;
// Mid-composition: only adopt when parent moved away from the compose-start
// baseline. Without a baseline, never fight the IME for self-driven drafts.
if (input.valueAtComposeStart !== undefined) {
return input.externalValue !== input.valueAtComposeStart;
}
return false;
}
/**
* Resolve an input event when a composition may have been externally superseded
* (navigation clear mid-IME). Browsers often re-fire the composed text via
* `onChange` with `isComposing=false` immediately after `compositionend`; that
* follow-up must not reassert the stale draft.
*
* - While still composing after supersede: keep ignoring event values (draft stays
* on the external value) and keep the latch armed.
* - Once composition has fully ended: ignore that one post-composition change and
* clear the latch so subsequent ordinary typing commits normally.
*/
export function resolveSupersededImeInputEvent(input: {
compositionExternallySuperseded: boolean;
isComposingSession: boolean;
nativeEventIsComposing?: boolean;
}): {
ignoreEventValue: boolean;
clearSupersedeLatch: boolean;
} {
if (!input.compositionExternallySuperseded) {
return { ignoreEventValue: false, clearSupersedeLatch: false };
}
const compositionFullyEnded =
!input.isComposingSession && input.nativeEventIsComposing !== true;
return {
ignoreEventValue: true,
clearSupersedeLatch: compositionFullyEnded,
};
}

180
domain/jmsDeepLink.test.ts Normal file
View File

@@ -0,0 +1,180 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildJmsDeepLinkEphemeralHost,
isSupportedJmsProtocol,
parseJmsDeepLink,
} from "./jmsDeepLink";
import { resolveHostAutofillPassword } from "./sshAuth";
const encodePayload = (payload: Record<string, unknown>): string =>
`jms://${Buffer.from(JSON.stringify(payload), "utf8").toString("base64")}`;
const validPayload = {
version: 2,
id: "legacy-id",
value: "legacy-secret",
name: "account@asset[2024-01-01_12:00:00]",
protocol: "ssh",
token: { id: "token-id", value: "token-secret" },
asset: { id: "asset-id", name: "Production Server", address: "10.0.0.1" },
endpoint: { host: "gw.example.com", port: 2222 },
file: {},
command: "",
};
test("parseJmsDeepLink accepts a valid ssh payload", () => {
const target = parseJmsDeepLink(encodePayload(validPayload));
assert.deepEqual(target, {
protocol: "ssh",
hostname: "gw.example.com",
port: 2222,
username: "JMS-token-id",
password: "token-secret",
label: "Production Server",
});
});
test("parseJmsDeepLink tolerates URL-safe base64 and trailing slash", () => {
const encoded = Buffer.from(JSON.stringify(validPayload), "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const target = parseJmsDeepLink(`jms://${encoded}/`);
assert.equal(target?.hostname, "gw.example.com");
assert.equal(target?.username, "JMS-token-id");
});
test("parseJmsDeepLink falls back to legacy top-level token fields", () => {
const target = parseJmsDeepLink(encodePayload({
protocol: "sftp",
id: "legacy-id",
value: "legacy-secret",
endpoint: { host: "gw.example.com", port: 2222 },
}));
assert.equal(target?.username, "JMS-legacy-id");
assert.equal(target?.password, "legacy-secret");
assert.equal(target?.protocol, "sftp");
});
test("parseJmsDeepLink returns null when token or endpoint is missing", () => {
assert.equal(parseJmsDeepLink(encodePayload({
protocol: "ssh",
token: { id: "token-id", value: "token-secret" },
})), null);
assert.equal(parseJmsDeepLink(encodePayload({
protocol: "ssh",
token: { id: "token-id", value: "token-secret" },
endpoint: { host: "gw.example.com" },
})), null);
assert.equal(parseJmsDeepLink(encodePayload({
protocol: "ssh",
endpoint: { host: "gw.example.com", port: 2222 },
})), null);
});
test("parseJmsDeepLink returns null for bad base64 or JSON", () => {
assert.equal(parseJmsDeepLink("jms://%%%"), null);
assert.equal(parseJmsDeepLink("jms://eyJub3QtanNvbiI6"), null);
});
test("parseJmsDeepLink still parses unsupported protocols", () => {
const target = parseJmsDeepLink(encodePayload({
...validPayload,
protocol: "rdp",
}));
assert.equal(target?.protocol, "rdp");
assert.equal(target?.hostname, "gw.example.com");
});
test("parseJmsDeepLink coerces string ports and rejects invalid ports", () => {
const target = parseJmsDeepLink(encodePayload({
...validPayload,
endpoint: { host: "gw.example.com", port: "2222" },
}));
assert.equal(target?.port, 2222);
assert.equal(parseJmsDeepLink(encodePayload({
...validPayload,
endpoint: { host: "gw.example.com", port: 70000 },
})), null);
assert.equal(parseJmsDeepLink(encodePayload({
...validPayload,
endpoint: { host: "gw.example.com", port: "abc" },
})), null);
});
test("parseJmsDeepLink uses name or hostname for label", () => {
const fromName = parseJmsDeepLink(encodePayload({
...validPayload,
asset: undefined,
name: "account@asset",
}));
assert.equal(fromName?.label, "account@asset");
const fromHostname = parseJmsDeepLink(encodePayload({
...validPayload,
asset: undefined,
name: "",
}));
assert.equal(fromHostname?.label, "gw.example.com");
});
test("isSupportedJmsProtocol accepts ssh, sftp, and telnet", () => {
assert.equal(isSupportedJmsProtocol("ssh"), true);
assert.equal(isSupportedJmsProtocol("SFTP"), true);
assert.equal(isSupportedJmsProtocol("telnet"), true);
assert.equal(isSupportedJmsProtocol("rdp"), false);
});
test("buildJmsDeepLinkEphemeralHost builds password ssh host with mosh and et disabled", () => {
const target = parseJmsDeepLink(encodePayload(validPayload))!;
const host = buildJmsDeepLinkEphemeralHost(target, { id: "ephemeral-id", now: 456 });
assert.equal(host.id, "ephemeral-id");
assert.equal(host.label, "Production Server");
assert.equal(host.hostname, "gw.example.com");
assert.equal(host.port, 2222);
assert.equal(host.username, "JMS-token-id");
assert.equal(host.password, "token-secret");
assert.equal(host.authMethod, "password");
assert.equal(host.savePassword, false);
assert.equal(resolveHostAutofillPassword({ host, keys: [] }), undefined);
assert.equal(host.protocol, "ssh");
assert.equal(host.moshEnabled, false);
assert.equal(host.ephemeral, true);
assert.equal(host.etEnabled, false);
assert.equal(host.createdAt, 456);
assert.equal(host.autoOpenSftpPanel, undefined);
});
test("buildJmsDeepLinkEphemeralHost flags sftp payloads for the SFTP side panel", () => {
const target = parseJmsDeepLink(encodePayload({
...validPayload,
protocol: "sftp",
}))!;
const host = buildJmsDeepLinkEphemeralHost(target, { id: "ephemeral-id", now: 456 });
assert.equal(host.protocol, "ssh");
assert.equal(host.autoOpenSftpPanel, true);
assert.equal(host.ephemeral, true);
});
test("buildJmsDeepLinkEphemeralHost keeps telnet payloads on the JumpServer ssh gateway", () => {
const target = parseJmsDeepLink(encodePayload({
...validPayload,
protocol: "telnet",
}))!;
const host = buildJmsDeepLinkEphemeralHost(target, { id: "ephemeral-id", now: 456 });
assert.equal(host.protocol, "ssh");
assert.equal(host.hostname, "gw.example.com");
assert.equal(host.port, 2222);
assert.equal(host.username, "JMS-token-id");
assert.equal(host.password, "token-secret");
assert.equal(host.telnetUsername, undefined);
assert.equal(host.telnetPassword, undefined);
assert.equal(host.autoOpenSftpPanel, undefined);
assert.equal(host.ephemeral, true);
});

127
domain/jmsDeepLink.ts Normal file
View File

@@ -0,0 +1,127 @@
import type { Host } from "./models";
export interface JmsDeepLinkTarget {
protocol: string;
hostname: string;
port: number;
username: string;
password: string;
label: string;
}
export interface JmsDeepLinkDraftOptions {
id: string;
now: number;
}
const JMS_PROTOCOL_PREFIX = "jms://";
const decodeBase64Payload = (encoded: string): string | null => {
let normalized = encoded.replace(/-/g, "+").replace(/_/g, "/");
while (normalized.length % 4 !== 0) {
normalized += "=";
}
try {
if (typeof Buffer !== "undefined") {
return Buffer.from(normalized, "base64").toString("utf8");
}
const binary = globalThis.atob(normalized);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
return new TextDecoder().decode(bytes);
} catch {
return null;
}
};
const parsePort = (value: unknown): number | null => {
const port = typeof value === "string" ? Number(value.trim()) : Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return null;
}
return port;
};
const nonEmptyString = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
};
export const isSupportedJmsProtocol = (protocol: string): boolean => {
const normalized = protocol.toLowerCase();
return normalized === "ssh" || normalized === "sftp" || normalized === "telnet";
};
export const parseJmsDeepLink = (rawUrl: string): JmsDeepLinkTarget | null => {
if (typeof rawUrl !== "string") return null;
const trimmed = rawUrl.trim();
if (!trimmed.toLowerCase().startsWith(JMS_PROTOCOL_PREFIX)) return null;
const encoded = trimmed.slice(JMS_PROTOCOL_PREFIX.length).replace(/\/+$/, "");
if (!encoded) return null;
const jsonText = decodeBase64Payload(encoded);
if (!jsonText) return null;
let payload: Record<string, unknown>;
try {
payload = JSON.parse(jsonText) as Record<string, unknown>;
} catch {
return null;
}
const protocol = String(payload.protocol || "").toLowerCase();
if (!protocol) return null;
const tokenId = nonEmptyString(
(payload.token as Record<string, unknown> | undefined)?.id ?? payload.id,
);
const tokenValue = nonEmptyString(
(payload.token as Record<string, unknown> | undefined)?.value ?? payload.value,
);
if (!tokenId || !tokenValue) return null;
const endpoint = payload.endpoint as Record<string, unknown> | undefined;
const hostname = nonEmptyString(endpoint?.host);
const port = parsePort(endpoint?.port);
if (!hostname || port === null) return null;
const asset = payload.asset as Record<string, unknown> | undefined;
const label = nonEmptyString(asset?.name) || nonEmptyString(payload.name) || hostname;
return {
protocol,
hostname,
port,
username: `JMS-${tokenId}`,
password: tokenValue,
label,
};
};
export const buildJmsDeepLinkEphemeralHost = (
target: JmsDeepLinkTarget,
options: JmsDeepLinkDraftOptions,
): Host => {
return {
id: options.id,
label: target.label,
hostname: target.hostname,
username: target.username,
port: target.port,
password: target.password,
authMethod: "password",
savePassword: false,
ephemeral: true,
protocol: "ssh",
// JumpServer sftp payloads target file transfer: connect the gateway
// shell and surface Netcatty's SFTP side panel for that session.
...(target.protocol === "sftp" ? { autoOpenSftpPanel: true } : {}),
group: "",
tags: [],
os: "linux",
createdAt: options.now,
moshEnabled: false,
etEnabled: false,
};
};

View File

@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_KEY_BINDINGS, matchesKeyBinding } from './models/keyBindings.ts';
import { getTerminalPassthroughActions } from '../application/state/useGlobalHotkeys.ts';
test('default shortcuts include terminal font size controls', () => {
const byAction = new Map(DEFAULT_KEY_BINDINGS.map((binding) => [binding.action, binding]));
assert.equal(byAction.get('increaseTerminalFontSize')?.pc, 'Ctrl + =');
assert.equal(byAction.get('decreaseTerminalFontSize')?.pc, 'Ctrl + -');
assert.equal(byAction.get('resetTerminalFontSize')?.pc, 'Ctrl + 0');
assert.equal(byAction.get('increaseTerminalFontSize')?.category, 'terminal');
assert.equal(byAction.get('decreaseTerminalFontSize')?.category, 'terminal');
assert.equal(byAction.get('resetTerminalFontSize')?.category, 'terminal');
});
test('terminal font size shortcuts are handled inside xterm', () => {
const actions = getTerminalPassthroughActions();
assert.equal(actions.has('increaseTerminalFontSize'), true);
assert.equal(actions.has('decreaseTerminalFontSize'), true);
assert.equal(actions.has('resetTerminalFontSize'), true);
});
test('pane magnification uses Alt+M without colliding with existing defaults', () => {
const binding = DEFAULT_KEY_BINDINGS.find((entry) => entry.id === 'toggle-pane-zoom');
assert.equal(binding?.mac, '⌥ + M');
assert.equal(binding?.pc, 'Alt + M');
assert.equal(matchesKeyBinding({
key: 'µ',
code: 'KeyM',
metaKey: false,
ctrlKey: false,
altKey: true,
shiftKey: false,
} as KeyboardEvent, binding?.mac ?? '', true), true);
const duplicatePcShortcuts = DEFAULT_KEY_BINDINGS.filter((entry) => entry.pc === binding?.pc);
const duplicateMacShortcuts = DEFAULT_KEY_BINDINGS.filter((entry) => entry.mac === binding?.mac);
assert.deepEqual(duplicatePcShortcuts.map((entry) => entry.id), ['toggle-pane-zoom']);
assert.deepEqual(duplicateMacShortcuts.map((entry) => entry.id), ['toggle-pane-zoom']);
});

View File

@@ -0,0 +1,151 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DEFAULT_KEYWORD_HIGHLIGHT_RULES,
KeywordHighlightRule,
normalizeTerminalSettings,
} from "./models";
const IP_MAC_RULE = "ip-mac";
const ipMacDefault = () => {
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === IP_MAC_RULE);
if (!def) throw new Error("ip-mac default rule missing");
return def;
};
const getRule = (
rules: KeywordHighlightRule[],
id: string,
): KeywordHighlightRule => {
const rule = rules.find((r) => r.id === id);
if (!rule) throw new Error(`rule ${id} missing`);
return rule;
};
const matchesAny = (patterns: string[], input: string): boolean =>
patterns.some((p) => new RegExp(p, "gi").test(input));
test("ip-mac built-in rule includes IPv6 patterns by default", () => {
const def = ipMacDefault();
// Compressed mid-form (issue #958 example #1)
assert.ok(
matchesAny(def.patterns, "2001:11:22:33::5"),
"expected default ip-mac rule to match 2001:11:22:33::5",
);
// Link-local compressed (issue #958 example #2)
assert.ok(
matchesAny(def.patterns, "fe80::d2dd:bff:fe79:f2bb"),
"expected default ip-mac rule to match fe80::d2dd:bff:fe79:f2bb",
);
// Loopback
assert.ok(matchesAny(def.patterns, "::1"), "expected ::1 to match");
// Full form
assert.ok(
matchesAny(def.patterns, "2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
"expected full-form IPv6 to match",
);
});
test("ip-mac IPv6 regex still matches IPv4 and MAC", () => {
const def = ipMacDefault();
assert.ok(matchesAny(def.patterns, "10.0.0.1"), "expected IPv4 still matches");
assert.ok(
matchesAny(def.patterns, "aa:bb:cc:dd:ee:ff"),
"expected MAC still matches",
);
});
test("ip-mac IPv6 regex does not match obviously-not-IPv6 hex blobs", () => {
const def = ipMacDefault();
// A single hex word without colons must not match
assert.ok(!matchesAny(def.patterns, "deadbeef"), "single hex word matched");
// A typical sha-like string with colons separating fewer than two groups
assert.ok(!matchesAny(def.patterns, "abc"), "stray hex matched");
});
test("normalize adds newly-shipped default rules to legacy saved sets", () => {
// Simulate an older save that only has 'error' and an old-shape 'ip-mac'
// (i.e. without IPv6). Because the rule is NOT marked customized, normalize
// should re-sync it with the latest shipped patterns.
const legacyIpMacPatterns = ["legacy-pattern-from-old-default"];
const saved: KeywordHighlightRule[] = [
{
id: "error",
label: "Error",
patterns: ["\\berror\\b"],
color: "#F87171",
enabled: true,
},
{
id: IP_MAC_RULE,
label: "URL, IP & MAC",
patterns: legacyIpMacPatterns,
color: "#EC4899",
enabled: true,
},
];
const settings = normalizeTerminalSettings({
keywordHighlightRules: saved,
});
const rules = settings.keywordHighlightRules;
// Every shipped default exists (warning/ok/info/debug get added).
for (const def of DEFAULT_KEYWORD_HIGHLIGHT_RULES) {
assert.ok(
rules.some((r) => r.id === def.id),
`expected normalize to include shipped rule ${def.id}`,
);
}
// ip-mac was not customized → patterns re-sync to defaults, picking up IPv6.
const ipMac = getRule(rules, IP_MAC_RULE);
assert.deepEqual(ipMac.patterns, ipMacDefault().patterns);
assert.ok(matchesAny(ipMac.patterns, "2001:11:22:33::5"));
});
test("normalize preserves user-edited patterns when rule.customized is set", () => {
const customPatterns = ["\\bMY_CUSTOM\\b", "\\bANOTHER\\b"];
const customLabel = "My Errors";
const saved: KeywordHighlightRule[] = [
{
id: "error",
label: customLabel,
patterns: customPatterns,
color: "#FF0000",
enabled: false,
customized: true,
},
];
const settings = normalizeTerminalSettings({
keywordHighlightRules: saved,
});
const rule = getRule(settings.keywordHighlightRules, "error");
assert.equal(rule.label, customLabel);
assert.deepEqual(rule.patterns, customPatterns);
assert.equal(rule.color, "#FF0000");
assert.equal(rule.enabled, false);
assert.equal(rule.customized, true);
});
test("normalize keeps custom (non-built-in) rules verbatim", () => {
const customRule: KeywordHighlightRule = {
id: "user-uuid-1",
label: "Pager",
patterns: ["\\b[A-Z]{3}-\\d+\\b"],
color: "#00FF00",
enabled: true,
};
const settings = normalizeTerminalSettings({
keywordHighlightRules: [customRule],
});
const rule = getRule(settings.keywordHighlightRules, "user-uuid-1");
assert.deepEqual(rule.patterns, customRule.patterns);
assert.equal(rule.label, customRule.label);
});

260
domain/knownHosts.test.ts Normal file
View File

@@ -0,0 +1,260 @@
import test from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
import type { KnownHost } from "./models";
import {
fingerprintFromPublicKey,
normalizeKnownHost,
normalizeKnownHosts,
upsertKnownHost,
} from "./knownHosts";
const knownHost = (overrides: Partial<KnownHost> = {}): KnownHost => ({
id: "kh-existing",
hostname: "10.2.0.32",
port: 22,
keyType: "ssh-ed25519",
publicKey: "ssh-ed25519 old-key",
fingerprint: "old-fingerprint",
discoveredAt: 100,
...overrides,
});
test("upsertKnownHost updates an existing host key instead of appending a duplicate", () => {
const existing = knownHost({ convertedToHostId: "host-1" });
const incoming = knownHost({
id: "kh-new",
publicKey: "ssh-ed25519 new-key",
fingerprint: "new-fingerprint",
discoveredAt: 200,
});
const result = upsertKnownHost([existing], incoming);
assert.equal(result.length, 1);
assert.deepEqual(result[0], {
...existing,
publicKey: "ssh-ed25519 new-key",
fingerprint: "new-fingerprint",
lastSeen: 200,
});
});
test("upsertKnownHost updates by id even when the incoming key type is unknown", () => {
const existing = knownHost({
id: "kh-1",
keyType: "ssh-ed25519",
publicKey: "SHA256:old-key",
fingerprint: "old-fingerprint",
discoveredAt: 100,
});
const incoming = knownHost({
id: "kh-1",
keyType: "unknown",
publicKey: undefined,
fingerprint: "new-fingerprint",
discoveredAt: 200,
});
const result = upsertKnownHost([existing], incoming);
assert.equal(result.length, 1);
assert.equal(result[0].id, "kh-1");
assert.equal(result[0].keyType, "unknown");
assert.equal(result[0].fingerprint, "new-fingerprint");
assert.equal(result[0].lastSeen, 200);
});
test("upsertKnownHost prefers the matching id over an earlier selector match", () => {
const duplicate = knownHost({
id: "kh-duplicate",
fingerprint: "duplicate-fingerprint",
discoveredAt: 50,
});
const target = knownHost({
id: "kh-target",
fingerprint: "target-fingerprint",
discoveredAt: 100,
});
const incoming = knownHost({
id: "kh-target",
fingerprint: "new-fingerprint",
discoveredAt: 200,
});
const result = upsertKnownHost([duplicate, target], incoming);
assert.equal(result.length, 2);
assert.equal(result[0].fingerprint, "duplicate-fingerprint");
assert.equal(result[1].id, "kh-target");
assert.equal(result[1].fingerprint, "new-fingerprint");
});
test("upsertKnownHost appends genuinely new host keys", () => {
const existing = knownHost();
const incoming = knownHost({
id: "kh-other",
hostname: "10.2.0.33",
fingerprint: "other-fingerprint",
});
const result = upsertKnownHost([existing], incoming);
assert.deepEqual(result, [existing, incoming]);
});
// --- Fingerprint derivation -------------------------------------------------
const makeRawPublicKey = (keyType: string, body = "trusted imported host key") => {
const type = Buffer.from(keyType);
const length = Buffer.alloc(4);
length.writeUInt32BE(type.length, 0);
return Buffer.concat([length, type, Buffer.from(body)]);
};
test("fingerprintFromPublicKey matches Node's SHA-256 over a base64-decoded OpenSSH line", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const base64Body = rawKey.toString("base64");
const expected = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
assert.equal(fingerprintFromPublicKey(`ssh-ed25519 ${base64Body}`), expected);
assert.equal(
fingerprintFromPublicKey(`ssh-ed25519 ${base64Body} comment-tail`),
expected,
"trailing comment is ignored",
);
});
test("fingerprintFromPublicKey strips a SHA256: prefix and trailing padding", () => {
assert.equal(fingerprintFromPublicKey("SHA256:abc123=="), "abc123");
assert.equal(fingerprintFromPublicKey("sha256:abc123"), "abc123");
});
test("fingerprintFromPublicKey returns empty string on missing input", () => {
assert.equal(fingerprintFromPublicKey(undefined), "");
assert.equal(fingerprintFromPublicKey(null), "");
assert.equal(fingerprintFromPublicKey(""), "");
});
// --- Migration --------------------------------------------------------------
test("normalizeKnownHost backfills fingerprint when only publicKey is stored", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const base64Body = rawKey.toString("base64");
const expected = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
const stored: KnownHost = {
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${base64Body}`,
discoveredAt: 1,
};
const migrated = normalizeKnownHost(stored);
assert.notEqual(migrated, stored, "should return a new object when fingerprint is added");
assert.equal(migrated.fingerprint, expected);
assert.equal(migrated.keyType, "ssh-ed25519");
});
test("normalizeKnownHost backfills keyType from an OpenSSH-format publicKey", () => {
const rawKey = makeRawPublicKey("ssh-rsa");
const base64Body = rawKey.toString("base64");
const stored: KnownHost = {
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "",
publicKey: `ssh-rsa ${base64Body}`,
discoveredAt: 1,
};
const migrated = normalizeKnownHost(stored);
assert.equal(migrated.keyType, "ssh-rsa");
});
test("normalizeKnownHost returns the same reference when nothing needs backfilling", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const fp = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
const stored: KnownHost = {
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
fingerprint: fp,
discoveredAt: 1,
};
assert.equal(normalizeKnownHost(stored), stored);
});
test("normalizeKnownHost is a no-op when publicKey is opaque and nothing else is known", () => {
const stored: KnownHost = {
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "unknown",
publicKey: "SHA256:already-just-a-fingerprint",
discoveredAt: 1,
};
const migrated = normalizeKnownHost(stored);
// The SHA256: prefix becomes the fingerprint; keyType stays as "unknown" since
// we cannot recover it from a bare fingerprint.
assert.equal(migrated.fingerprint, "already-just-a-fingerprint");
assert.equal(migrated.keyType, "unknown");
});
test("normalizeKnownHosts returns the same array reference when nothing needs migration", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const fp = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
const list: KnownHost[] = [{
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
fingerprint: fp,
discoveredAt: 1,
}];
assert.equal(normalizeKnownHosts(list), list);
});
test("normalizeKnownHosts migrates each entry that needs backfilling", () => {
const rawKeyA = makeRawPublicKey("ssh-ed25519", "host-a-key");
const rawKeyB = makeRawPublicKey("ssh-rsa", "host-b-key");
const fpA = crypto.createHash("sha256").update(rawKeyA).digest("base64").replace(/=+$/g, "");
const fpB = crypto.createHash("sha256").update(rawKeyB).digest("base64").replace(/=+$/g, "");
const list: KnownHost[] = [
{
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${rawKeyA.toString("base64")}`,
discoveredAt: 1,
},
{
id: "kh-2",
hostname: "vps-2.example.com",
port: 22,
keyType: "",
publicKey: `ssh-rsa ${rawKeyB.toString("base64")}`,
discoveredAt: 2,
},
];
const migrated = normalizeKnownHosts(list);
assert.notEqual(migrated, list);
assert.equal(migrated[0].fingerprint, fpA);
assert.equal(migrated[1].fingerprint, fpB);
assert.equal(migrated[1].keyType, "ssh-rsa");
});

227
domain/knownHosts.ts Normal file
View File

@@ -0,0 +1,227 @@
import type { KnownHost } from "./models";
const normalizeHost = (value: string) => value.trim().toLowerCase();
const sameKnownHostSelector = (a: KnownHost, b: KnownHost) =>
normalizeHost(a.hostname) === normalizeHost(b.hostname) &&
a.port === b.port &&
a.keyType === b.keyType;
export const upsertKnownHost = (
knownHosts: KnownHost[],
incoming: KnownHost,
): KnownHost[] => {
const idIndex = knownHosts.findIndex((existing) => existing.id === incoming.id);
const index = idIndex !== -1
? idIndex
: knownHosts.findIndex((existing) => sameKnownHostSelector(existing, incoming));
if (index === -1) {
return [...knownHosts, incoming];
}
const existing = knownHosts[index];
const updated: KnownHost = {
...existing,
...incoming,
id: existing.id,
discoveredAt: existing.discoveredAt,
convertedToHostId: existing.convertedToHostId ?? incoming.convertedToHostId,
lastSeen: incoming.lastSeen ?? incoming.discoveredAt,
};
return [
...knownHosts.slice(0, index),
updated,
...knownHosts.slice(index + 1),
];
};
const SSH_KEY_TYPE_PREFIX = /^(?:ssh-|ecdsa-|sk-)/;
const stripPadding = (value: string) => value.replace(/=+$/g, "");
// Pure-JS SHA-256 used to migrate stored knownHosts records on hydration.
// crypto.subtle is async and would force the migration through useEffect; for
// a one-shot read-and-rewrite of a typically-small list, the sync path keeps
// the call sites simple. Runs at most a handful of times per app start.
const sha256Bytes = (data: Uint8Array): Uint8Array => {
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
const length = data.length;
const bitLength = BigInt(length) * 8n;
const padded = new Uint8Array(((length + 9 + 63) >> 6) << 6);
padded.set(data);
padded[length] = 0x80;
const view = new DataView(padded.buffer);
view.setBigUint64(padded.length - 8, bitLength, false);
const H = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
const W = new Uint32Array(64);
for (let chunk = 0; chunk < padded.length; chunk += 64) {
for (let i = 0; i < 16; i += 1) W[i] = view.getUint32(chunk + i * 4, false);
for (let i = 16; i < 64; i += 1) {
const s0 = ((W[i - 15] >>> 7) | (W[i - 15] << 25)) ^ ((W[i - 15] >>> 18) | (W[i - 15] << 14)) ^ (W[i - 15] >>> 3);
const s1 = ((W[i - 2] >>> 17) | (W[i - 2] << 15)) ^ ((W[i - 2] >>> 19) | (W[i - 2] << 13)) ^ (W[i - 2] >>> 10);
W[i] = (W[i - 16] + s0 + W[i - 7] + s1) >>> 0;
}
let [a, b, c, d, e, f, g, h] = H;
for (let i = 0; i < 64; i += 1) {
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
const ch = (e & f) ^ (~e & g);
const temp1 = (h + S1 + ch + K[i] + W[i]) >>> 0;
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
const mj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (S0 + mj) >>> 0;
h = g; g = f; f = e; e = (d + temp1) >>> 0;
d = c; c = b; b = a; a = (temp1 + temp2) >>> 0;
}
H[0] = (H[0] + a) >>> 0; H[1] = (H[1] + b) >>> 0; H[2] = (H[2] + c) >>> 0; H[3] = (H[3] + d) >>> 0;
H[4] = (H[4] + e) >>> 0; H[5] = (H[5] + f) >>> 0; H[6] = (H[6] + g) >>> 0; H[7] = (H[7] + h) >>> 0;
}
const out = new Uint8Array(32);
const outView = new DataView(out.buffer);
for (let i = 0; i < 8; i += 1) outView.setUint32(i * 4, H[i], false);
return out;
};
const base64Decode = (value: string): Uint8Array | null => {
try {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return bytes;
} catch {
return null;
}
};
const base64Encode = (bytes: Uint8Array): string => {
let bin = "";
for (let i = 0; i < bytes.length; i += 1) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
};
/**
* Compute the SHA-256 base64 fingerprint (no padding, no SHA256: prefix) from
* a stored `publicKey` field. Mirrors `fingerprintFromPublicKey` in
* electron/bridges/hostKeyVerifier.cjs so renderer-side migration produces the
* same value the verifier compares against at connect time.
*/
export const fingerprintFromPublicKey = (publicKey: string | undefined | null): string => {
if (typeof publicKey !== "string") return "";
const trimmed = publicKey.trim();
if (!trimmed) return "";
if (/^SHA256:/i.test(trimmed)) {
return stripPadding(trimmed.replace(/^SHA256:/i, ""));
}
const parts = trimmed.split(/\s+/);
if (parts.length >= 2 && SSH_KEY_TYPE_PREFIX.test(parts[0])) {
const bytes = base64Decode(parts[1]);
if (bytes) return stripPadding(base64Encode(sha256Bytes(bytes)));
}
return stripPadding(trimmed);
};
const extractKeyTypeFromPublicKey = (publicKey: string | undefined | null): string => {
if (typeof publicKey !== "string") return "";
const first = publicKey.trim().split(/\s+/)[0] ?? "";
return SSH_KEY_TYPE_PREFIX.test(first) ? first : "";
};
/**
* Backfill missing `fingerprint` / `keyType` on a stored record so the host
* verifier can match it without falling back to the brittle re-derivation
* path. Returns the same reference when nothing changes so callers can skip
* persistence writes and React re-renders.
*/
export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
const hasFingerprint = typeof knownHost.fingerprint === "string" && knownHost.fingerprint.length > 0;
const hasKeyType = typeof knownHost.keyType === "string"
&& knownHost.keyType.length > 0
&& knownHost.keyType !== "unknown";
if (hasFingerprint && hasKeyType) return knownHost;
const derivedFingerprint = hasFingerprint
? knownHost.fingerprint!
: fingerprintFromPublicKey(knownHost.publicKey);
const derivedKeyType = hasKeyType
? knownHost.keyType
: extractKeyTypeFromPublicKey(knownHost.publicKey);
const fingerprintChanged = derivedFingerprint && derivedFingerprint !== knownHost.fingerprint;
const keyTypeChanged = derivedKeyType && derivedKeyType !== knownHost.keyType;
if (!fingerprintChanged && !keyTypeChanged) return knownHost;
return {
...knownHost,
fingerprint: fingerprintChanged ? derivedFingerprint : knownHost.fingerprint,
keyType: keyTypeChanged ? derivedKeyType : knownHost.keyType,
};
};
/**
* Normalize a whole list. Returns the same array reference when no entries
* needed migration so referential-equality consumers (React.memo, prop
* comparisons in TerminalLayer) don't re-render on every hydration.
*/
export const normalizeKnownHosts = (knownHosts: KnownHost[]): KnownHost[] => {
let changed = false;
const next = knownHosts.map((entry) => {
const normalized = normalizeKnownHost(entry);
if (normalized !== entry) changed = true;
return normalized;
});
return changed ? next : knownHosts;
};
/** Minimal host-key shape shared by terminal / SFTP / port-forward verification. */
export type HostKeyInfoLike = {
hostname: string;
port?: number;
keyType: string;
fingerprint: string;
publicKey?: string;
knownHostId?: string;
};
/**
* Create a KnownHost record from verified host-key info.
* Call sites may supply a fallback port (e.g. the SSH host's configured port).
*/
export const createKnownHostFromHostKeyInfo = (
hostKeyInfo: HostKeyInfoLike,
options?: {
defaultPort?: number;
now?: number;
idSuffix?: string;
},
): KnownHost => {
const now = options?.now ?? Date.now();
const idSuffix = options?.idSuffix ?? Math.random().toString(36).slice(2, 11);
return {
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
hostname: hostKeyInfo.hostname,
port: hostKeyInfo.port || options?.defaultPort || 22,
keyType: hostKeyInfo.keyType,
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
fingerprint: hostKeyInfo.fingerprint,
discoveredAt: now,
};
};

90
domain/models.test.ts Normal file
View File

@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { keyEventToString, keyStringToKeyboardEvent, matchesKeyBinding, tabShortcutDigitFromEvent } from './models.ts';
const keyboardEvent = (
key: string,
code: string,
modifiers: Partial<KeyboardEvent> = {},
): KeyboardEvent => ({
key,
code,
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
...modifiers,
}) as KeyboardEvent;
test('shortcut matching falls back to physical keys for non-Latin layouts', () => {
const event = keyboardEvent('\u0446', 'KeyW', { ctrlKey: true });
assert.equal(matchesKeyBinding(event, 'Ctrl + W', false), true);
assert.equal(keyEventToString(event, false), 'Ctrl + W');
});
test('shortcut matching respects Latin characters from non-QWERTY layouts', () => {
const event = keyboardEvent('w', 'Comma', { ctrlKey: true });
assert.equal(matchesKeyBinding(event, 'Ctrl + W', false), true);
assert.equal(matchesKeyBinding(event, 'Ctrl + ,', false), false);
assert.equal(keyEventToString(event, false), 'Ctrl + W');
});
test('shortcut matching respects non-ASCII Latin layout characters', () => {
const event = keyboardEvent('ß', 'Minus', { ctrlKey: true });
assert.equal(matchesKeyBinding(event, 'Ctrl + ß', false), true);
assert.equal(matchesKeyBinding(event, 'Ctrl + -', false), false);
assert.equal(keyEventToString(event, false), 'Ctrl + ß');
});
test('shortcut matching respects punctuation characters from non-QWERTY layouts', () => {
const event = keyboardEvent(',', 'KeyW', { ctrlKey: true });
assert.equal(matchesKeyBinding(event, 'Ctrl + ,', false), true);
assert.equal(matchesKeyBinding(event, 'Ctrl + W', false), false);
assert.equal(keyEventToString(event, false), 'Ctrl + ,');
});
test('shortcut matching keeps physical digit ranges layout-independent', () => {
const event = keyboardEvent('&', 'Digit1', { ctrlKey: true });
assert.equal(matchesKeyBinding(event, 'Ctrl + [1...9]', false), true);
assert.equal(keyEventToString(event, false), 'Ctrl + &');
});
test('shortcut matching preserves shifted number-row symbols', () => {
const event = keyboardEvent('!', 'Digit1', { ctrlKey: true, shiftKey: true });
assert.equal(matchesKeyBinding(event, 'Ctrl + Shift + !', false), true);
assert.equal(matchesKeyBinding(event, 'Ctrl + Shift + 1', false), false);
assert.equal(keyEventToString(event, false), 'Ctrl + Shift + !');
});
test('shifted digit ranges still match via physical Digit code', () => {
const event = keyboardEvent('!', 'Digit1', { ctrlKey: true, shiftKey: true });
assert.equal(matchesKeyBinding(event, 'Ctrl + Shift + [1...9]', false), true);
assert.equal(tabShortcutDigitFromEvent(event), 1);
});
test('tab shortcut digit falls back to e.key when code is missing', () => {
assert.equal(tabShortcutDigitFromEvent({ key: '3', code: '' }), 3);
assert.equal(tabShortcutDigitFromEvent({ key: '!', code: '' }), null);
});
test('stored shortcut strings rebuild matching keyboard events', () => {
const event = keyStringToKeyboardEvent('Ctrl + 1');
assert.ok(event);
assert.equal(event.key, '1');
assert.equal(event.ctrlKey, true);
assert.equal(matchesKeyBinding(event, 'Ctrl + [1...9]', false), true);
const macEvent = keyStringToKeyboardEvent('⌘ + B');
assert.ok(macEvent);
assert.equal(macEvent.key, 'b');
assert.equal(macEvent.metaKey, true);
assert.equal(matchesKeyBinding(macEvent, '⌘ + B', true), true);
});

8
domain/models.ts Normal file
View File

@@ -0,0 +1,8 @@
export * from './models/connection';
export * from './models/history';
export * from './models/keyBindings';
export * from './models/portForwarding';
export * from './models/sftp';
export * from './models/terminal';
export * from './models/workspace';
export * from './pluginConnection';

457
domain/models/connection.ts Normal file
View File

@@ -0,0 +1,457 @@
import type { SftpFilenameEncoding } from './sftp';
import type { KeywordHighlightRule } from './terminal';
// Proxy configuration for SSH connections
type ProxyType = 'http' | 'socks5' | 'command';
// UI locale identifier, stored in settings and used for i18n (e.g., "en", "zh-CN").
export type UILanguage = string;
export interface ProxyConfig {
type: ProxyType;
host: string;
port: number;
command?: string;
identityId?: string;
username?: string;
password?: string;
}
export interface ProxyProfile {
id: string;
label: string;
config: ProxyConfig;
createdAt: number;
updatedAt?: number;
order?: number;
}
// Host chain configuration for jump host / bastion connections
export interface HostChainConfig {
hostIds: string[]; // Array of host IDs in order (first = closest to client)
}
export type MultiLineRunMode = 'lineDelay' | 'paste';
// Per-host SSH algorithm override lists (advanced). Each property, when
// present and non-empty, fully replaces the offered list for that category.
// Category names mirror ssh2's `algorithms` shape (note: `compress`, not
// `compression`). Empty arrays or missing properties keep the default.
export interface HostAlgorithmOverrides {
kex?: string[];
cipher?: string[];
hmac?: string[];
serverHostKey?: string[];
compress?: string[];
}
// Environment variable for SSH session
export interface EnvVar {
name: string;
value: string;
}
// Protocol type for connections
export type BuiltInHostProtocol = 'ssh' | 'telnet' | 'mosh' | 'et' | 'local' | 'serial';
export type PluginHostProtocol = `plugin:${string}`;
export type HostProtocol = BuiltInHostProtocol | PluginHostProtocol;
export type PluginConfigurationValue =
| null
| boolean
| number
| string
| PluginConfigurationValue[]
| { [key: string]: PluginConfigurationValue };
export interface PluginConnectionConfig {
/** Exact namespaced connection Provider contribution ID. */
providerId: string;
/** Opaque, schema-validated Provider configuration retained if the plugin is absent. */
configuration: PluginConfigurationValue;
authenticationProviderId?: string;
/** Host-owned opaque credential reference; never plaintext. */
credentialId?: string;
}
export type HostIconMode = 'auto' | 'custom';
export type HostIconColorMode = 'auto' | 'manual';
export type HostIconId =
| 'server'
| 'terminal'
| 'database'
| 'cloud'
| 'router'
| 'shield'
| 'code'
| 'box'
| 'globe'
| 'cpu'
| 'hard-drive'
| 'network'
| 'wifi'
| 'lock'
| 'key'
| 'monitor'
| 'container'
| 'activity'
| 'zap'
| 'server-cog';
export type HostIconColorId =
| 'blue'
| 'green'
| 'red'
| 'amber'
| 'purple'
| 'cyan'
| 'orange'
| 'slate'
| 'violet'
| 'pink'
| 'rose'
| 'lime'
| 'teal'
| 'sky'
| 'indigo'
| 'zinc';
// Serial port configuration
export type SerialParity = 'none' | 'even' | 'odd' | 'mark' | 'space';
export type SerialFlowControl = 'none' | 'xon/xoff' | 'rts/cts';
export interface SerialConfig {
path: string; // Serial port path (e.g., /dev/ttyUSB0, COM1)
baudRate: number; // Baud rate (e.g., 9600, 115200)
dataBits?: 5 | 6 | 7 | 8; // Data bits (default: 8)
stopBits?: 1 | 1.5 | 2; // Stop bits (default: 1)
parity?: SerialParity; // Parity (default: 'none')
flowControl?: SerialFlowControl; // Flow control (default: 'none')
localEcho?: boolean; // Force local echo (default: false, rely on remote echo)
lineMode?: boolean; // Line mode - buffer input and send on Enter (default: false)
// Store the default explicitly so an open/restored session keeps its launch-time behavior.
backspaceBehavior?: 'default' | 'ctrl-h';
}
// Per-protocol configuration
interface ProtocolConfig {
protocol: HostProtocol;
port: number;
enabled: boolean;
// Mosh-specific
moshServerPath?: string;
// EternalTerminal-specific
etPort?: number;
// Protocol-specific theme override
theme?: string;
}
export interface SftpBookmark {
id: string;
path: string;
label: string;
global?: boolean;
}
export type HostAuthMethod = 'auto' | 'password' | 'key' | 'certificate';
export type HostOperatingSystem = 'linux' | 'windows' | 'macos' | 'freebsd' | 'unknown';
export type HostOsSelection = 'auto' | HostOperatingSystem;
export interface Host {
id: string;
label: string;
hostname: string;
port?: number;
username: string;
// Optional reference to a reusable identity (username + auth) stored in Keychain.
identityId?: string;
group?: string;
tags: string[];
// Legacy compatibility value; use resolveHostOs for runtime decisions.
os: 'linux' | 'windows' | 'macos';
// Absent on old records: preserve Windows/macOS, treat old Linux defaults as auto.
osOverride?: HostOsSelection;
// Device type: 'general' for standard servers, 'network' for switches/routers/firewalls.
// Network devices use raw command execution (no shell wrapping) for AI agent compatibility.
deviceType?: 'general' | 'network';
identityFileId?: string; // Reference to SSHKey
protocol?: HostProtocol; // Default/primary protocol, including namespaced plugin protocols
pluginConnection?: PluginConnectionConfig;
// Runtime marker for in-memory-only hosts (e.g. password deep links).
// Ephemeral hosts are never persisted to the vault or session restore.
ephemeral?: boolean;
// Runtime hint for deep-link launches that target file transfer (e.g.
// JumpServer sftp payloads): auto-open the SFTP side panel on connect.
autoOpenSftpPanel?: boolean;
password?: string;
savePassword?: boolean; // Whether to save the password (default: true)
authMethod?: HostAuthMethod;
// Version 1 distinguishes the explicit per-host login choices from the
// legacy "password" default, which did not mean password-only.
authPolicyVersion?: 1;
// Prefer keyboard-interactive before the password method for MFA/PAM hosts.
requiresMfa?: boolean;
// Use the local SSH agent for login. This is separate from agentForwarding,
// which exposes the local agent to the remote host after login.
useSshAgent?: boolean;
// OpenSSH config metadata used for agent-backed authentication.
identityAgent?: string;
identitiesOnly?: boolean;
addKeysToAgent?: string;
useKeychain?: boolean;
agentForwarding?: boolean;
x11Forwarding?: boolean;
createdAt?: number; // Timestamp when host was created
startupCommand?: string;
startupCommandRunMode?: MultiLineRunMode;
/** Script id (kind=script) to run automatically after connect. */
loginScriptId?: string;
/** Ordered onConnect script IDs for this host (canonical run order). */
connectScriptIds?: string[];
/** Output regex triggers that launch scripts on terminal output. */
outputTriggers?: HostOutputTrigger[];
hostChaining?: string; // Deprecated: use hostChain instead
proxy?: string; // Deprecated: use proxyConfig instead
proxyProfileId?: string; // Reference to reusable proxy profile
proxyConfig?: ProxyConfig; // New structured proxy configuration
hostChain?: HostChainConfig; // New structured host chain configuration
envVars?: string; // Deprecated: use environmentVariables instead
environmentVariables?: EnvVar[]; // Structured environment variables
charset?: string;
moshEnabled?: boolean;
moshServerPath?: string; // Custom mosh-server path (e.g., /usr/local/bin/mosh-server)
etEnabled?: boolean;
etPort?: number; // EternalTerminal server port (default: 2022)
theme?: string;
themeOverride?: boolean; // Explicitly override the global terminal theme for this host
fontFamily?: string; // Terminal font family for this host
fontFamilyOverride?: boolean; // Explicitly override the global terminal font family for this host
fontSize?: number; // Terminal font size for this host (pt)
fontSizeOverride?: boolean; // Explicitly override the global terminal font size for this host
fontWeight?: number; // Terminal font weight for this host (100-900)
fontWeightOverride?: boolean; // Explicitly override the global terminal font weight for this host
distro?: string; // detected distro id (e.g., ubuntu, debian)
distroMode?: 'auto' | 'manual'; // whether distro icon comes from detection or manual override
manualDistro?: string; // manually selected distro id when distroMode='manual'
iconMode?: HostIconMode; // Optional host icon mode. Missing/auto preserves distro detection.
iconId?: HostIconId; // Curated icon override used when iconMode='custom'
iconColorMode?: HostIconColorMode; // Whether icon color follows the icon default or a manual override
iconColor?: HostIconColorId; // Palette color used when iconColorMode='manual'
iconColorCustom?: string; // Custom hex color used when iconColorMode='manual'
// Multi-protocol support
protocols?: ProtocolConfig[]; // Multiple protocol configurations
telnetPort?: number; // Telnet-specific port (for quick access)
telnetEnabled?: boolean; // Is Telnet enabled for this host
telnetIdentityId?: string; // Reference to a Telnet-specific reusable identity
telnetUsername?: string; // Telnet-specific username
telnetPassword?: string; // Telnet-specific password
// Serial-specific configuration (for protocol='serial' hosts)
serialConfig?: SerialConfig;
// SFTP specific configuration
sftpSudo?: boolean; // Use sudo for SFTP operations (requires password)
// Remote file browser protocol: Auto tries SFTP then falls back to SCP-mode
// (shell browse + scp -t/-f transfers) when the SFTP subsystem is unavailable.
sftpFileProtocol?: 'auto' | 'sftp' | 'scp';
sftpEncoding?: SftpFilenameEncoding; // Filename encoding for SFTP operations
sftpBookmarks?: SftpBookmark[]; // Bookmarked SFTP paths for quick navigation
sftpFollowTerminalCwd?: boolean; // Overrides global SFTP follow-terminal-directory setting
// Managed source: if this host is managed by an external file (e.g., ~/.ssh/config)
managedSourceId?: string; // Reference to ManagedSource.id
// Host-level keyword highlighting (overrides/extends global settings)
keywordHighlightRules?: KeywordHighlightRule[];
keywordHighlightEnabled?: boolean;
// Legacy SSH algorithm support for older network equipment (switches, routers)
legacyAlgorithms?: boolean;
// Drop every ecdsa-sha2-* from the offered host-key list. Some old Huawei
// VRP / Cisco IOS stacks negotiate ECDSA but produce signatures ssh2's
// strict RFC verifier rejects ("signature verification failed"). Forcing
// RSA / DSA / Ed25519 fallback restores compatibility — see #1027.
skipEcdsaHostKey?: boolean;
// Per-host SSH algorithm overrides (advanced). When a category's array is
// non-empty, it fully replaces the offered list for that category. Use
// sparingly — incorrect values make the host unreachable.
algorithms?: HostAlgorithmOverrides;
// Per-host SSH keepalive override. When `keepaliveOverride === true`, the
// host uses its own `keepaliveInterval` / `keepaliveCountMax` instead of
// inheriting the global TerminalSettings values. Lets a user keep an
// aggressive cloud-friendly keepalive globally while disabling it for a
// specific router / embedded device whose SSH stack doesn't reply to
// OpenSSH keepalive global requests (issue #581 / #939).
keepaliveInterval?: number; // Seconds; 0 = disabled
keepaliveCountMax?: number; // Unanswered keepalives before declaring dead
keepaliveOverride?: boolean;
// Per-host SSH connection timeouts. Missing values retain Netcatty defaults.
sshTcpConnectTimeoutSeconds?: number;
sshAuthReadyTimeoutSeconds?: number;
// Show local timestamps for this host beside terminal output rows.
// Kept per-host because timestamp visibility is usually a host/workflow preference.
showLineTimestamps?: boolean;
// What the Backspace key sends: undefined = xterm default (no interception), 'ctrl-h' = ^H (0x08)
backspaceBehavior?: 'ctrl-h';
// When true, tab titles stay on the connection label instead of following the
// shell-reported window title (OSC 0/2). Useful when many hosts share one
// bastion profile name.
disableDynamicTabTitle?: boolean;
// Local SSH key file paths (from SSH config IdentityFile or user-added)
// Resolved at connection time — the app reads the file content when connecting.
identityFilePaths?: string[];
// Pin host to top of All hosts view for quick access
pinned?: boolean;
// Timestamp of last successful connection, used for Recently Connected section
lastConnectedAt?: number;
// Per-session shell override for local terminals (from shell discovery)
localShell?: string;
localShellArgs?: string[];
localShellName?: string;
localShellIcon?: string;
localStartDir?: string;
/** User-authored Markdown notes (project, hardware, region, etc.) */
notes?: string;
order?: number;
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
type KeySource = 'generated' | 'imported' | 'reference';
export type KeyCategory = 'key' | 'certificate' | 'identity';
type IdentityAuthMethod = 'password' | 'key' | 'certificate';
export interface SSHKey {
id: string;
label: string;
type: KeyType;
keySize?: number; // RSA: 4096/2048/1024, ECDSA: 521/384/256
privateKey: string;
publicKey?: string;
certificate?: string;
passphrase?: string; // encrypted or stored securely
savePassphrase?: boolean;
source: KeySource;
category: KeyCategory;
created: number;
filePath?: string;
order?: number;
}
// Identity combines username with authentication method
export interface Identity {
id: string;
label: string;
username: string;
authMethod: IdentityAuthMethod;
password?: string; // For password auth
keyId?: string; // Reference to SSHKey for key/certificate auth
created: number;
order?: number;
}
export type SnippetKind = 'snippet' | 'script';
export type SnippetMultiLineRunMode = MultiLineRunMode;
export type ScriptLanguage = 'javascript' | 'python';
export type ScriptTrigger = 'manual' | 'onConnect' | 'onOutput';
export interface Snippet {
id: string;
label: string;
command: string; // Multi-line script or automation script source
tags?: string[];
package?: string; // package path
targets?: string[]; // host ids
/** Group paths resolved against the latest host inventory when the snippet runs. */
targetGroups?: string[];
/** When true, script/snippet applies to every connectable host (no per-host picker). */
targetsAllHosts?: boolean;
shortkey?: string; // Keyboard shortcut to send this snippet in terminal (e.g., "F1", "Ctrl + F1")
noAutoRun?: boolean; // If true, paste command without executing (no trailing Enter)
multiLineRunMode?: SnippetMultiLineRunMode; // Multi-line auto-run behavior; default is paste.
order?: number;
/** Default 'snippet' — static text paste. 'script' runs via nct automation engine. */
kind?: SnippetKind;
language?: ScriptLanguage;
description?: string;
trigger?: ScriptTrigger;
/** Regex pattern when trigger is 'onOutput'. */
triggerPattern?: string;
}
export interface HostOutputTrigger {
id: string;
pattern: string;
scriptId: string;
}
export interface VaultNote {
id: string;
title: string;
content: string;
group?: string;
tags?: string[];
linkedHostIds?: string[];
createdAt: number;
updatedAt: number;
order?: number;
isPinned?: boolean;
}
export interface ChatMessage {
role: 'user' | 'model';
text: string;
}
export interface GroupNode {
name: string;
path: string;
children: Record<string, GroupNode>;
hosts: Host[];
/** Pre-computed total host count including all descendants. Set during tree construction. */
totalHostCount?: number;
}
/** Default configuration for a group. Hosts in this group inherit these values when not explicitly set. */
export interface GroupConfig {
path: string;
order?: number;
username?: string;
password?: string;
savePassword?: boolean;
authMethod?: HostAuthMethod;
identityId?: string;
identityFileId?: string;
identityFilePaths?: string[];
port?: number;
protocol?: 'ssh' | 'telnet';
deviceType?: 'general' | 'network';
agentForwarding?: boolean;
proxyProfileId?: string;
proxyConfig?: ProxyConfig;
hostChain?: HostChainConfig;
startupCommand?: string;
startupCommandRunMode?: MultiLineRunMode;
loginScriptId?: string;
legacyAlgorithms?: boolean;
skipEcdsaHostKey?: boolean;
algorithms?: HostAlgorithmOverrides;
environmentVariables?: EnvVar[];
charset?: string;
moshEnabled?: boolean;
moshServerPath?: string;
etEnabled?: boolean;
etPort?: number;
telnetEnabled?: boolean;
telnetPort?: number;
telnetIdentityId?: string;
telnetUsername?: string;
telnetPassword?: string;
theme?: string;
themeOverride?: boolean;
fontFamily?: string;
fontFamilyOverride?: boolean;
fontSize?: number;
fontSizeOverride?: boolean;
fontWeight?: number;
fontWeightOverride?: boolean;
backspaceBehavior?: 'ctrl-h';
}
export interface SyncConfig {
gistId: string;
githubToken: string;
gistToken?: string; // Alias for githubToken (deprecated, use githubToken)
lastSync?: number;
}

79
domain/models/history.ts Normal file
View File

@@ -0,0 +1,79 @@
// Known Hosts - discovered from system SSH known_hosts file
import type { HostIconColorId, HostIconColorMode, HostIconId, HostIconMode } from './connection';
export interface KnownHost {
id: string;
hostname: string; // The host pattern from known_hosts
port: number;
keyType: string; // ssh-rsa, ssh-ed25519, ecdsa-sha2-nistp256, etc.
publicKey: string; // The host's public key fingerprint or full key
fingerprint?: string; // SHA256 fingerprint without the SHA256: prefix
discoveredAt: number;
lastSeen?: number;
convertedToHostId?: string; // If converted to managed host
order?: number;
}
// Shell History - records real commands executed in terminal sessions
export interface ShellHistoryEntry {
id: string;
command: string;
hostId: string; // ID of the host where command was executed
hostLabel: string; // Label for display
sessionId: string;
timestamp: number;
}
// Remote Shell History - commands parsed from a remote host's own shell
// history file (~/.bash_history, ~/.zsh_history, fish_history), read on
// demand through the SSH/ET exec channel. Distinct from ShellHistoryEntry,
// which records commands typed inside Netcatty's own terminal sessions.
export type RemoteHistorySource = 'bash' | 'zsh' | 'fish';
export interface RemoteHistoryEntry {
id: string;
command: string;
source: RemoteHistorySource;
timestamp?: number; // Only set when the history file carries one (zsh EXTENDED_HISTORY, fish `when`)
}
// Connection Log - records connection history
export interface ConnectionLog {
id: string;
sessionId?: string; // Terminal session ID for matching during capture
hostId: string; // Host ID (can be empty for local terminal)
hostLabel: string; // Display label (e.g., 'Local Terminal' or host label)
hostname: string; // Target hostname or 'localhost'
username: string; // SSH username or system username
protocol: 'ssh' | 'telnet' | 'local' | 'mosh' | 'et' | 'serial';
hostOs?: 'linux' | 'windows' | 'macos'; // Snapshot of the connected host OS for log icons
hostDistro?: string; // Snapshot of the connected host distro/vendor icon id
hostIconMode?: HostIconMode; // Snapshot of the host icon mode for log icons
hostIconId?: HostIconId; // Snapshot of the built-in host icon id
hostIconColorMode?: HostIconColorMode; // Snapshot of the host icon color source
hostIconColor?: HostIconColorId; // Snapshot of the host icon color id
hostIconColorCustom?: string; // Snapshot of the custom host icon color
startTime: number; // Connection start timestamp
endTime?: number; // Connection end timestamp (undefined if still active)
localUsername: string; // System username of the local user
localHostname: string; // Local machine hostname
saved: boolean; // Whether this log is bookmarked/saved
terminalData?: string; // Captured terminal output data for replay
themeId?: string; // Terminal theme ID for this log view
fontSize?: number; // Terminal font size for this log view
}
// Session Logs Settings - for auto-saving terminal logs to local filesystem
export type SessionLogFormat = 'txt' | 'raw' | 'html';
// Managed Source - external file that manages a group of hosts (e.g., ~/.ssh/config)
type ManagedSourceType = 'ssh_config';
export interface ManagedSource {
id: string;
type: ManagedSourceType;
filePath: string;
groupName: string;
lastSyncedAt: number;
lastFileHash?: string;
}

View File

@@ -0,0 +1,290 @@
// Keyboard Shortcuts / Hotkeys
export type HotkeyScheme = 'disabled' | 'mac' | 'pc';
export interface KeyBinding {
id: string;
action: string;
label: string;
mac: string; // e.g., '⌘+1', '⌘+⌥+arrows'
pc: string; // e.g., 'Ctrl+1', 'Ctrl+Alt+arrows'
category: 'tabs' | 'terminal' | 'navigation' | 'app' | 'sftp';
}
// User's custom key bindings - only stores overrides from defaults
export type CustomKeyBindings = Record<string, { mac?: string; pc?: string }>;
// Parse a key string like "⌘ + Shift + K" or "Ctrl + Alt + T" into normalized form
export const parseKeyCombo = (keyStr: string): { modifiers: string[]; key: string } | null => {
if (!keyStr || keyStr === 'Disabled') return null;
const parts = keyStr.split('+').map(p => p.trim());
const key = parts.pop() || '';
return { modifiers: parts, key };
};
const KEY_STRING_TO_EVENT_KEY: Record<string, string> = {
Space: ' ',
'↑': 'ArrowUp',
'↓': 'ArrowDown',
'←': 'ArrowLeft',
'→': 'ArrowRight',
Esc: 'Escape',
'⌫': 'Backspace',
Del: 'Delete',
'↵': 'Enter',
'⇥': 'Tab',
};
/** Rebuild a keydown-like event from a stored shortcut string for matching. */
export const keyStringToKeyboardEvent = (keyString: string): KeyboardEvent | null => {
const parsed = parseKeyCombo(keyString);
if (!parsed) return null;
const modifiers = new Set(parsed.modifiers);
const mappedKey = KEY_STRING_TO_EVENT_KEY[parsed.key];
const key = mappedKey ?? (parsed.key.length === 1 ? parsed.key.toLowerCase() : parsed.key);
return {
key,
code: '',
metaKey: modifiers.has('⌘') || modifiers.has('Win'),
ctrlKey: modifiers.has('⌃') || modifiers.has('Ctrl'),
altKey: modifiers.has('⌥') || modifiers.has('Alt'),
shiftKey: modifiers.has('Shift'),
} as KeyboardEvent;
};
const PHYSICAL_SHORTCUT_KEY_NAMES: Record<string, string> = {
Backquote: '`',
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Semicolon: ';',
Quote: "'",
Comma: ',',
Period: '.',
Slash: '/',
};
const physicalShortcutKeyName = (e: Pick<KeyboardEvent, 'code'>): string | null => {
const code = e.code;
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
return PHYSICAL_SHORTCUT_KEY_NAMES[code] ?? null;
};
/**
* Resolve the 1-9 tab shortcut digit from a key event.
* Prefer the physical Digit code so Shift+[1...9] still works when e.key is "!" etc.
*/
export const tabShortcutDigitFromEvent = (
e: Pick<KeyboardEvent, 'key' | 'code'>,
): number | null => {
const key = physicalShortcutKeyName(e) ?? e.key;
if (!/^[1-9]$/.test(key)) return null;
return Number.parseInt(key, 10);
};
const LATIN_SHORTCUT_KEY_PATTERN = /^\p{Script=Latin}$/u;
const ASCII_SHORTCUT_KEY_PATTERN = /^[A-Za-z]$/;
const PRINTABLE_NON_LETTER_SHORTCUT_KEY_PATTERN = /^[^\p{Letter}\p{Number}\s]$/u;
const shortcutEventKey = (e: KeyboardEvent): string => {
const physicalKey = physicalShortcutKeyName(e);
if (
LATIN_SHORTCUT_KEY_PATTERN.test(e.key) ||
PRINTABLE_NON_LETTER_SHORTCUT_KEY_PATTERN.test(e.key)
) {
return e.key;
}
return physicalKey ?? e.key;
};
// Convert keyboard event to a key string
export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
const parts: string[] = [];
if (isMac) {
if (e.metaKey) parts.push('⌘');
if (e.ctrlKey) parts.push('⌃');
if (e.altKey) parts.push('⌥');
if (e.shiftKey) parts.push('Shift');
} else {
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
if (e.metaKey) parts.push('Win');
}
// Get the key name
let keyName = shortcutEventKey(e);
// Normalize special keys
if (keyName === ' ') keyName = 'Space';
else if (keyName === 'ArrowUp') keyName = '↑';
else if (keyName === 'ArrowDown') keyName = '↓';
else if (keyName === 'ArrowLeft') keyName = '←';
else if (keyName === 'ArrowRight') keyName = '→';
else if (keyName === 'Escape') keyName = 'Esc';
else if (keyName === 'Backspace') keyName = '⌫';
else if (keyName === 'Delete') keyName = 'Del';
else if (keyName === 'Enter') keyName = '↵';
else if (keyName === 'Tab') keyName = '⇥';
else if (ASCII_SHORTCUT_KEY_PATTERN.test(keyName)) keyName = keyName.toUpperCase();
// Don't include modifier keys themselves
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) {
return parts.join(' + ');
}
parts.push(keyName);
return parts.join(' + ');
};
// Check if a keyboard event matches a key binding string
export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boolean): boolean => {
if (!keyStr || keyStr === 'Disabled') return false;
// Handle range patterns like "[1...9]"
if (keyStr.includes('[1...9]')) {
const basePattern = keyStr.replace('[1...9]', '');
const digit = tabShortcutDigitFromEvent(e);
if (digit === null) return false;
const key = String(digit);
// Check modifiers match the base pattern
const testStr = basePattern + key;
const physicalDigitEvent = {
key,
code: e.code,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
altKey: e.altKey,
shiftKey: e.shiftKey,
} as KeyboardEvent;
return matchesKeyBinding(physicalDigitEvent, testStr.trim(), isMac);
}
// Handle arrow key patterns like "arrows"
if (keyStr.includes('arrows')) {
const basePattern = keyStr.replace('arrows', '');
const key = e.key;
// Check if it's an arrow key
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) return false;
// Map arrow key to symbol for matching
const arrowSymbol = key === 'ArrowUp' ? '↑'
: key === 'ArrowDown' ? '↓'
: key === 'ArrowLeft' ? '←'
: '→';
// Check modifiers match the base pattern
const testStr = basePattern + arrowSymbol;
return matchesKeyBinding(e, testStr.trim(), isMac);
}
const parsed = parseKeyCombo(keyStr);
if (!parsed) return false;
const { modifiers, key } = parsed;
const hasMacModifiers = modifiers.some((modifier) => ['⌘', '⌃', '⌥'].includes(modifier));
const hasPcModifiers = modifiers.some((modifier) => ['Ctrl', 'Alt', 'Win'].includes(modifier));
if ((!isMac && hasMacModifiers) || (isMac && hasPcModifiers)) {
return false;
}
// Check modifiers
if (isMac) {
const needMeta = modifiers.includes('⌘');
const needCtrl = modifiers.includes('⌃');
const needAlt = modifiers.includes('⌥');
const needShift = modifiers.includes('Shift');
if (e.metaKey !== needMeta) return false;
if (e.ctrlKey !== needCtrl) return false;
if (e.altKey !== needAlt) return false;
if (e.shiftKey !== needShift) return false;
} else {
const needCtrl = modifiers.includes('Ctrl');
const needAlt = modifiers.includes('Alt');
const needShift = modifiers.includes('Shift');
const needMeta = modifiers.includes('Win');
if (e.ctrlKey !== needCtrl) return false;
if (e.altKey !== needAlt) return false;
if (e.shiftKey !== needShift) return false;
if (e.metaKey !== needMeta) return false;
}
const normalizeKey = (rawKey: string): string => {
let normalizedKey = rawKey;
if (normalizedKey === ' ') normalizedKey = 'Space';
else if (normalizedKey === 'ArrowUp') normalizedKey = '↑';
else if (normalizedKey === 'ArrowDown') normalizedKey = '↓';
else if (normalizedKey === 'ArrowLeft') normalizedKey = '←';
else if (normalizedKey === 'ArrowRight') normalizedKey = '→';
else if (normalizedKey === 'Escape') normalizedKey = 'Esc';
else if (normalizedKey === 'Backspace') normalizedKey = '⌫';
else if (normalizedKey === 'Delete') normalizedKey = 'Del';
else if (normalizedKey === '[') normalizedKey = '[';
else if (normalizedKey === ']') normalizedKey = ']';
else if (normalizedKey === 'Del') normalizedKey = 'Del';
return normalizedKey;
};
const eventKey = normalizeKey(shortcutEventKey(e));
const parsedKey = normalizeKey(key);
return eventKey.toLowerCase() === parsedKey.toLowerCase();
};
export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
// Tab Management
{ id: 'switch-tab-1-9', action: 'switchToTab', label: 'Switch to Tab [1...9]', mac: '⌘ + [1...9]', pc: 'Ctrl + [1...9]', category: 'tabs' },
{ id: 'next-tab', action: 'nextTab', label: 'Next Tab', mac: '⌘ + Shift + ]', pc: 'Ctrl + Tab', category: 'tabs' },
{ id: 'prev-tab', action: 'prevTab', label: 'Previous Tab', mac: '⌘ + Shift + [', pc: 'Ctrl + Shift + Tab', category: 'tabs' },
{ id: 'close-tab', action: 'closeTab', label: 'Close Tab', mac: '⌘ + W', pc: 'Ctrl + W', category: 'tabs' },
{ id: 'close-session', action: 'closeSession', label: 'Close Session Pane', mac: '⌘ + Shift + W', pc: 'Ctrl + Shift + W', category: 'tabs' },
{ id: 'new-tab', action: 'newTab', label: 'New Local Tab', mac: '⌘ + T', pc: 'Ctrl + T', category: 'tabs' },
// Terminal Operations
{ id: 'copy', action: 'copy', label: 'Copy from Terminal', mac: '⌘ + C', pc: 'Ctrl + Shift + C', category: 'terminal' },
{ id: 'paste', action: 'paste', label: 'Paste to Terminal', mac: '⌘ + V', pc: 'Ctrl + Shift + V', category: 'terminal' },
{ id: 'paste-selection', action: 'pasteSelection', label: 'Paste Selection to Terminal', mac: '⌘ + Shift + X', pc: 'Ctrl + Shift + X', category: 'terminal' },
{ id: 'select-all', action: 'selectAll', label: 'Select All in Terminal', mac: '⌘ + A', pc: 'Ctrl + Shift + A', category: 'terminal' },
{ id: 'clear-buffer', action: 'clearBuffer', label: 'Clear Terminal Buffer', mac: '⌘ + ⌃ + K', pc: 'Ctrl + Shift + K', category: 'terminal' },
{ id: 'search-terminal', action: 'searchTerminal', label: 'Open Terminal Search', mac: '⌘ + F', pc: 'Ctrl + F', category: 'terminal' },
{ id: 'increase-terminal-font-size', action: 'increaseTerminalFontSize', label: 'Increase Terminal Font Size', mac: '⌘ + =', pc: 'Ctrl + =', category: 'terminal' },
{ id: 'decrease-terminal-font-size', action: 'decreaseTerminalFontSize', label: 'Decrease Terminal Font Size', mac: '⌘ + -', pc: 'Ctrl + -', category: 'terminal' },
{ id: 'reset-terminal-font-size', action: 'resetTerminalFontSize', label: 'Reset Terminal Font Size', mac: '⌘ + 0', pc: 'Ctrl + 0', category: 'terminal' },
// Navigation / Split View
{ id: 'move-focus', action: 'moveFocus', label: 'Move focus between Split View panes', mac: '⌘ + ⌥ + arrows', pc: 'Ctrl + Alt + arrows', category: 'navigation' },
{ id: 'split-horizontal', action: 'splitHorizontal', label: 'Split Horizontal', mac: '⌘ + D', pc: 'Ctrl + Shift + D', category: 'navigation' },
{ id: 'split-vertical', action: 'splitVertical', label: 'Split Vertical', mac: '⌘ + Shift + D', pc: 'Ctrl + Shift + E', category: 'navigation' },
{ id: 'toggle-pane-zoom', action: 'togglePaneZoom', label: 'Toggle Pane Zoom', mac: '⌥ + M', pc: 'Alt + M', category: 'navigation' },
// App Features
{ id: 'open-hosts', action: 'openHosts', label: 'Open Hosts Page', mac: 'Disabled', pc: 'Disabled', category: 'app' },
{ id: 'open-local', action: 'openLocal', label: 'Open Local Terminal', mac: '⌘ + L', pc: 'Ctrl + L', category: 'app' },
{ id: 'open-sftp', action: 'openSftp', label: 'Open SFTP', mac: '⌘ + Shift + O', pc: 'Ctrl + Shift + O', category: 'app' },
{ id: 'port-forwarding', action: 'portForwarding', label: 'Open Port Forwarding', mac: '⌘ + P', pc: 'Ctrl + P', category: 'app' },
{ id: 'command-palette', action: 'commandPalette', label: 'Open Command Palette', mac: '⌘ + K', pc: 'Ctrl + K', category: 'app' },
{ id: 'quick-switch', action: 'quickSwitch', label: 'Quick Switch', mac: '⌘ + J', pc: 'Ctrl + J', category: 'app' },
{ id: 'new-workspace', action: 'newWorkspace', label: 'New Workspace', mac: '⌘ + Shift + J', pc: 'Ctrl + Shift + J', category: 'app' },
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
{ id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' },
{ id: 'toggle-side-panel', action: 'toggleSidePanel', label: 'Toggle Side Panel', mac: '⌘ + \\', pc: 'Ctrl + \\', category: 'app' },
{ id: 'open-settings', action: 'openSettings', label: 'Open Settings', mac: '⌘ + ,', pc: 'Ctrl + ,', category: 'app' },
// SFTP Operations
{ id: 'sftp-copy', action: 'sftpCopy', label: 'Copy Files', mac: '⌘ + C', pc: 'Ctrl + C', category: 'sftp' },
{ id: 'sftp-cut', action: 'sftpCut', label: 'Cut Files', mac: '⌘ + X', pc: 'Ctrl + X', category: 'sftp' },
{ id: 'sftp-paste', action: 'sftpPaste', label: 'Paste Files', mac: '⌘ + V', pc: 'Ctrl + V', category: 'sftp' },
{ id: 'sftp-select-all', action: 'sftpSelectAll', label: 'Select All Files', mac: '⌘ + A', pc: 'Ctrl + A', category: 'sftp' },
{ id: 'sftp-rename', action: 'sftpRename', label: 'Rename File', mac: 'F2', pc: 'F2', category: 'sftp' },
{ id: 'sftp-delete', action: 'sftpDelete', label: 'Delete Files', mac: '⌘ + ⌫', pc: 'Delete', category: 'sftp' },
{ id: 'sftp-refresh', action: 'sftpRefresh', label: 'Refresh', mac: '⌘ + R', pc: 'F5', category: 'sftp' },
{ id: 'sftp-new-folder', action: 'sftpNewFolder', label: 'New Folder', mac: '⌘ + Shift + N', pc: 'Ctrl + Shift + N', category: 'sftp' },
{ id: 'sftp-open', action: 'sftpOpen', label: 'Open File / Enter Directory', mac: 'Enter', pc: 'Enter', category: 'sftp' },
{ id: 'sftp-go-parent', action: 'sftpGoParent', label: 'Go to Parent Directory', mac: '⌫', pc: 'Backspace', category: 'sftp' },
{ id: 'sftp-navigate-to', action: 'sftpNavigateTo', label: 'Navigate to Selected Directory', mac: '⌘ + Enter', pc: 'Ctrl + Enter', category: 'sftp' },
];

View File

@@ -0,0 +1,39 @@
// Port Forwarding Types
export type PortForwardingType = 'local' | 'remote' | 'dynamic';
/**
* Display / projection status for a port-forwarding rule.
* `unknown` means the authoritative main-process snapshot could not be read;
* it must never be persisted to localStorage.
*/
export type PortForwardingStatus =
| 'inactive'
| 'connecting'
| 'active'
| 'error'
| 'unknown';
export interface PortForwardingRule {
id: string;
label: string;
order?: number;
type: PortForwardingType;
// Common fields
localPort: number;
bindAddress: string; // e.g., '127.0.0.1', '0.0.0.0'
// For local and remote forwarding
remoteHost?: string;
remotePort?: number;
// Host to tunnel through
hostId?: string;
// Auto-start: if true, this rule will automatically start when the app launches
autoStart?: boolean;
/**
* Runtime projection for the UI. Authoritative phase lives in the Electron
* main-process registry; this field is rebuilt from snapshots / events and
* must not be treated as durable configuration.
*/
status: PortForwardingStatus;
error?: string;
createdAt: number;
lastUsedAt?: number;
}

137
domain/models/sftp.ts Normal file
View File

@@ -0,0 +1,137 @@
// SFTP Types
export type SftpFilenameEncoding = 'auto' | 'utf-8' | 'gb18030';
export interface SftpFileEntry {
name: string;
type: 'file' | 'directory' | 'symlink';
size: number;
sizeFormatted: string;
lastModified: number;
lastModifiedFormatted: string;
permissions?: string;
owner?: string;
group?: string;
linkTarget?: 'file' | 'directory' | null; // For symlinks: the type of the target, or null if broken
hidden?: boolean; // Windows hidden attribute (only set for local Windows filesystem)
}
export interface SftpConnection {
id: string;
hostId: string;
hostLabel: string;
isLocal: boolean;
status: 'connecting' | 'connected' | 'disconnected' | 'error';
error?: string;
currentPath: string;
homeDir?: string;
/** True when this SFTP connection reuses an existing terminal SSH session */
reusedConnection?: boolean;
/** Terminal session whose confirmed SSH transport backs this connection. */
sourceSessionId?: string;
fileProtocol?: 'auto' | 'sftp' | 'scp';
}
export type TransferStatus =
| 'pending'
| 'queued'
| 'transferring'
| 'pausing'
| 'paused'
| 'attention'
| 'interrupted'
| 'completed'
| 'failed'
| 'cancelled';
export type TransferDirection = 'upload' | 'download' | 'remote-to-remote' | 'local-copy';
export type TransferOrigin = 'manual' | 'drag-drop' | 'editor-sync' | 'agent' | 'internal';
export type TransferPhase = 'scanning' | 'compressing' | 'uploading' | 'transferring' | 'extracting' | 'verifying';
export type TransferControlKind = 'stream' | 'compressed-upload';
export interface DirectoryResumeCheckpoint {
/** Version 1 used a full SHA-256 digest for every appended entry. Version 2
* keeps the SHA-256 compression state so adding another fixed-width identity
* is still cryptographically chained without re-hashing string wrappers. */
version: 1 | 2;
/** Traversal prefix whose source/target metadata is covered by manifestHash. */
coveredEntries: number;
/** Covered entries already completed and compacted out of the task array. */
completedEntries: number;
/** Fixed-size chained SHA-256 value of the covered traversal prefix. */
manifestHash: string;
}
export interface TransferTask {
id: string;
batchId?: string;
fileName: string;
originalFileName?: string;
sourcePath: string;
targetPath: string;
sourceConnectionId: string;
targetConnectionId: string;
targetHostId?: string;
/** Full endpoint key (hostId:hostname:port:protocol) for distinguishing
* same-hostId uploads with different session-time overrides. */
targetConnectionKey?: string;
direction: TransferDirection;
status: TransferStatus;
totalBytes: number;
transferredBytes: number;
speed: number; // bytes per second
error?: string;
startTime: number;
endTime?: number;
isDirectory: boolean;
progressMode?: 'bytes' | 'files';
childTasks?: string[]; // For directory transfers
parentTaskId?: string;
sourceLastModified?: number; // Cached from file list to avoid redundant stat
skipConflictCheck?: boolean; // Skip conflict check for replace operations
replaceExistingTarget?: boolean; // Delete the existing target before transferring
retryable?: boolean; // False for task types that cannot be safely replayed through generic retry
ownerId?: string;
sourceHostId?: string;
sourceHostLabel?: string;
targetHostLabel?: string;
origin?: TransferOrigin;
background?: boolean;
phase?: TransferPhase;
/** Selects the background job API used by the global transfer center. */
controlKind?: TransferControlKind;
/** Monotonic backend lifecycle version. Newer pause/resume truth wins over stale progress or panel snapshots. */
lifecycleEpoch?: number;
resumable?: boolean;
checkpointBytes?: number;
resumeStage?: 'direct' | 'download' | 'upload';
downloadCheckpointBytes?: number;
uploadCheckpointBytes?: number;
priority?: number;
updatedAt?: number;
pauseUnavailableReason?: string;
conflict?: FileConflict;
stagedTargetPath?: string;
sourceFingerprint?: string;
reconnectRequired?: boolean;
/** Stable position and identity used to compact completed directory children. */
directoryEntryIndex?: number;
directoryEntryIdentity?: string;
/** Fixed-size resume record stored only on a top-level directory task. */
directoryResumeCheckpoint?: DirectoryResumeCheckpoint;
}
export type FileConflictAction = 'stop' | 'skip' | 'replace' | 'duplicate' | 'merge';
export interface FileConflict {
transferId: string;
batchId?: string;
fileName: string;
sourcePath: string;
targetPath: string;
isDirectory: boolean;
existingType?: 'file' | 'directory' | 'symlink';
applyToAllCount?: number;
existingSize: number;
newSize: number;
existingModified: number;
newModified: number;
}

Some files were not shown because too many files have changed in this diff Show More