feat: move opkssh and warpgate into plugins (C3)

This commit is contained in:
LukeGus committed 2026-09-24 19:28:19 -05:00
1 parent 925da4a661
commit 98d367d7e2
336 files changed
+22380 -4746

No files matched your search

+143 -48
View File
@@ -38,6 +38,7 @@ dependencies. Entry points:
| `@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/ssh-certs` | `applyCertificateAuth()`: OpenSSH certificate auth on an ssh2 client, for plugins that issue short-lived certificates |
| `@termix/plugin-sdk/testing` | Test helpers: `createMockCtx()`, `createTestDb()` and more |
Plugins never import from `src/` or `@/`, and core never imports from
@@ -217,7 +218,9 @@ handler)` for a socket. Core runs the same middleware in front of every plugin:
audited when the router is registered and logged on every request, because
"this plugin opened a hole in auth" should be findable later. It matches the
full path, with `:param` segments allowed, so declaring `/webhook/:token`
public cannot open `/webhook/:token/anything`.
public cannot open `/webhook/:token/anything`. A trailing `/*` is the one
deliberate prefix (**C3**): `/chooser/:id/*` opens that path and everything
below it, for a page the plugin proxies whose assets it cannot list.
2. **The actor**, from the user core authenticated, never from the request body.
3. **An enabled check** returning 503 while the plugin is disabled. That is a
different fact from 404 ("no such plugin") and a caller can act on it.
@@ -476,7 +479,10 @@ bridge core installs.
Slots core owns: `terminal.toolbar`, `terminal.toolbarStatus`,
`terminal.sidePanel`, `terminal.overlay` (declared by ssh-terminal; **B9**
renamed `terminal.dock` to `terminal.sidePanel` and added the status slot,
where host-metrics puts its CPU, memory and disk bars), `onboarding.steps`, `onboarding.features`,
where host-metrics puts its CPU, memory and disk bars; **C3** gave overlay
components `send(type, data)` for the session socket and `connectPayload()`,
the data an `<interaction>_auth_completed` needs to reconnect, so a sign-in
flow such as OPKSSH's lives entirely in its plugin), `onboarding.steps`, `onboarding.features`,
`onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`,
`shell.overlay`, `dashboard.hostMetrics`, `homepage.hostMetrics` and
`dashboard.secondaryView`. **B16** added the middle two, component slots in
@@ -577,12 +583,15 @@ tooltip, sheet, section-card, metric-card, charts, the card grid,
`SnippetVariablesDialog`, `FullScreenAppWrapper`, the connection log context,
`TOTPDialog` (its `MFAPromptMode` type lives in
`@termix/plugin-sdk/frontend` since **C1**, so the shell spells no `"totp"`),
`SSHAuthDialog`, `WarpgateDialog`, `PassphraseDialog`,
`OPKSSHDialog` (until opkssh moves in Phase C), `HostKeyVerificationDialog`,
`SSHAuthDialog`, `BrowserSignInDialog`, `PassphraseDialog`,
`HostKeyVerificationDialog`,
`useTabs`/`useTabsSafe`, `ActionSlot`, `ComponentSlot`, `PluginComponent` and
`FOLDER_COLORS` (the colour swatches folders and workspaces pick from).
**B10** added `popover`, `scroll-area` and `skeleton` for the tmux-monitor
plugin's session tree and its new-session/kill popovers.
plugin's session tree and its new-session/kill popovers. **C3** removed
`OPKSSHDialog` (the opkssh plugin draws its own) and replaced `WarpgateDialog`
with `BrowserSignInDialog` (`label`, `url`, `code`), the one dialog every
transport shows for a keyboard-interactive browser round.
**B9** added what a terminal-like surface needs from the shell: `cn`,
`useConfirmation`, `useIsMobile`, `runAdaptivePolling`; the terminal look
@@ -999,7 +1008,7 @@ auto-session and trusted devices, plus the base SSH auth types password, key,
stored credential, agent and none. Every other login method, second factor and
SSH auth method is a plugin through `ctx.auth`.
In 2.9.0, OPKSSH, Step-CA, Vault, Tailscale and Warpgate still live in core.
In 2.9.0, Step-CA and Vault still live in core.
They register through the same interfaces from
`src/backend/auth/legacy-providers.ts` and `src/ui/auth/legacy-auth-ui.tsx`
with `pluginId: "core"`. Phase C moves each one into its plugin by moving its
@@ -1089,6 +1098,50 @@ Nothing else in core branches on those type names.
live in `database/routes/auth-compat-routes.ts` and only forward to the
registered methods, so with the plugins off they answer 404.
**C3** moved OPKSSH and Warpgate out:
- `opkssh` registers the `opkssh` SSH auth type. It adopts `opkssh_tokens` as
`p_opkssh_tokens`, the certificate and key sealed with `ctx.secrets.seal`.
Rows 2.8 wrote were encrypted with the user's data key, which a plugin cannot
open: they read as expired and the user signs in once, which is what a
cached certificate is for (at most 24 hours old).
- The binary comes from `ctx.process.ensureBinary`: the copy the Docker image
bakes into `/app/opkssh-bundled` (`OPKSSH_BUNDLED_DIR`) when its checksum
matches, else the one in the plugin's data folder, else a download pinned to
the release's SHA-256. `opkssh login` runs through `ctx.process.run` and is
killed on cancel, close and deactivate. The config lives at
`<DATA_DIR>/plugins/opkssh/config.yml`; `redirect_uris` there are still the
localhost listener candidates only, and the public callback still goes to
OPKSSH as `--remote-redirect-uri`.
- The chooser and callback pages are public plugin routes
(`/plugin-api/opkssh/chooser/:id/*`, `/callback`, `/callback/:id/*`).
New installs register `<base>/plugin-api/opkssh/callback` with their
identity provider. `utils/crypto-migration/opkssh-config-migration.ts`
(run from `plugin-data-moves.ts`) copies a 2.8 `.opk/config.yml` in and
turns on the admin setting `legacyCallback`, so an upgraded install keeps
sending `<base>/host/opkssh-callback`, which core answers with a 307 to the
plugin (`database/routes/host-compat-routes.ts`). The admin page shows the
URI to register.
- The sign-in dialog is a `terminal.overlay` contribution. The terminal knows
no sign-in by name: it forwards `<interaction>_start_auth`, `_cancel` and
`_auth_completed` to `ctx.ssh` for any provider.
- `warpgate` registers a keyboard-interactive handler
(`ctx.auth.registerKeyboardInteractiveHandler`) and the host setting
`useWarpgate`, which `warpgate-settings-migration.ts` fills from
`ssh_data.use_warpgate` (and from hosts still on auth type `warpgate`).
The column is out of `schema.ts`; its drizzle drop is a no-op.
- A handler's browser round is the generic `browser` decision
(`{ id, label, url, code, instructions }`). The terminal sends it as
`<id>_auth_required` and takes `<id>_auth_continue`, so Warpgate keeps its
2.8 message names for Termix-Mobile; the file manager and docker answer
`requires_browser_sign_in` and continue on `connect-browser-sign-in`.
- `@termix/plugin-sdk/ssh-certs` (`applyCertificateAuth`) replaces core's
`opkssh-cert-auth.ts`: opkssh, core key auth with a CA certificate, and the
Step-CA and Vault providers still in core use it.
- Step-CA no longer shares anything with OPKSSH: its interaction is
`stepca`, its messages `stepca_*`, and it keeps issued certificates in
memory until it becomes a plugin.
#### One SSH connect pipeline
Every SSH connection, core's and plugins', goes through
@@ -1107,8 +1160,8 @@ Every SSH connection, core's and plugins', goes through
stored password for password prompts and nothing else.
Keyboard-interactive rounds are classified once (`classifyKeyboardInteractive`):
an interceptor first (Warpgate), then push MFA, TOTP, and plain input. The
transport decides how to ask.
a plugin's keyboard-interactive handler first, then push MFA, TOTP, and plain
input. The transport decides how to ask.
A host whose `authType` has no provider fails with
`This host uses <type>, which needs the <plugin> plugin` (the plugin is found
@@ -1159,6 +1212,28 @@ instead draw its own editor with `app.registerSshAuthEditor`.
A transport that can show a browser step sends `<interaction>_auth_required`
and later calls the provider's `startInteraction` for `<interaction>_start_auth`.
A server that speaks its own prompt style on any host, whatever its auth type
(an SSH bastion asking for a browser approval), is a keyboard-interactive
handler instead (**C3**):
```ts
ctx.auth.registerKeyboardInteractiveHandler({
id: "gate", // listed in contributes.auth.keyboardInteractive
label: "Gate", // shown in the sign-in dialog title
detect: (round, host, settings) =>
/gate/i.test(round.name)
? { kind: "browser", url, code, instructions: round.instructions }
: null,
autoAnswerPasswords: (host, settings) => settings.enabled === true,
});
```
`settings` is the plugin's own host settings for the host, defaults filled
in: the host resolver puts every plugin's host settings on the connect host
(`pluginSettings`) because both hooks run synchronously inside ssh2's
callback, and the handler only sees its own slice. Needs `auth:provide`; the
declaration is checked at registration, and deactivate removes the handler.
#### Login methods and second factors
```ts
@@ -1427,6 +1502,7 @@ not do.
"loginMethods": ["corp-sso"],
"secondFactors": ["pin"],
"secretSchemes": ["op"], // "<scheme>://..." secret references this plugin resolves
"keyboardInteractive": ["gate"], // keyboard-interactive handler ids
},
"settings": {
"admin": [
@@ -1594,6 +1670,8 @@ deciding, not the mechanism.
- A capability is checked where the privileged thing happens, not at
activation. Activation-time checks say what a plugin might do; call-time
checks say what it did.
- `process:spawn` is checked by `ctx.process` (**C3**); before that it was
only a statement of intent.
- `events:core` is the exception that proves the shape: without it a plugin may
only emit under `plugin.<its id>.`, because otherwise it could publish
`host.status` and drive the automations engine as though core had.
@@ -1604,42 +1682,44 @@ deciding, not the mechanism.
Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| Member | Capability | Status |
| ------------------------------------------------ | ------------------------------------ | ----------------------- |
| `ctx.pluginId`, `ctx.manifest` | none | **A1** |
| `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.files.dataDir` | `files:own` | **B13** |
| `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** |
| `ctx.secrets.seal` / `.unseal` | `secrets:own` | **C1** |
| `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** |
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.http.baseUrl(req)` | none | **C2** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** |
| `ctx.hosts.status.*` | `hosts:read` | **B16** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.channels` / `.send` | `notify:send` | **B17** |
| `ctx.auth.*` | `auth:provide` | **A8** |
| `ctx.auth.completeRedirectLogin` and the rest | `auth:provide` | **C2** |
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.schedule.every` / `.after` | none | **B16** |
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18** |
| Member | Capability | Status |
| ------------------------------------------------ | -------------------------------------------------- | ----------------------- |
| `ctx.pluginId`, `ctx.manifest` | none | **A1** |
| `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.files.dataDir` | `files:own` | **B13** |
| `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** |
| `ctx.secrets.seal` / `.unseal` | `secrets:own` | **C1** |
| `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** |
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.http.baseUrl(req)` | none | **C2** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, **B5**, **B18** |
| `ctx.hosts.status.*` | `hosts:read` | **B16** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.channels` / `.send` | `notify:send` | **B17** |
| `ctx.auth.*` | `auth:provide` | **A8** |
| `ctx.auth.completeRedirectLogin` and the rest | `auth:provide` | **C2** |
| `ctx.desktop.openIsolatedWindow` | `desktop:window` | **B8** |
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.credentials.registerSecretResolver` | `auth:provide` | **B20** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.schedule.every` / `.after` | none | **B16** |
| `ctx.fetch` | `network:outbound` | **B17**, signal **B18** |
| `ctx.process.run` / `.ensureBinary` | `process:spawn` (+ `network:outbound` to download) | **C3** |
| `ctx.auth.registerKeyboardInteractiveHandler` | `auth:provide` | **C3** |
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
generic check for a capability no other ctx member wraps. Unlike every other
@@ -1670,11 +1750,11 @@ connect pipeline instead of importing ssh2 helpers from core:
- `prepare(host, { client, purpose })`, `openTransport`,
`classifyKeyboardInteractive` and `autoResponses` are the lower level for a
transport with its own prompt flow (**B6**'s interactive file-manager
connect with its TOTP/Warpgate parking flow).
connect with its TOTP and browser sign-in parking flow).
- **B15** showed the higher level covers a multi-request HTTP handshake too:
the Docker panel passes `connect` a `prompt` channel whose `ask` answers the
waiting request with what to ask (a code, a Warpgate sign-in, credentials)
and resolves when the next request (`connect-totp`, `connect-warpgate`)
waiting request with what to ask (a code, a browser sign-in, credentials)
and resolves when the next request (`connect-totp`, `connect-browser-sign-in`)
brings the answer. A retry TOTP round re-asks instead of failing. docker
has no auth code of its own left.
- `requiresSecret(authType)` and `supportsBackground(authType)` ask the
@@ -1796,6 +1876,19 @@ resolution (the owner's shared snapshot or their own override), never the
owner's raw secret; no connect access, or no host, is `null`. Every call is
audited as `plugin_credentials_read`, allowed or refused.
**C3** added `ctx.process`, for a plugin that ships a program (opkssh):
- `run(file, args, { env, cwd, timeoutMs })` starts it without a shell and
returns `{ pid, onStdout, onStderr, exited, kill }`. Output arrives as UTF-8
text. Every program still running is killed on deactivate. Audited as
`process_run`.
- `ensureBinary({ name, version, url, sha256, prebuilt })` returns the path of
a verified executable: the first `prebuilt` path whose SHA-256 matches (a
copy baked into the Docker image, used in place), else
`<DATA_DIR>/plugins/<id>/bin/<name>` when it matches, else a download over
https (redirects followed, `network:outbound` checked only then), written
atomically with mode 755. A mismatch throws and keeps nothing.
**B20** added `ctx.credentials.registerSecretResolver(scheme, resolve)`,
behind `auth:provide` like `ctx.auth`'s registrations, for a plugin that
expands `"<scheme>://..."` references in a host's secret fields into the
@@ -2151,6 +2244,7 @@ upgrade on all three engines, and the checks in step 15 are green.
| `ctx.http.router`, `ctx.ws.*` | `network:serve` |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` |
| `ctx.auth.*` | `auth:provide` |
| `ctx.process.*` | `process:spawn` |
| `ctx.settings.readCore` | `settings:read-core` |
| `ctx.events.emit` outside `plugin.<id>.` | `events:core` |
| anything registered through `app` | `ui:surface` |
@@ -2425,6 +2519,7 @@ Then, with the app running:
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
(B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2),
opkssh and warpgate (C3),
which import nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
@@ -2502,4 +2597,4 @@ Known specifics:
owns no module state, and its guacamole-lite server is built in `activate`
with its own signal handlers removed (guacamole-lite installs SIGTERM and
SIGINT handlers in its constructor). guacd is always an external service,
so there is no `ctx.process`; its address is an admin setting.
so it does not use `ctx.process`; its address is an admin setting.
+50 -4
View File
@@ -168,10 +168,6 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
`setHostAutoTmux`, `getCookie`) and the connection helpers. They belong on
the typed host bridge, which is also where B7's `app.logActivity` line
above lands. Owner: D1.
- **B9 (ssh-terminal):** `OPKSSHDialog` is exported from
`@termix/plugin-sdk/ui` so the terminal can show the opkssh sign-in. It
leaves the ui entry when opkssh becomes a plugin that draws its own UI in
`terminal.overlay`. Owner: Phase C (opkssh).
- **B9 (host-metrics):** the terminal toolbar's CPU/memory/disk bars
(`TerminalMetricsStatus`) poll the local backend only; on the desktop app a
host that lives on a connected remote server shows no bars. Same fix as the
@@ -487,6 +483,47 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
(`h.pluginSettings?.["web-endpoint"]`). The file is untouched since before
C2. Owner: D0.
- **C3 (opkssh), for the Step-CA step:** Step-CA is still core and no longer
shares OPKSSH's pieces. Its interaction is `stepca` and its socket messages
`stepca_*`, but nothing draws them yet: until its plugin contributes a
`terminal.overlay` (the opkssh plugin's `OpksshOverlay` and `OpksshDialog`
are the model), a Step-CA host asks for a sign-in the terminal cannot show.
Its issued certificates are cached in memory (`step-ca-auth.ts`), so they
are lost on restart; its plugin should own a table the way opkssh owns
`p_opkssh_tokens`. Step-CA certificates 2.8 cached in `opkssh_tokens` stay
in the adopted `p_opkssh_tokens`, unused, until they expire. Owner: the
Phase C step that moves Step-CA.
- **C3 (opkssh):** `database/routes/host-compat-routes.ts` answers the 2.8
redirect URI `/host/opkssh-callback` with a 307 to the plugin and so names
the plugin's path. Keep it until identity providers have moved to
`/plugin-api/opkssh/callback` (the admin page shows it, and the
`legacyCallback` setting switches over). Owner: 3.0.0.
- **C3 (opkssh/warpgate):** `ssh_data.use_warpgate` and the `.opk` folder
under `DATA_DIR` stay on disk, unused (the drizzle drop is `SELECT 1;`, the
config is copied, not moved). Remove them in 3.0.0. Owner: 3.0.0.
- **C3 (opkssh):** the Termix docs still describe `DATA_DIR/.opk/config.yml`
and `/host/opkssh-callback`. They need the new config path
(`DATA_DIR/plugins/opkssh/config.yml`), the new redirect URI and the
`legacyCallback` setting. Owner: D0 (docs repo).
- **C3 (warpgate):** Termix-Mobile's file manager and docker clients call
`/ssh/connect-warpgate` and read `requiresWarpgate`; the plugins answer
`connect-browser-sign-in` and `requires_browser_sign_in` now (both clients
were already on paths B6 and B15 moved). The terminal socket keeps
`warpgate_auth_required` / `warpgate_auth_continue`. Owner: D1, with the
rest of the mobile client changes.
- **C3:** `host-bulk-routes.ts` still validates imported auth types against a
fixed list that names plugin types (`opkssh`, `stepca`, `tailscale`,
`vault`), and the host create/update routes no longer accept a
`useWarpgate` field inline (the host editor sends plugin settings). The
bulk list should come from the registered providers plus
`contributes.auth.sshAuthTypes`. Owner: D1.
- **Pre-existing, found in C3:** the Docker `opkssh-downloader` stage's
`OPKSSH_VERSION` build arg is not passed on at runtime, so building with
another version gives a prebaked binary the plugin's pinned checksum
rejects (it then downloads the pinned version instead). Pass
`OPKSSH_VERSION` and the matching `OPKSSH_SHA256` as `ENV` in the final
stage. Owner: D0.
## Manual checks after 2.9.0
- SSH terminal: password auth, key auth (and an encrypted key's passphrase
@@ -572,3 +609,12 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
sign-in; a second factor after an external login with the admin setting
on and off; disable each plugin and its buttons go away and its URLs stop
signing anyone in, then enable it again.
- OPKSSH and Warpgate: on a 2.8 Docker install with OPKSSH set up, the config
shows up under the plugin, the admin page shows the old redirect URI and a
terminal sign-in to an OPKSSH host works through the provider chooser
without touching the identity provider; switch to the new URI, register
it and sign in again; a second connect within 24 hours skips the browser;
an offline Docker install uses the baked binary; disable the opkssh plugin
and the host says it needs it. A Warpgate host's toggle is on in the host
editor's Plugins group after the upgrade, and the terminal, file manager
and docker each show the sign-in dialog and connect after it.
+4
View File
@@ -56,6 +56,10 @@
"types": "./dist/host-commands.d.ts",
"default": "./dist/host-commands.js"
},
"./ssh-certs": {
"types": "./dist/ssh-certs.d.ts",
"default": "./dist/ssh-certs.js"
},
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
"./schema/manifest.schema.json": "./schema/manifest.schema.json"
},
@@ -296,6 +296,10 @@
"secretSchemes": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
},
"keyboardInteractive": {
"type": "array",
"items": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }
}
}
},
+119 -13
View File
@@ -281,7 +281,9 @@ export type PluginMiddleware = (
export interface PluginRouterOptions {
/**
* Paths served without authentication, relative to the plugin's mount point
* and matched exactly ("/callback", not "/plugin-api/<id>/callback").
* and matched exactly ("/callback", not "/plugin-api/<id>/callback"). A
* ":name" segment matches one segment, and a trailing "/*" matches any
* number of further segments, for a proxied page ("/chooser/:id/*").
*
* For the handful of routes an unauthenticated third party has to reach: an
* OIDC callback, an inbound webhook. Every entry is audited when the router
@@ -775,26 +777,69 @@ export interface PluginKeyboardInteractivePrompt {
echo?: boolean;
}
/**
* A round the user finishes in a browser: open a URL, confirm a code there,
* then continue. Transports show one generic dialog for it and name their
* messages after `id` (the terminal sends "<id>_auth_required" and waits for
* "<id>_auth_continue").
*/
export interface PluginBrowserSignInRound {
kind: "browser";
/** The keyboard-interactive handler that claimed the round. */
id: string;
/** Product name for the dialog title. */
label: string;
url: string | null;
/** A code to compare in the browser, or "N/A". */
code: string;
instructions: string;
}
export type PluginKeyboardInteractiveDecision =
| { kind: "auto"; responses: string[] }
| {
kind: "warpgate";
url: string | null;
securityKey: string;
instructions: string;
}
| PluginBrowserSignInRound
| { 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;
| PluginBrowserSignInRound;
/** What a keyboard-interactive handler's detect may return. */
export type PluginKeyboardInteractiveDetection =
| Exclude<PluginKeyboardInteractiveDecision, PluginBrowserSignInRound>
| Omit<PluginBrowserSignInRound, "id" | "label">;
/**
* Claims keyboard-interactive rounds on any host, whatever its auth type,
* for a server that speaks its own prompt style (a bastion asking for a
* browser sign-in). Handlers run before core's TOTP and push detection.
*
* `settings` is this plugin's host-scope settings for the host, with manifest
* defaults filled in. Both hooks are synchronous; they run inside ssh2's
* keyboard-interactive callback.
*/
export interface PluginKeyboardInteractiveHandler {
/** Listed in contributes.auth.keyboardInteractive. */
id: string;
/** Product name shown when a browser round is claimed. */
label: string;
detect: (
round: {
name: string;
instructions: string;
};
prompts: PluginKeyboardInteractivePrompt[];
},
host: PluginSshHost,
settings: Record<string, unknown>,
) => PluginKeyboardInteractiveDetection | null;
/** Answer password prompts with the stored password instead of asking. */
autoAnswerPasswords?: (
host: PluginSshHost,
settings: Record<string, unknown>,
) => boolean;
}
/** Answers keyboard-interactive prompts. Resolve null to give up. */
export interface PluginSshPromptChannel {
@@ -1213,6 +1258,10 @@ export interface PluginSecondFactor {
*/
export interface PluginAuth {
registerSshAuthProvider: (provider: PluginSshAuthProvider) => void;
/** A host-independent keyboard-interactive handler. */
registerKeyboardInteractiveHandler: (
handler: PluginKeyboardInteractiveHandler,
) => void;
registerLoginMethod: (method: PluginLoginMethod) => void;
registerSecondFactor: (factor: PluginSecondFactor) => void;
/**
@@ -1459,7 +1508,8 @@ export type PluginFetch = (
* hardware (a serial port, a USB device) is the first caller. Core cannot
* mediate that access the way it mediates ctx.ssh or ctx.db, so the
* capability here is a declared, reviewable, audited statement of intent
* rather than a technical gate, matching process:spawn.
* rather than a technical gate. Running programs has its own member,
* ctx.process.
*/
export interface PluginCapabilities {
/** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */
@@ -1468,6 +1518,60 @@ export interface PluginCapabilities {
require: (capability: string) => Promise<void>;
}
export interface PluginProcessOptions {
/** Added to the server's environment. */
env?: Record<string, string>;
cwd?: string;
/** Kills the program with SIGKILL after this long. */
timeoutMs?: number;
}
/** A running program started with ctx.process.run. */
export interface PluginProcessHandle {
readonly pid: number | undefined;
/** Output as UTF-8 text, in the chunks it arrives in. */
onStdout: (listener: (chunk: string) => void) => void;
onStderr: (listener: (chunk: string) => void) => void;
/** Settles when the program ends, or rejects when it cannot start. */
readonly exited: Promise<{ code: number | null; signal: string | null }>;
kill: (signal?: "SIGTERM" | "SIGKILL") => void;
}
/** A program a plugin downloads and runs, pinned to one checksum. */
export interface PluginBinarySpec {
/** File name under the plugin's data dir, e.g. "opkssh-linux-amd64". */
name: string;
version: string;
/** https URL of the release asset. Redirects are followed. */
url: string;
/** Hex SHA-256 the file must have. */
sha256: string;
/**
* Paths checked before downloading, such as a copy baked into the Docker
* image. One whose checksum matches is used in place.
*/
prebuilt?: readonly string[];
}
/**
* Programs on the Termix server. Needs process:spawn. Every program still
* running is killed on deactivate.
*/
export interface PluginProcess {
/** Starts `file` with `args`, no shell. Audited. */
run: (
file: string,
args: readonly string[],
options?: PluginProcessOptions,
) => Promise<PluginProcessHandle>;
/**
* Returns the path of a verified executable: a matching prebuilt copy, the
* copy downloaded earlier, or a fresh download (which also needs
* network:outbound). Throws when the checksum does not match.
*/
ensureBinary: (spec: PluginBinarySpec) => Promise<string>;
}
/**
* Timers owned by the plugin. Every timer is cleared on deactivate, a run that
* throws is logged instead of crashing the server, and a repeating job never
@@ -1527,6 +1631,8 @@ export interface PluginContext {
readonly notify: PluginNotify;
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
readonly fetch: PluginFetch;
/** Programs on the Termix server. Needs process:spawn. */
readonly process: PluginProcess;
/**
* Runs `fn` with `userId` as the acting user, for background work that has
+12 -2
View File
@@ -259,6 +259,8 @@ export interface PluginAuthContribution {
secondFactors?: string[];
/** Schemes this plugin resolves for "<scheme>://..." secret references, e.g. "op". */
secretSchemes?: string[];
/** Keyboard-interactive handler ids, e.g. "warpgate". */
keyboardInteractive?: string[];
}
export interface PluginContributions {
@@ -792,7 +794,13 @@ function validateAuthContribution(value: unknown, errors: string[]): void {
}
rejectUnknown(
value,
["sshAuthTypes", "loginMethods", "secondFactors", "secretSchemes"],
[
"sshAuthTypes",
"loginMethods",
"secondFactors",
"secretSchemes",
"keyboardInteractive",
],
"contributes.auth",
errors,
);
@@ -801,6 +809,7 @@ function validateAuthContribution(value: unknown, errors: string[]): void {
"loginMethods",
"secondFactors",
"secretSchemes",
"keyboardInteractive",
]) {
const list = value[key];
if (list === undefined) continue;
@@ -1351,7 +1360,8 @@ export function parseManifest(raw: unknown): ParsedManifest {
(auth?.sshAuthTypes?.length ?? 0) +
(auth?.loginMethods?.length ?? 0) +
(auth?.secondFactors?.length ?? 0) +
(auth?.secretSchemes?.length ?? 0) >
(auth?.secretSchemes?.length ?? 0) +
(auth?.keyboardInteractive?.length ?? 0) >
0;
if (contributesAuth && !manifest.capabilities.includes("auth:provide")) {
errors.push(
+340
View File
@@ -0,0 +1,340 @@
/**
* OpenSSH certificate auth for ssh2, which has no native support for it.
*
* Grafts the certificate onto the parsed key, converts ECDSA signatures from
* DER to SSH wire format, and patches Protocol.authPK so the signature
* wrapper carries the base algorithm (OpenSSH's sshkey_check_sigtype wants
* that). Pure: no core state, it only touches the config and client it gets.
*
* Used by core key auth with a CA-signed certificate and by every plugin that
* issues short-lived certificates (opkssh, step-ca, vault).
*/
import type {
AnyAuthMethod,
AuthHandlerMiddleware,
AuthenticationType,
Client,
ConnectConfig,
PublicKeyAuthMethod,
} from "ssh2";
type SignCallback = (
data: Buffer,
callback: (signature: Buffer) => void,
) => void;
interface ParsedPrivateKey {
type: string;
sign: (data: Buffer, algo?: string) => Buffer | Error;
getPublicSSH: () => Buffer;
[key: symbol]: unknown;
}
interface PatchedProtocol {
authPK: (
user: string,
pubKey: ParsedPrivateKey,
keyAlgo: string | undefined,
cbSign?: SignCallback,
) => unknown;
_kex: {
sessionID: Buffer;
};
_packetRW: {
write: {
alloc: (payloadLength: number) => Buffer;
allocStart: number;
finalize: (packet: Buffer) => Buffer;
};
};
_authsQueue: string[];
_debug?: (message: string) => void;
_cipher: {
encrypt: (packet: Buffer) => void;
};
}
type PatchedClient = Client & {
_protocol?: PatchedProtocol;
};
type NextAuthHandler = (
authInfo: AuthenticationType | AnyAuthMethod | false,
) => void;
// Grafts an OpenSSH certificate onto an already-parsed private key object and
// patches the ssh2 client so that certificate-based publickey auth succeeds.
async function applyCertToConnection(
config: ConnectConfig,
client: Client,
privKey: ParsedPrivateKey,
certStr: string,
): Promise<void> {
// Extract cert type and blob from the stored certificate
const certParts = certStr.trim().split(/\s+/);
if (certParts.length < 2) {
throw new Error(
"Invalid certificate format: expected '<type> <base64>' string",
);
}
const certType = certParts[0];
const certBlob = Buffer.from(certParts[1], "base64");
// Graft cert type and blob onto the parsed private key
privKey.type = certType;
const pubSSHSym = Object.getOwnPropertySymbols(privKey).find(
(s) => String(s) === "Symbol(Public key SSH)",
);
if (!pubSSHSym) {
throw new Error(
"Cannot find public SSH symbol on parsed key; ssh2 internals may have changed",
);
}
privKey[pubSSHSym] = certBlob;
// Wrap sign() for ECDSA cert keys (DER → SSH wire format)
if (privKey.type.startsWith("ecdsa-")) {
const origSign = privKey.sign.bind(privKey);
privKey.sign = (data: Buffer, algo?: string) => {
const sigAlgo = algo?.includes("-cert-")
? algo.replace(/-cert-v\d+@openssh\.com$/, "")
: algo;
const sig = origSign(data, sigAlgo);
if (sig instanceof Error || sig[0] !== 0x30) return sig;
// Convert DER-encoded ECDSA signature to SSH wire format
try {
let pos = 2;
if (sig[1] & 0x80) pos += sig[1] & 0x7f;
pos++;
const rLen = sig[pos++];
const r = sig.subarray(pos, pos + rLen);
pos += rLen + 1;
const sLen = sig[pos++];
const s = sig.subarray(pos, pos + sLen);
const out = Buffer.allocUnsafe(4 + r.length + 4 + s.length);
out.writeUInt32BE(r.length, 0);
r.copy(out, 4);
out.writeUInt32BE(s.length, 4 + r.length);
s.copy(out, 4 + r.length + 4);
return out;
} catch {
return sig;
}
};
}
// Set up authHandler to bypass ssh2's cert type rejection
let certAuthAttempted = false;
const authHandler: AuthHandlerMiddleware = (
methodsLeft: string[],
_partialSuccess: boolean,
callback,
) => {
const next = callback as NextAuthHandler;
if (
!certAuthAttempted &&
(!methodsLeft || methodsLeft.includes("publickey"))
) {
certAuthAttempted = true;
next({
type: "publickey",
username: (config as Record<string, unknown>).username as string,
key: privKey as unknown as PublicKeyAuthMethod["key"],
});
} else {
next(false);
}
};
config.authHandler = authHandler;
// Monkey-patch Protocol.authPK after connect() to fix the signature
// wrapper algorithm for cert types.
const baseAlgo = certType.replace(/-cert-v\d+@openssh\.com$/, "");
const origConnect = client.connect.bind(client);
const patchedClient = client as PatchedClient;
patchedClient.connect = (cfg: ConnectConfig) => {
const connectedClient = origConnect(cfg);
const proto = patchedClient._protocol;
if (!proto) return connectedClient;
const origAuthPK = proto.authPK.bind(proto);
proto.authPK = (
user: string,
pubKey: ParsedPrivateKey,
keyAlgo: string | undefined,
cbSign?: SignCallback,
) => {
const isCertAuth = !!cbSign && pubKey?.type?.includes("-cert-");
if (!isCertAuth) {
return origAuthPK(user, pubKey, keyAlgo, cbSign);
}
// Signed auth with cert type: rebuild packet with base algo in
// the signature wrapper. keyAlgo may be undefined for ECDSA.
const certAlgo = keyAlgo || pubKey.type;
const pubSSH = pubKey.getPublicSSH();
const sessionID = proto._kex.sessionID;
const sesLen = sessionID.length;
const userLen = Buffer.byteLength(user);
const certAlgoLen = Buffer.byteLength(certAlgo);
const baseAlgoLen = Buffer.byteLength(baseAlgo);
const pubKeyLen = pubSSH.length;
// Build data to sign (uses the cert algo, which the server verifies against)
const sigDataLen =
4 +
sesLen +
1 +
4 +
userLen +
4 +
14 +
4 +
9 +
1 +
4 +
certAlgoLen +
4 +
pubKeyLen;
const sigData = Buffer.allocUnsafe(sigDataLen);
let sp = 0;
sigData.writeUInt32BE(sesLen, sp);
sp += 4;
sessionID.copy(sigData, sp);
sp += sesLen;
sigData[sp++] = 50; // SSH_MSG_USERAUTH_REQUEST
sigData.writeUInt32BE(userLen, sp);
sp += 4;
sigData.write(user, sp, userLen, "utf8");
sp += userLen;
sigData.writeUInt32BE(14, sp);
sp += 4;
sigData.write("ssh-connection", sp, 14, "utf8");
sp += 14;
sigData.writeUInt32BE(9, sp);
sp += 4;
sigData.write("publickey", sp, 9, "utf8");
sp += 9;
sigData[sp++] = 1; // TRUE
sigData.writeUInt32BE(certAlgoLen, sp);
sp += 4;
sigData.write(certAlgo, sp, certAlgoLen, "utf8");
sp += certAlgoLen;
sigData.writeUInt32BE(pubKeyLen, sp);
sp += 4;
pubSSH.copy(sigData, sp);
cbSign(sigData, (signature: Buffer) => {
const sigLen = signature.length;
const payloadLen =
1 +
4 +
userLen +
4 +
14 +
4 +
9 +
1 +
4 +
certAlgoLen +
4 +
pubKeyLen +
4 +
4 +
baseAlgoLen +
4 +
sigLen;
const packet = proto._packetRW.write.alloc(payloadLen);
let pp = proto._packetRW.write.allocStart;
packet[pp] = 50; // SSH_MSG_USERAUTH_REQUEST
packet.writeUInt32BE(userLen, ++pp);
pp += 4;
packet.write(user, pp, userLen, "utf8");
pp += userLen;
packet.writeUInt32BE(14, pp);
pp += 4;
packet.write("ssh-connection", pp, 14, "utf8");
pp += 14;
packet.writeUInt32BE(9, pp);
pp += 4;
packet.write("publickey", pp, 9, "utf8");
pp += 9;
packet[pp++] = 1; // TRUE
// Header: cert type
packet.writeUInt32BE(certAlgoLen, pp);
pp += 4;
packet.write(certAlgo, pp, certAlgoLen, "utf8");
pp += certAlgoLen;
// Public key blob
packet.writeUInt32BE(pubKeyLen, pp);
pp += 4;
pubSSH.copy(packet, pp);
pp += pubKeyLen;
// Signature wrapper: base algo (NOT cert type)
packet.writeUInt32BE(4 + baseAlgoLen + 4 + sigLen, pp);
pp += 4;
packet.writeUInt32BE(baseAlgoLen, pp);
pp += 4;
packet.write(baseAlgo, pp, baseAlgoLen, "utf8");
pp += baseAlgoLen;
packet.writeUInt32BE(sigLen, pp);
pp += 4;
signature.copy(packet, pp);
proto._authsQueue.push("publickey");
proto._debug?.("Outbound: Sending USERAUTH_REQUEST (publickey)");
const finalized = proto._packetRW.write.finalize(packet);
proto._cipher.encrypt(finalized);
});
};
return connectedClient;
};
}
export interface CertificateCredentials {
/** OpenSSH or PEM private key. */
privateKey: Buffer | string;
/** The certificate line, e.g. "ssh-ed25519-cert-v01@openssh.com AAAA...". */
certificate: string;
passphrase?: string;
}
/**
* Sets up certificate publickey auth on an ssh2 client. Call before
* `client.connect(config)`; it sets `config.authHandler` and
* `config.username`.
*/
export async function applyCertificateAuth(
config: ConnectConfig,
client: Client,
credentials: CertificateCredentials,
username: string,
): Promise<void> {
const { createRequire } = await import("node:module");
const esmRequire = createRequire(import.meta.url);
const {
utils: { parseKey },
} = esmRequire("ssh2");
(config as Record<string, unknown>).username = username;
const keyBuf = Buffer.isBuffer(credentials.privateKey)
? credentials.privateKey
: Buffer.from(credentials.privateKey, "utf8");
const parsed = credentials.passphrase
? parseKey(keyBuf, credentials.passphrase)
: parseKey(keyBuf);
if (parsed instanceof Error || !parsed) {
const reason = parsed instanceof Error ? parsed.message : "unknown error";
throw new Error(
`Failed to parse private key for certificate auth: ${reason}`,
);
}
const privKey = (
Array.isArray(parsed) ? parsed[0] : parsed
) as ParsedPrivateKey;
await applyCertToConnection(config, client, privKey, credentials.certificate);
}
+114 -3
View File
@@ -68,6 +68,10 @@ import type {
PluginNotification,
PluginNotificationChannel,
PluginFetchInit,
PluginBinarySpec,
PluginKeyboardInteractiveHandler,
PluginProcessHandle,
PluginProcessOptions,
} from "./backend.js";
/** A timer registered through ctx.schedule, run by hand with runScheduled. */
@@ -127,6 +131,11 @@ export interface FakeContextOptions {
* reaches the network by accident.
*/
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
/**
* Answers ctx.process. Without run every ctx.process.run rejects; without
* ensureBinary it answers "/tmp/<pluginId>/bin/<name>".
*/
process?: FakeProcessOptions;
/**
* Makes ctx.auth.recordEnrollment refuse the way core's policy does (trusted
* proxy login on, password login off), with this message and a 409.
@@ -140,8 +149,55 @@ export interface FakeContextOptions {
loginAttemptLimit?: number;
}
export interface FakeProcessOptions {
run?: (
file: string,
args: readonly string[],
options?: PluginProcessOptions,
) => PluginProcessHandle | Promise<PluginProcessHandle>;
ensureBinary?: (spec: PluginBinarySpec) => Promise<string>;
}
/** A ctx.process handle a test drives by hand. */
export interface FakeProcess extends PluginProcessHandle {
emitStdout: (chunk: string) => void;
emitStderr: (chunk: string) => void;
exit: (code: number | null, signal?: string | null) => void;
killed: string[];
}
export function createFakeProcess(pid = 4242): FakeProcess {
const stdout: Array<(chunk: string) => void> = [];
const stderr: Array<(chunk: string) => void> = [];
const killed: string[] = [];
let resolveExit: (value: {
code: number | null;
signal: string | null;
}) => void = () => {};
const exited = new Promise<{ code: number | null; signal: string | null }>(
(resolve) => {
resolveExit = resolve;
},
);
return {
pid,
onStdout: (listener) => void stdout.push(listener),
onStderr: (listener) => void stderr.push(listener),
exited,
kill: (signal = "SIGTERM") => {
killed.push(signal);
resolveExit({ code: null, signal });
},
emitStdout: (chunk) => stdout.forEach((listener) => listener(chunk)),
emitStderr: (chunk) => stderr.forEach((listener) => listener(chunk)),
exit: (code, signal = null) => resolveExit({ code, signal }),
killed,
};
}
export interface FakeAuthRegistrations {
sshAuthProviders: PluginSshAuthProvider[];
keyboardInteractiveHandlers: PluginKeyboardInteractiveHandler[];
loginMethods: PluginLoginMethod[];
secondFactors: PluginSecondFactor[];
/** "<userId>:<factorId>" for every recorded enrolment. */
@@ -227,6 +283,14 @@ export interface FakePluginContext {
secretStore: Map<string, string>;
/** Every ctx.fetch call, in order. */
fetches: Array<{ url: string; init?: PluginFetchInit }>;
/** Every ctx.process.run call, in order. */
processRuns: Array<{
file: string;
args: readonly string[];
options?: PluginProcessOptions;
}>;
/** Every ctx.process.ensureBinary call, in order. */
binaries: PluginBinarySpec[];
/** Every ctx.audit.record entry, in order. */
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
/** Every ctx.hosts.recordActivity call, in order. */
@@ -330,6 +394,8 @@ export function createFakeContext(
const credentialReads: FakePluginContext["credentialReads"] = [];
const notifications: FakePluginContext["notifications"] = [];
const fetches: FakePluginContext["fetches"] = [];
const processRuns: FakePluginContext["processRuns"] = [];
const binaries: FakePluginContext["binaries"] = [];
const secretStore = new Map<string, string>();
const audits: FakePluginContext["audits"] = [];
const activities: FakePluginContext["activities"] = [];
@@ -371,6 +437,7 @@ export function createFakeContext(
const registryProviders = new Map<string, unknown>();
const auth: FakeAuthRegistrations = {
sshAuthProviders: [],
keyboardInteractiveHandlers: [],
loginMethods: [],
secondFactors: [],
enrollments: new Set(),
@@ -811,13 +878,20 @@ export function createFakeContext(
requiresSecret: (authType) =>
["password", "key", "credential", "agent"].includes(authType),
supportsBackground: (authType) =>
!["none", "opkssh", "stepca"].includes(authType),
!["none", "stepca"].includes(authType) &&
!auth.sshAuthProviders.some(
(provider) =>
provider.type === authType && provider.supportsBackground === false,
),
},
auth: {
registerSshAuthProvider: (provider) => {
auth.sshAuthProviders.push(provider);
},
registerKeyboardInteractiveHandler: (handler) => {
auth.keyboardInteractiveHandlers.push(handler);
},
registerLoginMethod: (method) => {
auth.loginMethods.push(method);
},
@@ -927,6 +1001,23 @@ export function createFakeContext(
return options.fetch(url, init);
},
process: {
run: async (file, args, runOptions) => {
processRuns.push({ file, args, options: runOptions });
if (!options.process?.run) {
throw new Error("ctx.process.run is not stubbed");
}
return options.process.run(file, args, runOptions);
},
ensureBinary: async (spec) => {
binaries.push(spec);
if (options.process?.ensureBinary) {
return options.process.ensureBinary(spec);
}
return `/tmp/${pluginId}/bin/${spec.name}`;
},
},
asUser: async (userId, fn) => {
const previous = actor;
actor = userId;
@@ -960,6 +1051,8 @@ export function createFakeContext(
credentialReads,
notifications,
fetches,
processRuns,
binaries,
secretStore,
audits,
activities,
@@ -1024,6 +1117,8 @@ export interface MockContextOptions {
*/
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
/** See FakeContextOptions. */
process?: FakeProcessOptions;
/** See FakeContextOptions. */
refuseEnrollment?: string;
/** See FakeContextOptions. */
baseUrl?: string;
@@ -1046,8 +1141,8 @@ export interface MockPluginContext extends FakePluginContext {
* ctx.db, network:serve on ctx.http and ctx.ws, events:core on emitting a
* topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
* notify:send on ctx.notify, network:outbound on ctx.fetch, and
* auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
* notify:send on ctx.notify, network:outbound on ctx.fetch, process:spawn on
* ctx.process, 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(
@@ -1073,6 +1168,7 @@ export function createMockCtx(
hostStatuses: options.hostStatuses,
notificationChannels: options.notificationChannels,
fetch: options.fetch,
process: options.process,
refuseEnrollment: options.refuseEnrollment,
baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers,
@@ -1309,6 +1405,10 @@ export function createMockCtx(
require("auth:provide");
ctx.auth.registerSshAuthProvider(provider);
},
registerKeyboardInteractiveHandler: (handler) => {
require("auth:provide");
ctx.auth.registerKeyboardInteractiveHandler(handler);
},
registerLoginMethod: (method) => {
require("auth:provide");
ctx.auth.registerLoginMethod(method);
@@ -1388,6 +1488,17 @@ export function createMockCtx(
return ctx.fetch(url, init);
},
process: {
run: async (file, args, runOptions) => {
require("process:spawn");
return ctx.process.run(file, args, runOptions);
},
ensureBinary: async (spec) => {
require("process:spawn");
return ctx.process.ensureBinary(spec);
},
},
secrets: {
...ctx.secrets,
get: async (key) => {