feat: add auth extension points and one ssh connect pipeline

This commit is contained in:
LukeGus committed 2026-09-23 00:44:33 -05:00
1 parent d3c209af13
commit d774df8b41
159 files changed
+43243 -7451

No files matched your search

+391 -3
View File
@@ -6,11 +6,11 @@
* description of the contract rather than an implementation a plugin could
* reach around.
*
* Members marked A3-A8 are not built yet. A plugin written against them today
* will type-check and fail at runtime, which is the intended signal.
* ctx.ssh is the part of step B's host surface that A8 needed early, so plugin
* transports could leave core's internals behind.
*/
import type { PluginManifest } from "./manifest.js";
import type { PluginManifest, PluginSettingsField } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js";
/**
@@ -36,6 +36,23 @@ export class PluginCapabilityError extends Error {
}
}
/**
* Thrown by a login method or second factor to refuse a sign-in with a
* message the user can see. `code` is a stable id the login screen can map to
* its own text; `status` is the HTTP status for form methods.
*/
export class LoginMethodError extends Error {
readonly status: number;
readonly code?: string;
constructor(message: string, status = 401, code?: string) {
super(message);
this.name = "LoginMethodError";
this.status = status;
this.code = code;
}
}
export interface PluginLogger {
debug: (message: string) => void;
info: (message: string) => void;
@@ -349,6 +366,373 @@ export interface PluginSettings {
readCore: (key: string) => Promise<string | null>;
}
/**
* 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
* should not build one from scratch.
*/
export interface PluginSshHost {
id: number;
ip: string;
port: number;
username: string;
userId?: string | null;
authType?: string | null;
[key: string]: unknown;
}
/** Picks keepalive and timeout defaults. "plugin" is the generic one. */
export type PluginSshPurpose =
| "plugin"
| "docker"
| "docker-console"
| "metrics"
| "proxmox"
| "fleet"
| "remote-desktop"
| "tunnel";
export type PluginSshAuthOutcome =
| { status: "ready" }
| {
status: "interaction-required";
/** Transports send "<interaction>_auth_required", e.g. "opkssh". */
interaction: string;
message: string;
/** Extra boolean HTTP transports put in their 401 body. */
flag?: string;
}
| {
status: "error";
code:
| "missing-secret"
| "invalid-key"
| "passphrase-required"
| "provider-missing"
| "failed";
message: string;
}
| {
/** Try once more with these config changes. */
status: "retry";
patch: Record<string, unknown>;
message: string;
};
export interface PluginKeyboardInteractivePrompt {
prompt: string;
echo?: boolean;
}
export type PluginKeyboardInteractiveDecision =
| { kind: "auto"; responses: string[] }
| {
kind: "warpgate";
url: string | null;
securityKey: string;
instructions: string;
}
| { kind: "totp"; promptIndex: number }
| { kind: "input"; promptIndex: number; isPush: boolean };
export type PluginSshPromptRequest =
| { kind: "totp"; prompt: string; retry: boolean }
| { kind: "input"; prompt: string; echo: boolean; isPush: boolean }
| {
kind: "warpgate";
url: string | null;
securityKey: string;
instructions: string;
};
/** Answers keyboard-interactive prompts. Resolve null to give up. */
export interface PluginSshPromptChannel {
ask: (request: PluginSshPromptRequest) => Promise<string | null>;
}
export interface PluginSshConnectOptions {
purpose?: PluginSshPurpose;
/** Overall time to reach "ready". Defaults to 30s. */
timeoutMs?: number;
/** Without one, the stored password answers password prompts. */
prompt?: PluginSshPromptChannel;
/** Config fields to force, e.g. a longer readyTimeout. */
overrides?: Record<string, unknown>;
}
export interface PluginSshConnection<Client = unknown> {
/** An ssh2 Client, already "ready". */
client: Client;
jumpClient: Client | null;
/** Ends the connection and its jump chain. Also runs on deactivate. */
dispose: () => void;
}
/**
* SSH through core's one connect pipeline: host resolution, auth providers,
* jump hosts, proxies, host key checks, keepalive and port knocking.
*
* Every call needs ssh:connect and credentials:use, runs as the acting user
* (or the host's owner for background work on a resolved host) and writes an
* audit line per new connection. Connections still open on deactivate are
* closed.
*/
export interface PluginSsh {
connect: <Client = unknown>(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
) => Promise<PluginSshConnection<Client>>;
/** Borrows a pooled connection. The pool key is `<pool>:<owner>:<address>`. */
withConnection: <T, Client = unknown>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
) => Promise<T>;
/**
* A ready client at the end of a jump host chain, each hop resolved and
* authenticated through the pipeline. For forwarding to something that is
* not an SSH server (an RDP port behind a bastion).
*/
jumpChain: <Client = unknown>(
jumpHosts: Array<{ hostId: number }>,
options?: {
/** Background work: resolve the hops as this host's owner. */
forHost?: PluginSshHost;
},
) => Promise<PluginSshConnection<Client>>;
/** The pool key withConnection would use, for clearing it. */
poolKey: (pool: string, host: PluginSshHost) => string;
/**
* Lower level: fills an ssh2 config for a client the plugin drives itself,
* for transports with their own prompt flow. Never connects.
*/
prepare: (
host: PluginSshHost,
options: {
purpose?: PluginSshPurpose;
client: unknown;
/** Server-side host id when it differs from host.id. */
serverHostId?: number;
log?: (level: "info" | "warning" | "error", message: string) => void;
},
) => Promise<{
config: Record<string, unknown>;
outcome: PluginSshAuthOutcome;
}>;
/** Lower level: port knocking, jump hosts or proxy; sets config.sock. */
openTransport: (
host: PluginSshHost,
config: Record<string, unknown>,
) => Promise<{ jumpClient: unknown | null; via: string }>;
/** What a keyboard-interactive round is. Pure; no capability needed. */
classifyKeyboardInteractive: (
round: {
name: string;
instructions: string;
prompts: PluginKeyboardInteractivePrompt[];
},
host: PluginSshHost,
) => PluginKeyboardInteractiveDecision;
/** Stored password for password prompts, empty for the rest. */
autoResponses: (
prompts: PluginKeyboardInteractivePrompt[],
password: string | null | undefined,
) => string[];
/** Whether an auth type carries a secret (drives "auth_required" flows). */
requiresSecret: (authType: string) => boolean;
/** Whether an auth type can connect unattended, for polling. */
supportsBackground: (authType: string) => boolean;
}
export interface PluginSshAuthEnv {
/** The ssh2 Client about to connect; certificate auth patches it. */
client: unknown;
userId: string;
hostId: number;
purpose: string;
interactive: boolean;
log: (level: "info" | "warning" | "error", message: string) => void;
}
/**
* An SSH auth type. Its `type` is what ssh_data.auth_type stores, and it must
* be listed in the manifest's contributes.auth.sshAuthTypes.
*/
export interface PluginSshAuthProvider {
type: string;
labelKey: string;
descriptionKey?: string;
/** Host and credential editor fields, in the settings field schema. */
fields?: PluginSettingsField[];
/** Also offered as a stored credential type. */
credentialType?: boolean;
/** Needs a browser sign-in or a person at the keyboard. */
needsUserInteraction?: boolean;
/** Carries a secret, so a shared host needs one resolved per recipient. */
requiresSecret?: boolean;
/** Can connect unattended. Defaults to true. */
supportsBackground?: boolean;
/** Interaction name its outcomes use, for startInteraction routing. */
interaction?: string;
connectOptions?: (
host: PluginSshHost,
purpose: string,
) => Record<string, unknown>;
/** Fills the ssh2 config for this host. Never connects. */
prepare: (
config: Record<string, unknown>,
host: PluginSshHost,
env: PluginSshAuthEnv,
) => Promise<PluginSshAuthOutcome>;
/** Claims keyboard-interactive rounds for hosts of this type. */
onKeyboardInteractive?: (
round: {
name: string;
instructions: string;
prompts: PluginKeyboardInteractivePrompt[];
},
host: PluginSshHost,
) => PluginKeyboardInteractiveDecision | null;
/** Synchronous: decide on a retry before the socket closes. */
onAuthFailed?: (
host: PluginSshHost,
env: PluginSshAuthEnv,
context: {
error: Error;
retries: number;
canRetry: boolean;
methodNotAvailable: boolean;
},
) => PluginSshAuthOutcome | undefined;
/** Starts the browser step behind an interaction-required outcome. */
startInteraction?: (request: {
userId: string;
hostId: number;
host: { name?: string | null; ip: string; username: string };
socket: unknown;
requestOrigin: string;
payload: Record<string, unknown>;
}) => Promise<void>;
}
/**
* Who a login method says the user is. Core finds or provisions the user,
* runs second factors and issues the session; a method never does.
*/
export type PluginVerifiedIdentity =
| {
kind: "user";
userId: string;
/** The method already proved a second factor (a verified passkey). */
mfaSatisfied?: boolean;
/** Password logins unlock the user's data key with it. */
password?: string;
/** Redirect methods: where the browser goes back to. */
returnTo?: string;
rememberMe?: boolean;
}
| {
kind: "external";
/** Stable provider id, e.g. an SSO provider row id. */
provider: string;
/** The provider's id for the user. At most 255 characters. */
subject: string;
email?: string | null;
name?: string | null;
groups?: string[];
/** Provider says the user is an admin (admin group, LDAP group). */
isAdmin?: boolean;
/** Allowed-users list to check before provisioning or signing in. */
allowedUsers?: string | null;
mfaSatisfied?: boolean;
/** Redirect methods: where the browser goes back to. */
returnTo?: string;
rememberMe?: boolean;
};
/** Request shape a login method sees. Structural, like PluginRequestLike. */
export interface PluginLoginRequest {
body: Record<string, unknown>;
query: Record<string, unknown>;
headers: Record<string, unknown>;
ip?: string;
[key: string]: unknown;
}
export interface PluginLoginInstance {
/** Passed back to start/verify as instanceId, e.g. an SSO provider id. */
id: string;
label: string;
enabled: boolean;
}
export interface PluginLoginMethod {
id: string;
labelKey: string;
icon?: string;
kind: "redirect" | "form";
/** Enabled instances, shown on the login screen. No secrets. */
describe?: () => Promise<PluginLoginInstance[]>;
/** Redirect methods: where to send the browser. */
start?: (
request: PluginLoginRequest,
instanceId: string | null,
) => Promise<{ redirectUrl: string }>;
/** Redirect methods: the provider's callback. */
callback?: (request: PluginLoginRequest) => Promise<PluginVerifiedIdentity>;
/** Form methods: check what the user typed. */
verify?: (
request: PluginLoginRequest,
instanceId: string | null,
) => Promise<PluginVerifiedIdentity>;
}
export interface PluginSecondFactor {
id: string;
labelKey: string;
isEnrolled: (userId: string) => Promise<boolean>;
/** Anything the client needs before the user answers. */
challenge?: (userId: string) => Promise<unknown>;
/**
* True when the answer is right. Return an object to refuse with a specific
* message, for example when the factor had to be reset.
*/
verify: (
userId: string,
body: Record<string, unknown>,
) => Promise<boolean | { ok: false; error: string; code?: string }>;
/** Removes the user's enrolment. Called by the admin reset. */
reset?: (userId: string) => Promise<void>;
}
/**
* Login methods, second factors and SSH auth types. Every register call needs
* auth:provide and a matching contributes.auth entry, and is undone on
* deactivate. Core owns sessions: a method returns an identity and core does
* the rest.
*/
export interface PluginAuth {
registerSshAuthProvider: (provider: PluginSshAuthProvider) => void;
registerLoginMethod: (method: PluginLoginMethod) => void;
registerSecondFactor: (factor: PluginSecondFactor) => void;
/**
* Marks a user as enrolled in one of this plugin's factors. Core keeps the
* row even when the plugin is gone, so it can refuse the login instead of
* skipping the factor.
*/
recordEnrollment: (userId: string, factorId: string) => Promise<void>;
removeEnrollment: (userId: string, factorId: string) => Promise<void>;
}
export interface PluginContext {
readonly pluginId: string;
readonly manifest: PluginManifest;
@@ -365,6 +749,10 @@ export interface PluginContext {
readonly rbac: PluginRbac;
readonly settings: PluginSettings;
readonly disposables: PluginDisposables;
/** 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. */
readonly auth: PluginAuth;
/**
* Runs `fn` with `userId` as the acting user, for background work that has
+67 -3
View File
@@ -365,18 +365,58 @@ export interface SshAuthEditorContribution {
component?: ComponentType<SshAuthEditorProps>;
}
/** A7 declares these; A8 wires them into the login screen. */
/** What the login screen hands a login method's UI. */
export interface LoginMethodUIProps {
methodId: string;
/** Enabled instances from the server, e.g. one per SSO provider. */
instances: Array<{ id: string; label: string }>;
rememberMe: boolean;
disabled: boolean;
/** What the user typed in the username field, when there is one. */
username?: string;
/**
* Form methods: posts the body to the method's verify endpoint, then the
* login screen finishes the login or shows the second-factor step.
*/
submit: (body: Record<string, unknown>, instanceId?: string) => Promise<void>;
/** Redirect methods: sends the browser (or the system browser) away. */
startRedirect: (instanceId?: string) => Promise<void>;
/** Hands a login response from a request the UI made itself. */
complete: (response: Record<string, unknown>) => Promise<void>;
}
/**
* A button or form on the login screen. `id` matches the login method the
* backend registered with ctx.auth.registerLoginMethod; the screen shows it
* only while the server reports that method as enabled.
*/
export interface LoginMethodContribution {
id: string;
titleKey: string;
icon?: IconComponent;
component: ComponentType<Record<string, unknown>>;
component: ComponentType<LoginMethodUIProps>;
}
/** What the second-factor step hands a factor's UI. */
export interface SecondFactorUIProps {
factorId: string;
rememberMe: boolean;
disabled: boolean;
/** Sends the user's answer; the login screen finishes or shows the error. */
verify: (body: Record<string, unknown>) => Promise<void>;
/** Whatever the backend factor's challenge() returns. */
challenge: () => Promise<unknown>;
cancel: () => void;
}
export interface SecondFactorContribution {
/** Matches the id the backend registered with ctx.auth.registerSecondFactor. */
id: string;
titleKey: string;
component: ComponentType<Record<string, unknown>>;
/** The challenge shown after the first login step. */
component: ComponentType<SecondFactorUIProps>;
/** Enrolment, shown in Settings > Security. */
enrollment?: ComponentType<Record<string, unknown>>;
}
/** The subset of axios a plugin uses, rooted at /plugin-api/<id>/. */
@@ -516,6 +556,18 @@ export interface SettingsState {
save: (values: Record<string, unknown>) => Promise<void>;
}
/** An SSH auth type the server can connect with. */
export interface SshAuthTypeInfo {
type: string;
/** Core translation key, or the plugin editor's title. */
labelKey: string;
pluginId: string;
/** Also offered as a stored credential type. */
credentialType: boolean;
/** Can connect unattended, for polling. */
supportsBackground: boolean;
}
/**
* What core implements behind the hooks. Internal: a plugin never calls this.
*/
@@ -539,6 +591,7 @@ export interface PluginHostBridge {
getApi: (pluginId: string) => PluginApiClient;
useTabs: () => TabsApi;
invokeAction: (id: string, ...args: unknown[]) => Promise<unknown>;
useSshAuthTypes: () => { types: SshAuthTypeInfo[]; loaded: boolean };
}
let host: PluginHostBridge | null = null;
@@ -630,6 +683,17 @@ export function useTabs(): TabsApi {
return requireHost().useTabs();
}
/**
* The SSH auth types this server can connect with, core's and every enabled
* plugin's, for pickers such as a default auth type.
*/
export function useSshAuthTypes(): {
types: SshAuthTypeInfo[];
loaded: boolean;
} {
return requireHost().useSshAuthTypes();
}
/**
* Runs an action another plugin registered, from anywhere in a plugin's code.
* Resolves undefined when nobody registered it, so an optional dependency
+66
View File
@@ -233,6 +233,20 @@ export interface PluginSettingsContribution {
host?: PluginHostSettingsContribution;
}
/**
* What a plugin adds to sign-in and SSH auth. Declared so core can tell who
* owns an auth type or factor even while the plugin is disabled, and so the
* login screen knows which bundles it needs before anyone has signed in.
*/
export interface PluginAuthContribution {
/** Values of ssh_data.auth_type this plugin provides. */
sshAuthTypes?: string[];
/** Login method ids. */
loginMethods?: string[];
/** Second factor ids. */
secondFactors?: string[];
}
export interface PluginContributions {
tabs?: PluginTabContribution[];
panels?: PluginViewContribution[];
@@ -252,6 +266,7 @@ export interface PluginContributions {
* proxmox and web-endpoint ship it today.
*/
hostCapability?: HostCapabilityContribution | HostCapabilityContribution[];
auth?: PluginAuthContribution;
}
export interface PluginManifest {
@@ -328,6 +343,7 @@ const ALLOWED_CONTRIBUTES = new Set([
"permissions",
"settings",
"hostCapability",
"auth",
]);
const ALLOWED_SETTINGS_FIELD = [
@@ -684,6 +700,44 @@ function validateContributes(
validateActionSlots(contributes.actionSlots, errors);
validateSettings(contributes.settings, errors);
validateHostCapability(contributes.hostCapability, errors);
validateAuthContribution(contributes.auth, errors);
}
const AUTH_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
function validateAuthContribution(value: unknown, errors: string[]): void {
if (value === undefined) return;
if (!isPlainObject(value)) {
errors.push('Field "contributes.auth" must be an object');
return;
}
rejectUnknown(
value,
["sshAuthTypes", "loginMethods", "secondFactors"],
"contributes.auth",
errors,
);
for (const key of ["sshAuthTypes", "loginMethods", "secondFactors"]) {
const list = value[key];
if (list === undefined) continue;
if (!Array.isArray(list)) {
errors.push(`Field "contributes.auth.${key}" must be an array`);
continue;
}
const seen = new Set<string>();
list.forEach((entry, index) => {
if (typeof entry !== "string" || !AUTH_ID_PATTERN.test(entry)) {
errors.push(
`Field "contributes.auth.${key}[${index}]" must be a lowercase id (letters, digits and dashes)`,
);
return;
}
if (seen.has(entry)) {
errors.push(`Duplicate "${entry}" in contributes.auth.${key}`);
}
seen.add(entry);
});
}
}
function validateSettings(settings: unknown, errors: string[]): void {
@@ -1205,6 +1259,18 @@ export function parseManifest(raw: unknown): ParsedManifest {
}
}
const auth = manifest.contributes?.auth;
const contributesAuth =
(auth?.sshAuthTypes?.length ?? 0) +
(auth?.loginMethods?.length ?? 0) +
(auth?.secondFactors?.length ?? 0) >
0;
if (contributesAuth && !manifest.capabilities.includes("auth:provide")) {
errors.push(
'contributes.auth needs the "auth:provide" capability in the capabilities array',
);
}
for (const secret of manifest.requiresSecret ?? []) {
if (secret.plugin === manifest.id) {
errors.push(
+145 -2
View File
@@ -18,6 +18,12 @@ import type { TermixApp } from "./frontend.js";
import type { PluginManifest } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js";
import type { PluginMiddleware, PluginRouterOptions } from "./backend.js";
import type {
PluginLoginMethod,
PluginSecondFactor,
PluginSshAuthProvider,
PluginSshHost,
} from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js";
export interface FakeContextOptions {
@@ -29,6 +35,16 @@ export interface FakeContextOptions {
settings?: Record<string, unknown>;
/** Seed for the core settings ctx.settings.readCore can reach. */
coreSettings?: Record<string, string>;
/** What ctx.ssh.connect and withConnection hand back as the client. */
sshClient?: unknown;
}
export interface FakeAuthRegistrations {
sshAuthProviders: PluginSshAuthProvider[];
loginMethods: PluginLoginMethod[];
secondFactors: PluginSecondFactor[];
/** "<userId>:<factorId>" for every recorded enrolment. */
enrollments: Set<string>;
}
export interface FakePluginContext {
@@ -51,6 +67,10 @@ export interface FakePluginContext {
settings: Map<string, unknown>;
/** Core settings readable through ctx.settings.readCore. */
coreSettings: Map<string, string>;
/** Every ctx.ssh.connect and withConnection call, in order. */
sshConnections: Array<{ host: number | PluginSshHost; pool?: string }>;
/** Everything registered through ctx.auth. */
auth: FakeAuthRegistrations;
}
/** How a settings row is keyed in the doubles. Mirrors the real unique index. */
@@ -93,6 +113,14 @@ export function createFakeContext(
Object.entries(options.coreSettings ?? {}),
);
const settingsListeners = new Map<string, Set<(value: unknown) => void>>();
const sshConnections: FakePluginContext["sshConnections"] = [];
const auth: FakeAuthRegistrations = {
sshAuthProviders: [],
loginMethods: [],
secondFactors: [],
enrollments: new Set(),
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
for (const [key, value] of Object.entries(options.settings ?? {})) {
@@ -267,6 +295,64 @@ export function createFakeContext(
disposables,
ssh: {
connect: async (host) => {
sshConnections.push({ host });
return {
client: sshClient as never,
jumpClient: null,
dispose: () => {},
};
},
withConnection: async (host, connectOptions, fn) => {
sshConnections.push({ host, pool: connectOptions.pool });
return fn(sshClient as never);
},
jumpChain: async () => ({
client: sshClient as never,
jumpClient: null,
dispose: () => {},
}),
poolKey: (pool, host) =>
`${pool}:${host.userId}:${host.ip}:${host.port}:${host.username}`,
prepare: async () => ({ config: {}, outcome: { status: "ready" } }),
openTransport: async () => ({ jumpClient: null, via: "direct" }),
classifyKeyboardInteractive: ({ prompts }, host) => ({
kind: "auto",
responses: prompts.map((p) =>
/password/i.test(p.prompt) && typeof host.password === "string"
? host.password
: "",
),
}),
autoResponses: (prompts, password) =>
prompts.map((p) =>
/password/i.test(p.prompt) && password ? password : "",
),
requiresSecret: (authType) =>
["password", "key", "credential", "agent"].includes(authType),
supportsBackground: (authType) =>
!["none", "opkssh", "stepca"].includes(authType),
},
auth: {
registerSshAuthProvider: (provider) => {
auth.sshAuthProviders.push(provider);
},
registerLoginMethod: (method) => {
auth.loginMethods.push(method);
},
registerSecondFactor: (factor) => {
auth.secondFactors.push(factor);
},
recordEnrollment: async (userId, factorId) => {
auth.enrollments.add(`${userId}:${factorId}`);
},
removeEnrollment: async (userId, factorId) => {
auth.enrollments.delete(`${userId}:${factorId}`);
},
},
asUser: async (userId, fn) => {
const previous = actor;
actor = userId;
@@ -291,6 +377,8 @@ export function createFakeContext(
httpRouters,
settings,
coreSettings,
sshConnections,
auth,
};
}
@@ -307,6 +395,8 @@ export interface MockContextOptions {
coreSettings?: Record<string, string>;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
/** What ctx.ssh.connect and withConnection hand back as the client. */
sshClient?: unknown;
}
export interface MockPluginContext extends FakePluginContext {
@@ -320,8 +410,9 @@ export interface MockPluginContext extends FakePluginContext {
*
* The gates on today's ctx surface: kv:own on every ctx.kv call, db:own on
* ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
* topic outside the plugin's own namespace, and settings:read-core on
* ctx.settings.readCore. Reading a plugin's own settings is deliberately
* topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh, and
* auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
* ungated. As the SDK grows a member, add its gate here in the same shape.
*/
export function createMockCtx(
@@ -335,6 +426,7 @@ export function createMockCtx(
actor: options.actor,
settings: options.settings,
coreSettings: options.coreSettings,
sshClient: options.sshClient,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -437,6 +529,57 @@ export function createMockCtx(
return ctx.settings.readCore(key);
},
},
ssh: {
...ctx.ssh,
connect: async (host, connectOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.connect(host, connectOptions);
},
withConnection: async (host, connectOptions, fn) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.withConnection(host, connectOptions, fn);
},
prepare: async (host, prepareOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.prepare(host, prepareOptions);
},
openTransport: async (host, config) => {
require("ssh:connect");
return ctx.ssh.openTransport(host, config);
},
jumpChain: async (jumpHosts, chainOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.jumpChain(jumpHosts, chainOptions);
},
},
auth: {
registerSshAuthProvider: (provider) => {
require("auth:provide");
ctx.auth.registerSshAuthProvider(provider);
},
registerLoginMethod: (method) => {
require("auth:provide");
ctx.auth.registerLoginMethod(method);
},
registerSecondFactor: (factor) => {
require("auth:provide");
ctx.auth.registerSecondFactor(factor);
},
recordEnrollment: async (userId, factorId) => {
require("auth:provide");
return ctx.auth.recordEnrollment(userId, factorId);
},
removeEnrollment: async (userId, factorId) => {
require("auth:provide");
return ctx.auth.removeEnrollment(userId, factorId);
},
},
};
return { ...base, ctx: gatedCtx, checked };
+10
View File
@@ -101,6 +101,16 @@ const offline = () =>
onReady: () => noop,
}),
invokeAction: async () => undefined,
useSshAuthTypes: () => ({
types: ["password", "key", "credential", "agent", "none"].map((type) => ({
type,
labelKey: type,
pluginId: "core",
credentialType: type === "password" || type === "key",
supportsBackground: type !== "none",
})),
loaded: true,
}),
};
afterEach(() => {