Files
NetMesh/domain/vaultGroupAgentOps.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

329 lines
14 KiB
TypeScript

import type { GroupConfig, Host, Identity, ManagedSource, ProxyProfile } from './models';
import { applyGroupDefaults, resolveGroupDefaults } from './groupConfig';
import {
findIntroducedVaultJumpGraphIssue,
findVaultGroupConfigJumpReference,
} from './vaultJumpGraph';
type GroupState = {
groups: string[];
configs: GroupConfig[];
hosts: Host[];
managedSources: ManagedSource[];
};
type Result = { ok: true; state: GroupState; config?: GroupConfig } | { ok: false; error: string };
const normalizePath = (value: unknown): string => String(value ?? '')
.replace(/\\/g, '/')
.split('/')
.map((part) => part.trim())
.filter(Boolean)
.join('/');
function parseDefaults(value: unknown): Record<string, unknown> | { error: string } {
if (value === undefined || value === null || value === '') return {};
if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>;
if (typeof value !== 'string') return { error: 'defaults must be a JSON object.' };
try {
const parsed = JSON.parse(value) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: { error: 'defaults must be a JSON object.' };
} catch {
return { error: 'defaults must be a valid JSON object.' };
}
}
const bool = (value: unknown): boolean | undefined => {
if (typeof value === 'boolean') return value;
const normalized = String(value ?? '').toLowerCase();
if (['true', '1', 'yes'].includes(normalized)) return true;
if (['false', '0', 'no'].includes(normalized)) return false;
return undefined;
};
const resolveEffectiveHostForConfigs = (configs: GroupConfig[]) => (host: Host): Host => host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, configs))
: host;
const hasInvalidTransport = (value: Pick<Host, 'protocol' | 'moshEnabled' | 'etEnabled'>): boolean => (
Boolean(value.moshEnabled && value.etEnabled) || Boolean(
value.protocol !== undefined
&& value.protocol !== 'ssh'
&& (value.moshEnabled || value.etEnabled)
)
);
const collectTransportIssueKeys = (
state: Pick<GroupState, 'groups' | 'configs' | 'hosts'>,
canonicalGroupPath: (path: string) => string = (path) => path,
): Set<string> => {
const issues = new Set<string>();
const groupPaths = new Set([...state.groups, ...state.configs.map((config) => config.path)]);
for (const groupPath of groupPaths) {
if (hasInvalidTransport(resolveGroupDefaults(groupPath, state.configs))) {
issues.add(`group:${canonicalGroupPath(groupPath)}`);
}
}
const resolveHost = resolveEffectiveHostForConfigs(state.configs);
for (const host of state.hosts) {
if (hasInvalidTransport(resolveHost(host))) issues.add(`host:${host.id}`);
}
return issues;
};
const findIntroducedJumpGraphIssue = (before: GroupState, after: GroupState) =>
findIntroducedVaultJumpGraphIssue(
before.hosts,
after.hosts,
resolveEffectiveHostForConfigs(before.configs),
resolveEffectiveHostForConfigs(after.configs),
);
const findReferencedJumpHostProtocolRegression = (
before: Pick<GroupState, 'configs' | 'hosts'>,
after: Pick<GroupState, 'configs' | 'hosts'>,
): string | undefined => {
const referencedHostIds = new Set(
after.configs.flatMap((config) => config.hostChain?.hostIds ?? []),
);
const resolveBefore = resolveEffectiveHostForConfigs(before.configs);
const resolveAfter = resolveEffectiveHostForConfigs(after.configs);
return [...referencedHostIds].find((hostId) => {
const beforeHost = before.hosts.find((host) => host.id === hostId);
const afterHost = after.hosts.find((host) => host.id === hostId);
if (!beforeHost || !afterHost) return false;
const beforeProtocol = resolveBefore(beforeHost).protocol;
const afterProtocol = resolveAfter(afterHost).protocol;
return (beforeProtocol === undefined || beforeProtocol === 'ssh')
&& afterProtocol !== undefined
&& afterProtocol !== 'ssh';
});
};
export function patchGroupConfig(
current: GroupConfig,
rawDefaults: unknown,
identities: Identity[],
proxyProfiles: ProxyProfile[],
hosts: Host[],
resolveEffectiveHost?: (host: Host) => Host,
): { ok: true; config: GroupConfig } | { ok: false; error: string } {
const defaults = parseDefaults(rawDefaults);
if ('error' in defaults) return { ok: false, error: String(defaults.error) };
const next: GroupConfig = { ...current };
if (Object.hasOwn(defaults, 'username')) {
next.username = String(defaults.username ?? '');
if (!Object.hasOwn(defaults, 'identityId') && current.identityId) {
next.identityId = '';
next.authMethod = undefined;
next.password = undefined;
next.savePassword = undefined;
next.identityFileId = undefined;
next.identityFilePaths = undefined;
}
}
if (Object.hasOwn(defaults, 'startupCommand')) next.startupCommand = String(defaults.startupCommand ?? '');
if (Object.hasOwn(defaults, 'moshServerPath')) next.moshServerPath = String(defaults.moshServerPath ?? '');
if (Object.hasOwn(defaults, 'identityId')) {
const identityId = String(defaults.identityId ?? '');
const identity = identities.find((item) => item.id === identityId);
if (identityId && !identity) return { ok: false, error: `Identity "${identityId}" was not found.` };
next.identityId = identityId;
if (identity) {
next.username = identity.username;
next.authMethod = identity.authMethod;
next.password = undefined;
next.savePassword = undefined;
next.identityFileId = undefined;
next.identityFilePaths = undefined;
} else {
if (!Object.hasOwn(defaults, 'username')) next.username = undefined;
next.authMethod = undefined;
}
}
if (Object.hasOwn(defaults, 'proxyProfileId')) {
const proxyProfileId = String(defaults.proxyProfileId ?? '');
if (proxyProfileId && !proxyProfiles.some((profile) => profile.id === proxyProfileId)) {
return { ok: false, error: `Proxy profile "${proxyProfileId}" was not found.` };
}
next.proxyProfileId = proxyProfileId;
next.proxyConfig = undefined;
}
if (Object.hasOwn(defaults, 'jumpHostIds')) {
let ids: unknown = defaults.jumpHostIds;
if (typeof ids === 'string') {
try { ids = JSON.parse(ids); } catch { return { ok: false, error: 'jumpHostIds must be a JSON array.' }; }
}
if (!Array.isArray(ids)) return { ok: false, error: 'jumpHostIds must be an array.' };
const hostIds = ids.map(String).map((id) => id.trim()).filter(Boolean);
if (new Set(hostIds).size !== hostIds.length) return { ok: false, error: 'jumpHostIds must not contain duplicates.' };
const missing = hostIds.find((id) => !hosts.some((host) => host.id === id));
if (missing) return { ok: false, error: `Jump host "${missing}" was not found.` };
const unsupported = hostIds.find((id) => {
const host = hosts.find((candidate) => candidate.id === id);
if (!host) return false;
const effectiveHost = resolveEffectiveHost?.(host) ?? host;
return effectiveHost.protocol !== undefined && effectiveHost.protocol !== 'ssh';
});
if (unsupported) return { ok: false, error: `Jump host "${unsupported}" does not support SSH jump connections.` };
next.hostChain = { hostIds };
}
if (Object.hasOwn(defaults, 'environmentVariables')) {
const raw = defaults.environmentVariables;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return { ok: false, error: 'environmentVariables must be a JSON object.' };
}
next.environmentVariables = Object.entries(raw).map(([name, value]) => ({ name, value: String(value ?? '') }));
}
for (const key of ['moshEnabled', 'etEnabled'] as const) {
if (Object.hasOwn(defaults, key)) {
const parsed = bool(defaults[key]);
if (parsed === undefined) return { ok: false, error: `${key} must be true or false.` };
next[key] = parsed;
}
}
if (next.moshEnabled && next.etEnabled) {
return { ok: false, error: 'Mosh and ET cannot both be enabled.' };
}
if (Object.hasOwn(defaults, 'etPort')) {
const etPort = Number(defaults.etPort);
if (!Number.isInteger(etPort) || etPort < 1 || etPort > 65535) return { ok: false, error: 'etPort must be between 1 and 65535.' };
next.etPort = etPort;
}
return { ok: true, config: next };
}
export function upsertGroup(
state: GroupState,
pathValue: unknown,
defaults: unknown,
identities: Identity[],
proxyProfiles: ProxyProfile[],
options: { create?: boolean; newPath?: unknown } = {},
): Result {
const path = normalizePath(pathValue);
if (!path) return { ok: false, error: 'path is required.' };
if (options.create && state.groups.includes(path)) return { ok: false, error: `Group "${path}" already exists.` };
if (!options.create && !state.groups.includes(path)) return { ok: false, error: `Group "${path}" was not found.` };
const newPath = normalizePath(options.newPath ?? path);
if (!newPath) return { ok: false, error: 'newPath must not be empty.' };
if (newPath.startsWith(`${path}/`)) return { ok: false, error: 'A group cannot be moved inside itself.' };
if (newPath !== path) {
const belongsToRenamedTree = (candidate: string) => candidate === path || candidate.startsWith(`${path}/`);
const renameCandidate = (candidate: string) => candidate === path
? newPath
: `${newPath}${candidate.slice(path.length)}`;
const occupiedPaths = new Set([
...state.groups.filter((candidate) => !belongsToRenamedTree(candidate)),
...state.configs.map((config) => config.path).filter((candidate) => !belongsToRenamedTree(candidate)),
]);
const collision = [...state.groups, ...state.configs.map((config) => config.path)]
.filter(belongsToRenamedTree)
.map(renameCandidate)
.find((candidate) => occupiedPaths.has(candidate));
if (collision) return { ok: false, error: `Group "${collision}" already exists.` };
}
const rename = (candidate: string) => candidate === path
? newPath
: candidate.startsWith(`${path}/`) ? `${newPath}${candidate.slice(path.length)}` : candidate;
const current = state.configs.find((config) => config.path === path) ?? { path };
const prospectiveConfig = { ...current, path: newPath };
const prospectiveConfigs = [
...state.configs
.filter((config) => config.path !== path)
.map((config) => ({ ...config, path: rename(config.path) })),
prospectiveConfig,
];
const resolveProspectiveHost = (host: Host): Host => {
const prospectiveHost = host.group ? { ...host, group: rename(host.group) } : host;
if (!prospectiveHost.group) return prospectiveHost;
return applyGroupDefaults(
prospectiveHost,
resolveGroupDefaults(prospectiveHost.group, prospectiveConfigs),
);
};
const patched = patchGroupConfig(
prospectiveConfig,
defaults,
identities,
proxyProfiles,
state.hosts,
resolveProspectiveHost,
);
if ('error' in patched) return { ok: false, error: patched.error };
const groups = options.create
? Array.from(new Set([...state.groups, newPath]))
: Array.from(new Set(state.groups.map(rename)));
const configs = [
...state.configs.filter((config) => config.path !== path).map((config) => ({ ...config, path: rename(config.path) })),
patched.config,
];
const nextState: GroupState = {
groups,
configs,
hosts: state.hosts.map((host) => host.group ? { ...host, group: rename(host.group) } : host),
managedSources: state.managedSources.map((source) => ({ ...source, groupName: rename(source.groupName) })),
};
const beforeTransportIssues = collectTransportIssueKeys(state);
const renamedGroupPaths = new Map(
[...state.groups, ...state.configs.map((config) => config.path)]
.filter((candidate) => candidate === path || candidate.startsWith(`${path}/`))
.map((candidate) => [rename(candidate), candidate]),
);
const afterTransportIssues = collectTransportIssueKeys(
nextState,
(groupPath) => renamedGroupPaths.get(groupPath) ?? groupPath,
);
const introducedTransportIssue = [...afterTransportIssues]
.find((issue) => !beforeTransportIssues.has(issue));
if (introducedTransportIssue) {
return { ok: false, error: `${introducedTransportIssue} has incompatible inherited Mosh, ET, or protocol settings.` };
}
const regressedJumpHostId = findReferencedJumpHostProtocolRegression(state, nextState);
if (regressedJumpHostId) {
return { ok: false, error: `Host "${regressedJumpHostId}" is still used as a group jump host and must keep an SSH connection type.` };
}
const jumpGraphIssue = findIntroducedJumpGraphIssue(state, nextState);
if (jumpGraphIssue) return { ok: false, error: jumpGraphIssue.error };
return {
ok: true,
state: nextState,
config: patched.config,
};
}
export function deleteGroup(state: GroupState, pathValue: unknown, deleteHosts: boolean): Result {
const path = normalizePath(pathValue);
if (!path || !state.groups.includes(path)) return { ok: false, error: `Group "${path}" was not found.` };
if (state.managedSources.some((source) => source.groupName === path || source.groupName.startsWith(`${path}/`))) {
return { ok: false, error: 'Managed groups must be unmanaged before the AI can delete them.' };
}
const inside = (candidate?: string) => Boolean(candidate && (candidate === path || candidate.startsWith(`${path}/`)));
const hasManagedParent = state.managedSources.some((source) => path.startsWith(`${source.groupName}/`));
const nextState: GroupState = {
groups: state.groups.filter((group) => !inside(group)),
configs: state.configs.filter((config) => !inside(config.path)),
hosts: deleteHosts
? state.hosts.filter((host) => !inside(host.group))
: state.hosts.map((host) => inside(host.group)
? { ...host, group: undefined, ...(hasManagedParent ? {} : { managedSourceId: undefined }) }
: host),
managedSources: state.managedSources,
};
const deletedHostIds = state.hosts
.filter((host) => !nextState.hosts.some((candidate) => candidate.id === host.id))
.map((host) => host.id);
const referencedDeletedHostId = deletedHostIds.find((hostId) => (
findVaultGroupConfigJumpReference(nextState.configs, hostId)
));
if (referencedDeletedHostId) {
return { ok: false, error: `Host "${referencedDeletedHostId}" is still used as a group jump host.` };
}
const jumpGraphIssue = findIntroducedJumpGraphIssue(state, nextState);
if (jumpGraphIssue) return { ok: false, error: jumpGraphIssue.error };
return { ok: true, state: nextState };
}