feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

This commit is contained in:
LukeGus committed 2026-09-23 11:24:16 -05:00
1 parent d2ff5fc78f
commit 3b2c849514
59 files changed
+31807 -2977

No files matched your search

+40 -13
View File
@@ -26,18 +26,19 @@ This is not a sandbox. See [What this protects](#what-this-protects-and-what-it-
A plugin imports `@termix/plugin-sdk`, its own files and its own npm
dependencies. Entry points:
| Entry | Contents |
| ---------------------------------- | ---------------------------------------------------------- |
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
| `@termix/plugin-sdk/db` | `defineTable()`, the column builders, the legacy-table map |
| `@termix/plugin-sdk/ddl` | The per-dialect DDL emitter, shared with the CLI |
| `@termix/plugin-sdk/table-builder` | `buildTable()`, a definition as a queryable Drizzle table |
| `@termix/plugin-sdk/frontend` | The `app` object types, the hooks, `invokeAction()` |
| `@termix/plugin-sdk/ui` | Core's shared components, a fixed public list (see UI) |
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
| `@termix/plugin-sdk/capabilities` | The capability catalog |
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
| `@termix/plugin-sdk/testing` | Test helpers: `createMockCtx()`, `createTestDb()` and more |
| Entry | Contents |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
| `@termix/plugin-sdk/db` | `defineTable()`, the column builders, the legacy-table map |
| `@termix/plugin-sdk/ddl` | The per-dialect DDL emitter, shared with the CLI |
| `@termix/plugin-sdk/table-builder` | `buildTable()`, a definition as a queryable Drizzle table |
| `@termix/plugin-sdk/frontend` | The `app` object types, the hooks, `invokeAction()` |
| `@termix/plugin-sdk/ui` | Core's shared components, a fixed public list (see UI) |
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
| `@termix/plugin-sdk/capabilities` | The capability catalog |
| `@termix/plugin-sdk/settings` | Settings field types and the shared value validator |
| `@termix/plugin-sdk/host-commands` | Platform detection, package manager commands, sudo elevation - pure helpers over an ssh2 `Client` a plugin got from `ctx.ssh` |
| `@termix/plugin-sdk/testing` | Test helpers: `createMockCtx()`, `createTestDb()` and more |
Plugins never import from `src/` or `@/`, and core never imports from
`plugins/`. Anything a plugin needs from core becomes a typed, documented SDK
@@ -1045,7 +1046,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | B |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.*` | `notify:send` | A6 |
@@ -1073,6 +1074,32 @@ connect pipeline instead of importing ssh2 helpers from core:
Each new connection is audited and runs as the current actor; pooled reuse is
not. Connections and pool entries are closed on deactivate.
**B4** added `ctx.hosts`, for a plugin that groups or acts across hosts it
does not own the way fleets does:
- `list()` / `get(hostId)` return `PluginHostSummary` (id, userId, name, ip,
port, username, tags, folder, authType - no secrets) for the acting user's
own and shared hosts. Needs `hosts:read`.
- `checkAccess(hostId, level)` mirrors `PermissionManager.canAccessHost`,
naming the level ("connect" | "view" | "edit" | "manage") rather than a
bare yes/no, because a plugin fanning out across a fleet needs to know
whether it can only connect, or also manage. Needs `hosts:read`.
- `share(hostId, targets, permissionLevel, durationHours)` grants access the
same way the host editor's own share action does, snapshotting shared
secrets per target through core's `SharedHostSecretsManager`. Refuses a
host the caller does not hold "manage" on. Needs `hosts:write`, because
granting access to a host is a write on that host even when the plugin
owns neither the host nor the grant.
- `listUsers()` / `listRoles()` return minimal `{ id, username }` /
`{ id, name, displayName }` lists (roles filtered to non-system) for a
share-target picker UI, without exposing the full admin user/role
management surface. Needs `hosts:write`, matching `share`.
`ctx.hosts.get` returns the same non-secret projection as `list`; a plugin
that needs to actually connect uses `ctx.ssh.connect(hostId)` or
`withConnection(hostId, ...)`, which resolve the full host (secrets included)
through the one connect pipeline. fleets never holds a `PluginSshHost` itself.
### The actor
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
+10 -4
View File
@@ -13,7 +13,13 @@ it's done.
- **B3 (tailscale):** `plugins/tailscale/src/backend/host-metrics-manager.ts`
and `routes.ts` still resolve hosts and access through core's
`PermissionManager`/`resolveHostById`/`DataCrypto` by relative import
instead of a `ctx.hosts`-style SDK API, because `ctx.hosts` is not built yet
(status **B** in the ctx surface table). Same debt every other host-metrics
manager already carries; not new, but tailscale now carries it too. Owner:
whichever step builds `ctx.hosts`.
instead of `ctx.hosts`, which **B4** built. Same debt every other
host-metrics manager already carries; not new, but tailscale now carries it
too. Owner: host-metrics's own Phase B step (it and every manager plugin
convert together).
- **B4 (fleets/host-metrics):** `src/backend/hosts/metrics-shared/` (platform
detection, package commands, exec-elevated) is still core's own copy, used
by host-metrics, which has not converted yet. `@termix/plugin-sdk/host-commands`
is the SDK copy fleets now uses; core's copy was left in place rather than
turned into a re-export, since host-metrics's own conversion step is where
its imports should move wholesale. Owner: host-metrics's Phase B step.
+9 -1
View File
@@ -52,6 +52,10 @@
"types": "./dist/vitest-preset.d.ts",
"default": "./dist/vitest-preset.js"
},
"./host-commands": {
"types": "./dist/host-commands.d.ts",
"default": "./dist/host-commands.js"
},
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
"./schema/manifest.schema.json": "./schema/manifest.schema.json"
},
@@ -71,11 +75,15 @@
"tar": "^7.5.22"
},
"peerDependencies": {
"drizzle-orm": "*"
"drizzle-orm": "*",
"ssh2": "*"
},
"peerDependenciesMeta": {
"drizzle-orm": {
"optional": true
},
"ssh2": {
"optional": true
}
}
}
+86
View File
@@ -395,6 +395,90 @@ export interface PluginSettings {
readCore: (key: string) => Promise<string | null>;
}
/** A host's non-secret fields, for listing, tag matching and display. */
export interface PluginHostSummary {
id: number;
userId: string;
name: string | null;
ip: string;
port: number;
username: string;
/** Comma-separated, as ssh_data stores them. Split on "," to match tags. */
tags: string | null;
folder: string | null;
authType: string;
}
/** The same shape canAccessHost returns internally, without secrets. */
export interface PluginHostAccess {
hasAccess: boolean;
isOwner: boolean;
isShared: boolean;
permissionLevel?: "connect" | "view" | "edit" | "manage";
expiresAt?: string | null;
}
export type PluginHostShareLevel = "connect" | "view" | "edit" | "manage";
export interface PluginShareTarget {
type: "user" | "role";
id: string | number;
}
export interface PluginHostShareResult {
hostId: number;
shared: boolean;
reason?: string;
}
/** A user or role a plugin can offer as a share target. No secrets, no roles' permission lists. */
export interface PluginShareableUser {
id: string;
username: string;
}
export interface PluginShareableRole {
id: number;
name: string;
displayName: string | null;
}
/**
* Hosts the acting user can see, and the RBAC + sharing operations a plugin
* needs to build a feature (like fleets) on top of hosts it does not own.
*
* list/get/checkAccess need hosts:read. share, and the user/role pickers a
* share UI needs, need hosts:write: sharing changes who can reach a host,
* which is a write on that host's access, even though the plugin owns
* neither the host nor the grant.
*/
export interface PluginHosts {
/** Every host the acting user owns or was shared, without secrets. */
list: () => Promise<PluginHostSummary[]>;
/** One host the acting user can see, or null if it does not exist or they cannot. */
get: (hostId: number) => Promise<PluginHostSummary | null>;
/** The same access rule ctx.ssh.connect enforces, but naming the level, not just yes/no. */
checkAccess: (
hostId: number,
level: PluginHostShareLevel,
) => Promise<PluginHostAccess>;
/**
* Grants access to a host the caller manages, snapshotting shared secrets
* for each target the same way the host editor's own share action does.
* Refuses a host the caller does not hold "manage" on.
*/
share: (
hostId: number,
targets: PluginShareTarget[],
permissionLevel: PluginHostShareLevel,
durationHours?: number,
) => Promise<PluginHostShareResult>;
/** Users the caller may pick as a share target, for building a picker UI. */
listUsers: () => Promise<PluginShareableUser[]>;
/** Non-system roles the caller may pick as a share target. */
listRoles: () => Promise<PluginShareableRole[]>;
}
/**
* A host as the SSH pipeline reads it: resolved by core, with secrets filled
* in for the acting user. Plugins get one from core and hand it back; they
@@ -814,6 +898,8 @@ export interface PluginContext {
readonly rbac: PluginRbac;
readonly settings: PluginSettings;
readonly disposables: PluginDisposables;
/** Hosts the actor can see, and host-sharing operations. Needs hosts:read / hosts:write. */
readonly hosts: PluginHosts;
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
readonly ssh: PluginSsh;
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
+498
View File
@@ -0,0 +1,498 @@
/**
* Generic host command helpers: platform detection, package manager actions
* and the shell-argument validators that guard them.
*
* Pure functions and thin ssh2 wrappers, no core imports. fleets and
* host-metrics both build their platform/package features on this instead of
* duplicating it or reaching into core.
*
* A plugin passes the `Client` it got from ctx.ssh.connect/withConnection.
*/
export type { Client, ClientChannel } from "ssh2";
import type { Client, ClientChannel } from "ssh2";
export interface ExecResult {
stdout: string;
stderr: string;
code: number | null;
}
/** Runs one non-interactive command over an existing ssh2 client. */
export function execCommand(
client: Client,
command: string,
timeoutMs = 30000,
): Promise<ExecResult> {
return new Promise((resolve, reject) => {
let settled = false;
let stream: ClientChannel | null = null;
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
cleanup();
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
}
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeout);
if (stream) {
try {
stream.removeAllListeners();
if (stream.stderr) {
stream.stderr.removeAllListeners();
}
stream.destroy();
} catch {
// expected - cleanup errors ignored
}
}
};
client.exec(command, { pty: false }, (err, _stream) => {
if (err) {
if (!settled) {
settled = true;
cleanup();
reject(err);
}
return;
}
stream = _stream;
let stdout = "";
let stderr = "";
let exitCode: number | null = null;
stream
.on("close", (code: number | undefined) => {
if (!settled) {
settled = true;
exitCode = typeof code === "number" ? code : null;
cleanup();
resolve({ stdout, stderr, code: exitCode });
}
})
.on("data", (data: Buffer) => {
stdout += data.toString("utf8");
})
.on("error", (streamErr: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(streamErr);
}
});
if (stream.stderr) {
stream.stderr
.on("data", (data: Buffer) => {
stderr += data.toString("utf8");
})
.on("error", (stderrErr: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(stderrErr);
}
});
}
});
});
}
export type PackageManager = "apt" | "dnf" | "yum" | "pacman" | null;
export interface PlatformInfo {
hasSystemd: boolean;
pkg: PackageManager;
hasCertbot: boolean;
hasAcmeSh: boolean;
hasDocker: boolean;
osPrettyName: string | null;
}
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
* Extend PATH with their common install locations before probing.
*/
export const EXTRA_PATH_DIRS =
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
const DOCKER_PROBE =
`command -v docker >/dev/null 2>&1` +
` || [ -S /var/run/docker.sock ]` +
` || [ -S "$HOME/.orbstack/run/docker.sock" ]`;
/**
* Single probe that reports which tooling is available. Each line is
* "key=value" so the parser is trivial and order-independent.
*/
export const PLATFORM_PROBE_COMMAND = [
`PATH="${EXTRA_PATH_DIRS}:$PATH"`,
"echo systemd=$(command -v systemctl >/dev/null 2>&1 && echo 1 || echo 0)",
"echo apt=$(command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0)",
"echo dnf=$(command -v dnf >/dev/null 2>&1 && echo 1 || echo 0)",
"echo yum=$(command -v yum >/dev/null 2>&1 && echo 1 || echo 0)",
"echo pacman=$(command -v pacman >/dev/null 2>&1 && echo 1 || echo 0)",
"echo certbot=$(command -v certbot >/dev/null 2>&1 && echo 1 || echo 0)",
'echo acmesh=$( { command -v acme.sh >/dev/null 2>&1 || [ -x "$HOME/.acme.sh/acme.sh" ]; } && echo 1 || echo 0)',
`echo docker=$(${DOCKER_PROBE} && echo 1 || echo 0)`,
'echo os=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")',
].join("; ");
export function parsePlatformProbe(output: string): PlatformInfo {
const map = new Map<string, string>();
for (const line of output.split("\n")) {
const idx = line.indexOf("=");
if (idx === -1) continue;
map.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
}
const on = (k: string) => map.get(k) === "1";
// Prefer dnf over yum when both exist (dnf is the modern front-end).
let pkg: PackageManager = null;
if (on("apt")) pkg = "apt";
else if (on("dnf")) pkg = "dnf";
else if (on("yum")) pkg = "yum";
else if (on("pacman")) pkg = "pacman";
const os = map.get("os");
return {
hasSystemd: on("systemd"),
pkg,
hasCertbot: on("certbot"),
hasAcmeSh: on("acmesh"),
hasDocker: on("docker"),
osPrettyName: os && os.length > 0 ? os : null,
};
}
export async function detectPlatform(client: Client): Promise<PlatformInfo> {
const { stdout } = await execCommand(client, PLATFORM_PROBE_COMMAND, 15000);
return parsePlatformProbe(stdout);
}
export type HostPlatform = "darwin" | "linux" | "windows" | "other";
// Windows OpenSSH's default shell is usually cmd.exe (occasionally
// PowerShell), so scripts are sent base64-encoded via -EncodedCommand -
// that avoids every cmd.exe quoting/escaping pitfall entirely, since the
// argument ends up being plain alphanumeric text with no shell metacharacters.
export function encodePowerShellCommand(script: string): string {
return Buffer.from(script, "utf16le").toString("base64");
}
export function execPowerShell(
client: Client,
script: string,
timeoutMs = 30000,
): Promise<ExecResult> {
const encoded = encodePowerShellCommand(script);
return execCommand(
client,
`powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
timeoutMs,
);
}
export async function detectHostPlatform(
client: Client,
): Promise<HostPlatform> {
try {
const { stdout, code } = await execCommand(client, "uname -s", 10000);
const kernel = stdout.trim().toLowerCase();
if (kernel === "darwin") return "darwin";
if (kernel === "linux") return "linux";
if (code === 0 && kernel) return "other";
} catch {
// expected on hosts with no POSIX shell (e.g. Windows via cmd.exe)
}
try {
const { stdout } = await execPowerShell(client, "'WIN_OK'", 10000);
if (stdout.includes("WIN_OK")) return "windows";
} catch {
// expected on hosts with no PowerShell available
}
return "other";
}
export interface UpgradablePackage {
name: string;
currentVersion?: string;
newVersion?: string;
}
export function buildListUpgradableCommand(pkg: PackageManager): string | null {
switch (pkg) {
case "apt":
return "apt list --upgradable 2>/dev/null | tail -n +2";
case "dnf":
return "dnf -q check-update 2>/dev/null || true";
case "yum":
return "yum -q check-update 2>/dev/null || true";
case "pacman":
return "pacman -Qu 2>/dev/null || true";
default:
return null;
}
}
export function parseUpgradable(
pkg: PackageManager,
output: string,
): UpgradablePackage[] {
const out: UpgradablePackage[] = [];
const lines = output
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (pkg === "apt") {
for (const line of lines) {
// name/suite newver arch [upgradable from: oldver]
const m = line.match(
/^([^/\s]+)\/\S+\s+(\S+)\s+\S+(?:\s+\[upgradable from:\s+(\S+)\])?/,
);
if (m) out.push({ name: m[1], newVersion: m[2], currentVersion: m[3] });
}
} else if (pkg === "dnf" || pkg === "yum") {
for (const line of lines) {
if (/^(Last metadata|Obsoleting|Security:)/i.test(line)) continue;
const m = line.match(/^(\S+)\s+(\S+)\s+\S+$/);
if (m && m[1].includes(".")) out.push({ name: m[1], newVersion: m[2] });
}
} else if (pkg === "pacman") {
for (const line of lines) {
const m = line.match(/^(\S+)\s+(\S+)\s+->\s+(\S+)$/);
if (m) out.push({ name: m[1], currentVersion: m[2], newVersion: m[3] });
}
}
return out;
}
export type PackageAction = "upgrade-all" | "install" | "upgrade";
export function buildPackageActionCommand(
pkg: PackageManager,
action: PackageAction,
name?: string,
): string | null {
const target = name ? ` ${name}` : "";
switch (pkg) {
case "apt":
if (action === "upgrade-all")
return "DEBIAN_FRONTEND=noninteractive apt-get -y upgrade";
return `DEBIAN_FRONTEND=noninteractive apt-get -y install${target}`;
case "dnf":
return action === "upgrade-all"
? "dnf -y upgrade"
: `dnf -y install${target}`;
case "yum":
return action === "upgrade-all"
? "yum -y update"
: `yum -y install${target}`;
case "pacman":
return action === "upgrade-all"
? "pacman -Syu --noconfirm"
: `pacman -S --noconfirm${target}`;
default:
return null;
}
}
export function buildPackageRemoveCommand(
pkg: PackageManager,
name: string,
): string | null {
switch (pkg) {
case "apt":
return `DEBIAN_FRONTEND=noninteractive apt-get -y remove ${name}`;
case "dnf":
return `dnf -y remove ${name}`;
case "yum":
return `yum -y remove ${name}`;
case "pacman":
return `pacman -R --noconfirm ${name}`;
default:
return null;
}
}
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
export function isValidPackageName(pkg: unknown): pkg is string {
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
}
export type ElevationErrorCode = "SUDO_REQUIRED" | "SUDO_FAILED" | "NOT_SUDOER";
export class ElevationError extends Error {
code: ElevationErrorCode;
constructor(code: ElevationErrorCode, message: string) {
super(message);
this.name = "ElevationError";
this.code = code;
}
}
export interface ElevatedResult {
stdout: string;
stderr: string;
code: number | null;
usedSudo: boolean;
}
const PERMISSION_DENIED = [
"permission denied",
"operation not permitted",
"must be run as root",
"must be superuser",
"you need to be root",
"are you root",
"access denied",
];
/**
* Phrases sudo itself prints to STDERR when authentication or authorization
* fails. Matched only against sudo's own stderr (never command output), so a
* command that happens to print "permission denied" on stdout is not mistaken
* for a sudo failure.
*/
const SUDO_AUTH_FAILED = [
"incorrect password",
"a password is required",
"a terminal is required",
"no tty present",
"sorry, try again",
"no password was provided",
"1 incorrect password attempt",
];
const SUDO_NOT_SUDOER = [
"is not in the sudoers file",
"not allowed to run sudo",
"not allowed to execute",
];
/**
* Marker printed only after sudo has successfully authenticated and started
* the inner shell. Its presence on stdout is the authoritative "elevation
* worked" signal; its absence (together with sudo stderr) means auth failed.
*/
const SUDO_OK_MARKER = "__TX_SUDO_OK__";
const SUDO_OK_LINE_RE = new RegExp(`^${SUDO_OK_MARKER}\\r?\\n?`);
/** Escape a value for single-quoted shell context. */
export function shellSingleQuote(value: string): string {
return `'${value.replace(/'/g, "'\"'\"'")}'`;
}
/**
* Build the elevated command string:
* echo '<pw>' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; <command>'
*
* `-p ''` suppresses the prompt. stderr is NOT merged into stdout, so the
* command's own output stays clean and sudo's auth errors stay on stderr. The
* marker is echoed by the inner shell once sudo authenticates, letting us
* tell a genuine auth failure from command output that merely contains scary
* words.
*/
export function buildSudoCommand(
command: string,
sudoPassword: string,
): string {
const pw = shellSingleQuote(sudoPassword);
const inner = shellSingleQuote(`echo ${SUDO_OK_MARKER}; ${command}`);
return `echo ${pw} | sudo -S -p '' sh -c ${inner}`;
}
function includesAny(text: string, needles: string[]): boolean {
const lower = text.toLowerCase();
return needles.some((n) => lower.includes(n));
}
function looksLikePermissionDenied(text: string): boolean {
return includesAny(text, PERMISSION_DENIED);
}
/** Strip the success marker line from the front of stdout. */
function stripMarker(stdout: string): string {
return stdout.replace(SUDO_OK_LINE_RE, "");
}
/**
* Run a command on a client, elevating with the host's stored sudo password
* only when needed (or when forced). Throws a typed `ElevationError` when
* elevation is required but unavailable/incorrect.
*/
export async function execElevated(
client: Client,
command: string,
sudoPassword: string | undefined,
opts: { forceSudo?: boolean; timeoutMs?: number } = {},
): Promise<ElevatedResult> {
const timeoutMs = opts.timeoutMs ?? 30000;
if (!opts.forceSudo) {
const direct = await execCommand(client, command, timeoutMs);
if (direct.code === 0) {
return { ...direct, usedSudo: false };
}
// Only escalate when the failure looks like a privilege problem. A
// permission-denied phrase on stderr (not arbitrary stdout) is the signal.
if (!looksLikePermissionDenied(direct.stderr)) {
return { ...direct, usedSudo: false };
}
if (!sudoPassword) {
throw new ElevationError(
"SUDO_REQUIRED",
"This action requires elevated privileges. Set a sudo password for this host to continue.",
);
}
} else if (!sudoPassword) {
throw new ElevationError(
"SUDO_REQUIRED",
"This action requires elevated privileges. Set a sudo password for this host to continue.",
);
}
const sudoCmd = buildSudoCommand(command, sudoPassword as string);
const result = await execCommand(client, sudoCmd, timeoutMs);
const authenticated = result.stdout.includes(SUDO_OK_MARKER);
if (!authenticated) {
// Elevation never started: diagnose from sudo's stderr only.
const notSudoer = includesAny(result.stderr, SUDO_NOT_SUDOER);
const authFailed = includesAny(result.stderr, SUDO_AUTH_FAILED);
if (notSudoer) {
throw new ElevationError(
"NOT_SUDOER",
"The connected user is not permitted to use sudo on this host.",
);
}
if (authFailed) {
throw new ElevationError(
"SUDO_FAILED",
"Elevation failed. Check the host's sudo password.",
);
}
// No marker and no recognizable sudo error: treat as a generic failure
// but keep the original output so the caller can surface it.
throw new ElevationError(
"SUDO_FAILED",
"Elevation failed. Check the host's sudo password.",
);
}
return {
stdout: stripMarker(result.stdout),
stderr: result.stderr,
code: result.code,
usedSudo: true,
};
}
+77
View File
@@ -23,6 +23,11 @@ import type {
PluginSecondFactor,
PluginSshAuthProvider,
PluginSshHost,
PluginHostSummary,
PluginHostAccess,
PluginHostShareResult,
PluginShareableUser,
PluginShareableRole,
} from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js";
@@ -48,6 +53,11 @@ export interface FakeContextOptions {
* when omitted every check passes, so tests that do not care stay simple.
*/
permissions?: string[];
/** Hosts ctx.hosts.list/get/checkAccess answer with. */
hosts?: PluginHostSummary[];
/** Users and roles ctx.hosts.listUsers/listRoles answer with. */
shareableUsers?: PluginShareableUser[];
shareableRoles?: PluginShareableRole[];
}
export interface FakeAuthRegistrations {
@@ -82,6 +92,13 @@ export interface FakePluginContext {
coreSettings: Map<string, string>;
/** Every ctx.ssh.connect and withConnection call, in order. */
sshConnections: Array<{ host: number | PluginSshHost; pool?: string }>;
/** Every ctx.hosts.share call, in order. */
hostShares: Array<{
hostId: number;
targets: unknown[];
permissionLevel: string;
durationHours?: number;
}>;
/** Everything registered through ctx.auth. */
auth: FakeAuthRegistrations;
/** Changes the acting user, as core's request middleware would. */
@@ -153,6 +170,10 @@ export function createFakeContext(
);
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = [];
const hostsById = new Map<number, PluginHostSummary>(
(options.hosts ?? []).map((h) => [h.id, h]),
);
const services = new Map<string, object>();
const auth: FakeAuthRegistrations = {
sshAuthProviders: [],
@@ -385,6 +406,34 @@ export function createFakeContext(
disposables,
hosts: {
list: async () => [...hostsById.values()],
get: async (hostId) => hostsById.get(hostId) ?? null,
checkAccess: async (hostId): Promise<PluginHostAccess> => {
const found = hostsById.get(hostId);
if (!found)
return { hasAccess: false, isOwner: false, isShared: false };
const isOwner = found.userId === actor;
return {
hasAccess: true,
isOwner,
isShared: !isOwner,
permissionLevel: "manage",
};
},
share: async (
hostId,
targets,
permissionLevel,
durationHours,
): Promise<PluginHostShareResult> => {
hostShares.push({ hostId, targets, permissionLevel, durationHours });
return { hostId, shared: true };
},
listUsers: async () => options.shareableUsers ?? [],
listRoles: async () => options.shareableRoles ?? [],
},
ssh: {
connect: async (host) => {
sshConnections.push({ host });
@@ -473,6 +522,7 @@ export function createFakeContext(
settings,
coreSettings,
sshConnections,
hostShares,
auth,
setActor: (userId) => {
actor = userId;
@@ -643,6 +693,33 @@ export function createMockCtx(
},
},
hosts: {
list: async () => {
require("hosts:read");
return ctx.hosts.list();
},
get: async (hostId) => {
require("hosts:read");
return ctx.hosts.get(hostId);
},
checkAccess: async (hostId, level) => {
require("hosts:read");
return ctx.hosts.checkAccess(hostId, level);
},
share: async (hostId, targets, permissionLevel, durationHours) => {
require("hosts:write");
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
},
listUsers: async () => {
require("hosts:write");
return ctx.hosts.listUsers();
},
listRoles: async () => {
require("hosts:write");
return ctx.hosts.listRoles();
},
},
ssh: {
...ctx.ssh,
connect: async (host, connectOptions) => {