diff --git a/README.md b/README.md index 9c3c9d8..5296813 100644 --- a/README.md +++ b/README.md @@ -440,3 +440,5 @@ See [authoring contract and style ownership](docs/AUTHORING_AND_LIVE_UI.md), [wo [Dialog, Confirmation Dialog, Popover, Progress Bar and Toast](docs/UI_COMPONENTS.md) offer separate HTML composition and scoped frontend controllers. See the [generated API reference](docs/UI_COMPONENT_API.generated.md) and [component example](examples/14-sdk-ui-components.ts). The [authoring components guide](docs/AUTHORING_COMPONENTS.md) covers persistent alerts, scoped toast queues, async action buttons, tabs, disclosures/accordions and the generated static preview gallery. + +Interactive SSH PTY sessions and opt-in X11 forwarding are documented in [SSH terminals](docs/SSH_TERMINALS.md), with an [FDO host wiring handoff](docs/FDO_HOST_TERMINAL_X11_HANDOFF.md). These require a compatible host adapter; existing noninteractive execution is unchanged. diff --git a/docs/FDO_HOST_REMOTE_COMMANDS_HANDOFF.md b/docs/FDO_HOST_REMOTE_COMMANDS_HANDOFF.md index 8344aef..1db9f3b 100644 --- a/docs/FDO_HOST_REMOTE_COMMANDS_HANDOFF.md +++ b/docs/FDO_HOST_REMOTE_COMMANDS_HANDOFF.md @@ -102,3 +102,7 @@ The identifiers `settings` and the surrounding handler are supplied by the plugi ## Acceptance checks in FDO Verify fresh initial status, heartbeat loss, reconnect, stale UI and teardown without shell probes. Test missing grants, cross-plugin IDs, revocation during monitoring/execution, pinned-key mismatch and credential rotation. Confirm output arrives before completion; exercise stdout/stderr ordering, split and malformed UTF-8, exact combined caps, truncation, slow/no consumers and commands that exceed deadlines. Test cancel before/after completion, timeout, disconnect and view closure; ensure no command retry and no false termination claim. Exercise missing/unwritable roots, disk full, flush rejection, later recovery and restart persistence. Test packaged Windows/macOS/Linux SSH without an external SSH executable. SDK mock tests cannot establish host/network integration. + +## Interactive sessions are a separate flow + +Use [the terminal and X11 host handoff](./FDO_HOST_TERMINAL_X11_HANDOFF.md) to add PTY sessions. Keep this document’s reviewed noninteractive execution unchanged; arbitrary terminal input requires explicit interactive authorization and independent view/forwarding cleanup. diff --git a/docs/FDO_HOST_TERMINAL_X11_HANDOFF.md b/docs/FDO_HOST_TERMINAL_X11_HANDOFF.md new file mode 100644 index 0000000..5bc3557 --- /dev/null +++ b/docs/FDO_HOST_TERMINAL_X11_HANDOFF.md @@ -0,0 +1,127 @@ +# FDO host wiring: interactive PTY and X11 + +The SDK facade is implemented; the sibling FDO host has **not** been modified. `ssh.exec()` and `ssh.start()` retain their existing contracts. The following changes are required in FDO before `ssh.openTerminal()` works. File paths below are relative to the inspected sibling FDO checkout. This is an implementation map, not a claim that the new IPC operations already exist. + +## 1. Permissions and profile policy + +Update `src/utils/hostServices/capabilities.json`, `contracts.cjs`, the host capability parser/preflight, authoring documentation and consent UI to recognize: + +- `ssh.terminal.open`: requires `ssh.use`, `secrets.use`, `secrets.consume`; explicitly permits arbitrary shell input. +- `ssh.x11.forward`: additionally requires `ssh.terminal.open` and separately approved per-profile/mode forwarding consent. + +In `profileValidation.js`, vault profile schema/migrations and **Settings → Credentials & SSH**, introduce explicit interactive and X11-mode authorization, default false/empty on all existing profiles. Do not reinterpret `allowAllCommands`, a command allowlist, Git consumer authorization or `ssh.exec` as interactive consent. Preserve command consumers and existing profiles. Show that interactive input bypasses command review/allowlists by design and therefore needs a stronger grant. Trusted X11 consent must explain local display/input access. A requested mode must be approved exactly. A persisted host policy can record consent; it must never store a cookie in plugin data. + +In `service.cjs` add an interactive authorization branch separate from the current `createLiveSSH` command-authorizer. Resolve the profile and credential references using the trusted endpoint's plugin identity, verify installation/grants, interactive consumer consent and pinned endpoint/key revision, and only then consume credentials. For X11, also verify the grant, mode-specific host consent and local-display preflight. Extend profile listing policy to include explicitly interactive-authorized profiles without enabling command execution or changing Git-only profile behavior. Never accept a worker-supplied plugin ID or arbitrary display, socket path, credentials or environment map. + +## 2. Host provider and display service + +The inspected provider is bundled `ssh2` ^1.17.0. `ssh.cjs` currently requests `exec(...,{pty:false})` and immediately sends stdin EOF. Leave that implementation intact. Add an independent terminal service using `client.shell({term,rows,cols,width:0,height:0}, {x11?}, callback)`, `channel.write`, `channel.setWindow(rows,columns,0,0)` and `channel.end`. Do not call end on session creation. Close the dedicated SSH connection on terminal teardown. + +Copy/adapt the tested host-only references [sshTerminal.cjs](../examples/host/sshTerminal.cjs) and [x11Forwarding.cjs](../examples/host/x11Forwarding.cjs) into `src/utils/hostServices/`. They accept an already authenticated dedicated client; they do not replace the service's independent request validation, credential loading, per-plugin handle limits, profile revision checks or teardown registry. Do not install these adapters in a plugin or expose their configuration through the UI bridge. + +Create/authenticate the client using the same profile endpoint, host `credentialsFor` consumer and `trusted(rawKey, enrolledFingerprint)` verification from `ssh.cjs`; require host-key verification before accepting ready. Keep handshake timeout/keepalive policy. Do not pool across workers or consumers. Release credentials after authentication/teardown and invalidate an opening connection when a profile/credential/grant changes. Register resource ownership **before** opening; a worker death before open acknowledgement must still destroy the pending client. Never reconnect or retry an uncertain shell start. + +Example main-process factory shape (host functions shown here must be implemented in FDO): + +```typescript +async function openTerminalForEndpoint(endpoint, options) { + validateTerminalOptionsStrictly(options); // reject unknown keys, enforce SDK and host ceilings + const lease = reserveTerminal(endpoint, options); // authentic identity + profile revision + quota + try { + const authorized = authorizeInteractive(endpoint, options); + const local = options.x11 ? await displays.prepare(authorized, options.x11.mode) : undefined; + lease.trackDisplay(local); // release even if authentication/open fails + const client = await connectPinnedSSH(authorized, lease.abortSignal); + lease.trackClient(client); + return await openSSHTerminal({ + client, options, localX11: local, + authorize: mode => reauthorizeLease(lease, mode), + onDispose: () => lease.release(), + }); + } catch (error) { + lease.release(); + throw toSafeHostServiceError(error); + } +} +``` + +`displays.prepare` is a required new host service, not an SDK API. Read local DISPLAY/XAUTHORITY and platform configuration only in the host; parse strictly rather than executing shell fragments. Discover a running authenticated local display, retrieve its authorization through a safe host API or fixed xauth executable/arguments with bounded output/time, and test local reachability. The reference supports only MIT-MAGIC-COOKIE-1 with 16-byte cookies. Reject other auth protocols/platform transports until implemented. Preserve authenticated local transport; prefer Unix sockets on supported systems. If an explicitly configured loopback TCP display is needed, require cookie authentication and never use `xhost +`, disabled access control, public listeners or a remote-accessible server. + +For untrusted forwarding, require verified X SECURITY support and create a restricted authorization with a bounded lifetime and revocation callback. Only then may the host descriptor contain `securityAuthorization: 'untrusted'`. A normal trusted cookie plus this string is **not** isolation. No such generation/probing service exists in the inspected host; initially return `X11_UNSUPPORTED` for untrusted mode. Trusted mode must also fail until local discovery and authentication are implemented. No silent trusted fallback. `local.connect()` must open a new local socket per authenticated incoming channel; its promise includes connection establishment and has a deadline. `local.dispose()` revokes session-specific authorizations and releases host state; do not revoke unrelated desktop credentials. No cookies or local addresses cross the endpoint boundary. + +Install the X11 listener before requesting the shell. `createX11Forwarding` generates a spoof cookie per terminal, puts only that cookie in the SSH request and checks incoming setup packets before any local connect. Reject channels without authorization, after session close, on cap/deadline or after revocation. Do not trust the incoming origin address as identity. Destroy both sockets on any failure; keep the setup deadline through connect and initial writes. Clear cookie buffers, reject late channels, release generated X authorizations, and destroy the dedicated SSH client. The helper contains no persistent storage or logging. Future use of connection pooling would require a separate, correctly routed channel-authorization design. + +## 3. Authenticated endpoint operations and worker adapter + +Extend the operation allowlist in `workerBridge.cjs` and endpoint dispatch in `service.cjs`; retain `watch/start/next/return/result/cancel` for existing APIs. Use **distinct** terminal operations and a terminal handle kind so a status/execution handle cannot be replayed as a terminal. Proposed exact wire operations: + +| Operation | Payload | Reply | +| --- | --- | --- | +| `terminal.open` | `SSHTerminalOpenOptions` | `{handle}` after PTY/shell and requested X11 success | +| `terminal.next` | `{handle}` | `{done:true}` or `{done:false,value:{sequence,dataBase64}}` | +| `terminal.write` | `{handle,dataBase64}` | `{accepted:true}` after write callback/drain | +| `terminal.resize` | `{handle,rows,columns}` | `{accepted:true}` | +| `terminal.eof` | `{handle}` | `{accepted:true}` | +| `terminal.result` | `{handle}` | `SSHTerminalResult` or safe rejection | +| `terminal.cancel` | `{handle}` | `SSHCancelResult`, retained idempotent acknowledgement | +| `terminal.close` | `{handle}` | `{closed:true}`; idempotent, also closes X11 | + +Use random host-issued handles stored within the authenticated endpoint/worker, not a global bearer-handle map. Bind type, identity, worker generation, profile ID/revision and grants. Reject unknown, cross-plugin, expired, cross-kind and foreign-worker handles; retain a bounded same-endpoint close/cancel tombstone long enough for racing acknowledgements. `terminal.open` is dispatched once; losing its reply does not authorize re-execution. Request IDs can be deduplicated within bounded pending/open state, but must never cause a second shell. + +Validate exact keys and types in the main process, including after worker decoding. Use canonical base64 over JSON IPC; cap encoded input at 43692 ASCII characters, validate padding/canonical form and decoded byte length <=32768 before allocation/dispatch. Return at most 32768 raw bytes per output packet. `workerLiveSource.js` decodes to a fresh `Uint8Array` and encodes input similarly; never expose a raw Node Buffer JSON object as terminal text. Binary-safe structured clone is also possible, but both ends must use one documented format. Base64 itself is not secret protection; never log bodies. + +Extend `WORKER_LIVE_SOURCE` with `openHostSSHTerminal(options)`. It calls `terminal.open` once, constructs a one-consumer pull iterator, starts `terminal.result` immediately (attach a rejection handler), and supplies write/resize/endInput/close/cancel closures. Each `next` requests one packet. Its `return` performs `terminal.close`, unlike existing execution-output `return` semantics. Memoize cancel and close replies. A terminated endpoint must reject/wake every outstanding request/next/result, including opening calls. Never send an iterable or promise through IPC. + +In `src/utils/plugins/security/pluginRuntimeSecurity.js` bootstrap add the optional adapter: + +```typescript +registry.configureHostServices({ + request: requestHostService, + watchSSHStatus: watchHostSSHStatus, + startSSHExecution: startHostSSHExecution, + openSSHTerminal: openHostSSHTerminal, +}); +``` + +These names are host bootstrap functions; only `openSSHTerminal` is the new SDK configuration property. Keep configuration before plugin initialization and keep the private endpoint out of `window.createBackendReq`. The plugin UI may invoke its own narrow backend handlers, never this service endpoint or a generic arbitrary-input operator action. + +Update host `contracts.cjs` error allowlists, worker error envelopes, diagnostics and docs with the new codes listed in [the public guide](./SSH_TERMINALS.md). Never forward provider exception text. Offer a host readiness signal for terminal renderer, terminal adapter and supported X11 modes independently; SDK version alone is insufficient. An old host must return `UNAVAILABLE` without fallback to exec/start/system ssh. + +## 4. Resource ownership and renderer + +Add a host session registry indexed by endpoint, profile revision and view lease. The reference provider enforces byte queues, open/idle/absolute timeouts, channel limits and cleanup after its client is handed over. FDO must also impose four sessions per worker/16 per host, bounded pending opens and overall memory budgets, finite control/write/result request lifetimes, and cleanup before authentication completes. The SDK has 32 pending input/control operations and 64 KiB input limits; the host must independently enforce them. Do not share the existing execution queue's truncation/drop policy with terminals. + +Pause application output at 64 KiB and hard-fail above 256 KiB. Inspected `ssh2/lib/Channel.js` fixes PACKET_SIZE at 32 KiB, MAX_WINDOW at 2 MiB and channel/extended-data high-water marks at 2 MiB; its public client API has no window tuning option. Reserve conservative provider-buffer memory per terminal and X11 channel in addition to the application queue, including pending writes and local socket buffers. Enforce a global forwarded-channel/memory quota before accepting each channel (for example 32 forwarded channels and a conservative 256 MiB provider reservation budget); the per-terminal gate cap alone is insufficient. If smaller channel windows are necessary, implement and test that change in the host provider rather than promising a configuration option that does not exist. Account for provider buffers and one incoming SSH packet beyond the threshold. Return credits after packet consumption, not after enqueueing into another IPC queue. A stalled view must leave input/cancel/close and deadlines functional. Keep enough of the bridge's existing 32 pending request slots reserved for control operations; cap outstanding `terminal.next` and `terminal.result` per handle at one and prevent request flooding from starving closure. Do not let long-poll reads monopolize a serialized UI handler. + +The minimal plugin's lease expires in 30 seconds, holds one packet until emulator acknowledgement and returns short open/cancel acknowledgements. Production FDO must have an authenticated view-release/lease notification to the backend/host; a DOM removal by itself is not a host cleanup signal. On explicit close, view release/lease expiry, worker exit/error, transport disconnect, plugin unload, profile edit/deletion, credential rotation, display change or grant revocation: invalidate ownership, stop input delivery, abort pending opens, close PTY and every X11/local socket, end pending readers/results, clear queues/timers, release authorization and settle cancellation evidence. Do not wait for JS garbage collection. Host shutdown needs an awaitable cleanup barrier before destroying workers. Preserve a completed exit result when cancellation races it. + +The reference resets idle on accepted input/control activity. Integrate host-authenticated lease renewal separately if desired; never reset idle from remote output alone. Apply lower policy ceilings in Settings; do not overstate channel closure as remote termination. The reference signals INT then closes on cancel and reports unknown termination; no remote process supervisor ships here. + +For renderer support, extend FDO's actual iframe library injection, asset bundling, CSP and types, with a vetted terminal emulator and its stylesheet. There is no existing terminal emulator to reuse: `BuildOutputTerminalComponent` uses `AnsiOutput` for SGR build logs, and injected ACE is only an editor. The example's proposed `window.FDOTerminal` wrapper and keyboard/resize/focus/disposal requirements are specified in [SSH_TERMINALS.md](./SSH_TERMINALS.md). Keep new library dependencies host-owned and document the injection before enabling plugin UI. Implement UTF-8 byte decoding in the renderer or pass raw bytes to the emulator. Never decode separate chunks independently or parse terminal output as HTML. Disable privileged OSC integrations by default. Add no X11 cookie/local socket API to this wrapper; graphical windows appear on the local X server. + +## 5. Verify and roll out + +SDK/unit validation commands: + +```sh +npm test +npm run test:examples +npm run verify:docs +npm run build +``` + +Real SSH loopback test, from the SDK checkout with the inspected sibling host dependencies installed: + +```sh +node tests/integration/ssh-terminal.cjs +# Alternate ssh2 installation: +FDO_SSH2_MODULE=/absolute/path/to/ssh2 node tests/integration/ssh-terminal.cjs +``` + +The loopback script runs an ephemeral localhost-only SSH server with ephemeral host keys and test-only credentials, verifies the pinned host key, PTY/input/resize/exit and real X11 request/channel handling. Its local display is simulated. It is not a real graphical application, OpenSSH-server configuration, installed profile/permission/UI test or untrusted SECURITY test. Unit tests mock host transports/providers and exercise cookie setup parsing, denial, backpressure, missing prerequisites, teardown and races. Do not describe either suite as desktop X11 acceptance. + +Before enabling in packaged FDO, add tests through `service.cjs`, `workerBridge.cjs`, `workerLiveSource.js` and the real iframe renderer: explicit grants/denial, command-only/Git-only profiles denied, pinned-key mismatch, foreign/kind/worker handle replay, revocation during open and while idle, request flooding, slow/no consumers, malformed/split UTF-8, binary/control/ANSI data, paste, IME, resize, focus, leases, abort before/after start, lost acknowledgements, deadlines, shutdown and zero leaked resources. Existing exec/start suites must pass unchanged. + +On a controlled real SSH server, launch nano, vi and less, enter password/input prompts, send Ctrl+C/Ctrl+D and resize. Test nonzero exit, EOF without exit, disconnect and a surviving background workload; UI must not claim all processes stopped. For X11 test real xeyes/xclock under Xorg/Xwayland, XQuartz and the selected Windows X server, authenticated local transport, disabled remote X11Forwarding, missing xauth/display, malformed/replayed cookies, handshake fragmentation, revoked/expired generated auth, channel caps and multiple windows. Inspect IPC, persistent plugin data and logs for cookie absence. Untrusted mode acceptance requires verifying restrictions against other display clients, not merely opening a window. Until those pass, list desktop platforms and untrusted mode as unverified/unsupported, independently of terminal availability. + +Roll out the updated SDK into FDO's worker build first, then permissions/profile policy, terminal provider/bridge, renderer, and terminal-only plugin UI. Enable trusted X11 only after display preflight and explicit consent exist; enable untrusted only after the SECURITY adapter and acceptance tests exist. Update installed SDK docs/authoring skills and Remote Commands' own skill to teach the new independent flow while preserving its reviewed noninteractive batch path. Use the repository release workflow; no automatic grant migration, publishing, deployment or version bump is performed by this SDK change. diff --git a/docs/INJECTED_LIBRARIES.md b/docs/INJECTED_LIBRARIES.md index bf96529..3a688bf 100644 --- a/docs/INJECTED_LIBRARIES.md +++ b/docs/INJECTED_LIBRARIES.md @@ -467,3 +467,7 @@ export default class SplitPanelPlugin extends FDO_SDK { - [FontAwesome Icons](https://fontawesome.com/icons) - [Split Grid Documentation](https://github.com/nathancahill/split/tree/master/packages/splitjs) - [ACE Editor Documentation](https://ace.c9.io/) + +## Terminal emulator availability + +No terminal emulator is currently guaranteed by this injection contract. ACE and FDO build-output components cannot emulate a PTY. [Interactive SSH](./SSH_TERMINALS.md) specifies a proposed host renderer wrapper that requires new host wiring; it is not an installed global. diff --git a/docs/SECRETS_AND_SSH.md b/docs/SECRETS_AND_SSH.md index e1a0421..a107633 100644 --- a/docs/SECRETS_AND_SSH.md +++ b/docs/SECRETS_AND_SSH.md @@ -18,6 +18,8 @@ Declare permissions through the existing `declareCapabilities()` hook. Declarati | `secrets.consume` | Allow an authorized host service to consume a referenced secret (requires `secrets.use`) | | `ssh.use` | Base permission to access SSH services; grants no operation by itself | | `ssh.exec` | Execute a remote command on an authorized host profile | +| `ssh.terminal.open` | Open a PTY shell with separately approved arbitrary input; requires secret consumption grants and host interactive profile consent | +| `ssh.x11.forward` | Additionally authorize a selected X11 mode; display and cookies stay host-owned | | `ssh.status.watch` | Monitor authenticated SSH reachability with freshness; also requires secret consumption grants | | `ssh.profiles.list` | List authorized SSH command profile IDs and labels, plus optional host, port, username, authentication type and trusted host-key fingerprint; excludes credentials, secret references and command policies | @@ -71,7 +73,7 @@ Profiles belong to the host. The exported `SSHConnectionProfile` describes an id The host should bundle `ssh2`, so users need no installed SSH executable on Windows, macOS or Linux. Remote command syntax still depends on the remote shell. A command string is shell input: do not concatenate untrusted input. The host must apply its own command policy and reject unauthorized targets and commands. Do not automatically retry commands that might have already executed. -This initial API supports buffered execution. Interactive terminals, SFTP, forwarding, agent integration, streaming events and explicit caller cancellation are future extensions. The host must enforce deadlines and dispose channels/connections on worker teardown. Closing a channel does not guarantee the remote process has stopped. +This initial API supports buffered execution. Interactive PTY terminals and optional X11 forwarding now have separate additive adapters and grants; see [Interactive SSH](./SSH_TERMINALS.md). SFTP and agent integration remain outside this API. The host must enforce deadlines and dispose channels/connections on worker teardown. Closing a channel does not guarantee the remote process has stopped. ## Live connection status and command execution @@ -149,7 +151,7 @@ Existing plugins need no changes unless adopting the new services. Adopting plug Older hosts can continue running existing plugins, but cannot execute the new service APIs until their backend transport is installed. New capabilities must be recognized by the host's capability parser and consent UI. The current SDK handshake does not advertise host readiness for these services: a new SDK version or successful SDK handshake alone is insufficient. Before enabling feature UI, the host must provide its own readiness/availability signal or handle unavailable service calls explicitly. Do not silently fall back to plaintext storage or unrestricted system SSH. -Release notes must distinguish the implemented SDK facade and protocol from the required FDO host encryption/network implementation. Current FDO hosts can reuse profiles through the existing Git operator API. No dedicated Git client, transport helper or new Git permission family ships inside this SDK package; these host features require a compatible FDO version. Interactive SSH, SFTP and agent forwarding remain outside v1. Streaming events, cancellation and connection monitoring use the optional live adapters documented above; they do not change the buffered v1 request/reply actions. End-to-end cross-platform claims require packaged host verification. +Release notes must distinguish the implemented SDK facade and protocol from the required FDO host encryption/network implementation. Current FDO hosts can reuse profiles through the existing Git operator API. No dedicated Git client, transport helper or new Git permission family ships inside this SDK package; these host features require a compatible FDO version. Interactive SSH and optional X11 use the separate adapter documented in [Interactive SSH](./SSH_TERMINALS.md); SFTP and agent forwarding remain outside v1. Streaming events, cancellation and connection monitoring use the optional live adapters documented above; they do not change the buffered v1 request/reply actions. End-to-end cross-platform claims require packaged host verification. Use the repository's Release Please workflow for the version and changelog update. A conventional `feat` commit describes the additive feature; do not mark it as a breaking change or manually promise Git integration in the release title. The safe profile metadata fields are an additive extension to the SDK 1.8 service contract; older hosts returning only id/label remain compatible. Let Release Please determine the actual next version from the release baseline and commit history. diff --git a/docs/SSH_TERMINALS.md b/docs/SSH_TERMINALS.md new file mode 100644 index 0000000..2f70299 --- /dev/null +++ b/docs/SSH_TERMINALS.md @@ -0,0 +1,110 @@ +# Interactive SSH terminals and optional X11 + +`ssh.openTerminal(options, signal?)` adds a separately authorized PTY shell. `ssh.exec()` and `ssh.start()` remain noninteractive, with their existing capture, timeout and command-policy behavior. There is no fallback between these APIs and no automatic retry after an uncertain start. SDK installation alone does not enable a terminal: FDO must install `openSSHTerminal`, authenticated IPC operations, lifecycle cleanup and a terminal renderer. See the [exact host handoff](./FDO_HOST_TERMINAL_X11_HANDOFF.md). + +## Backend API + +Declare `createSSHCapabilityBundle({terminal: true})`. Add `x11: true` only if the plugin offers forwarding; this requests a separate `ssh.x11.forward` grant. Declarations never approve grants. Existing bundles retain their permissions. Terminal operations require `ssh.use`, `ssh.terminal.open`, `secrets.use`, `secrets.consume`, and explicit host profile authorization for arbitrary interactive input. `ssh.exec` and an allowlisted command are insufficient. Host settings must make that distinction visible before granting access. + +```typescript +const session = await this.ssh.openTerminal({ + profileId, term: 'xterm-256color', rows: 24, columns: 80, + idleTimeoutMs: 300_000, sessionTimeoutMs: 3_600_000, +}, abortController.signal); +// Consume concurrently with result; publishBytes must wait for renderer acknowledgement. +const outputDone = (async () => { + for await (const {sequence, data} of session.output) await publishBytes(sequence, data); +})(); +try { + await session.write('nano notes.txt\r'); + await session.resize(40, 120); + const [result] = await Promise.all([session.result, outputDone]); + showExit(result); +} finally { + await session.close(); +} +``` + +`profileId`, the controller, renderer publication and exit UI are supplied by the plugin. Public types are `SSHTerminalOptions`, `SSHTerminalOpenOptions`, `SSHTerminalSession`, `SSHTerminalOutput`, `SSHTerminalResult` and `HostSSHTerminalSession`. No session ID, cookie, local display or socket is exposed by the SDK session object. + +| Operation | Meaning | +| --- | --- | +| `output` | Single-consumer async iterable of `{sequence, data: Uint8Array}`; no prefetch in SDK | +| `write(string or Uint8Array)` | UTF-8 text or exact bytes; acknowledgement means host write/drain, not remote execution | +| `resize(rows, columns)` | Ordered with input; sends SSH window change, not a shell command | +| `endInput()` | Idempotent SSH stdin EOF/half-close; does not close output or prove shell termination | +| `result` | Confirmed shell exit/status, channel closure, cancellation, or safe typed failure | +| `close()` | Idempotent release of terminal and forwarding; not a remote kill acknowledgement | +| `cancel()` | Idempotent termination request; separate `{remoteProcessStopped: boolean or null}` evidence | +| Abort signal | Reject pending open with `CANCELLED`, close a late acknowledged session; active abort requests cancellation | + +Ctrl+C is input byte `0x03` (`write('\x03')`), interpreted by remote PTY settings; it is not a session cancellation request. Ctrl+D is terminal input `0x04`, distinct from SSH EOF. Do not locally echo input; the remote PTY normally controls echo, including password prompts. PTY output typically combines stdout and stderr. Any separate provider extended data is merged by the host in observation order; there is no separate stderr API for this terminal. + +`result.reason` is `exited`, `closed` or `cancelled`. An exited result requires an actual SSH exit status or exit signal. A closed result has null exit code, signal and termination evidence. `remoteProcessStopped` refers to the shell/workload for which affirmative evidence exists, never all child jobs. Closing a socket, sending a signal, timing out, or losing the view cannot establish that evidence. Cancellation racing an already settled result preserves it. A supervisor protocol would be needed for guaranteed descendant termination. Failures such as disconnect and deadlines reject `result` with `HostServiceError`; attach handlers immediately while consuming output. A result may settle before its queued output is drained. + +## Bytes, ordering and pressure + +Output preserves every byte, including ANSI/VT sequences; the SDK neither strips escape sequences nor renders HTML. Each event contains 1–32768 bytes with a contiguous zero-based sequence. The SDK copies byte arrays at its boundary. Ordering is the host's observed channel order. Input writes, resize and EOF are serialized in call order. One output `next()` may be outstanding; concurrent reads reject with `BACKPRESSURE`. + +Prefer passing raw bytes to the emulator. If converting to text, use one incremental UTF-8 decoder for the entire merged stream, never decode each packet independently: + +```typescript +const decoder = new TextDecoder('utf-8', {fatal: false}); +for await (const event of session.output) { + await consumeText(decoder.decode(event.data, {stream: true})); +} +await consumeText(decoder.decode()); +``` + +Malformed UTF-8 becomes replacement characters only at this decoding boundary; binary input/output remains exact. Text input encodes each `write(string)` separately; do not split a UTF-16 surrogate pair across calls. Use raw bytes for other encodings. Do not apply text/UTF-8 processing to X11 protocol data. + +The SDK admits at most 32 pending input/control operations and 64 KiB of pending input, each write <=32 KiB. It rejects excess before dispatch; the rejected operation was not accepted and must not be silently ignored. A failed dispatched write can have an ambiguous remote prefix: close the session and report the failure, never replay it. No SDK output queue or total transcript capture exists. The host reference uses a 64 KiB output high-water mark, 256 KiB hard application output queue limit and 32 KiB packets. Inspected ssh2 1.17 additionally uses a 2 MiB channel window and 2 MiB stream high-water marks; these are provider buffers outside the application queue, not a promised 256 KiB total session budget. There is no public window-size tuning option in the inspected client API; budget those buffers or change the host provider deliberately. The host pauses upstream reads and grants credits only as consumers acknowledge bytes. If lossless delivery cannot be maintained, fail with `BACKPRESSURE` and close the session; never drop or truncate a terminal sequence and continue. + +The example holds one output packet until the iframe acknowledges emulator consumption, then pulls the next. Repeated polls return the same sequence; the view must deduplicate. Never acknowledge on receipt alone, or use an unbounded polling/push queue. Keep output draining independent of input/cancel operations; blocking on `next()` or `result` must not occupy the host operation scheduler. All bridge queues, event counts, bytes, requests and handles need their own host bounds. + +Defaults: idle five minutes, absolute session one hour; SDK request ceilings are one hour idle and 24 hours absolute. Dimensions are 1–1000 each; terminal names are 1–64 restricted ASCII characters. The host can impose lower limits and must explain its effective policy in Settings. Idle time resets on accepted keyboard/control activity and view lease renewal, not on remote output; output-only loops must not extend sessions forever. A missing view lease expires in 30 seconds. Suggested limits are four terminals per worker, 16 per host and eight X11 channels per terminal. Host handshake/open and control deadlines remain independent of idle/session timers. + +Safe new codes are `BACKPRESSURE`, `SESSION_CLOSED`, `IDLE_TIMEOUT`, `SESSION_LIMIT`, `X11_UNSUPPORTED`, `X11_UNAVAILABLE`, `X11_AUTH_UNAVAILABLE` and `X11_FORWARDING_DENIED`, in addition to existing host-service codes. No provider error text, output, credentials, cookie, path or display details belong in these errors or logs. + +## Terminal UI and example + +Inspection found `BuildOutputTerminalComponent`/`AnsiOutput` in FDO: a React build-output viewer supporting SGR styles, not cursor addressing, alternate screens, input, or PTY emulation. ACE is an editor; neither it nor `
` supports nano/vi/less. The [injected library inventory](./INJECTED_LIBRARIES.md) has no terminal emulator. This SDK adds no terminal-emulator npm dependency.
+
+[The minimal plugin](../examples/18-interactive-ssh-plugin.ts) keeps session objects in its backend, returns short open acknowledgements, sends one acknowledged output packet at a time, bounds keyboard traffic and uses a 30-second view lease. It demonstrates terminal-only mode and a separately selected X11 mode. It intentionally reports a missing renderer on existing hosts. It is an instructional single-view plugin; production Remote Commands should incorporate the same flow into its existing view lifecycle, not replace its reviewed batch executor.
+
+FDO must inject and document a new terminal renderer. The example's **proposed**, not currently installed, `window.FDOTerminal` wrapper contract is:
+
+- `create(element, {term, rows, columns})` returns `write(Uint8Array, consumedCallback)`, `onData(callback)`, `onResize(callback)`, `focus()`, `getSize()` (actual `{rows,columns}`), `reset()` and `dispose()`.
+- Event subscriptions return `{dispose()}`. `onData` produces terminal input strings; the host renderer handles keys, modifiers, paste, IME and terminal modes. It must support binary byte writes and callback backpressure.
+- FDO may implement this with a vetted VT emulator and a resize addon in its own iframe injection pipeline; include its styles there. Do not assume arbitrary imports work inside a serialized `renderOnLoad` callback.
+- Fit to the visible content area using ResizeObserver, coalesce pending size changes, and send the actual rows/columns after session readiness. Preserve focus across output; focus only on explicit open/click, never steal it from other controls. Provide visible focus, accessible labeling, selection/copy and deliberate paste behavior. Do not capture global shortcuts outside the focused terminal. Keep keyboard input disabled until the session is ready, and bind queued input to the specific session generation so it cannot reach a later session.
+- Default-deny remote clipboard writes (e.g. OSC 52), automatic link navigation and privileged escape-sequence integrations. Remote output is untrusted terminal data, never HTML.
+- On view release, dispose keyboard/resize listeners, observers, subscriptions and emulator; explicitly release the backend lease. Pagehide is best-effort; the host must independently close sessions on teardown/revocation/disconnect. Never depend on DOM or iterator finalizers for host cleanup.
+
+## Optional graphical applications
+
+```typescript
+const session = await this.ssh.openTerminal({profileId, x11: {mode: 'untrusted'}});
+// Only after separate host consent and successful negotiation:
+await session.write('xeyes\r');
+// The windows appear on the local X server, outside the FDO iframe.
+```
+
+Omit `x11` to leave it off. Selecting a mode requests it exactly; never downgrade untrusted to trusted or continue terminal opening with forwarding silently disabled after a denied request. The user can explicitly open a new terminal without forwarding. Display discovery, local socket addressing, local authorization and spoof cookies are strictly host-owned. The host transport exposes no cookie, authorization reference or local display address to the UI or plugin persistence/logs. Real local authorization never reaches the remote server. An unrestricted remote shell can itself print remote data, including its per-session spoof authorization; lossless PTY streaming cannot promise to censor that remote-generated text. The spoof authorization is session-scoped and cannot authenticate directly to the local X server. Do not persist or log terminal output by default. Host profile credentials and pinned host keys are reused, without granting plaintext `secrets.get`.
+
+Real SSH X11 forwarding requires an `x11-req` and handling incoming `x11` channels. The [host reference gate](../examples/host/x11Forwarding.cjs) verifies a random per-session spoof MIT-MAGIC-COOKIE-1 authorization in each fragmented X11 setup packet before connecting to the local display, then substitutes the host-held local authorization. It handles byte order, length/protocol checks, setup deadlines, channel caps, bidirectional piping and teardown. It is not `DISPLAY` injection, SSH TCP port forwarding, or an `xhost +` workaround. It requires a dedicated SSH connection per terminal because incoming X11 channels are connection-scoped. The real local cookie never goes to the remote server. See [RFC 4254 §6.3](https://www.rfc-editor.org/rfc/rfc4254.html#section-6.3).
+
+Trusted forwarding grants remote X clients broad access to the local display and potentially other clients' input or data. Untrusted forwarding needs an actual restricted authorization generated and enforced by the local X SECURITY extension (and expiry/revocation), typically via host-owned xauth tooling. `ssh2` exposes forwarding requests but has no OpenSSH `-X` isolation mode switch. A spoof cookie alone provides authentication, not untrusted isolation. The reference helper accepts a host assertion of an actual generated untrusted authorization; the supplied host integration must return `X11_UNSUPPORTED` until it implements and verifies that facility. Never set that assertion from a plugin field. See [X SECURITY](https://xorg.freedesktop.org/archive/X11R7.7/doc/xextproto/security.html) and [OpenSSH forwarding semantics](https://man.openbsd.org/ssh.1).
+
+| Platform/prerequisite | Requirement and current verification |
+| --- | --- |
+| Linux desktop | Local Xorg or compatible Xwayland display with authenticated local socket and MIT cookie; headless hosts require a separately provisioned display |
+| macOS | Running XQuartz and usable host-owned display socket/authorization; Electron startup environment may lack DISPLAY; discover in host settings |
+| Windows | An X server with authenticated local transport usable by the host; platform adapter required, no unauthenticated TCP fallback |
+| Remote server | SSH X11 forwarding allowed (e.g. X11Forwarding), xauth path/tooling where required, X11 application installed, PTY/shell permitted |
+| Untrusted mode | Local X SECURITY plus a generated restricted authorization and lifetime management; unsupported by default |
+| Actual tests | Node mock suites and real loopback ssh2 integration on macOS, using a simulated local X server; no XQuartz/Xorg/Windows GUI or packaged-host testing |
+
+Missing/unsupported display or provider maps to `X11_UNAVAILABLE`/`X11_UNSUPPORTED`; missing authorization/tooling maps to `X11_AUTH_UNAVAILABLE`; rejected SSH negotiation maps to `X11_FORWARDING_DENIED`. Host preflight must check prerequisites before starting the shell, and a negotiation failure must release all allocated state. The combined ssh2 shell callback cannot always distinguish PTY/shell denial from X11 request denial; with X11 requested the reference adapter returns `X11_FORWARDING_DENIED` without leaking provider diagnostics. Server-side investigation remains a host diagnostic task.
+
+X11 forwarding does not automatically support native Wayland applications; applications must use an X11 backend. It does not embed windows inside FDO. Embedding requires a separate remote-display/compositing solution. SFTP, agent forwarding, arbitrary TCP forwarding, native Wayland transport, guaranteed descendant termination, and automatic reconnect/resume are outside this API.
diff --git a/examples/18-interactive-ssh-plugin.ts b/examples/18-interactive-ssh-plugin.ts
new file mode 100644
index 0000000..86886ec
--- /dev/null
+++ b/examples/18-interactive-ssh-plugin.ts
@@ -0,0 +1,119 @@
+/** Minimal lossless, one-view terminal. Requires the proposed host terminal renderer injection
+ * in docs/SSH_TERMINALS.md; current FDO has no such injection. No emulator dependency is bundled.
+ */
+import {FDO_SDK, PluginRegistry, HostServiceError, createSSHCapabilityBundle} from '@anikitenko/fdo-sdk';
+import type {SSHTerminalSession, SSHTerminalResult, SSHTerminalOutput} from '@anikitenko/fdo-sdk';
+import {randomUUID} from 'node:crypto';
+
+type Run = {id:string; touched:number; session?:SSHTerminalSession; controller:AbortController;
+ packet?:SSHTerminalOutput; acknowledge?:()=>void; done:boolean; result?:SSHTerminalResult; error?:string};
+export default class InteractiveSSHExample extends FDO_SDK {
+ get metadata() {return {name:'Interactive SSH example',version:'1.0.0',author:'FDO SDK',description:'PTY and optional X11 through host profiles',icon:'console'};}
+ // Explicit declarations only. Host approval of arbitrary input and X11 mode is separate.
+ declareCapabilities() {return createSSHCapabilityBundle({terminal:true,x11:true});}
+ init():void {
+ let run:Run|undefined;
+ const release = () => {if(run){const old=run;run=undefined;old.controller.abort();old.acknowledge?.();void old.session?.close().catch(()=>{});}};
+ const timer=setInterval(()=>{if(run && Date.now()-run.touched>30000)release();},1000);timer.unref?.();
+ const data = (v:unknown):Record => {
+ if(!v || typeof v!=='object' || Array.isArray(v))throw new HostServiceError('INVALID_REQUEST');return v as Record;
+ };
+ const current = (v:Record) => {
+ if(!run || v.id!==run.id)throw new HostServiceError('ACCESS_DENIED');run.touched=Date.now();return run;
+ };
+ const register = (name:string,handler:(v:Record)=>unknown) => PluginRegistry.registerHandler(`terminal.${name}`,async(v:unknown)=>{
+ try{return {ok:true,data:await handler(data(v))};}catch(e){return {ok:false,code:e instanceof HostServiceError?e.code:'UNAVAILABLE'};}
+ });
+ register('open',v=>{
+ if(run)throw new HostServiceError('SESSION_LIMIT');
+ if(typeof v.profileId!=='string'||!['off','trusted','untrusted'].includes(v.mode as string))throw new HostServiceError('INVALID_REQUEST');
+ const entry:Run={id:randomUUID(),touched:Date.now(),controller:new AbortController(),done:false};run=entry;
+ // Return promptly: FDO serializes UI handlers. Never await the whole session in a handler.
+ void (async()=>{
+ try{
+ const session=entry.session=await this.ssh.openTerminal({profileId:v.profileId as string,
+ ...(v.mode==='off'?{}:{x11:{mode:v.mode as 'trusted'|'untrusted'}})},entry.controller.signal);
+ const result=session.result.then(value=>{entry.result=value;},e=>{entry.error=e instanceof HostServiceError?e.code:'UNAVAILABLE';});
+ for await(const packet of session.output){
+ if(run!==entry)break;
+ await new Promise(resolve=>{entry.acknowledge=resolve;entry.packet=packet;});
+ }
+ await result;
+ }catch(e){entry.error=e instanceof HostServiceError?e.code:'UNAVAILABLE';}
+ finally{entry.done=true;void entry.session?.close().catch(()=>{});}
+ })();
+ return {id:entry.id};
+ });
+ register('poll',v=>{
+ const r=current(v);
+ if(r.packet && v.ack===r.packet.sequence){r.packet=undefined;r.acknowledge?.();r.acknowledge=undefined;}
+ return {ready:!!r.session,packet:r.packet?{sequence:r.packet.sequence,bytes:Array.from(r.packet.data)}:null,done:r.done,result:r.result,error:r.error};
+ });
+ register('input',async v=>{
+ const r=current(v);if(!r.session)throw new HostServiceError('UNAVAILABLE');
+ if(typeof v.text!=='string' || v.text.length>8192)throw new HostServiceError('INVALID_REQUEST');
+ await r.session.write(v.text);return {};
+ });
+ register('resize',async v=>{const r=current(v);if(!r.session)throw new HostServiceError('UNAVAILABLE');await r.session.resize(v.rows as number,v.columns as number);return {};});
+ register('eof',async v=>{const r=current(v);await r.session?.endInput();return {};});
+ register('close',v=>{current(v);release();return {};});
+ register('cancel',v=>{const r=current(v);r.controller.abort();return {requested:true,remoteProcessStopped:null};});
+ // Host worker teardown must close its authenticated endpoint even if this timer cannot run.
+ }
+ render():string {return '';}
+ renderOnLoad():string {return `(${mountTerminalExample.toString()})();`;}
+}
+
+/** Serialized into the iframe; depends only on host-injected globals. */
+function mountTerminalExample() {
+ const win=window as any, screen=document.getElementById('terminal-screen')!,status=document.getElementById('terminal-status')!;
+ // Proposed, documented host wrapper, NOT an existing FDO injected API.
+ if(!win.FDOTerminal){status.textContent='This host needs a terminal emulator injection. See the SDK terminal host handoff.';return;}
+ let id='',ready=false,disposed=false,polling=false,ack=-1,queued=0,pending=0,tail=Promise.resolve();
+ const term=win.FDOTerminal.create(screen,{term:'xterm-256color',rows:24,columns:80});
+ const request=async(name:string,content:Record)=>{
+ const raw=await win.createBackendReq('UI_MESSAGE',{handler:`terminal.${name}`,content});
+ const response=typeof raw==='string'?JSON.parse(raw):raw;
+ const result=response?.result??response?.content??response;
+ if(!result.ok)throw new Error(result.code||'UNAVAILABLE');return result.data;
+ };
+ const report=(e:unknown)=>{status.textContent=e instanceof Error?e.message:'Terminal failed';};
+ const subscriptions=[term.onData((text:string)=>{
+ if(!id||!ready)return;
+ const active=id;
+ const bytes=new TextEncoder().encode(text).length;
+ if(text.length>8192||pending>=32||bytes>32768||queued+bytes>65536){report(new Error('Input pressure limit reached; session closed.'));void close();return;}
+ pending++;queued+=bytes;
+ tail=tail.then(()=>id===active?request('input',{id:active,text}):undefined).catch(e=>{report(e);void close();}).finally(()=>{pending--;queued-=bytes;});
+ }),term.onResize(({rows,columns}:{rows:number;columns:number})=>{
+ const active=id;
+ if(active && ready && queued===0){queued=1;tail=tail.then(()=>id===active?request('resize',{id:active,rows,columns}):undefined).catch(report).finally(()=>{queued-=1;});}
+ })];
+ const close=async()=>{const old=id;id='';ready=false;if(old)await request('close',{id:old}).catch(report);};
+ document.getElementById('terminal-open')!.onclick=async()=>{
+ try{
+ await close();term.reset();ack=-1;
+ const opened=await request('open',{profileId:(document.getElementById('terminal-profile') as HTMLInputElement).value,mode:(document.getElementById('terminal-mode') as HTMLSelectElement).value});
+ id=opened.id;status.textContent='Opening authorized session…';term.focus();
+ }catch(e){report(e);}
+ };
+ document.getElementById('terminal-close')!.onclick=()=>{void close();};
+ const timer=setInterval(async()=>{
+ if(!id||polling||disposed)return;polling=true;const active=id;
+ try{
+ const state=await request('poll',{id:active,ack});if(id!==active)return;
+ if(state.ready&&!ready){const {rows,columns}=term.getSize();await request('resize',{id:active,rows,columns});if(id!==active)return;ready=true;}
+ if(state.packet && state.packet.sequence!==ack){
+ if(state.packet.sequence!==ack+1){report(new Error('Terminal output gap'));await close();return;}
+ // The host wrapper must acknowledge only after emulator consumption; bytes remain raw.
+ await new Promise(resolve=>term.write(Uint8Array.from(state.packet.bytes),resolve));ack=state.packet.sequence;
+ }
+ if(state.done){status.textContent=state.error||`Session ${state.result?.reason}; exit ${state.result?.exitCode??'unknown'}.`;await close();}
+ else if(state.ready)status.textContent='Interactive shell. Ctrl+C sends terminal input.';
+ }catch(e){report(e);await close();}finally{polling=false;}
+ },50);
+ const dispose=()=>{if(disposed)return;disposed=true;clearInterval(timer);void close();subscriptions.forEach(s=>s.dispose());term.dispose();observer.disconnect();};
+ const observer=new MutationObserver(()=>{if(!screen.isConnected)dispose();});observer.observe(document.body,{childList:true,subtree:true});
+ window.addEventListener('pagehide',dispose,{once:true});
+}
+new InteractiveSSHExample();
diff --git a/examples/README.md b/examples/README.md
index 3de8151..c4b2612 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -334,3 +334,7 @@ Thank you for helping improve the FDO SDK examples!
### Authoring components and static previews
[16-authoring-components.ts](16-authoring-components.ts) composes and mounts alerts, a bounded toast region, an async action button, tabs and disclosures/accordions with explicit cleanup. [17-static-component-gallery.ts](17-static-component-gallery.ts) renders every SDK state without mounting handlers.
+
+## Interactive SSH and host references
+
+[18-interactive-ssh-plugin.ts](./18-interactive-ssh-plugin.ts) demonstrates separately authorized PTY shells, one-packet acknowledged output and optional X11 requests. It needs the new adapter and proposed host renderer described in [SSH terminals](../docs/SSH_TERMINALS.md). [host/sshTerminal.cjs](./host/sshTerminal.cjs) and [host/x11Forwarding.cjs](./host/x11Forwarding.cjs) are host-only reference implementations, never plugin imports. See the [host handoff](../docs/FDO_HOST_TERMINAL_X11_HANDOFF.md) for authentication, consent, platform prerequisites and integration testing.
diff --git a/examples/host/sshTerminal.cjs b/examples/host/sshTerminal.cjs
new file mode 100644
index 0000000..382643c
--- /dev/null
+++ b/examples/host/sshTerminal.cjs
@@ -0,0 +1,108 @@
+'use strict';
+// HOST ONLY reference adapter over a dedicated, already authenticated/pinned ssh2 Client.
+// Host performs profile/interactive/X11 authorization BEFORE calling this function.
+const { createX11Forwarding } = require('./x11Forwarding.cjs');
+const failure = code => Object.assign(new Error(code), { code });
+function openSSHTerminal({ client, options, authorize, localX11, onDispose = () => {} }) {
+ return new Promise((resolve, reject) => {
+ let stream, gate, done = false, published = false, resultResolve, resultReject;
+ let waiter, queue = [], bytes = 0, sequence = 0, exitCode = null, exitSignal = null;
+ let idleTimer, sessionTimer, inputEnded = false, reading = false, pendingInput = 0, pendingOps = 0;
+ let writeTail = Promise.resolve(), cancelPromise;
+ const result = new Promise((a,b) => { resultResolve = a; resultReject = b; }); result.catch(() => {});
+ const safe = e => ['ACCESS_DENIED','X11_UNSUPPORTED','X11_UNAVAILABLE','X11_AUTH_UNAVAILABLE','X11_FORWARDING_DENIED'].includes(e?.code) ? e.code : 'CONNECTION_FAILED';
+ const verify = () => { authorize('terminal'); if (options.x11) authorize(options.x11.mode); };
+ const finish = (reason, code) => {
+ if (done) return;
+ done = true; clearTimeout(idleTimer); clearTimeout(sessionTimer); clearTimeout(openTimer);
+ client.removeListener('error', connectionError); client.removeListener('close', disconnected); client.on('error', () => {});
+ // X11 closes even if the consumer never drains terminal output.
+ gate?.close(); stream?.destroy(); client.destroy(); gate?.dispose(); onDispose();
+ if (code) { resultReject(failure(code)); queue = []; bytes = 0; waiter?.reject(failure(code)); }
+ else {
+ resultResolve({ reason, exitCode: reason === 'exited' ? exitCode : null,
+ signal: reason === 'exited' ? exitSignal : null,
+ remoteProcessStopped: reason === 'exited' ? true : null });
+ waiter?.resolve({ done: true });
+ }
+ waiter = undefined;
+ if (!published) reject(failure(code || 'SESSION_CLOSED'));
+ };
+ const touch = () => {
+ clearTimeout(idleTimer);
+ idleTimer = setTimeout(() => finish(null, 'IDLE_TIMEOUT'), options.idleTimeoutMs);
+ };
+ const connectionError = () => finish(null, 'CONNECTION_FAILED');
+ const disconnected = () => finish(null, 'CONNECTION_FAILED');
+ const openTimer = setTimeout(() => finish(null, 'TIMEOUT'), 10000);
+ client.on('error', connectionError); client.on('close', disconnected);
+ try {
+ verify();
+ if (options.x11) gate = createX11Forwarding({client,local:localX11,mode:options.x11.mode,authorize});
+ client.shell({term:options.term,rows:options.rows,cols:options.columns,width:0,height:0},
+ gate ? {x11:gate.request} : {}, (error, channel) => {
+ if (done) { channel?.destroy(); return; }
+ if (error || !channel) { finish(null, gate ? 'X11_FORWARDING_DENIED' : 'CONNECTION_FAILED'); return; }
+ stream = channel;
+ try { verify(); } catch { finish(null, 'ACCESS_DENIED'); return; }
+ clearTimeout(openTimer); touch();
+ sessionTimer = setTimeout(() => finish(null, 'SESSION_LIMIT'), options.sessionTimeoutMs);
+ const pause = () => { stream.pause(); stream.stderr.pause(); };
+ const resume = () => { if (!done && bytes < 65536) { stream.resume(); stream.stderr.resume(); } };
+ const output = chunk => {
+ if (done) return;
+ try { verify(); } catch { finish(null, 'ACCESS_DENIED'); return; }
+ // Includes both PTY stdout and any provider extended data, in observed order.
+ if (!Buffer.isBuffer(chunk) || bytes + chunk.length > 262144) { finish(null, 'BACKPRESSURE'); return; }
+ for (let offset = 0; offset < chunk.length; offset += 32768) {
+ const data = new Uint8Array(chunk.subarray(offset, offset + 32768));
+ const event = {sequence:sequence++,data};
+ if (waiter) { const w = waiter; waiter = undefined; w.resolve({done:false,value:event}); }
+ else { queue.push(event); bytes += data.length; }
+ }
+ if (bytes >= 65536) pause();
+ };
+ stream.on('data', output); stream.stderr.on('data', output);
+ stream.on('error', connectionError); stream.stderr.on('error', connectionError);
+ stream.on('exit', (code, signal) => { exitCode = Number.isSafeInteger(code) ? code : null; exitSignal = typeof signal === 'string' ? signal : null; });
+ stream.once('close', () => finish(exitCode !== null || exitSignal !== null ? 'exited' : 'closed'));
+ const live = () => { verify(); if (done) throw failure('SESSION_CLOSED'); };
+ const enqueue = (size, task) => {
+ try { live(); } catch (e) { return Promise.reject(e); }
+ if (pendingOps >= 32 || pendingInput + size > 65536) return Promise.reject(failure('BACKPRESSURE'));
+ pendingOps++; pendingInput += size;
+ const work = writeTail.then(() => { live(); touch(); return task(); });
+ writeTail = work.catch(() => {});
+ return work.finally(() => { pendingOps--; pendingInput -= size; });
+ };
+ published = true;
+ resolve({result,
+ output:{[Symbol.asyncIterator]() { return {
+ next() {
+ try { verify(); } catch { finish(null,'ACCESS_DENIED'); return Promise.reject(failure('ACCESS_DENIED')); }
+ if (reading) return Promise.reject(failure('BACKPRESSURE'));
+ if (queue.length) {const value=queue.shift();bytes-=value.data.length;resume();return Promise.resolve({done:false,value});}
+ if (done) return result.then(()=>({done:true}));
+ reading=true;
+ return new Promise((a,b)=>{waiter={resolve:a,reject:b};}).finally(()=>{reading=false;});
+ },
+ async return() {queue=[];bytes=0;finish('closed');return {done:true};},
+ };}},
+ write(data) {
+ if (!(data instanceof Uint8Array) || data.byteLength > 32768 || inputEnded) return Promise.reject(failure('INVALID_REQUEST'));
+ const copy=Buffer.from(data);
+ return enqueue(copy.length,()=>new Promise((a,b)=>stream.write(copy,e=>e?b(failure('CONNECTION_FAILED')):a())));
+ },
+ resize(rows, columns) {
+ if (!Number.isInteger(rows)||!Number.isInteger(columns)||rows<1||columns<1||rows>1000||columns>1000) return Promise.reject(failure('INVALID_REQUEST'));
+ return enqueue(0,async()=>{stream.setWindow(rows,columns,0,0);});
+ },
+ endInput() {if(inputEnded)return Promise.resolve();inputEnded=true;return enqueue(0,()=>new Promise(a=>stream.end(a)));},
+ async close() {queue=[];bytes=0;finish('closed');},
+ cancel() {return cancelPromise ||= (async()=>{if(!done)try{stream.signal('INT');}catch{}finish('cancelled');return {remoteProcessStopped:null};})();},
+ });
+ });
+ } catch(e) { finish(null, safe(e)); }
+ });
+}
+module.exports = { openSSHTerminal };
diff --git a/examples/host/x11Forwarding.cjs b/examples/host/x11Forwarding.cjs
new file mode 100644
index 0000000..fe72a6f
--- /dev/null
+++ b/examples/host/x11Forwarding.cjs
@@ -0,0 +1,100 @@
+'use strict';
+// HOST ONLY. Never bundle into a plugin. Install on a dedicated authenticated ssh2 Client.
+const { randomBytes, timingSafeEqual } = require('node:crypto');
+const PROTOCOL = Buffer.from('MIT-MAGIC-COOKIE-1');
+const failure = code => Object.assign(new Error(code), { code });
+const pad = n => (n + 3) & ~3;
+
+/** local is a host-owned display authorization, not plugin input.
+ * connect() must open an authenticated local X socket; cookie is its authorization.
+ * untrusted requires a real X SECURITY generated untrusted authorization, not a label.
+ * Caller installs this before client.shell(..., {x11: gate.request}, ...).
+ */
+function createX11Forwarding({ client, local, mode, authorize, maxChannels = 8, setupTimeoutMs = 5000 }) {
+ if (!['trusted', 'untrusted'].includes(mode)) throw failure('INVALID_REQUEST');
+ authorize(mode);
+ if (!local || typeof local.connect !== 'function') throw failure('X11_UNAVAILABLE');
+ if (!Buffer.isBuffer(local.cookie) || local.cookie.length !== 16) throw failure('X11_AUTH_UNAVAILABLE');
+ if (mode === 'untrusted' && local.securityAuthorization !== 'untrusted') throw failure('X11_UNSUPPORTED');
+ if (!Number.isSafeInteger(local.screen) || local.screen < 0 || local.screen > 65535) throw failure('X11_UNAVAILABLE');
+ if (!Number.isSafeInteger(maxChannels) || maxChannels < 1 || maxChannels > 32
+ || !Number.isSafeInteger(setupTimeoutMs) || setupTimeoutMs < 1 || setupTimeoutMs > 30000) throw failure('INVALID_REQUEST');
+ const fake = randomBytes(16), real = Buffer.from(local.cookie), active = new Set();
+ let closed = false;
+ const onX11 = (_info, accept, reject) => {
+ try { authorize(mode); } catch { reject(); return; }
+ if (closed || active.size >= maxChannels) { reject(); return; }
+ let channel;
+ try { channel = accept(); } catch { return; }
+ let socket, header = Buffer.alloc(0), authenticated = false, finished = false;
+ const timer = setTimeout(() => end(), setupTimeoutMs);
+ const end = () => {
+ if (finished) return;
+ finished = true; clearTimeout(timer); active.delete(end);
+ channel.removeListener('data', setup);
+ channel.destroy(); socket?.destroy(); header.fill(0);
+ };
+ active.add(end);
+ channel.on('error', end); channel.on('close', end);
+ const setup = chunk => {
+ if (finished || authenticated) return;
+ if (!Buffer.isBuffer(chunk) || chunk.length > 65536) { end(); return; }
+ // Buffer ONLY the bounded X11 setup header. Forward application bytes via pipes.
+ let offset = 0;
+ while (offset < chunk.length) {
+ const target = header.length < 12 ? 12 : 48;
+ const count = Math.min(target - header.length, chunk.length - offset);
+ header = Buffer.concat([header, chunk.subarray(offset, offset + count)]); offset += count;
+ if (header.length < 12) return;
+ const little = header[0] === 0x6c;
+ if (!little && header[0] !== 0x42) { end(); return; }
+ const word = at => little ? header.readUInt16LE(at) : header.readUInt16BE(at);
+ if (word(2) !== 11 || word(4) !== 0 || word(6) !== PROTOCOL.length || word(8) !== 16) { end(); return; }
+ const total = 12 + pad(word(6)) + pad(word(8));
+ if (header.length < total) continue;
+ const cookieOffset = 12 + pad(PROTOCOL.length);
+ if (!header.subarray(12, 12 + PROTOCOL.length).equals(PROTOCOL)
+ || !timingSafeEqual(header.subarray(cookieOffset, cookieOffset + 16), fake)) { end(); return; }
+ try { authorize(mode); } catch { end(); return; }
+ authenticated = true;
+ channel.pause(); channel.removeListener('data', setup);
+ // Do not connect to the local server before authenticating the spoof cookie.
+ const rest = Buffer.from(chunk.subarray(offset));
+ real.copy(header, cookieOffset);
+ Promise.resolve().then(() => local.connect()).then(value => {
+ if (finished || closed) { value.destroy(); return; }
+ socket = value;
+ socket.on('error', end); socket.on('close', end);
+ // Keep setup/connect/write covered by the setup deadline.
+ socket.write(header, () => {
+ if (finished) return;
+ header.fill(0); header = Buffer.alloc(0);
+ const bridge = () => {
+ if (finished) return;
+ clearTimeout(timer);
+ channel.pipe(socket); socket.pipe(channel); channel.resume();
+ };
+ if (rest.length) socket.write(rest, bridge); else bridge();
+ });
+ }, end).catch(end);
+ return;
+ }
+ };
+ channel.on('data', setup);
+ };
+ client.on('x11', onX11);
+ return {
+ // Only fake credentials enter the remote SSH request; this object stays in the host.
+ request: { protocol: 'MIT-MAGIC-COOKIE-1', cookie: fake.toString('hex'), screen: local.screen, single: false },
+ close() {
+ if (closed) return;
+ closed = true;
+ // Keep a rejecting listener until the dedicated SSH connection is destroyed.
+ for (const end of [...active]) end();
+ fake.fill(0); real.fill(0); this.request.cookie = '';
+ local.dispose?.();
+ },
+ dispose() { this.close(); client.removeListener('x11', onX11); },
+ };
+}
+module.exports = { createX11Forwarding };
diff --git a/src/types.ts b/src/types.ts
index 2fd7071..a5ef750 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -772,7 +772,7 @@ export type DiagnosticFixTemplate = {
/** Secret operations require secrets.use plus an operation grant; SSH consumes references without secrets.get. */
export type HostServiceCapability =
| "secrets.set" | "secrets.get" | "secrets.delete" | "secrets.list" | "secrets.use" | "secrets.consume"
- | "ssh.use" | "ssh.exec" | "ssh.profiles.list" | "ssh.status.watch";
+ | "ssh.use" | "ssh.exec" | "ssh.profiles.list" | "ssh.status.watch" | "ssh.terminal.open" | "ssh.x11.forward";
/** References are names in the authenticated plugin namespace, not bearer credentials. */
export type SecretReference = Readonly<{ kind: "secret"; key: string }>;
@@ -846,6 +846,50 @@ export type HostSSHExecution = {
cancel(): Promise;
};
+/** Explicitly authorized arbitrary shell input; independent of ssh.exec command policy. */
+export type SSHTerminalOptions = {
+ profileId: string;
+ term?: string;
+ rows?: number;
+ columns?: number;
+ idleTimeoutMs?: number;
+ sessionTimeoutMs?: number;
+ /** Separate grant and host consent. No display, socket or cookie crosses this boundary. */
+ x11?: { mode: "trusted" | "untrusted" };
+};
+export type SSHTerminalOpenOptions = Required> & Pick;
+export type SSHTerminalOutput = { sequence: number; data: Uint8Array };
+export type SSHTerminalResult = {
+ reason: "exited" | "closed" | "cancelled";
+ exitCode: number | null;
+ signal: string | null;
+ /** Evidence about the shell only, never all descendants. */
+ remoteProcessStopped: boolean | null;
+};
+export type SSHTerminalSession = {
+ /** Raw bytes, one consumer, chunks <= 32 KiB. PTY usually merges stderr into stdout. */
+ output: AsyncIterable;
+ result: Promise;
+ /** UTF-8 string or raw bytes. Resolves on host write/drain acknowledgement, not remote execution. */
+ write(data: string | Uint8Array): Promise;
+ resize(rows: number, columns: number): Promise;
+ /** SSH input half-close; Ctrl+D in a PTY is instead terminal input (\x04). */
+ endInput(): Promise;
+ /** Release channel/forwarding without claiming termination. */
+ close(): Promise;
+ cancel(): Promise;
+};
+/** Backend adapter only; never serialize iterators, promises, cookies or sockets into the UI. */
+export type HostSSHTerminalSession = {
+ output: AsyncIterable;
+ result: Promise;
+ write(data: Uint8Array): Promise;
+ resize(rows: number, columns: number): Promise;
+ endInput(): Promise;
+ close(): Promise;
+ cancel(): Promise;
+};
+
/** Backend-only protocol v1. No plugin id, storage path or raw SSH credentials are accepted. */
export type HostServiceRequest =
| { action: "secrets.set"; payload: { key: string; value: string } }
@@ -863,8 +907,11 @@ export type HostServicesConfiguration = {
/** Optional live adapters. An absent adapter fails with UNAVAILABLE, without buffered fallback. */
watchSSHStatus?(options: { profileId: string }): Promise>;
startSSHExecution?(options: Required): Promise;
+ openSSHTerminal?(options: SSHTerminalOpenOptions): Promise;
};
export type HostServiceErrorCode =
| "ACCESS_DENIED" | "UNAVAILABLE" | "LOCKED" | "TIMEOUT" | "CANCELLED"
- | "HOST_KEY_REJECTED" | "CONNECTION_FAILED" | "INVALID_REQUEST";
+ | "HOST_KEY_REJECTED" | "CONNECTION_FAILED" | "INVALID_REQUEST"
+ | "BACKPRESSURE" | "SESSION_CLOSED" | "IDLE_TIMEOUT" | "SESSION_LIMIT"
+ | "X11_UNSUPPORTED" | "X11_UNAVAILABLE" | "X11_AUTH_UNAVAILABLE" | "X11_FORWARDING_DENIED";
diff --git a/src/utils/capabilities.ts b/src/utils/capabilities.ts
index 2ce3ea1..2eb77a1 100644
--- a/src/utils/capabilities.ts
+++ b/src/utils/capabilities.ts
@@ -74,11 +74,13 @@ function getCapabilityPrerequisites(capability: string): string[] {
const prerequisites = new Set();
if (capability.startsWith("secrets.") && capability !== "secrets.use") prerequisites.add("secrets.use");
if (capability.startsWith("ssh.") && capability !== "ssh.use") prerequisites.add("ssh.use");
- if (capability === "ssh.exec" || capability === "ssh.status.watch") {
+ if (capability === "ssh.exec" || capability === "ssh.status.watch" || capability === "ssh.terminal.open" || capability === "ssh.x11.forward") {
prerequisites.add("secrets.use");
prerequisites.add("secrets.consume");
}
+ if (capability === "ssh.x11.forward") prerequisites.add("ssh.terminal.open");
+
if (capability.startsWith(STORAGE_BACKEND_CAPABILITY_PREFIX)) {
prerequisites.add("storage");
}
@@ -126,6 +128,12 @@ function buildCapabilityPreflightRemediation(capability: string): string {
return `Grant "${capability}" with prerequisite capabilities ${prerequisites.map((entry) => `"${entry}"`).join(", ")} before plugin initialization. Configure PluginRegistry.configureCapabilities({ granted: [${snippet}] }) in the host before plugin initialization.`;
}
+const capabilityListeners = new Set<() => void>();
+/** Internal resource lifecycle hook; host authorization remains authoritative. */
+export function onCapabilityChange(listener: () => void): () => void {
+ capabilityListeners.add(listener);
+ return () => { capabilityListeners.delete(listener); };
+}
export function configureCapabilities(configuration: CapabilityConfiguration): void {
grantedCapabilities.clear();
for (const capability of configuration.granted) {
@@ -134,6 +142,7 @@ export function configureCapabilities(configuration: CapabilityConfiguration): v
grantedCapabilities.add(alias as PluginCapability);
}
}
+ for (const listener of capabilityListeners) listener();
}
export function requireCapability(capability: PluginCapability, action: string): void {
@@ -321,11 +330,15 @@ export function describeCapability(capability: PluginCapability | string): Capab
category: "secrets",
};
}
- if (capability === "ssh.use" || capability === "ssh.exec" || capability === "ssh.profiles.list" || capability === "ssh.status.watch") {
+ if (capability === "ssh.use" || capability === "ssh.exec" || capability === "ssh.profiles.list" || capability === "ssh.status.watch" || capability === "ssh.terminal.open" || capability === "ssh.x11.forward") {
return {
capability,
- label: capability === "ssh.use" ? "SSH Access" : capability === "ssh.exec" ? "SSH Execution" : capability === "ssh.status.watch" ? "SSH Connection Monitoring" : "SSH Profiles List",
- description: capability === "ssh.use"
+ label: capability === "ssh.terminal.open" ? "Interactive SSH Terminal" : capability === "ssh.x11.forward" ? "SSH X11 Forwarding" : capability === "ssh.use" ? "SSH Access" : capability === "ssh.exec" ? "SSH Execution" : capability === "ssh.status.watch" ? "SSH Connection Monitoring" : "SSH Profiles List",
+ description: capability === "ssh.terminal.open"
+ ? "Allows arbitrary interactive shell input on separately authorized host profiles; command allowlists do not constrain this session."
+ : capability === "ssh.x11.forward"
+ ? "Allows separately approved X11 forwarding. Trusted forwarding can expose local display input and other clients; never enabled automatically."
+ : capability === "ssh.use"
? "Base permission to access SSH services; pair with specific SSH operation capabilities."
: "Allows this operation on authorized profiles when paired with ssh.use. Execution and monitoring also require secrets.use and secrets.consume.",
category: "ssh",
@@ -650,7 +663,9 @@ export function createSecretsCapabilityBundle(
}
/** SSH uses references inside the host, so plaintext secret reads are not required. */
-export function createSSHCapabilityBundle(options: { monitorStatus?: boolean } = {}): PluginCapability[] {
+export function createSSHCapabilityBundle(options: { monitorStatus?: boolean; terminal?: boolean; x11?: boolean } = {}): PluginCapability[] {
return createCapabilityBundle(["ssh.use", "ssh.exec", "ssh.profiles.list", "secrets.use", "secrets.consume",
+ ...(options.terminal ? ["ssh.terminal.open" as const] : []),
+ ...(options.x11 ? ["ssh.terminal.open" as const, "ssh.x11.forward" as const] : []),
...(options.monitorStatus ? ["ssh.status.watch" as const] : [])]);
}
diff --git a/src/utils/contracts.ts b/src/utils/contracts.ts
index 77d39d2..3321fed 100644
--- a/src/utils/contracts.ts
+++ b/src/utils/contracts.ts
@@ -26,7 +26,7 @@ export interface PluginInitPayload {
const KNOWN_PLUGIN_CAPABILITIES = new Set([
"secrets.set", "secrets.get", "secrets.delete", "secrets.list", "secrets.use", "secrets.consume",
- "ssh.use", "ssh.exec", "ssh.profiles.list", "ssh.status.watch",
+ "ssh.use", "ssh.exec", "ssh.profiles.list", "ssh.status.watch", "ssh.terminal.open", "ssh.x11.forward",
"system.ai",
"system.ai.assistants.list",
"system.ai.request",
diff --git a/src/utils/hostServices.ts b/src/utils/hostServices.ts
index 0cb4acd..b630446 100644
--- a/src/utils/hostServices.ts
+++ b/src/utils/hostServices.ts
@@ -1,7 +1,8 @@
+import { openTerminal } from "./sshTerminal";
import type {
HostServiceRequest, HostServicesConfiguration, SecretMetadata, SecretReference,
SSHExecOptions, SSHExecResult, SSHProfileSummary, HostServiceErrorCode,
- SSHExecution, SSHConnectionStatus, SSHOutputEvent, SSHCancelResult, HostServiceCapability,
+ SSHTerminalOptions, SSHTerminalSession, SSHExecution, SSHConnectionStatus, SSHOutputEvent, SSHCancelResult, HostServiceCapability,
} from "../types";
import { requireCapability } from "./capabilities";
@@ -23,13 +24,14 @@ export function configureHostServices(value: HostServicesConfiguration | null):
if (value !== null && (!value || typeof value.request !== "function")) {
throw new Error("Host services require a request transport.");
}
- for (const key of ["watchSSHStatus", "startSSHExecution"] as const) {
+ for (const key of ["watchSSHStatus", "startSSHExecution", "openSSHTerminal"] as const) {
if (value?.[key] !== undefined && typeof value[key] !== "function") throw new Error("Invalid SSH host adapter.");
}
for (const cleanup of activeResources) cleanup();
activeResources.clear();
configuration = value === null ? undefined : Object.freeze({
request: value.request.bind(value),
+ openSSHTerminal: value.openSSHTerminal?.bind(value),
watchSSHStatus: value.watchSSHStatus?.bind(value),
startSSHExecution: value.startSSHExecution?.bind(value),
});
@@ -56,17 +58,18 @@ function authorize(action: HostServiceCapability): void {
throw new Error("Host services are only available in the plugin backend runtime.");
}
if (action.startsWith("ssh.")) requireCapability("ssh.use", "access SSH services");
- if (action.startsWith("secrets.") || action === "ssh.exec" || action === "ssh.status.watch") {
+ if (action.startsWith("secrets.") || action === "ssh.exec" || action === "ssh.status.watch" || action === "ssh.terminal.open" || action === "ssh.x11.forward") {
requireCapability("secrets.use", "access the secret store");
}
requireCapability(action, "access host services");
- if (action === "ssh.exec" || action === "ssh.status.watch") {
+ if (action === "ssh.exec" || action === "ssh.status.watch" || action === "ssh.terminal.open" || action === "ssh.x11.forward") {
requireCapability("secrets.consume", "consume secret references for SSH");
}
}
function safeError(error: unknown): HostServiceError {
- const codes: HostServiceErrorCode[] = ["ACCESS_DENIED", "UNAVAILABLE", "LOCKED", "TIMEOUT", "CANCELLED", "HOST_KEY_REJECTED", "CONNECTION_FAILED", "INVALID_REQUEST"];
+ const codes: HostServiceErrorCode[] = ["ACCESS_DENIED", "UNAVAILABLE", "LOCKED", "TIMEOUT", "CANCELLED", "HOST_KEY_REJECTED", "CONNECTION_FAILED", "INVALID_REQUEST", "BACKPRESSURE", "SESSION_CLOSED", "IDLE_TIMEOUT", "SESSION_LIMIT",
+ "X11_UNSUPPORTED", "X11_UNAVAILABLE", "X11_AUTH_UNAVAILABLE", "X11_FORWARDING_DENIED"];
return new HostServiceError(record(error) && codes.includes(error.code as HostServiceErrorCode)
? error.code as HostServiceErrorCode : "UNAVAILABLE");
}
@@ -180,6 +183,22 @@ export class SecretStore {
/** Builtin SSH facade. The host ships the SSH implementation and owns connection profiles. */
export class SSHClient {
+ /** PTY shell with explicit interactive authorization; never falls back to exec/start. */
+ async openTerminal(options: SSHTerminalOptions, signal?: AbortSignal): Promise {
+ let transport: Readonly;
+ try { transport = getTransport("ssh.terminal.open"); }
+ catch { throw new HostServiceError(configuration ? "ACCESS_DENIED" : "UNAVAILABLE"); }
+ const wantsX11 = options?.x11 !== undefined;
+ const check = () => {
+ try {
+ assertCurrent(transport, "ssh.terminal.open");
+ if (wantsX11) assertCurrent(transport, "ssh.x11.forward");
+ } catch { throw new HostServiceError(configuration === transport ? "ACCESS_DENIED" : "UNAVAILABLE"); }
+ };
+ check();
+ return openTerminal(options, signal, transport.openSSHTerminal, check, activeResources, safeError);
+ }
+
async listProfiles(): Promise {
const result = await request({ action: "ssh.profiles.list", payload: {} });
if (!record(result) || !Array.isArray(result.profiles)) invalidResponse();
diff --git a/src/utils/sshTerminal.ts b/src/utils/sshTerminal.ts
new file mode 100644
index 0000000..ee5733b
--- /dev/null
+++ b/src/utils/sshTerminal.ts
@@ -0,0 +1,202 @@
+import type { HostServicesConfiguration, SSHTerminalOptions, SSHTerminalOpenOptions, SSHTerminalSession,
+ SSHTerminalResult, SSHCancelResult } from '../types';
+import { HostServiceError } from './hostServices';
+import { onCapabilityChange } from './capabilities';
+
+const CHUNK = 32768, INPUT = 65536, OPERATIONS = 32;
+function fail(code: ConstructorParameters[0]): never { throw new HostServiceError(code); }
+const record = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v);
+const positive = (v: number, max: number) => Number.isSafeInteger(v) && v > 0 && v <= max;
+function dimensions(rows: number, columns: number) {
+ if (!positive(rows, 1000) || !positive(columns, 1000)) fail('INVALID_REQUEST');
+}
+function normalize(o: SSHTerminalOptions): SSHTerminalOpenOptions {
+ if (!record(o) || typeof o.profileId !== 'string' || !o.profileId.trim()) fail('INVALID_REQUEST');
+ const term = o.term ?? 'xterm-256color', rows = o.rows ?? 24, columns = o.columns ?? 80;
+ if (typeof term !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9+._-]{0,63}$/.test(term)) fail('INVALID_REQUEST');
+ dimensions(rows, columns);
+ const idleTimeoutMs = o.idleTimeoutMs ?? 300000, sessionTimeoutMs = o.sessionTimeoutMs ?? 3600000;
+ if (!positive(idleTimeoutMs, 3600000) || !positive(sessionTimeoutMs, 86400000)) fail('INVALID_REQUEST');
+ if (o.x11 !== undefined && (!record(o.x11) || !['trusted', 'untrusted'].includes(o.x11.mode))) fail('INVALID_REQUEST');
+ return { profileId: o.profileId, term, rows, columns, idleTimeoutMs, sessionTimeoutMs,
+ ...(o.x11 ? { x11: { mode: o.x11.mode } } : {}) };
+}
+
+/** No prefetch or unbounded SDK queue. Host must implement byte credits and bounded channel windows. */
+export async function openTerminal(options: SSHTerminalOptions, signal: AbortSignal | undefined,
+ adapter: HostServicesConfiguration['openSSHTerminal'], check: () => void,
+ resources: Set<() => void>, safe: (error: unknown) => HostServiceError): Promise {
+ const payload = normalize(options);
+ if (!adapter) fail('UNAVAILABLE');
+ if (signal?.aborted) fail('CANCELLED');
+ let stop!: (result: SSHTerminalResult) => void;
+ let stopped = false, inputEnded = false, settled = false;
+ let wakeOutput: (() => void) | undefined;
+ const localEnd = new Promise(resolve => { stop = value => { stopped = true; resolve(value); wakeOutput?.(); }; });
+ const closed: SSHTerminalResult = { reason: 'closed', exitCode: null, signal: null, remoteProcessStopped: null };
+ const cancelled: SSHTerminalResult = { ...closed, reason: 'cancelled' };
+ let pendingError: HostServiceError | undefined;
+ let rejectPending!: (error: HostServiceError) => void;
+ const interrupted = new Promise((_resolve, reject) => { rejectPending = reject; });
+ const interruptPending = (code: 'CANCELLED' | 'UNAVAILABLE') => {
+ if (pendingError) return;
+ pendingError = new HostServiceError(code);
+ stopped = true;
+ rejectPending(pendingError);
+ };
+ const abortPending = () => interruptPending('CANCELLED');
+ const invalidatePending = () => interruptPending('UNAVAILABLE');
+ // An adapter may never acknowledge an open. Register before dispatch so host
+ // replacement wakes the caller independently of the adapter or caller signal.
+ resources.add(invalidatePending);
+ signal?.addEventListener('abort', abortPending, { once: true });
+ let host: Awaited>>;
+ const opening = Promise.resolve().then(() => { if (pendingError) throw pendingError; check(); return adapter!(payload); });
+ // A lost start acknowledgement is never retried. Clean up a late successful open once.
+ let lateClosed = false;
+ const closeLate = (value: Awaited) => {
+ if (lateClosed) return;
+ lateClosed = true;
+ try { void Promise.resolve(value.result).catch(() => {}); } catch {}
+ try { void Promise.resolve(value.close()).catch(() => {}); } catch {}
+ };
+ void opening.then(value => { if (stopped) closeLate(value); }, () => {});
+ try {
+ host = await Promise.race([opening, interrupted]);
+ check();
+ } catch (error) {
+ stopped = true;
+ void opening.then(closeLate, () => {});
+ throw safe(error);
+ } finally {
+ resources.delete(invalidatePending);
+ signal?.removeEventListener('abort', abortPending);
+ }
+ let iterator: AsyncIterator | undefined;
+ let closePromise: Promise | undefined, cancelPromise: Promise | undefined;
+ const close = (): Promise => {
+ if (!closePromise) {
+ stop(closed);
+ try { void Promise.resolve(iterator?.return?.()).catch(() => {}); } catch {}
+ unsubscribe();
+ resources.delete(cleanup);
+ signal?.removeEventListener('abort', abort);
+ closePromise = Promise.resolve().then(() => host.close()).catch(e => { throw safe(e); });
+ }
+ return closePromise;
+ };
+ let unsubscribe = () => {};
+ const cleanup = () => { unsubscribe(); void close().catch(() => {}); };
+ const abort = () => { stop(cancelled); void cancel().catch(() => {}); cleanup(); };
+ resources.add(cleanup);
+ unsubscribe = onCapabilityChange(() => { try { check(); } catch { cleanup(); } });
+ const resultHost = Promise.resolve(host?.result).then(value => {
+ check();
+ if (!record(value) || !['exited', 'closed', 'cancelled'].includes(value.reason as string)
+ || !(value.exitCode === null || Number.isSafeInteger(value.exitCode))
+ || !(value.signal === null || typeof value.signal === 'string' && value.signal.length <= 64)
+ || !(value.remoteProcessStopped === null || typeof value.remoteProcessStopped === 'boolean')
+ || (value.reason === 'exited' && value.exitCode === null && value.signal === null)
+ || (value.reason === 'closed' && (value.exitCode !== null || value.signal !== null || value.remoteProcessStopped !== null))) fail('INVALID_REQUEST');
+ return { reason: value.reason, exitCode: value.exitCode, signal: value.signal,
+ remoteProcessStopped: value.remoteProcessStopped } as SSHTerminalResult;
+ }, e => { throw safe(e); });
+ const result = Promise.race([resultHost, localEnd]);
+ void result.then(() => { settled = true; }, cleanup);
+ // Attach rejection handling immediately, even when callers consume output first.
+ void result.catch(() => {});
+ try {
+ if (!host || ['write', 'resize', 'endInput', 'close', 'cancel'].some(k => typeof host[k as 'write' | 'resize' | 'endInput' | 'close' | 'cancel'] !== 'function')
+ || !host.result || typeof host.result.then !== 'function'
+ || typeof host.output?.[Symbol.asyncIterator] !== 'function') fail('INVALID_REQUEST');
+ iterator = host.output[Symbol.asyncIterator]();
+ if (typeof iterator.next !== 'function') fail('INVALID_REQUEST');
+ check();
+ } catch (e) { cleanup(); throw safe(e); }
+ const live = () => {
+ try { check(); } catch(e) { cleanup(); throw safe(e); }
+ if (stopped || settled) fail('SESSION_CLOSED');
+ };
+ // Admit before copying. At most 64 KiB / 32 queued operations, ordered across write/resize/EOF.
+ let pendingBytes = 0, pending = 0, tail: Promise = Promise.resolve();
+ const enqueue = (bytes: number, task: () => Promise): Promise => {
+ live();
+ if (pending >= OPERATIONS || pendingBytes + bytes > INPUT) fail('BACKPRESSURE');
+ pending++; pendingBytes += bytes;
+ const operation = tail.then(() => { live(); return task(); }).then(() => check()).catch(e => { cleanup(); throw safe(e); });
+ tail = operation.catch(() => {});
+ return operation.finally(() => { pending--; pendingBytes -= bytes; });
+ };
+ const cancel = (): Promise => {
+ check();
+ return cancelPromise ??= Promise.resolve().then(() => host.cancel()).then(value => {
+ check();
+ if (!record(value) || !(value.remoteProcessStopped === null || typeof value.remoteProcessStopped === 'boolean')) fail('INVALID_REQUEST');
+ if (!settled) stop(cancelled);
+ return { remoteProcessStopped: value.remoteProcessStopped as boolean | null };
+ }).catch(e => { throw safe(e); }).finally(cleanup);
+ };
+ signal?.addEventListener('abort', abort, { once: true });
+ if (signal?.aborted) abort();
+ let consumed = false, sequence = 0;
+ return {
+ result, close, cancel,
+ async write(data) {
+ live();
+ if (inputEnded) fail('SESSION_CLOSED');
+ if (!(typeof data === 'string' || data instanceof Uint8Array)) fail('INVALID_REQUEST');
+ const size = typeof data === 'string' ? Buffer.byteLength(data, 'utf8') : data.byteLength;
+ if (size > CHUNK) fail('BACKPRESSURE');
+ // Admission happens synchronously before the task captures the bounded copy.
+ let copy: Uint8Array;
+ const operation = enqueue(size, () => host.write(copy));
+ copy = typeof data === 'string' ? new Uint8Array(Buffer.from(data, 'utf8')) : new Uint8Array(data);
+ await operation;
+ },
+ async resize(rows, columns) { dimensions(rows, columns); await enqueue(0, () => host.resize(rows, columns)); },
+ async endInput() {
+ if (inputEnded) return;
+ const operation = enqueue(0, () => host.endInput());
+ inputEnded = true;
+ await operation;
+ },
+ output: {
+ [Symbol.asyncIterator]() {
+ if (consumed) fail('INVALID_REQUEST');
+ consumed = true;
+ let reading = false;
+ return {
+ async next() {
+ if (reading) fail('BACKPRESSURE');
+ if (stopped) return { done: true as const, value: undefined };
+ reading = true;
+ try {
+ check();
+ // A removable wakeup avoids retaining one localEnd.then handler per packet
+ // for the entire lifetime of a long-running terminal.
+ const next = await new Promise>((resolve, reject) => {
+ const wake = () => { if (wakeOutput === wake) wakeOutput = undefined; resolve({done: true, value: undefined}); };
+ wakeOutput = wake;
+ Promise.resolve().then(() => stopped ? {done: true, value: undefined} : iterator!.next()).then(value => {
+ if (wakeOutput === wake) wakeOutput = undefined;
+ resolve(value);
+ }, error => {
+ if (wakeOutput === wake) wakeOutput = undefined;
+ reject(error);
+ });
+ });
+ check();
+ if (next.done) { await result; cleanup(); return { done: true as const, value: undefined }; }
+ const v = next.value;
+ if (!record(v) || v.sequence !== sequence || !(v.data instanceof Uint8Array)
+ || !v.data.byteLength || v.data.byteLength > CHUNK) fail('INVALID_REQUEST');
+ return { done: false as const, value: { sequence: sequence++, data: new Uint8Array(v.data) } };
+ } catch (e) { cleanup(); throw safe(e); }
+ finally { reading = false; }
+ },
+ async return() { cleanup(); return { done: true as const, value: undefined }; },
+ };
+ },
+ },
+ };
+}
diff --git a/tests/integration/ssh-terminal.cjs b/tests/integration/ssh-terminal.cjs
new file mode 100644
index 0000000..1c32959
--- /dev/null
+++ b/tests/integration/ssh-terminal.cjs
@@ -0,0 +1,44 @@
+'use strict';
+// Explicit loopback integration. Requires ssh2 supplied by the host checkout, not an SDK dependency.
+const assert = require('node:assert/strict');
+const {generateKeyPairSync,createHash} = require('node:crypto');
+const {Duplex} = require('node:stream');
+const {Client,Server} = require(process.env.FDO_SSH2_MODULE || '../../../fdo/node_modules/ssh2');
+const {openSSHTerminal} = require('../../examples/host/sshTerminal.cjs');
+const key=generateKeyPairSync('rsa',{modulusLength:2048,privateKeyEncoding:{format:'pem',type:'pkcs1'},publicKeyEncoding:{format:'pem',type:'pkcs1'}}).privateKey;
+const sockets=new Set();let request,remoteX11,remoteSession,windowChange;
+const server=new Server({hostKeys:[key]},conn=>{
+ sockets.add(conn);conn.on('error',()=>{});conn.on('close',()=>sockets.delete(conn));
+ conn.on('authentication',ctx=>ctx.method==='password'&&ctx.username==='test'&&ctx.password==='test-only'?ctx.accept():ctx.reject());
+ conn.on('ready',()=>conn.on('session',accept=>{
+ const session=accept();session.on('pty',(accept)=>accept());
+ session.on('window-change',(accept,_reject,info)=>{windowChange=info;accept?.();});
+ session.on('x11',(accept,_reject,info)=>{request=info;accept();});
+ session.on('shell',accept=>{
+ const stream=remoteSession=accept();stream.on('error',()=>{});
+ stream.on('data',b=>stream.write(b));
+ stream.write(Buffer.from([27,91,72,0xe2]));setTimeout(()=>stream.write(Buffer.from([0x82,0xac])),5);
+ if(request) conn.x11('127.0.0.1',6000,(e,ch)=>{assert.ifError(e);remoteX11=ch;ch.on('error',()=>{});const p=Buffer.alloc(48);p[0]=0x6c;p.writeUInt16LE(11,2);p.writeUInt16LE(18,6);p.writeUInt16LE(16,8);Buffer.from('MIT-MAGIC-COOKIE-1').copy(p,12);Buffer.from(request.cookie.toString(),'hex').copy(p,32);ch.write(p);});
+ });
+ }));
+});
+const deadline=setTimeout(()=>{console.error('Integration timeout');process.exitCode=1;for(const c of sockets)c.end();server.close();},10000);
+(async()=>{
+ await new Promise((a,b)=>{server.once('error',b);server.listen(0,'127.0.0.1',a);});
+ const pub=require(process.env.FDO_SSH2_MODULE || '../../../fdo/node_modules/ssh2').utils.parseKey(key).getPublicSSH();
+ const pinned=createHash('sha256').update(pub).digest();
+ const client=new Client();await new Promise((a,b)=>{client.once('ready',a);client.once('error',b);client.connect({host:'127.0.0.1',port:server.address().port,username:'test',password:'test-only',hostVerifier:raw=>createHash('sha256').update(raw).digest().equals(pinned)});});
+ class LocalDisplay extends Duplex { constructor(){super();this.received=[];} _read(){} _write(b,_enc,cb){this.received.push(Buffer.from(b));cb();} }
+ const display=new LocalDisplay(), cookie=Buffer.alloc(16,0x25);
+ const terminal=await openSSHTerminal({client,options:{term:'xterm-256color',rows:24,columns:80,idleTimeoutMs:3000,sessionTimeoutMs:8000,x11:{mode:'trusted'}},authorize(){},localX11:{screen:0,cookie,connect:async()=>display}});
+ const iterator=terminal.output[Symbol.asyncIterator]();let text='';const decoder=new TextDecoder();
+ while(!text.includes('€'))text+=decoder.decode((await iterator.next()).value.data,{stream:true});assert.equal(text,'\x1b[H€');
+ await terminal.write(Uint8Array.from([3,65,10]));const echo=(await iterator.next()).value.data;assert.deepEqual([...echo],[3,65,10]);
+ await terminal.resize(40,120);await new Promise(a=>setTimeout(a,50));assert.equal(windowChange.rows,40);assert.equal(windowChange.cols,120);
+ assert.equal(request.protocol,'MIT-MAGIC-COOKIE-1');assert.notEqual(request.cookie.toString(),cookie.toString('hex'));
+ for(let i=0;i<100 && !display.received.length;i++) await new Promise(a=>setTimeout(a,10));
+ assert.deepEqual(Buffer.concat(display.received).subarray(32,48),cookie);
+ remoteSession.exit(7);remoteSession.end();assert.deepEqual(await terminal.result,{reason:'exited',exitCode:7,signal:null,remoteProcessStopped:true});
+ assert.equal(display.destroyed,true);await terminal.close();
+ console.log('PASS: real loopback SSH authentication/pinning, PTY, split UTF-8, bidirectional control bytes, resize, exit, X11 negotiation/cookie substitution and teardown. Local X server is simulated.');
+})().catch(e=>{console.error(e);process.exitCode=1;}).finally(()=>{clearTimeout(deadline);for(const c of sockets)c.end();server.close();});
diff --git a/tests/utils/sshTerminal.test.ts b/tests/utils/sshTerminal.test.ts
new file mode 100644
index 0000000..c451016
--- /dev/null
+++ b/tests/utils/sshTerminal.test.ts
@@ -0,0 +1,179 @@
+import { PluginRegistry, SSHClient, createSSHCapabilityBundle, runCapabilityPreflight, validatePluginInitPayload } from '../../src';
+import type { HostSSHTerminalSession } from '../../src';
+import { resetCapabilityStateForTests } from '../../src/utils/capabilities';
+
+const ssh = new SSHClient();
+const exited = { reason: 'exited', exitCode: 0, signal: null, remoteProcessStopped: true };
+async function* values(...items: unknown[]) { yield* items; }
+function deferred() { let resolve!: (value: T) => void; let reject!: (e: unknown) => void;
+ const promise = new Promise((a,b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; }
+function host(overrides: Partial = {}): HostSSHTerminalSession {
+ return { output: values(), result: new Promise(() => {}), write: vi.fn().mockResolvedValue(undefined),
+ resize: vi.fn().mockResolvedValue(undefined), endInput: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined), cancel: vi.fn().mockResolvedValue({ remoteProcessStopped: null }), ...overrides };
+}
+function setup(h?: HostSSHTerminalSession) {
+ PluginRegistry.configureCapabilities({ granted: createSSHCapabilityBundle({ terminal: true, x11: true }) });
+ const openSSHTerminal = h ? vi.fn().mockResolvedValue(h) : undefined;
+ PluginRegistry.configureHostServices({ request: vi.fn(), openSSHTerminal });
+ return openSSHTerminal;
+}
+beforeEach(() => { resetCapabilityStateForTests(); setup(); });
+afterEach(() => { PluginRegistry.configureHostServices(null); resetCapabilityStateForTests(); });
+
+test('optional adapter fails closed, without noninteractive fallback', async () => {
+ await expect(ssh.openTerminal({ profileId: 'vm' })).rejects.toMatchObject({ code: 'UNAVAILABLE' });
+});
+test('new capabilities validate and require explicit grants; defaults do not grant them', async () => {
+ expect(createSSHCapabilityBundle()).not.toContain('ssh.terminal.open');
+ expect(createSSHCapabilityBundle({ terminal: true })).not.toContain('ssh.x11.forward');
+ expect(validatePluginInitPayload({capabilities: ['ssh.terminal.open','ssh.x11.forward']})).toMatchObject({capabilities:['ssh.terminal.open','ssh.x11.forward']});
+ setup(host());
+ PluginRegistry.configureCapabilities({ granted: createSSHCapabilityBundle() });
+ await expect(ssh.openTerminal({profileId:'vm'})).rejects.toMatchObject({code:'ACCESS_DENIED'});
+ PluginRegistry.configureCapabilities({ granted: createSSHCapabilityBundle({terminal:true}) });
+ await expect(ssh.openTerminal({profileId:'vm',x11:{mode:'trusted'}})).rejects.toMatchObject({code:'ACCESS_DENIED'});
+ expect(runCapabilityPreflight({declared:['ssh.terminal.open'],granted:['ssh.terminal.open','ssh.use']} as never).ok).toBe(false);
+});
+test('normalizes a safe request and projects bytes/result without extra host data', async () => {
+ const h = host({ output: values({sequence:0,data:Uint8Array.from([27,91,72]),cookie:'SECRET'}), result: Promise.resolve({...exited,cookie:'SECRET'}) });
+ const adapter = setup(h);
+ const s = await ssh.openTerminal({profileId:'vm',x11:{mode:'trusted',cookie:'SECRET'} } as never);
+ expect(adapter).toHaveBeenCalledWith({profileId:'vm',term:'xterm-256color',rows:24,columns:80,idleTimeoutMs:300000,sessionTimeoutMs:3600000,x11:{mode:'trusted'}});
+ const output = []; for await (const e of s.output) output.push(e);
+ expect(output).toEqual([{sequence:0,data:Uint8Array.from([27,91,72])}]);
+ expect(await s.result).toEqual(exited);
+});
+test('bidirectional bytes preserve ANSI, split UTF-8, keyboard control bytes and order', async () => {
+ const h = host({output:values({sequence:0,data:Uint8Array.from([27,91,72,0xe2])},{sequence:1,data:Uint8Array.from([0x82,0xac])})}); setup(h);
+ const s = await ssh.openTerminal({profileId:'vm',rows:40,columns:120});
+ const decoder = new TextDecoder(); let text = '';
+ for await (const e of s.output) { text += decoder.decode(e.data,{stream:true}); if(e.sequence===1) break; }
+ text += decoder.decode(); expect(text).toBe('\x1b[H€');
+ const h2 = host(); setup(h2); const t = await ssh.openTerminal({profileId:'vm'});
+ await Promise.all([t.write('é'),t.write(Uint8Array.of(3)),t.resize(30,100),t.write('\x1b[A'),t.endInput()]);
+ expect(h2.write).toHaveBeenNthCalledWith(1,Uint8Array.of(0xc3,0xa9));
+ expect(h2.write).toHaveBeenNthCalledWith(2,Uint8Array.of(3));
+ expect(h2.resize).toHaveBeenCalledWith(30,100);
+ expect((h2.write as any).mock.invocationCallOrder[1]).toBeLessThan((h2.resize as any).mock.invocationCallOrder[0]);
+ expect((h2.resize as any).mock.invocationCallOrder[0]).toBeLessThan((h2.write as any).mock.invocationCallOrder[2]);
+ await expect(t.write('x')).rejects.toMatchObject({code:'SESSION_CLOSED'});
+ expect(h2.close).not.toHaveBeenCalled(); // EOF is a half-close, not session release.
+});
+test('bounded input waits for acknowledgements, rejects overflow, never drops an accepted byte', async () => {
+ const ack = deferred(); const h = host({write:vi.fn().mockImplementation(()=>ack.promise)}); setup(h);
+ const s = await ssh.openTerminal({profileId:'vm'});
+ const bytes = new Uint8Array(32768).fill(65);
+ const a = s.write(bytes), b = s.write(bytes); bytes.fill(66);
+ await expect(s.write('x')).rejects.toMatchObject({code:'BACKPRESSURE'});
+ expect(h.write).toHaveBeenCalledTimes(1);
+ expect((h.write as any).mock.calls[0][0][0]).toBe(65);
+ ack.resolve(); await Promise.all([a,b]);
+ expect(h.write).toHaveBeenCalledTimes(2);
+});
+test('no output prefetch; concurrent reads reject; close wakes a blocked next and result', async () => {
+ const next = vi.fn().mockReturnValue(new Promise(()=>{}));
+ const h = host({output:{[Symbol.asyncIterator]:()=>({next})}}); setup(h);
+ const s = await ssh.openTerminal({profileId:'vm'}); expect(next).not.toHaveBeenCalled();
+ const iterator = s.output[Symbol.asyncIterator](); const waiting = iterator.next();
+ await expect(iterator.next()).rejects.toMatchObject({code:'BACKPRESSURE'});
+ await s.close(); await expect(waiting).resolves.toMatchObject({done:true});
+ expect(await s.result).toEqual({reason:'closed',exitCode:null,signal:null,remoteProcessStopped:null});
+ await s.close(); expect(h.close).toHaveBeenCalledOnce();
+});
+test.each([ {sequence:1,data:Uint8Array.of(1)}, {sequence:0,data:'x'}, {sequence:0,data:new Uint8Array(32769)}, {sequence:0,data:new Uint8Array()} ])('malformed output fails safely and closes: %j', async event => {
+ const h=host({output:values(event)});setup(h);const s=await ssh.openTerminal({profileId:'vm'});
+ await expect(s.output[Symbol.asyncIterator]().next()).rejects.toMatchObject({code:'INVALID_REQUEST'});
+ expect(h.close).toHaveBeenCalledOnce();
+});
+test('returning the output iterator explicitly releases this terminal', async () => {
+ const h=host({output:values({sequence:0,data:Uint8Array.of(1)})});setup(h);
+ const s=await ssh.openTerminal({profileId:'vm'});
+ const iterator=s.output[Symbol.asyncIterator]();
+ expect(await iterator.next()).toMatchObject({done:false,value:{sequence:0,data:Uint8Array.of(1)}});
+ await iterator.return?.();
+ await s.close(); expect(h.close).toHaveBeenCalledOnce();
+});
+test('abort during pending open closes late session, without retry', async () => {
+ const pending=deferred(), controller=new AbortController(), h=host(); setup();
+ const openSSHTerminal=vi.fn().mockReturnValue(pending.promise);
+ PluginRegistry.configureHostServices({request:vi.fn(),openSSHTerminal});
+ const opening=ssh.openTerminal({profileId:'vm'},controller.signal);await Promise.resolve();controller.abort();
+ await expect(opening).rejects.toMatchObject({code:'CANCELLED'});pending.resolve(h);
+ await new Promise(resolve=>setTimeout(resolve,0)); expect(h.close).toHaveBeenCalledOnce();expect(openSSHTerminal).toHaveBeenCalledOnce();
+});
+test.each(['clear', 'replace'] as const)('host service %s wakes an unacknowledged open and closes a late session once', async change => {
+ const pending=deferred(),late=host(),replacement=host();
+ const openSSHTerminal=vi.fn().mockReturnValue(pending.promise);
+ const replacementOpen=vi.fn().mockResolvedValue(replacement);
+ PluginRegistry.configureHostServices({request:vi.fn(),openSSHTerminal});
+ const opening=ssh.openTerminal({profileId:'vm'});
+ await Promise.resolve();expect(openSSHTerminal).toHaveBeenCalledOnce();
+ const rejected=expect(opening).rejects.toMatchObject({code:'UNAVAILABLE'});
+ PluginRegistry.configureHostServices(change==='clear'?null:{request:vi.fn(),openSSHTerminal:replacementOpen});
+ // Must settle before the old adapter resolves, without an explicit caller abort.
+ await rejected;
+ expect(late.close).not.toHaveBeenCalled();expect(replacementOpen).not.toHaveBeenCalled();
+ const fresh=change==='replace'?await ssh.openTerminal({profileId:'vm'}):undefined;
+ pending.resolve(late);
+ await new Promise(resolve=>setTimeout(resolve,0));
+ expect(late.close).toHaveBeenCalledOnce();expect(openSSHTerminal).toHaveBeenCalledOnce();
+ expect(replacement.close).not.toHaveBeenCalled();
+ await fresh?.close();
+ PluginRegistry.configureHostServices(null);
+ expect(late.close).toHaveBeenCalledOnce();
+});
+test('host invalidation before dispatch prevents the old adapter from starting', async () => {
+ const adapter=setup(host());
+ const opening=ssh.openTerminal({profileId:'vm'});
+ const rejected=expect(opening).rejects.toMatchObject({code:'UNAVAILABLE'});
+ PluginRegistry.configureHostServices(null);
+ await rejected;expect(adapter).not.toHaveBeenCalled();
+});
+test('a late adapter rejection after host invalidation is handled without retry', async () => {
+ const pending=deferred();
+ const openSSHTerminal=vi.fn().mockReturnValue(pending.promise);
+ PluginRegistry.configureHostServices({request:vi.fn(),openSSHTerminal});
+ const opening=ssh.openTerminal({profileId:'vm'});await Promise.resolve();
+ const rejected=expect(opening).rejects.toMatchObject({code:'UNAVAILABLE'});
+ PluginRegistry.configureHostServices(null);await rejected;
+ pending.reject(new Error('private provider failure'));
+ await new Promise(resolve=>setTimeout(resolve,0));expect(openSSHTerminal).toHaveBeenCalledOnce();
+});
+test('cancel is idempotent, preserves completed exit, and never invents termination evidence', async () => {
+ const done=deferred(), h=host({result:done.promise});setup(h);
+ const s=await ssh.openTerminal({profileId:'vm'});done.resolve(exited);expect(await s.result).toEqual(exited);
+ expect(await s.cancel()).toEqual({remoteProcessStopped:null});await s.cancel();
+ expect(h.cancel).toHaveBeenCalledOnce();expect(await s.result).toEqual(exited);
+});
+test('active abort invokes cancellation and closes forwarding', async () => {
+ const controller=new AbortController(), h=host();setup(h);
+ const s=await ssh.openTerminal({profileId:'vm'},controller.signal);controller.abort();
+ await expect(s.result).resolves.toMatchObject({reason:'cancelled',remoteProcessStopped:null});
+ await s.close();expect(h.cancel).toHaveBeenCalledOnce();expect(h.close).toHaveBeenCalledOnce();
+});
+test('revocation rejects further input and tears down; reconfiguration closes active resources', async () => {
+ const h=host();setup(h);const s=await ssh.openTerminal({profileId:'vm'});
+ PluginRegistry.configureCapabilities({granted:[]});await Promise.resolve();expect(h.close).toHaveBeenCalledOnce();await expect(s.write('x')).rejects.toThrow();
+ PluginRegistry.configureHostServices(null);await s.close();expect(h.close).toHaveBeenCalledOnce();expect(h.write).not.toHaveBeenCalled();
+});
+test.each(['X11_UNSUPPORTED','X11_UNAVAILABLE','X11_AUTH_UNAVAILABLE','X11_FORWARDING_DENIED','ACCESS_DENIED','IDLE_TIMEOUT','SESSION_LIMIT'])('safe host code %s excludes provider details', async code => {
+ setup();PluginRegistry.configureHostServices({request:vi.fn(),openSSHTerminal:async()=>{throw {code,message:'COOKIE'};}});
+ await expect(ssh.openTerminal({profileId:'vm',x11:{mode:'untrusted'}})).rejects.toMatchObject({code,message:`Host service request failed (${code}).`});
+});
+test.each([{rows:0},{columns:1001},{term:'x\n'},{idleTimeoutMs:0},{sessionTimeoutMs:86400001},{x11:{mode:'yes'}}])('invalid options never launch: %j', async bad => {
+ const adapter=setup(host());await expect(ssh.openTerminal({profileId:'vm',...bad} as never)).rejects.toMatchObject({code:'INVALID_REQUEST'});expect(adapter).not.toHaveBeenCalled();
+});
+
+test('abort before adapter dispatch never starts a shell', async () => {
+ const adapter=setup(host()),controller=new AbortController();
+ const pending=ssh.openTerminal({profileId:'vm'},controller.signal);controller.abort();
+ await expect(pending).rejects.toMatchObject({code:'CANCELLED'});expect(adapter).not.toHaveBeenCalled();
+});
+test('abort settles and wakes output even if cancellation acknowledgement stalls', async () => {
+ const h=host({cancel:()=>new Promise(()=>{}),output:{[Symbol.asyncIterator]:()=>({next:()=>new Promise(()=>{})})}});setup(h);
+ const controller=new AbortController(),s=await ssh.openTerminal({profileId:'vm'},controller.signal);
+ const read=s.output[Symbol.asyncIterator]().next();controller.abort();
+ expect(await s.result).toMatchObject({reason:'cancelled',remoteProcessStopped:null});
+ expect(await read).toMatchObject({done:true});await s.close();expect(h.close).toHaveBeenCalledOnce();
+});
diff --git a/tests/utils/sshTerminalHost.test.ts b/tests/utils/sshTerminalHost.test.ts
new file mode 100644
index 0000000..d059589
--- /dev/null
+++ b/tests/utils/sshTerminalHost.test.ts
@@ -0,0 +1,56 @@
+import {createRequire} from 'node:module';
+import {EventEmitter} from 'node:events';
+import {Duplex} from 'node:stream';
+const {openSSHTerminal}=createRequire(import.meta.url)('../../examples/host/sshTerminal.cjs');
+class Channel extends Duplex {
+ stderr=new Duplex({read(){},write(_b,_e,cb){cb();}});
+ writes:Buffer[]=[];setWindow=vi.fn();signal=vi.fn();
+ _read(){} _write(b:Buffer,_e:string,cb:()=>void){this.writes.push(Buffer.from(b));cb();}
+}
+function setup(extra:Record={}) {
+ const channel=new Channel(),client=Object.assign(new EventEmitter(),{shell:vi.fn((_p,_o,cb)=>cb(null,channel)),destroy:vi.fn()});
+ const authorize=vi.fn(),onDispose=vi.fn();
+ const options={profileId:'vm',term:'xterm-256color',rows:24,columns:80,idleTimeoutMs:100,sessionTimeoutMs:1000};
+ return {channel,client,authorize,onDispose,open:()=>openSSHTerminal({client,options,authorize,onDispose,...extra})};
+}
+afterEach(()=>vi.useRealTimers());
+test('host allocates requested PTY, ordered input/resize/EOF and preserves exit status',async()=>{
+ const f=setup(),s=await f.open();expect(f.client.shell.mock.calls[0][0]).toMatchObject({term:'xterm-256color',rows:24,cols:80});
+ const iterator=s.output[Symbol.asyncIterator](),waiting=iterator.next();f.channel.push(Buffer.from([27,91,72]));
+ expect((await waiting).value).toEqual({sequence:0,data:Uint8Array.from([27,91,72])});
+ await s.write(Uint8Array.of(3));await s.resize(40,120);await s.endInput();
+ expect(f.channel.writes).toEqual([Buffer.from([3])]);expect(f.channel.setWindow).toHaveBeenCalledWith(40,120,0,0);
+ f.channel.emit('exit',2,null);f.channel.emit('close');expect(await s.result).toMatchObject({reason:'exited',exitCode:2});expect(f.onDispose).toHaveBeenCalledOnce();
+});
+test('channel closure without an exit never confirms termination',async()=>{
+ const f=setup(),s=await f.open();f.channel.emit('close');expect(await s.result).toEqual({reason:'closed',exitCode:null,signal:null,remoteProcessStopped:null});
+});
+test('idle and absolute deadlines terminate even with no output consumer',async()=>{
+ vi.useFakeTimers();const f=setup(),s=await f.open();const idle=expect(s.result).rejects.toMatchObject({code:'IDLE_TIMEOUT'});await vi.advanceTimersByTimeAsync(101);await idle;
+ const g=setup({options:{term:'xterm',rows:24,columns:80,idleTimeoutMs:10000,sessionTimeoutMs:50}}),t=await g.open();const absolute=expect(t.result).rejects.toMatchObject({code:'SESSION_LIMIT'});await vi.advanceTimersByTimeAsync(51);await absolute;
+ expect(f.client.destroy).toHaveBeenCalledOnce();expect(g.client.destroy).toHaveBeenCalledOnce();
+});
+test('revocation, disconnect and explicit release wake blocked output readers',async()=>{
+ for(const reason of ['revoke','disconnect','close']){
+ const f=setup(),s=await f.open(),next=s.output[Symbol.asyncIterator]().next();
+ const waiting=reason==='close'?expect(next).resolves.toMatchObject({done:true}):expect(next).rejects.toHaveProperty('code');
+ const result=reason==='close'?expect(s.result).resolves.toMatchObject({reason:'closed'}):expect(s.result).rejects.toHaveProperty('code');
+ if(reason==='revoke'){f.authorize.mockImplementation(()=>{throw {code:'ACCESS_DENIED'};});f.channel.push(Buffer.from('x'));}
+ else if(reason==='disconnect')f.client.emit('close');else await s.close();
+ await waiting;await result;expect(f.onDispose).toHaveBeenCalledOnce();
+ }
+});
+test('bounded output pauses and fails explicitly if provider exceeds hard bound',async()=>{
+ const f=setup(),s=await f.open();f.channel.push(Buffer.alloc(65536));await new Promise(r=>setTimeout(r,0));expect(f.channel.isPaused()).toBe(true);
+ // A misbehaving provider can emit despite pause; fail rather than truncate terminal state.
+ const failure=expect(s.result).rejects.toMatchObject({code:'BACKPRESSURE'});f.channel.emit('data',Buffer.alloc(262145));await failure;
+});
+test('X11 denial disposes local authorization and SSH transport, without shell fallback',async()=>{
+ const f=setup(),dispose=vi.fn();f.client.shell.mockImplementation((_p,_o,cb)=>cb(new Error('provider private message')));
+ await expect(openSSHTerminal({client:f.client,options:{term:'xterm',rows:24,columns:80,idleTimeoutMs:1000,sessionTimeoutMs:1000,x11:{mode:'trusted'}},authorize:f.authorize,localX11:{screen:0,cookie:Buffer.alloc(16),connect:vi.fn(),dispose}})).rejects.toMatchObject({code:'X11_FORWARDING_DENIED'});
+ expect(dispose).toHaveBeenCalledOnce();expect(f.client.shell).toHaveBeenCalledOnce();expect(f.client.destroy).toHaveBeenCalledOnce();
+});
+test('cancel/close races are idempotent and cancellation is not termination proof',async()=>{
+ const f=setup(),s=await f.open();expect(await s.cancel()).toEqual({remoteProcessStopped:null});await s.cancel();await s.close();
+ expect(await s.result).toMatchObject({reason:'cancelled',remoteProcessStopped:null});expect(f.onDispose).toHaveBeenCalledOnce();
+});
diff --git a/tests/utils/x11Forwarding.test.ts b/tests/utils/x11Forwarding.test.ts
new file mode 100644
index 0000000..9c4f7f2
--- /dev/null
+++ b/tests/utils/x11Forwarding.test.ts
@@ -0,0 +1,62 @@
+import { createRequire } from 'node:module';
+import { EventEmitter } from 'node:events';
+import { Duplex } from 'node:stream';
+const {createX11Forwarding} = createRequire(import.meta.url)('../../examples/host/x11Forwarding.cjs');
+class Wire extends Duplex {
+ writes: Buffer[] = [];
+ _read() {}
+ _write(b: Buffer, _enc: string, cb: () => void) { this.writes.push(Buffer.from(b)); cb(); }
+}
+const wait = () => new Promise(resolve => setTimeout(resolve, 5));
+function packet(cookie: string, little = true) {
+ const p = Buffer.alloc(48); p[0] = little ? 0x6c : 0x42;
+ const word = (v:number,at:number) => little ? p.writeUInt16LE(v,at) : p.writeUInt16BE(v,at);
+ word(11,2);word(18,6);word(16,8);Buffer.from('MIT-MAGIC-COOKIE-1').copy(p,12);Buffer.from(cookie,'hex').copy(p,32);return p;
+}
+function setup(extra: Record = {}) {
+ const client = new EventEmitter(), socket = new Wire(), cookie = Buffer.alloc(16, 0x21);
+ const local = {cookie,screen:0,securityAuthorization:'untrusted',connect:vi.fn().mockResolvedValue(socket),dispose:vi.fn()};
+ const authorize = vi.fn();
+ const gate = createX11Forwarding({client,local,mode:'trusted',authorize,...extra});
+ const channel = new Wire(), reject = vi.fn();
+ const open = () => client.emit('x11', {srcIP:'untrusted'}, () => channel, reject);
+ return {gate,client,socket,channel,local,authorize,reject,open};
+}
+test.each([true,false])('fragmented X11 setup authenticates spoof cookie and substitutes host authorization (little=%s)', async little => {
+ const f=setup();f.open();const p=packet(f.gate.request.cookie,little);
+ expect(f.gate.request.cookie).not.toBe(f.local.cookie.toString('hex'));
+ for(let i=0;i {
+ const f=setup();f.open();const p=packet(f.gate.request.cookie);
+ if(kind==='cookie')p[32]^=1;if(kind==='protocol')p[12]=0;if(kind==='byteorder')p[0]=0;if(kind==='length')p.writeUInt16LE(65535,8);if(kind==='version')p[2]=12;
+ f.channel.push(p);await wait();expect(f.local.connect).not.toHaveBeenCalled();expect(f.channel.destroyed).toBe(true);f.gate.dispose();
+});
+test('authorization, channel cap, revocation and teardown reject channels', async () => {
+ const f=setup({maxChannels:1});f.open();f.client.emit('x11',{},vi.fn(),f.reject);expect(f.reject).toHaveBeenCalledOnce();
+ f.authorize.mockImplementation(()=>{throw new Error('private');});f.channel.push(packet(f.gate.request.cookie));await wait();
+ expect(f.local.connect).not.toHaveBeenCalled();expect(f.channel.destroyed).toBe(true);
+ f.gate.close();f.open();expect(f.reject).toHaveBeenCalledTimes(2);f.gate.dispose();expect(f.client.listenerCount('x11')).toBe(0);
+});
+test('setup timeout closes a fragmented header; close disposes a late local connection', async () => {
+ const f=setup({setupTimeoutMs:10});f.open();f.channel.push(Buffer.from([0x6c]));await new Promise(r=>setTimeout(r,20));expect(f.channel.destroyed).toBe(true);f.gate.dispose();
+ let resolve!: (v:Wire)=>void;const g=setup();g.local.connect.mockImplementation(()=>new Promise(r=>{resolve=r;}));g.open();g.channel.push(packet(g.gate.request.cookie));await wait();
+ g.gate.close();resolve(g.socket);await wait();expect(g.socket.destroyed).toBe(true);g.gate.dispose();
+});
+test('untrusted requires actual X SECURITY authorization and missing prerequisites fail closed', () => {
+ const client=new EventEmitter(),authorize=vi.fn();
+ expect(()=>createX11Forwarding({client,authorize,mode:'trusted'})).toThrow('X11_UNAVAILABLE');
+ expect(()=>createX11Forwarding({client,authorize,mode:'trusted',local:{connect:vi.fn()}})).toThrow('X11_AUTH_UNAVAILABLE');
+ expect(()=>createX11Forwarding({client,authorize,mode:'untrusted',local:{connect:vi.fn(),cookie:Buffer.alloc(16),screen:0}})).toThrow('X11_UNSUPPORTED');
+ const f=setup({mode:'untrusted'});f.gate.dispose();
+});
+test('local display connection failure is contained and closes the X11 channel', async () => {
+ const f=setup();f.local.connect.mockRejectedValue({message:'COOKIE'});f.open();f.channel.push(packet(f.gate.request.cookie));await wait();expect(f.channel.destroyed).toBe(true);f.gate.dispose();
+});