[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
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:
328
domain/vaultGroupAgentOps.ts
Normal file
328
domain/vaultGroupAgentOps.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user