Files
NetMesh/infrastructure/ai/vaultAgentBridgeClient.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1482 lines
54 KiB
TypeScript

import { resolveHostOs } from '../../domain/host';
import type { GroupConfig, Host, Identity, KnownHost, ManagedSource, PortForwardingRule, ProxyProfile, Snippet, SSHKey, TerminalSettings, VaultNote } from '../../domain/models';
import type { RememberImportedKeyPassphraseResult } from '../../application/defaultKeyPassphrases';
import {
normalizeVaultNotes,
sanitizeNoteTitle,
sanitizeVaultNote,
} from '../../domain/notes';
import { getScriptApiReference } from '../../domain/scriptApiReference.ts';
import {
applyScriptTargetsPatch,
applySnippetCreateToVault,
applySnippetUpdateToVault,
buildSnippetFromAgentDraft,
applySnippetAgentPatch,
deleteSnippetFromVault,
filterScriptSnippets,
serializeScriptForAgentGet,
serializeScriptForAgentList,
serializeSnippetForAgentGet,
serializeSnippetForAgentList,
setHostConnectScriptIds,
summarizeConnectScriptsForHost,
} from '../../domain/snippetAgentOps.ts';
import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { applySnippetVariables, parseSnippetVariables } from '../../domain/snippetVariables';
import { getNextVaultOrder } from '../../domain/vaultOrder';
import { readVaultNote } from '../../domain/vaultNoteRead';
import {
runAutomationScript,
stopScriptRun,
pauseScriptRun,
resumeScriptRun,
waitForScriptRun,
} from '../../application/state/scriptAutomationCoordinator.ts';
import {
applyVaultHostDelete,
applyVaultHostCreates,
applyVaultHostUpdate,
buildVaultHostsFromDrafts,
parseVaultHostDraftsInput,
} from '../../domain/vaultHostCreate';
import {
applyVaultHostImport,
detectVaultImportFormat,
filterVaultImportKeyPassphrasesAgainstExisting,
importVaultHostsFromText,
mergeVaultImportIssues,
resolveVaultImportKeyPassphraseConflicts,
VAULT_IMPORT_FORMATS,
type VaultImportFormat,
} from '../../domain/vaultImport';
import { resolveHostAuth } from '../../domain/sshAuth';
import { netcattyBridge } from '../services/netcattyBridge';
import {
createPortForwardingRule,
duplicatePortForwardingRule,
hasPortForwardingConnectionChanged,
updatePortForwardingRule,
validatePortForwardingHost,
} from '../../domain/portForwardingAgentOps';
import { deleteGroup, upsertGroup } from '../../domain/vaultGroupAgentOps';
import {
remapSnippetTargetGroupPaths,
removeSnippetTargetGroupPaths,
} from '../../domain/hostGroupPathMutations';
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from '../../domain/vaultGroupMutation';
const SENSITIVE_HOST_KEYS = new Set([
'password',
'telnetPassword',
'privateKey',
'passphrase',
]);
/**
* Reserved chatSessionId the TCP bridge forces onto every authenticated
* external-MCP socket (see electron/bridges/mcpServerBridge.cjs and
* electron/cli/externalMcpDiscoveryPath.cjs). A missing chatSessionId is NOT
* a reliable "external MCP" signal — the stdio server always sends one, and
* the bridge overwrites it with this value for external-token sockets — so
* callers must compare against this exact constant instead.
*/
const EXTERNAL_MCP_CHAT_SESSION_ID = '__external_mcp__';
const VAULT_HOST_UPDATE_FIELDS = [
'label',
'name',
'hostname',
'host',
'ip',
'port',
'username',
'password',
'savePassword',
'keyPath',
'keypath',
'group',
'tags',
'notes',
'protocol',
'os',
'identityId',
'jumpHostIds',
'proxyProfileId',
'startupCommand',
'startupCommandRunMode',
'environmentVariables',
'moshEnabled',
'moshServerPath',
'etEnabled',
'etPort',
'serialConfig',
] as const;
export function sanitizeHostForAgent(host: Host): Record<string, unknown> {
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(host)) {
if (SENSITIVE_HOST_KEYS.has(key)) continue;
if (key === 'proxyConfig' && value && typeof value === 'object' && !Array.isArray(value)) {
const safeProxyConfig = { ...(value as Record<string, unknown>) };
delete safeProxyConfig.password;
sanitized[key] = safeProxyConfig;
continue;
}
sanitized[key] = value;
}
sanitized.os = resolveHostOs(host);
return sanitized;
}
function summarizeHostForList(host: Host) {
return {
id: host.id,
label: host.label,
hostname: host.hostname,
port: host.port,
username: host.username,
protocol: host.protocol,
group: host.group,
tags: host.tags,
os: resolveHostOs(host),
createdAt: host.createdAt,
connectScriptIds: host.connectScriptIds,
loginScriptId: host.loginScriptId,
};
}
function parseOptionalBoolean(value: unknown): boolean | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value === 'boolean') return value;
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes') return true;
if (normalized === 'false' || normalized === '0' || normalized === 'no') return false;
return undefined;
}
function resolveVaultImportFormat(raw: unknown): VaultImportFormat | 'auto' | { error: string } {
const format = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
if (!format) return { error: 'format is required.' };
if (format === 'auto') return 'auto';
if ((VAULT_IMPORT_FORMATS as readonly string[]).includes(format)) {
return format as VaultImportFormat;
}
return {
error: `Unsupported format "${format}". Use csv, putty, mobaxterm, securecrt, ssh_config, or auto.`,
};
}
export function sanitizePortForwardRuleForAgent(rule: PortForwardingRule): Record<string, unknown> {
return {
id: rule.id,
label: rule.label,
type: rule.type,
localPort: rule.localPort,
bindAddress: rule.bindAddress,
remoteHost: rule.remoteHost,
remotePort: rule.remotePort,
hostId: rule.hostId,
autoStart: rule.autoStart,
status: rule.status,
error: rule.error,
createdAt: rule.createdAt,
lastUsedAt: rule.lastUsedAt,
};
}
function summarizeVaultNoteForList(note: VaultNote) {
return {
id: note.id,
title: note.title,
group: note.group,
tags: note.tags,
linkedHostIds: note.linkedHostIds,
updatedAt: note.updatedAt,
contentLength: note.content.length,
};
}
function serializeVaultNoteForAgent(note: VaultNote) {
return {
id: note.id,
title: note.title,
content: note.content,
group: note.group,
tags: note.tags,
linkedHostIds: note.linkedHostIds,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
};
}
const SAFE_GROUP_CONFIG_KEYS = [
'path',
'order',
'username',
'authMethod',
'identityId',
'port',
'protocol',
'deviceType',
'agentForwarding',
'proxyProfileId',
'hostChain',
'startupCommandRunMode',
'loginScriptId',
'legacyAlgorithms',
'skipEcdsaHostKey',
'algorithms',
'charset',
'moshEnabled',
'moshServerPath',
'etEnabled',
'etPort',
'telnetEnabled',
'telnetPort',
'telnetIdentityId',
'telnetUsername',
'theme',
'themeOverride',
'fontFamily',
'fontFamilyOverride',
'fontSize',
'fontSizeOverride',
'fontWeight',
'fontWeightOverride',
'backspaceBehavior',
] as const satisfies readonly (keyof GroupConfig)[];
function sanitizeGroupConfigForAgent(config: GroupConfig): Record<string, unknown> {
const safe = Object.fromEntries(
SAFE_GROUP_CONFIG_KEYS
.filter((key) => Object.hasOwn(config, key))
.map((key) => [key, config[key]]),
);
return {
...safe,
...(config.proxyConfig ? {
proxyConfig: {
type: config.proxyConfig.type,
host: config.proxyConfig.host,
port: config.proxyConfig.port,
identityId: config.proxyConfig.identityId,
username: config.proxyConfig.username,
},
} : {}),
};
}
function parseSnippetVariableValues(
raw: unknown,
): Record<string, string> | { error: string } {
if (raw === undefined || raw === null) return {};
if (typeof raw === 'object' && !Array.isArray(raw)) {
return Object.fromEntries(
Object.entries(raw as Record<string, unknown>).map(([key, value]) => [key, String(value ?? '')]),
);
}
if (typeof raw !== 'string' || !raw.trim()) return {};
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return Object.fromEntries(
Object.entries(parsed as Record<string, unknown>).map(([key, value]) => [key, String(value ?? '')]),
);
}
return { error: 'variables must be a JSON object string.' };
} catch {
return { error: 'variables must be a JSON object string.' };
}
}
function parseOptionalStringArray(
value: unknown,
fieldName: string,
): string[] | undefined | { error: string } {
if (value === undefined || value === null) return undefined;
if (Array.isArray(value)) {
return value.map((entry) => String(entry).trim()).filter(Boolean);
}
if (typeof value !== 'string') {
return { error: `${fieldName} must be a string or array.` };
}
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!Array.isArray(parsed)) {
return { error: `${fieldName} must be a JSON array.` };
}
return parsed.map((entry) => String(entry).trim()).filter(Boolean);
} catch {
return { error: `${fieldName} must be valid JSON array.` };
}
}
return trimmed.split(',').map((entry) => entry.trim()).filter(Boolean);
}
function snippetDraftFromParams(params: Record<string, unknown>) {
const command = params.command ?? params.content;
return {
label: params.label,
command,
kind: params.kind,
tags: params.tags,
targets: params.targets,
targetGroups: params.targetGroups,
targetsAllHosts: params.targetsAllHosts,
package: params.package,
shortkey: params.shortkey,
noAutoRun: params.noAutoRun,
multiLineRunMode: params.multiLineRunMode,
language: params.language,
description: params.description,
trigger: params.trigger,
triggerPattern: params.triggerPattern,
};
}
async function executeSnippetOrScriptRun(
snippet: Snippet,
sessionId: string,
params: Record<string, unknown>,
sessionMeta?: { connected?: boolean; name?: string; hostname?: string; username?: string },
): Promise<Record<string, unknown>> {
if (isScriptSnippet(snippet)) {
const wait = parseOptionalBoolean(params.wait) ?? false;
try {
const { runId } = await runAutomationScript({
snippet,
sessionId,
sessionMeta,
});
if (!wait) {
return { ok: true, sessionId, snippetId: snippet.id, runId, kind: 'script' };
}
const run = await waitForScriptRun(runId);
return {
ok: true,
sessionId,
snippetId: snippet.id,
runId,
kind: 'script',
status: run.status,
error: run.error,
};
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
let variableValues: Record<string, string> = {};
const parsedVariables = parseSnippetVariableValues(params.variables);
if ('error' in parsedVariables) {
return { ok: false, error: parsedVariables.error };
}
variableValues = parsedVariables;
const defs = parseSnippetVariables(snippet.command);
for (const def of defs) {
if (variableValues[def.name] === undefined && def.defaultValue !== undefined) {
variableValues[def.name] = def.defaultValue;
}
}
for (const def of defs) {
if ((variableValues[def.name] ?? '').trim() === '' && def.defaultValue === undefined) {
return { ok: false, error: `Missing snippet variable "${def.name}".` };
}
}
const applied = applySnippetVariables(snippet.command, variableValues);
if (!applied.ok) {
return { ok: false, error: `Missing snippet variable "${applied.missing.join('", "')}".` };
}
const command = applied.command;
const bridge = netcattyBridge.get();
if (!bridge?.aiExec) {
return { ok: false, error: 'Terminal execution bridge is unavailable.' };
}
const chatSessionId = typeof params.chatSessionId === 'string' ? params.chatSessionId : undefined;
const result = await bridge.aiExec(sessionId, command, chatSessionId);
// Real execution failures (timeout, disconnect, no stream) carry an `error`
// field. A bare ok:false with exitCode/stdout means the command ran and
// exited non-zero — preserve that evidence instead of masking it (#2718).
const execError = result && typeof result === 'object' && 'ok' in result && result.ok === false
? (result as { error?: string }).error
: undefined;
if (execError) {
return { ok: false, error: execError, sessionId, snippetId: snippet.id, command, kind: 'snippet', result };
}
return { ok: true, sessionId, snippetId: snippet.id, command, kind: 'snippet', result };
}
export interface VaultAgentApiDeps {
getHosts: () => Host[];
getNotes: () => VaultNote[];
getCustomGroups: () => string[];
getGroupConfigs: () => GroupConfig[];
getPortForwardingRules: () => PortForwardingRule[];
getManagedSources: () => ManagedSource[];
snippets: Snippet[];
keys: SSHKey[];
identities: Identity[];
knownHosts: KnownHost[];
proxyProfiles: ProxyProfile[];
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
resolveEffectiveHost: (host: Host) => Host;
updateHostNotes: (hostId: string, notes: string) => void;
updateCustomGroups: (groups: string[]) => void;
updateGroupConfigs: (configs: GroupConfig[]) => void;
updatePortForwardingRules: (rules: PortForwardingRule[]) => void;
updateManagedSources: (sources: ManagedSource[]) => void;
updateHosts: (hosts: Host[]) => void;
commitVaultGroupMutation: (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
saveKeyPassphrase: (keyPath: string, passphrase: string) => Promise<void>;
saveImportedKeyPassphrase?: (
keyPath: string,
passphrase: string,
) => Promise<RememberImportedKeyPassphraseResult>;
resolveKeyPassphraseAliases: (keyPath: string) => Promise<string[]>;
readKeyPassphrases: (keyPath: string) => Promise<{
values: string[];
unreadable: boolean;
}>;
removeKeyPassphrases: (keyPaths: string[]) => Promise<void> | void;
updateNotes: (notes: VaultNote[]) => boolean | void;
updateSnippets: (
snippets: Snippet[] | ((current: Snippet[]) => Snippet[]),
) => void;
startTunnel: (
rule: PortForwardingRule,
host: Host,
hosts: Host[],
keys: SSHKey[],
identities: Identity[],
onStatusChange?: (status: PortForwardingRule['status'], error?: string) => void,
enableReconnect?: boolean,
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>,
knownHosts?: KnownHost[],
) => Promise<{ success: boolean; error?: string }>;
stopTunnel: (
ruleId: string,
onStatusChange?: (status: PortForwardingRule['status']) => void,
) => Promise<{ success: boolean; error?: string }>;
stopRuleTunnels: (ruleId: string) => Promise<{ success: boolean; error?: string }>;
/**
* Open a vault host as a terminal tab (same path as tray / host list click).
* Must return the new sessionId so MCP can target terminal tools. `isExternalMcpCall`
* is true only when the request has no chatSessionId — i.e. it came from an actual
* external MCP client rather than the in-app Catty AI chat — and gates the "silent
* sessions" setting so the in-app chat's host_open still opens a visible tab.
*/
openHost?: (host: Host, isExternalMcpCall: boolean) => {
ok: true;
sessionId: string;
host: Host;
} | {
ok: false;
error: string;
};
closeSession?: (sessionId: string) => {
ok: true;
} | {
ok: false;
error: string;
};
getScriptSessionMeta?: (sessionId: string) => {
connected?: boolean;
name?: string;
hostname?: string;
username?: string;
} | undefined;
}
function resolveEffectiveHostKeyPath(host: Host, deps: VaultAgentApiDeps): string | undefined {
const effectiveHost = deps.resolveEffectiveHost(host);
const resolvedAuth = resolveHostAuth({
host: effectiveHost,
keys: deps.keys,
identities: deps.identities,
});
return resolvedAuth.identityFilePath ?? effectiveHost.identityFilePaths?.[0];
}
async function registerOpenedSessionInMcpScope(
sessionId: string,
host: Host,
chatSessionId?: string,
): Promise<void> {
let bridge: ReturnType<typeof netcattyBridge.get> | undefined;
try {
bridge = netcattyBridge.get();
} catch {
// Node unit tests / non-renderer contexts have no window.
return;
}
if (!bridge?.aiMcpMergeSessions) return;
const protocol = host.etEnabled
? 'et'
: host.moshEnabled
? 'mosh'
: (host.protocol || 'ssh');
const sessionInfo = {
sessionId,
hostId: host.id,
hostname: host.hostname || '',
label: host.label || host.hostname || sessionId,
os: resolveHostOs(host),
username: host.username || '',
protocol,
deviceType: host.deviceType || '',
connected: false,
hostChain: [],
activePortForwards: [],
};
const scopes = new Set<string>();
if (chatSessionId && chatSessionId.trim()) {
scopes.add(chatSessionId.trim());
}
// Always merge into the reserved external MCP scope when that surface is
// active; External MCP agents can then terminal_execute without waiting for
// the next React session-sync tick.
scopes.add(EXTERNAL_MCP_CHAT_SESSION_ID);
await Promise.all(
[...scopes].map(async (scopeId) => {
try {
await bridge.aiMcpMergeSessions?.([sessionInfo], scopeId);
} catch {
// Scope merge is best-effort; open itself already succeeded.
}
}),
);
}
export async function handleVaultAgentOp(
op: string,
params: Record<string, unknown>,
deps: VaultAgentApiDeps,
): Promise<Record<string, unknown>> {
switch (op) {
case 'session.close': {
const sessionId = String(params.sessionId || '').trim();
if (!sessionId) return { ok: false, error: 'sessionId is required.' };
if (typeof deps.closeSession !== 'function') {
return { ok: false, error: 'Session close is not available in this window.' };
}
const closed = deps.closeSession(sessionId);
if (!closed.ok) return closed;
return { ok: true, sessionId, status: 'closed' };
}
case 'host.get': {
const hostId = String(params.hostId || '');
const host = deps.getHosts().find((entry) => entry.id === hostId);
if (!host) return { ok: false, error: `Host "${hostId}" was not found.` };
return { ok: true, host: sanitizeHostForAgent(deps.resolveEffectiveHost(host)) };
}
case 'host.list': {
return {
ok: true,
hosts: deps.getHosts().map((host) => summarizeHostForList(deps.resolveEffectiveHost(host))),
};
}
case 'host.open': {
const hostId = String(params.hostId || '').trim();
if (!hostId) return { ok: false, error: 'hostId is required.' };
const host = deps.getHosts().find((entry) => entry.id === hostId);
if (!host) return { ok: false, error: `Host "${hostId}" was not found.` };
if (typeof deps.openHost !== 'function') {
return { ok: false, error: 'Host open is not available in this window.' };
}
const chatSessionId = typeof params.chatSessionId === 'string'
? params.chatSessionId
: undefined;
// The TCP bridge forces every authenticated external-MCP socket's
// chatSessionId to this reserved value, so a missing chatSessionId is
// not a reliable signal — compare against the constant instead.
const isExternalMcpCall = chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID;
const effectiveHost = deps.resolveEffectiveHost(host);
const opened = deps.openHost(effectiveHost, isExternalMcpCall);
if (!opened.ok) {
return { ok: false, error: opened.error };
}
await registerOpenedSessionInMcpScope(opened.sessionId, effectiveHost, chatSessionId);
const protocol = effectiveHost.etEnabled
? 'et'
: effectiveHost.moshEnabled
? 'mosh'
: (effectiveHost.protocol || 'ssh');
return {
ok: true,
sessionId: opened.sessionId,
hostId: effectiveHost.id,
status: 'connecting',
protocol,
host: summarizeHostForList(effectiveHost),
message:
'Terminal tab opened. Connection may still be establishing; use get_environment or wait briefly before terminal_execute if the session is not ready yet.',
};
}
case 'hosts.create': {
const parsedDrafts = parseVaultHostDraftsInput(params.hosts);
if (!parsedDrafts.ok) return { ok: false, error: parsedDrafts.error };
const dryRun = parseOptionalBoolean(params.dryRun) ?? false;
const skipDuplicates = parseOptionalBoolean(params.skipDuplicates) ?? true;
const {
hosts: builtHosts,
issues: buildIssues,
keyPassphrases,
} = buildVaultHostsFromDrafts(parsedDrafts.drafts);
if (builtHosts.length === 0) {
return {
ok: false,
error: buildIssues[0]?.error || 'No valid hosts to create.',
issues: buildIssues,
};
}
const previewHosts = builtHosts.map((host) => sanitizeHostForAgent(host));
if (dryRun) {
return {
ok: true,
dryRun: true,
parsedCount: parsedDrafts.drafts.length,
validCount: builtHosts.length,
issues: buildIssues,
previewHosts,
};
}
const merged = applyVaultHostCreates(
deps.getHosts(),
deps.getCustomGroups(),
builtHosts,
{ skipDuplicates },
);
if (merged.addedCount === 0) {
return {
ok: false,
error: 'No new hosts were added (all duplicates or invalid).',
issues: buildIssues,
skippedExistingCount: merged.skippedExistingCount,
previewHosts,
};
}
const addedHostIds = new Set(merged.addedHosts.map((host) => host.id));
for (const entry of keyPassphrases) {
if (addedHostIds.has(entry.hostId)) {
await deps.saveKeyPassphrase(entry.keyPath, entry.passphrase);
}
}
deps.updateHosts(merged.hosts);
deps.updateCustomGroups(merged.customGroups);
return {
ok: true,
dryRun: false,
parsedCount: parsedDrafts.drafts.length,
validCount: builtHosts.length,
addedCount: merged.addedCount,
skippedExistingCount: merged.skippedExistingCount,
issues: buildIssues,
previewHosts: merged.addedHosts.map((host) => sanitizeHostForAgent(host)),
};
}
case 'host.update': {
const hostId = String(params.hostId || '').trim();
if (!hostId) return { ok: false, error: 'hostId is required.' };
const currentHosts = deps.getHosts();
const currentHost = currentHosts.find((host) => host.id === hostId);
if (!currentHost) return { ok: false, error: `Host "${hostId}" was not found.` };
const passphraseProvided = Object.prototype.hasOwnProperty.call(params, 'passphrase');
if (passphraseProvided && typeof params.passphrase !== 'string') {
return { ok: false, error: 'passphrase must be a string.' };
}
const passphrase = typeof params.passphrase === 'string' ? params.passphrase : undefined;
const effectiveKeyPathInput = params.keyPath ?? params.keypath;
const clearedLocalKeyPath = passphrase === ''
&& typeof effectiveKeyPathInput === 'string'
&& !effectiveKeyPathInput.trim()
? currentHost.identityFilePaths?.find((path) => path.trim())?.trim()
: undefined;
const hasHostPatch = VAULT_HOST_UPDATE_FIELDS.some((field) => (
Object.prototype.hasOwnProperty.call(params, field)
));
if (!hasHostPatch && !passphraseProvided) {
return { ok: false, error: 'At least one host field is required.' };
}
let updatedHost = currentHost;
let updatedHosts: Host[] | undefined;
let updatedCustomGroups: string[] | undefined;
if (hasHostPatch) {
const updated = applyVaultHostUpdate(
currentHosts,
deps.getCustomGroups(),
hostId,
params,
{
resolveEffectiveHost: deps.resolveEffectiveHost,
groupConfigs: deps.getGroupConfigs(),
managedSources: deps.getManagedSources(),
identities: deps.identities,
proxyProfiles: deps.proxyProfiles,
},
);
if (!updated.ok) return updated;
updatedHost = updated.updatedHost;
updatedHosts = updated.hosts;
updatedCustomGroups = updated.customGroups;
}
if (passphraseProvided) {
let keyPath = clearedLocalKeyPath ?? resolveEffectiveHostKeyPath(updatedHost, deps);
if (!keyPath && passphrase === '') {
keyPath = resolveEffectiveHostKeyPath(currentHost, deps);
}
if (!keyPath) {
return { ok: false, error: 'A keyPath is required when passphrase is provided.' };
}
if (passphrase) {
await deps.saveKeyPassphrase(keyPath, passphrase);
} else {
await deps.removeKeyPassphrases([keyPath]);
}
}
if (updatedHosts && updatedCustomGroups) {
deps.updateHosts(updatedHosts);
deps.updateCustomGroups(updatedCustomGroups);
}
return {
ok: true,
hostId,
host: sanitizeHostForAgent(updatedHost),
};
}
case 'host.delete': {
const hostId = String(params.hostId || '').trim();
if (!hostId) return { ok: false, error: 'hostId is required.' };
const deleted = applyVaultHostDelete(
deps.getHosts(),
hostId,
deps.resolveEffectiveHost,
deps.getGroupConfigs(),
);
if (!deleted.ok) return deleted;
deps.updateHosts(deleted.hosts);
return {
ok: true,
hostId,
deletedHost: sanitizeHostForAgent(deleted.deletedHost),
};
}
case 'host.import': {
const text = typeof params.text === 'string' ? params.text : '';
if (!text.trim()) return { ok: false, error: 'text is required.' };
const formatParam = resolveVaultImportFormat(params.format);
if (typeof formatParam === 'object' && 'error' in formatParam) {
return { ok: false, error: formatParam.error };
}
let resolvedFormat: VaultImportFormat;
if (formatParam === 'auto') {
const detected = detectVaultImportFormat(text);
if (!detected) {
return {
ok: false,
error: 'Could not detect import format. Specify csv, putty, mobaxterm, securecrt, or ssh_config.',
};
}
resolvedFormat = detected;
} else {
resolvedFormat = formatParam;
}
const dryRun = parseOptionalBoolean(params.dryRun) ?? false;
const skipDuplicates = parseOptionalBoolean(params.skipDuplicates) ?? true;
const fileName = typeof params.fileName === 'string' && params.fileName.trim()
? params.fileName.trim()
: undefined;
const importResult = importVaultHostsFromText(resolvedFormat, text, { fileName });
const previewHosts = importResult.hosts.map((host) => sanitizeHostForAgent(host));
const merged = applyVaultHostImport(
deps.getHosts(),
deps.getCustomGroups(),
importResult,
{ skipDuplicates },
);
const addedHostIds = new Set(merged.addedHosts.map((host) => host.id));
const addedHostKeyPaths = new Map(merged.addedHosts.flatMap((host) => {
const keyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
return keyPath ? [[host.id, keyPath] as const] : [];
}));
const resolved = await resolveVaultImportKeyPassphraseConflicts(
importResult.keyPassphraseCandidates ?? importResult.keyPassphrases ?? [],
deps.resolveKeyPassphraseAliases,
addedHostIds,
addedHostKeyPaths,
);
const checked = await filterVaultImportKeyPassphrasesAgainstExisting(
resolved.keyPassphrases,
deps.readKeyPassphrases,
);
const credentialIssues = mergeVaultImportIssues(resolved.issues, checked.issues);
if (dryRun) {
return {
ok: true,
dryRun: true,
format: resolvedFormat,
stats: importResult.stats,
issues: mergeVaultImportIssues(importResult.issues, credentialIssues),
groups: importResult.groups,
previewHosts,
};
}
if (merged.addedCount === 0 && importResult.stats.parsed === 0) {
return {
ok: false,
error: importResult.issues[0]?.message || 'No hosts were imported.',
format: resolvedFormat,
stats: importResult.stats,
issues: importResult.issues,
};
}
deps.updateHosts(merged.hosts);
deps.updateCustomGroups(merged.customGroups);
const saveIssues = [...credentialIssues];
for (const entry of checked.keyPassphrases) {
try {
let saved: RememberImportedKeyPassphraseResult = 'saved';
if (deps.saveImportedKeyPassphrase) {
saved = await deps.saveImportedKeyPassphrase(entry.keyPath, entry.passphrase);
} else {
await deps.saveKeyPassphrase(entry.keyPath, entry.passphrase);
}
if (saved === 'conflict') {
saveIssues.push({
level: 'warning',
message: `CSV passphrase conflicts with an existing saved passphrase for KeyPath "${entry.keyPath}"; the existing passphrase was kept.`,
});
} else if (saved === 'unreadable') {
saveIssues.push({
level: 'warning',
message: `Could not verify the existing saved passphrase for KeyPath "${entry.keyPath}"; the imported passphrase was not saved.`,
});
}
} catch {
saveIssues.push({
level: 'warning',
message: `Could not save the passphrase for KeyPath "${entry.keyPath}".`,
});
}
}
return {
ok: true,
dryRun: false,
format: resolvedFormat,
stats: importResult.stats,
issues: mergeVaultImportIssues(importResult.issues, saveIssues),
addedCount: merged.addedCount,
skippedExistingCount: merged.skippedExistingCount,
previewHosts: previewHosts.slice(0, 20),
};
}
case 'host.notes.get': {
const hostId = String(params.hostId || '');
const host = deps.getHosts().find((entry) => entry.id === hostId);
if (!host) return { ok: false, error: `Host "${hostId}" was not found.` };
return { ok: true, hostId, notes: host.notes || '' };
}
case 'host.notes.set': {
const hostId = String(params.hostId || '');
const notes = typeof params.notes === 'string' ? params.notes : '';
const host = deps.getHosts().find((entry) => entry.id === hostId);
if (!host) return { ok: false, error: `Host "${hostId}" was not found.` };
deps.updateHostNotes(hostId, notes);
return { ok: true, hostId };
}
case 'note.list': {
return {
ok: true,
notes: deps.getNotes().map(summarizeVaultNoteForList),
};
}
case 'note.get': {
const noteId = String(params.noteId || '');
const note = deps.getNotes().find((entry) => entry.id === noteId);
if (!note) return { ok: false, error: `Vault note "${noteId}" was not found.` };
return readVaultNote(note, params);
}
case 'note.create': {
const title = sanitizeNoteTitle(params.title);
if (!title) return { ok: false, error: 'title is required.' };
const content = typeof params.content === 'string' ? params.content : '';
const linkedHostIds = parseOptionalStringArray(params.linkedHostIds, 'linkedHostIds');
if (linkedHostIds && 'error' in linkedHostIds) return { ok: false, error: linkedHostIds.error };
const tags = parseOptionalStringArray(params.tags, 'tags');
if (tags && 'error' in tags) return { ok: false, error: tags.error };
const note = sanitizeVaultNote({
title,
content,
group: typeof params.group === 'string' && params.group.trim() ? params.group.trim() : undefined,
linkedHostIds,
tags,
order: getNextVaultOrder(deps.getNotes()),
});
const nextNotes = normalizeVaultNotes([...deps.getNotes(), note]);
deps.updateNotes(nextNotes);
return { ok: true, note: serializeVaultNoteForAgent(note) };
}
case 'note.update': {
const noteId = String(params.noteId || '');
const existing = deps.getNotes().find((entry) => entry.id === noteId);
if (!existing) return { ok: false, error: `Vault note "${noteId}" was not found.` };
const linkedHostIds = parseOptionalStringArray(params.linkedHostIds, 'linkedHostIds');
if (linkedHostIds && 'error' in linkedHostIds) return { ok: false, error: linkedHostIds.error };
const tags = parseOptionalStringArray(params.tags, 'tags');
if (tags && 'error' in tags) return { ok: false, error: tags.error };
const note = sanitizeVaultNote({
...existing,
title: params.title !== undefined ? sanitizeNoteTitle(params.title) : existing.title,
content: typeof params.content === 'string' ? params.content : existing.content,
group: params.group !== undefined
? (typeof params.group === 'string' && params.group.trim() ? params.group.trim() : undefined)
: existing.group,
linkedHostIds: linkedHostIds ?? existing.linkedHostIds,
tags: tags ?? existing.tags,
updatedAt: Date.now(),
});
if (!note.title) return { ok: false, error: 'title cannot be empty.' };
const nextNotes = normalizeVaultNotes(
deps.getNotes().map((entry) => (entry.id === noteId ? note : entry)),
);
deps.updateNotes(nextNotes);
return { ok: true, note: serializeVaultNoteForAgent(note) };
}
case 'note.delete': {
const noteId = String(params.noteId || '');
if (!deps.getNotes().some((note) => note.id === noteId)) {
return { ok: false, error: `Vault note "${noteId}" was not found.` };
}
deps.updateNotes(normalizeVaultNotes(deps.getNotes().filter((note) => note.id !== noteId)));
return { ok: true, noteId };
}
case 'identity.list': {
return {
ok: true,
identities: deps.identities.map((identity) => ({
id: identity.id,
label: identity.label,
username: identity.username,
authMethod: identity.authMethod,
keyId: identity.keyId,
})),
};
}
case 'proxyProfile.list': {
return {
ok: true,
proxyProfiles: deps.proxyProfiles.map((profile) => ({
id: profile.id,
label: profile.label,
type: profile.config.type,
host: profile.config.host,
port: profile.config.port,
})),
};
}
case 'group.list': {
const configs = new Map(deps.getGroupConfigs().map((config) => [config.path, config]));
return {
ok: true,
groups: deps.getCustomGroups().map((path) => {
const config = configs.get(path);
if (!config) return { path };
return { path, defaults: sanitizeGroupConfigForAgent(config) };
}),
};
}
case 'group.create':
case 'group.update': {
let committed: Awaited<ReturnType<VaultAgentApiDeps['commitVaultGroupMutation']>>;
let savedConfig: GroupConfig | undefined;
try {
committed = await deps.commitVaultGroupMutation((current) => {
const result = upsertGroup({
groups: current.groups,
configs: current.configs,
hosts: current.hosts,
managedSources: current.managedSources,
}, params.path, params.defaults, deps.identities, deps.proxyProfiles, {
create: op === 'group.create',
newPath: params.newPath,
});
if ('error' in result) return result;
savedConfig = result.config;
return {
ok: true,
state: {
...result.state,
snippets: op === 'group.update' && result.config?.path
? remapSnippetTargetGroupPaths(
current.snippets,
String(params.path),
result.config.path,
)
: current.snippets,
},
};
});
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
if (!committed.ok) {
return 'superseded' in committed
? { ok: false, error: 'The Vault changed while the group was being saved. Please retry.' }
: committed;
}
return { ok: true, group: sanitizeGroupConfigForAgent(savedConfig ?? { path: String(params.path) }) };
}
case 'group.delete': {
const deleteHosts = parseOptionalBoolean(params.deleteHosts);
if (params.deleteHosts !== undefined && deleteHosts === undefined) {
return { ok: false, error: 'deleteHosts must be true or false.' };
}
let committed: Awaited<ReturnType<VaultAgentApiDeps['commitVaultGroupMutation']>>;
try {
committed = await deps.commitVaultGroupMutation((current) => {
const result = deleteGroup({
groups: current.groups,
configs: current.configs,
hosts: current.hosts,
managedSources: current.managedSources,
}, params.path, deleteHosts ?? false);
if ('error' in result) return result;
return {
ok: true,
state: {
...result.state,
snippets: removeSnippetTargetGroupPaths(current.snippets, [String(params.path)]),
},
};
});
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
if (!committed.ok) {
return 'superseded' in committed
? { ok: false, error: 'The Vault changed while the group was being deleted. Please retry.' }
: committed;
}
return { ok: true, path: String(params.path), deletedHosts: deleteHosts ?? false };
}
case 'snippets.list': {
return {
ok: true,
snippets: deps.snippets.map(serializeSnippetForAgentList),
};
}
case 'snippets.get': {
const snippetId = String(params.snippetId || '');
const snippet = deps.snippets.find((entry) => entry.id === snippetId);
if (!snippet) return { ok: false, error: `Snippet "${snippetId}" was not found.` };
return { ok: true, snippet: serializeSnippetForAgentGet(snippet) };
}
case 'snippets.create': {
const built = buildSnippetFromAgentDraft(snippetDraftFromParams(params), deps.snippets);
if (!built.ok) return { ok: false, error: built.error };
const merged = applySnippetCreateToVault(deps.snippets, deps.getHosts(), built.snippet);
deps.updateSnippets(merged.snippets);
deps.updateHosts(merged.hosts);
return { ok: true, snippet: serializeSnippetForAgentGet(built.snippet) };
}
case 'snippets.update': {
const snippetId = String(params.snippetId || '');
const existing = deps.snippets.find((entry) => entry.id === snippetId);
if (!existing) return { ok: false, error: `Snippet "${snippetId}" was not found.` };
const patched = applySnippetAgentPatch(existing, snippetDraftFromParams(params));
if (!patched.ok) return { ok: false, error: patched.error };
const merged = applySnippetUpdateToVault(
deps.snippets,
deps.getHosts(),
patched.snippet,
existing,
patched.prevTargetIds,
);
deps.updateSnippets(merged.snippets);
deps.updateHosts(merged.hosts);
return { ok: true, snippet: serializeSnippetForAgentGet(patched.snippet) };
}
case 'snippets.delete': {
const snippetId = String(params.snippetId || '');
const removed = deleteSnippetFromVault(deps.snippets, deps.getHosts(), snippetId);
if ('error' in removed) return { ok: false, error: removed.error };
deps.updateSnippets(removed.snippets);
deps.updateHosts(removed.hosts);
return { ok: true, snippetId };
}
case 'snippets.run': {
const snippetId = String(params.snippetId || '');
const sessionId = String(params.sessionId || '');
const snippet = deps.snippets.find((entry) => entry.id === snippetId);
if (!snippet) return { ok: false, error: `Snippet "${snippetId}" was not found.` };
if (!sessionId) return { ok: false, error: 'sessionId is required.' };
return executeSnippetOrScriptRun(snippet, sessionId, params, deps.getScriptSessionMeta?.(sessionId));
}
case 'scripts.list': {
return {
ok: true,
scripts: filterScriptSnippets(deps.snippets).map(serializeScriptForAgentList),
};
}
case 'scripts.get': {
const scriptId = String(params.scriptId || params.snippetId || '');
const script = deps.snippets.find((entry) => entry.id === scriptId && isScriptSnippet(entry));
if (!script) return { ok: false, error: `Script "${scriptId}" was not found.` };
return { ok: true, script: serializeScriptForAgentGet(script) };
}
case 'scripts.create': {
const built = buildSnippetFromAgentDraft(
snippetDraftFromParams(params),
deps.snippets,
{ forceKind: 'script' },
);
if (!built.ok) return { ok: false, error: built.error };
const merged = applySnippetCreateToVault(deps.snippets, deps.getHosts(), built.snippet);
deps.updateSnippets(merged.snippets);
deps.updateHosts(merged.hosts);
return { ok: true, script: serializeScriptForAgentGet(built.snippet) };
}
case 'scripts.update': {
const scriptId = String(params.scriptId || params.snippetId || '');
const existing = deps.snippets.find((entry) => entry.id === scriptId);
if (!existing || !isScriptSnippet(existing)) {
return { ok: false, error: `Script "${scriptId}" was not found.` };
}
const patched = applySnippetAgentPatch(existing, snippetDraftFromParams(params), { forceKind: 'script' });
if (!patched.ok) return { ok: false, error: patched.error };
const merged = applySnippetUpdateToVault(
deps.snippets,
deps.getHosts(),
patched.snippet,
existing,
patched.prevTargetIds,
);
deps.updateSnippets(merged.snippets);
deps.updateHosts(merged.hosts);
return { ok: true, script: serializeScriptForAgentGet(patched.snippet) };
}
case 'scripts.delete': {
const scriptId = String(params.scriptId || params.snippetId || '');
const existing = deps.snippets.find((entry) => entry.id === scriptId);
if (!existing || !isScriptSnippet(existing)) {
return { ok: false, error: `Script "${scriptId}" was not found.` };
}
const removed = deleteSnippetFromVault(deps.snippets, deps.getHosts(), scriptId);
if ('error' in removed) return { ok: false, error: removed.error };
deps.updateSnippets(removed.snippets);
deps.updateHosts(removed.hosts);
return { ok: true, scriptId };
}
case 'scripts.run': {
const scriptId = String(params.scriptId || params.snippetId || '');
const sessionId = String(params.sessionId || '');
const script = deps.snippets.find((entry) => entry.id === scriptId && isScriptSnippet(entry));
if (!script) return { ok: false, error: `Script "${scriptId}" was not found.` };
if (!sessionId) return { ok: false, error: 'sessionId is required.' };
return executeSnippetOrScriptRun(script, sessionId, params, deps.getScriptSessionMeta?.(sessionId));
}
case 'scripts.reference': {
return { ok: true, reference: getScriptApiReference() };
}
case 'scripts.runs.list': {
const bridge = netcattyBridge.get();
if (!bridge?.scriptGetRuns) {
return { ok: false, error: 'Script runs bridge is unavailable.' };
}
const sessionId = typeof params.sessionId === 'string' && params.sessionId.trim()
? params.sessionId.trim()
: undefined;
const runs = await bridge.scriptGetRuns(sessionId);
return { ok: true, runs };
}
case 'scripts.run.stop': {
const runId = String(params.runId || '');
if (!runId) return { ok: false, error: 'runId is required.' };
const result = await stopScriptRun(runId);
if (!result.ok) return { ok: false, error: `Script run "${runId}" was not found or could not be stopped.` };
return { ok: true, runId };
}
case 'scripts.run.pause': {
const runId = String(params.runId || '');
if (!runId) return { ok: false, error: 'runId is required.' };
const result = await pauseScriptRun(runId);
if (!result.ok) return { ok: false, error: `Script run "${runId}" was not found or could not be paused.` };
return { ok: true, runId };
}
case 'scripts.run.resume': {
const runId = String(params.runId || '');
if (!runId) return { ok: false, error: 'runId is required.' };
const result = await resumeScriptRun(runId);
if (!result.ok) return { ok: false, error: `Script run "${runId}" was not found or could not be resumed.` };
return { ok: true, runId };
}
case 'scripts.targets.set': {
const scriptId = String(params.scriptId || params.snippetId || '');
const existing = deps.snippets.find((entry) => entry.id === scriptId && isScriptSnippet(entry));
if (!existing) return { ok: false, error: `Script "${scriptId}" was not found.` };
const patched = applyScriptTargetsPatch(existing, {
targets: params.targets,
targetGroups: params.targetGroups,
targetsAllHosts: params.targetsAllHosts,
});
if (!patched.ok) return { ok: false, error: patched.error };
const merged = applySnippetUpdateToVault(
deps.snippets,
deps.getHosts(),
patched.snippet,
existing,
patched.prevTargetIds,
);
deps.updateSnippets(merged.snippets);
deps.updateHosts(merged.hosts);
return { ok: true, script: serializeScriptForAgentGet(patched.snippet) };
}
case 'host.connectScripts.list': {
const hostId = String(params.hostId || '');
const host = deps.getHosts().find((entry) => entry.id === hostId);
if (!host) return { ok: false, error: `Host "${hostId}" was not found.` };
return {
ok: true,
...summarizeConnectScriptsForHost(host, deps.snippets),
};
}
case 'host.connectScripts.set': {
const hostId = String(params.hostId || '');
const host = deps.getHosts().find((entry) => entry.id === hostId);
if (!host) return { ok: false, error: `Host "${hostId}" was not found.` };
const scriptIds = parseOptionalStringArray(params.scriptIds, 'scriptIds');
if (scriptIds && 'error' in scriptIds) return { ok: false, error: scriptIds.error };
if (!scriptIds) return { ok: false, error: 'scriptIds is required.' };
const nextHost = setHostConnectScriptIds(host, scriptIds, deps.snippets);
if (!nextHost.ok) return { ok: false, error: nextHost.error };
const nextHosts = deps.getHosts().map((entry) => (
entry.id === hostId ? nextHost.host : entry
));
deps.updateHosts(nextHosts);
return {
ok: true,
hostId,
connectScriptIds: nextHost.host.connectScriptIds ?? [],
};
}
case 'portforward.rules.list': {
return {
ok: true,
rules: deps.getPortForwardingRules().map(sanitizePortForwardRuleForAgent),
};
}
case 'portforward.rules.create': {
const effectiveHosts = deps.getHosts().map((host) => deps.resolveEffectiveHost(host));
const result = createPortForwardingRule(deps.getPortForwardingRules(), effectiveHosts, params, {
id: crypto.randomUUID(), now: Date.now(),
});
if (!result.ok) return result;
deps.updatePortForwardingRules(result.value.rules);
return { ok: true, rule: sanitizePortForwardRuleForAgent(result.value.rule) };
}
case 'portforward.rules.update': {
const ruleId = String(params.ruleId || '');
const currentRules = deps.getPortForwardingRules();
const existingRule = currentRules.find((entry) => entry.id === ruleId);
const effectiveHosts = deps.getHosts().map((host) => deps.resolveEffectiveHost(host));
let result = updatePortForwardingRule(currentRules, effectiveHosts, ruleId, params);
if (!result.ok) return result;
if (
existingRule
&& hasPortForwardingConnectionChanged(existingRule, result.value.rule)
) {
const stopped = await deps.stopRuleTunnels(ruleId);
if (!stopped.success) {
return { ok: false, error: stopped.error || 'Failed to stop port forwarding tunnel.' };
}
const latestHosts = deps.getHosts().map((host) => deps.resolveEffectiveHost(host));
result = updatePortForwardingRule(
deps.getPortForwardingRules(),
latestHosts,
ruleId,
params,
);
if (!result.ok) return result;
const stoppedRule = {
...result.value.rule,
status: 'inactive' as const,
error: undefined,
};
result = {
ok: true,
value: {
rules: result.value.rules.map((rule) => rule.id === ruleId ? stoppedRule : rule),
rule: stoppedRule,
},
};
}
deps.updatePortForwardingRules(result.value.rules);
return { ok: true, rule: sanitizePortForwardRuleForAgent(result.value.rule) };
}
case 'portforward.rules.duplicate': {
const ruleId = String(params.ruleId || '');
const effectiveHosts = deps.getHosts().map((host) => deps.resolveEffectiveHost(host));
const result = duplicatePortForwardingRule(deps.getPortForwardingRules(), effectiveHosts, ruleId, {
id: crypto.randomUUID(), now: Date.now(),
});
if (!result.ok) return result;
deps.updatePortForwardingRules(result.value.rules);
return { ok: true, rule: sanitizePortForwardRuleForAgent(result.value.rule) };
}
case 'portforward.rules.delete': {
const ruleId = String(params.ruleId || '');
const rule = deps.getPortForwardingRules().find((entry) => entry.id === ruleId);
if (!rule) return { ok: false, error: `Port forwarding rule "${ruleId}" was not found.` };
const stopped = await deps.stopRuleTunnels(ruleId);
if (!stopped.success) return { ok: false, error: stopped.error || 'Failed to stop port forwarding tunnel.' };
deps.updatePortForwardingRules(deps.getPortForwardingRules().filter((entry) => entry.id !== ruleId));
return { ok: true, ruleId };
}
case 'portforward.start': {
const ruleId = String(params.ruleId || '');
const rule = deps.getPortForwardingRules().find((entry) => entry.id === ruleId);
if (!rule) return { ok: false, error: `Port forwarding rule "${ruleId}" was not found.` };
if (!rule.hostId) return { ok: false, error: 'Rule has no associated host.' };
const effectiveHosts = deps.getHosts().map((host) => deps.resolveEffectiveHost(host));
const validatedHost = validatePortForwardingHost(effectiveHosts, rule.hostId);
if (!validatedHost.ok) return validatedHost;
const host = validatedHost.value;
try {
resolveHostAuth({ host, keys: deps.keys, identities: deps.identities });
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
const result = await deps.startTunnel(
rule,
host,
effectiveHosts,
deps.keys,
deps.identities,
undefined,
false,
deps.terminalSettings,
deps.knownHosts,
);
if (!result.success) {
return { ok: false, error: result.error || 'Failed to start port forwarding tunnel.' };
}
return { ok: true, ruleId };
}
case 'portforward.stop': {
const ruleId = String(params.ruleId || '');
const result = await deps.stopRuleTunnels(ruleId);
if (!result.success) {
return { ok: false, error: result.error || 'Failed to stop port forwarding tunnel.' };
}
deps.updatePortForwardingRules(deps.getPortForwardingRules().map((rule) => (
rule.id === ruleId ? { ...rule, status: 'inactive', error: undefined } : rule
)));
return { ok: true, ruleId };
}
default:
return { ok: false, error: `Unknown vault agent operation "${op}".` };
}
}
export function shouldBypassVaultAgentSerialization(
op: string,
params: Record<string, unknown>,
): boolean {
if (op !== 'scripts.run' && op !== 'snippets.run') return false;
return parseOptionalBoolean(params.wait) ?? false;
}
export type VaultAgentHandler = (op: string, params: Record<string, unknown>) => Promise<Record<string, unknown>>;
let activeHandler: VaultAgentHandler | null = null;
let vaultAgentMutationChain: Promise<unknown> = Promise.resolve();
/** Serialize vault agent IPC handlers so read-modify-write mutations cannot clobber each other. */
export async function runSerializedVaultAgentRequest<T>(task: () => Promise<T>): Promise<T> {
const run = () => task();
const next = vaultAgentMutationChain.then(run, run);
vaultAgentMutationChain = next.then(
() => undefined,
() => undefined,
);
return next;
}
export function registerVaultAgentHandler(handler: VaultAgentHandler | null): void {
activeHandler = handler;
}
export function setupVaultAgentBridge(): () => void {
const bridge = netcattyBridge.get();
if (!bridge?.onVaultAgentRequest || !bridge.respondVaultAgent) {
return () => {};
}
const unsubscribe = bridge.onVaultAgentRequest(async (payload) => {
const { requestId, op, params } = payload;
const safeParams = params || {};
const runHandler = async () => {
try {
const result = activeHandler
? await activeHandler(op, safeParams)
: { ok: false, error: 'Vault agent bridge is not ready.' };
await bridge.respondVaultAgent?.(requestId, result);
} catch (err) {
await bridge.respondVaultAgent?.(requestId, {
ok: false,
error: err instanceof Error ? err.message : String(err),
});
}
};
if (shouldBypassVaultAgentSerialization(op, safeParams)) {
await runHandler();
return;
}
await runSerializedVaultAgentRequest(runHandler);
});
return unsubscribe;
}