Client session channels
After connect() authenticates, a client can start a command or an interactive shell. Both APIs
return a ClientSessionChannel, which is a Node.js Duplex stream. Its readable side is standard
output, its writable side is standard input, and channel.stderr is a separate readable stream.
At the packet boundary, opaque channel data and request-detail buffers are copied; callers may
safely reuse or clear input buffers after constructing a packet, and parsed details do not alias
the transport frame that carried them. Channel open results and scalar control packets likewise
snapshot their metadata before they can be queued or observed asynchronously.
import { once } from "node:events"
const channel = await client.exec("node --version")
channel.pipe(process.stdout)
channel.stderr.pipe(process.stderr)
await once(channel, "close")
console.log({ code: channel.exitCode, signal: channel.exitSignal })exitCode and exitSignal retain the terminal result after close. The channel also emits an
observational exit event when that result arrives, but durable state is preferable when code
attaches after starting a short-lived command.
client.shell() opens the same kind of stream and requests an interactive shell. openSession()
opens a session without choosing a program, so callers can issue lower-level channel requests.
exec() accepts typed session setup options and applies them before the program request, in SSH
protocol order:
const channel = await client.exec("stty size; printf '%s' \"$LANG\"", {
allowHalfOpen: false,
agentForward: true,
env: { LANG: "C.UTF-8" },
pty: { term: "xterm-256color", cols: 120, rows: 40 },
x11: { single: true, screen: 0 },
})Environment requests from this convenience API do not ask for replies, matching common
OpenSSH behavior; servers may silently ignore variables outside their AcceptEnv policy. PTY, X11,
and agent-forwarding requests require success before exec starts. shell() accepts the same
options and requests a default PTY unless pty: false is supplied.
sftp(environment, { requestTimeout }) sends the given environment before starting the subsystem
and can override the connection reply deadline for SFTP initialization and tagged replies.
exec() and shell() snapshot their session options when the operation starts, including the
environment, PTY terminal modes, and X11 cookie bytes. Later changes to caller-owned configuration
do not affect requests waiting for the session channel to open. sftp() likewise copies its
environment and timeout options at invocation.
Both session helpers validate the options object before allocating a channel. agentForward and
allowHalfOpen require actual booleans, environment values require strings, and PTY and X11
configuration must use their documented scalar or object shapes. Invalid input rejects the
returned Promise; it never throws synchronously or turns a truthy value such as the string
"false" into an enabled forwarding request.
The command text passed to exec() must be valid UTF-8, and subsystem() requires an RFC 4250 SSH
name. These values are validated before allocating a channel identifier. Invalid values reject the
operation without opening a disposable session first.
For PTY, environment, resize, signal, or subsystem setup, open a session explicitly and make the requests in protocol order:
import { TerminalMode } from "@bunkerch/modernssh"
const channel = await client.openSession()
await channel.requestPty({
term: "xterm-256color",
columns: 120,
rows: 40,
modes: {
[TerminalMode.VINTR]: 3,
[TerminalMode.IUTF8]: 1,
[TerminalMode.ECHO]: 1,
[TerminalMode.TTY_OP_ISPEED]: 115_200,
[TerminalMode.TTY_OP_OSPEED]: 115_200,
},
})
await channel.setEnv("LANG", "en_US.UTF-8")
await channel.exec("top")
await channel.sendData("help\n")
await channel.setWindow({ columns: 160, rows: 50 })
await channel.sendBreak(750)
await channel.signal("SIGTERM")subsystem(name) starts a named subsystem instead of exec() or shell(). Only one program-start
request can succeed on a session. TerminalMode contains every RFC 4254 mnemonic plus RFC 8160
IUTF8; value 0 disables UTF-8 terminal processing and value 1 enables it. TerminalModes is
an equivalent registry alias. PTY modes accept either an opcode-to-uint32 object or a ReadonlyMap;
numeric opcodes remain accepted for future assignments in the RFC's 1–159 range. The encoder
validates every opcode and value and adds the required TTY_OP_END terminator. The server exposes
received values through SessionPtyInfo.modes without discarding modes it does not recognize.
Client channels remain ordinary Node.js duplex streams for piping. Use await channel.sendData()
when subsequent protocol actions must follow fully flow-controlled stdin; concurrent calls are
queued in call order, split at the peer's packet limit, and own a copy of Buffer input.
Applications can also exchange private channel requests without bypassing channel state. Outbound
requests are matched to success or failure replies in wire order; pass false as the third argument
for a one-way notification:
await channel.request("refresh@example.com", Buffer.from("full"))
await channel.request("changed@example.com", Buffer.from("users"), false)
channel.hooker.hook("request", async (_hook, context, decision) => {
if (context.type !== "refresh@example.com") return
await refresh(context.args)
decision.success = true
})Server channels provide the same request(type, args, wantReply) operation. Incoming requests
that do not have a channel-specific built-in handler reach the server's awaited channelRequest
hook. Its request packet is the fifth argument; set handled = true and set success only after the
application work has completed. Unknown requests fail by default. One-way requests still invoke the
hook but never receive a protocol reply.
Generic request types use the RFC 4251 name grammar: 1–64 printable US-ASCII characters, no comma,
and at most one non-leading @ followed by a valid DNS domain. Arguments must be a Buffer.
Malformed input rejects before a reply waiter is allocated or a packet is sent.
Generic request policy fails closed in both directions: every registered handler must complete
without rejection before success or the server's handled override is honored. A contained later
failure discards decisions made by earlier handlers.
On a client-originated session, standardized setup and process-control requests are strictly
client-to-server. A server cannot route pty-req, x11-req, env, program-start, resize, signal,
BREAK, or agent-forwarding requests through the client's generic extension hook. Reply-requesting
forms receive channel failure; one-way forms are ignored. The client still accepts the
server-to-client xon-xoff, exit-result, and negotiated end-of-write notifications.
Request hooks are awaited before their decisions change channel state or produce application events. A peer CLOSE remains terminal and is acknowledged immediately even while a hook is pending; any decision that finishes after the channel closes is discarded. Pending writes and outbound request Promises reject on close instead of waiting for an application hook that may never settle. Channel traffic received before the corresponding open confirmation is a connection-level protocol error; an unconfirmed channel cannot be used as an early data, request, EOF, or CLOSE path.
sendBreak(duration) implements RFC 4335 and waits for the server to confirm that it performed a
terminal BREAK. The duration is an unsigned millisecond value; zero requests the device default.
break(duration) is an equivalent short form. Servers commonly clamp nonzero requests to 500
through 3,000 milliseconds as recommended by the RFC.
On an OpenSSH-compatible server running a BSD-derived operating system,
await channel.sendInfoSignal() sends the published INFO@openssh.com signal extension. With the
default strictVendor setting, the method rejects before writing when the peer does not identify as
compatible. SSH signal notifications do not request a protocol reply, so the Promise confirms that
the request was written, not that the remote process handled SIGINFO. Generic extension signals
remain available through signal(name) when an application negotiates a separate convention.
Calling channel.end() finishes standard input by sending channel EOF. Calling channel.close()
sends EOF followed by CLOSE. A peer CLOSE is always acknowledged before the stream is destroyed.
EOF waits behind data already accepted by sendData() and prevents every later write, so channel
data cannot overtake the RFC 4254 half-close even when the peer's receive window is exhausted.
The server-side Shell follows the same directional lifecycle. shell.end() finishes stdout by
sending EOF but keeps stdin readable; the client may continue sending data because RFC 4254 leaves
the channel open in the opposite direction. Use shell.close() to send EOF followed by CLOSE when
the program and its input are both finished. A typical terminal path is
shell.exit(0).close() after the final stdout write completes.
For a custom server-side channel notification, channel.sendRequest(name, data) always writes
want reply as false and returns synchronously. Use await channel.request(name, data) when the
peer must acknowledge the request. RFC 4254 orders success and failure replies against the requests
which asked for them, so the Promise-returning form owns that reply and rejects on failure, timeout,
channel close, or transport close.
OpenSSH's eow@openssh.com request is a different half-close: it asks the peer to stop sending
channel data while leaving the reverse direction and the channel itself open. Call
channel.sendEndOfWrite() on a client session or shell.sendEndOfWrite() on a server session. The
method returns false when the peer is not identified as OpenSSH; pass true only when the
application has separately established support. Incoming requests are validated, deduplicated,
and exposed through the awaited endOfWrite hook before the writable half is stopped. Client
channels also emit endOfWrite as a passive notification. A local packet-emission failure throws
without marking end-of-write as sent, so the application can handle the transport error or retry
if the transport remains open.
Disabling additional sessions
After opening every session it needs, a client can ask an OpenSSH-compatible server to reject any later session channels:
const session = await client.exec("deploy")
await client.opensshNoMoreSessions()opensshNoMoreSessions() and its compatibility alias openssh_noMoreSessions() both return a
Promise. The request is irreversible for the connection. Existing session channels and non-session
channel types remain usable until a violation. If the client later attempts another session, a
modernssh server follows the extension's attack response: it disconnects with reason
SSH_DISCONNECT_BY_APPLICATION before invoking application channel policy. That disconnect closes
the existing channels and rejects every pending channel or connection operation on both peers. A
successful reply has no response data; unexpected data is a protocol error and closes the
connection because the peer has already applied irreversible state.
OpenSSH-specific client APIs require a compatible OpenSSH server identification by default. Set
strictVendor: false on Client only when a non-OpenSSH peer is known to implement these vendor
requests correctly. The same gate applies to the explicit compatibility form of agent forwarding
and to stream-local forwarding.
Agent forwarding
Agent forwarding is disabled by default. Configure a forwardable SSHAgent or OnePasswordAgent.
For a single manually opened session, request forwarding before starting its program:
import { Client, SSHAgent } from "@bunkerch/modernssh"
const client = new Client({ hostname, username, agent: new SSHAgent() })
await client.connect()
const channel = await client.openSession()
await channel.forwardAgent()
await channel.exec("ssh-add -L")forwardAgent() uses RFC 9987's agent-req request and accepts agent-connect channels when the
server advertises exact version 0 of agent-forward. When the advertisement is absent, it safely
falls back to the pre-standardization names for identified OpenSSH servers. Use
openssh_forwardAgent() only when intentionally forcing that compatibility form; as with other
vendor requests, strictVendor: false is required to force it for an unidentified peer.
Set agentForward: true on the client to request forwarding automatically before every exec() or
shell() program request. A session may override the connection default in either direction.
const client = new Client({
hostname,
username,
agent: process.env.SSH_AUTH_SOCK,
agentForward: true,
})
await client.connect()
const forwarded = await client.exec("ssh-add -L")
const isolated = await client.exec("deploy", { agentForward: false })After the server accepts the request, each agent-connect channel—or its compatibility form—is
connected directly to a fresh local agent socket. Incoming agent channels are rejected unless a
session request has succeeded, and agents without a stream capability such as DiskAgent cannot
be forwarded. Establishing that local socket may be asynchronous. If the SSH transport closes
first, a socket that resolves later is destroyed without creating a channel or attempting a reply
on the closed transport. This remains true when the same Client reconnects: an agent lookup is
bound to the transport that requested it and cannot attach to a channel identifier reused by the
new connection.
A server can inspect an authorized forwarded agent through the Promise-based protocol client. The connection carries no request identifiers, so operations are serialized and each response has a 10-second deadline by default:
import { SSHAgentProtocolClient } from "@bunkerch/modernssh"
const channel = await connection.forwardAgent()
const agent = new SSHAgentProtocolClient(channel.stream)
try {
const identities = await agent.getPublicKeys()
const [id, publicKey] = identities[0]
const message = Buffer.from("application challenge")
const signature = await agent.sign(id, message)
if (!publicKey.verifySignature(message, signature))
throw new Error("Agent returned a bad signature")
} finally {
agent.destroy()
}SSHAgentProtocolServer serves an already-connected Duplex with awaited, deny-by-default
identity, signing, management, lock, and extension hooks. It is useful for exposing a deliberately
restricted agent rather than forwarding an entire local socket:
import { SSHAgentProtocolServer } from "@bunkerch/modernssh"
const agentServer = new SSHAgentProtocolServer()
agentServer.hooker.hook("identities", async (_hook, decision) => {
decision.identities = await identitiesAllowedForThisConnection()
})
agentServer.hooker.hook("sign", async (_hook, request, decision) => {
decision.signature = await signIfAuthorized(request.publicKey, request.data, request.algorithm)
})
await agentServer.serve(connectedAgentStream)Both roles enforce the agent's 256 KiB message ceiling, strict framing, fatal UTF-8 comments, exact
RSA SHA-2 flags, response types, and signature algorithms. Server hooks are awaited in wire order;
missing, rejected, or invalid policy decisions return the protocol failure response. The server
also verifies a supplied signature against the requested key and message before releasing it.
The client exclusively owns reads from its stream and must not share it with another protocol
consumer. Destroy the client when finished; a request deadline also destroys the stream because an
untagged late response cannot safely be matched to a later request. Call serve() only once per
stream and await it through peer closure or failure.
Forwarding still gives the remote host an interface capable of requesting signatures as your local
identity, so expose only the identities and destinations that host is trusted to use.
See SSH agent protocol for management methods, constraints, extension results,
lock semantics, and the complete server hook surface.
Protocol behavior
The implementation follows RFC 4254 channel rules:
- Local and remote channel numbers are tracked independently. A peer identifier is reserved as soon as its open request arrives, including while asynchronous policy is pending, and remains reserved until both CLOSE messages have been exchanged. Reusing an active identifier is a protocol error; reuse after the channel is fully closed is valid. If the connection closes while channel-open policy is pending, a late approval is discarded without creating or publishing a channel.
maxPendingChannelOpensbounds peer opens whose application decision is still pending and defaults to 64 in both roles. Reaching the limit rejects additional opens with RFC 4254 resource shortage without closing the connection or invoking their policy/provider. Zero rejects every peer-initiated open. Established channels do not consume this pending-decision allowance.- Independent peer opens run their awaited admission policies concurrently and may be answered out of arrival order because every RFC 4254 result identifies its channel. Applications that mutate shared authorization state must provide their own synchronization inside the Hooker handler.
maxChannelsbounds all active channels and pending peer opens on a connection and defaults to 1024 in both roles. It applies equally to local opens and peer opens. Reaching the limit reports RFC 4254 resource shortage without closing the connection or invoking policy for a rejected peer open. Capacity is recovered after both CLOSE messages are exchanged; zero disables all channels.- Outbound data is split to the peer's maximum packet size and paused when its window is empty.
- Inbound stdout and stderr share the advertised receive window. Window adjustments are sent as
stream consumers make room. A zero adjustment is a valid no-op; an adjustment that would raise
the current window above
2^32 - 1causes an RFC protocol-error disconnect. - Request success and failure replies are matched in request order. An RFC 4253
SSH_MSG_UNIMPLEMENTEDrejects the exact request identified by its outbound packet sequence and removes it without shifting another request's reply. - Outbound channel opens and reply-requesting channel requests use the connection's
replyTimeout. Expiry rejects the operation and closes the connection so a late ordered reply cannot be misattributed.SSH_MSG_UNIMPLEMENTEDrejects the identified open or request immediately without closing the otherwise usable connection. - Local channel numbers span the complete RFC 4254
uint32range, wrap after0xffffffff, and skip identifiers that are still active. An identifier becomes reusable only after its channel has closed. - Local or peer CLOSE promptly settles pending writes and outbound requests; settlement never waits for the peer's CLOSE acknowledgement. Late results from request hooks cannot create session resources, emit request events, or send request replies.
- At most 1024 packet operations may wait behind active global or per-channel async work on one connection. Overflow closes the connection because one-way channel requests cannot be rejected individually. Transport teardown rejects and removes queued operations instead of abandoning their Promises.
- Transport termination immediately makes every owned channel report
isOpen === false, even when the connection ended before the channel-level CLOSE exchange completed. exit-statusandexit-signalrequests are exposed through theexitevent and channel fields. Exit signals retainexitSignal,exitCoreDumped,exitErrorMessage, andexitLanguageTag. These one-way results are accepted only once on a session channel; signal names, UTF-8 messages, language tags, reply flags, and trailing data are validated before the event is emitted.- Data after EOF, oversized data, window overruns, duplicate or contradictory open outcomes, duplicate peer identifiers, and packets for unknown channels are treated as protocol errors. Protocol-error disconnects are sent consistently whether the triggering packet arrived alone, fragmented, or in a coalesced read.
The interoperability suite exercises session opening, exec, stdin, stdout, stderr, exit status,
EOF, CLOSE, end-of-write, and OpenSSH's no-more-sessions@openssh.com extension against OpenSSH.
Encrypted in-process coverage verifies that the modern server disconnects a violating client and
settles existing channel work. Fixed RFC and OpenSSH protocol byte vectors cover the exact request
encodings.
Serving exec and shell requests
The server denies channel opens by default. Allow session channels at the server policy layer, then
configure each accepted SessionChannel. Request hooks decide whether an individual operation is
accepted; channel events provide its duplex stream after the success reply is sent.
Channel admission also requires every registered channelOpenRequest handler to complete without
rejection; a contained failure discards an allow decision made by an earlier handler.
Aborting or closing the proposed channel during policy denies it even if a later handler sets
allowOpen; the server never confirms or publishes a proposal that is no longer open.
The server owns each proposal while policy is pending. Denial disposes it, and transport teardown
aborts it immediately even if an async policy handler has not returned, so retained channel
references cannot outlive their connection as apparently open resources.
Writing CHANNEL_OPEN_CONFIRMATION is the admission commit point. A validation or encoding failure
before that write removes and aborts the proposal before returning CHANNEL_OPEN_FAILURE. After
confirmation is written, an exception from a synchronous channel observer terminates the
connection through its error path; the server never sends a contradictory failure for the same
proposal.
The same single-outcome rule applies to reply-bearing channel requests. Once CHANNEL_SUCCESS or
CHANNEL_FAILURE is submitted, that request is committed. A later synchronous session observer
exception follows connection error handling without sending a second response, while a failure
before reply submission can still produce the one RFC 4254 failure response.
Generic Channel instances snapshot their opaque open and confirmation arguments.
Mutating a constructor buffer, a buffer assigned to serverArgs, or a defensive buffer returned by
either accessor cannot alter a later channel-open or confirmation packet.
When policy intentionally denies an open, it may assign a validated ChannelOpenError to
decision.rejection. The peer then receives its exact uint32 reason, UTF-8 description, and RFC
3066 language tag. Named standard reasons are available through ChannelOpenFailureReasonCodes;
future assignments and RFC 4254 private-use values remain usable as numbers. If opening is denied,
openSession(), forwardOut(), and the other channel-opening Promise APIs reject with the same
typed error and expose reasonCode, message, and languageTag.
import {
ChannelOpenError,
ChannelOpenFailureReasonCodes,
SessionChannel,
} from "@bunkerch/modernssh"
async function runStatusCommand(stream) {
stream.stdin.pipe(process.stdout)
await stream.writeStderr("diagnostics\n")
await stream.writeStdout("ok\n")
stream.exit(0)
stream.stdout.end()
}
server.hooker.hook("channelOpenRequest", (_hook, channel, decision) => {
if (channel instanceof SessionChannel) {
decision.allowOpen = true
return
}
decision.rejection = new ChannelOpenError(
ChannelOpenFailureReasonCodes.SSH_OPEN_ADMINISTRATIVELY_PROHIBITED,
"channel type disabled by policy",
"en-US",
)
})
server.on("connection", (connection) => {
connection.on("channel", (channel) => {
if (!(channel instanceof SessionChannel)) return
channel.hooker.hook("execRequest", (_hook, context, decision) => {
decision.success = context.command === "status"
})
channel.events.on("exec", (_command, stream) => {
void runStatusCommand(stream).catch((error) => {
stream.destroy(error)
})
})
})
})Program requests consume a session only after their policy succeeds. Every shellRequest,
execRequest, and subsystemRequest handler must complete without rejection; a contained later
failure discards an earlier success and does not activate or publish a shell or subsystem stream.
The Shell passed to exec and shell events is a Node.js Duplex. It is also available through
the stdin and stdout aliases, and has a separate writable stderr. Await writeStdout() or
writeStderr() when later protocol messages must follow the output; ordinary stream writes remain
available for piping. Output writes obey the client's shared channel window and maximum packet
size. exit(number) sends exit-status; passing a signal name sends exit-signal:
stream.exit("TERM", true, "terminated by policy", "en-US").close()The optional diagnostic message must be valid UTF-8, and the optional language tag must be a valid
RFC 3066 tag. Both are validated before the one-way result is sent. A session can send exactly one
exit result; a second exit() call throws locally instead of emitting a peer-invalid duplicate.
Ending stdout flushes queued output, sends EOF and CLOSE, and a remote EOF only ends stdin so the
server can still finish its response.
Destroying the Shell, including destroy(error) from an application failure, sends channel CLOSE
when the SSH connection is still available. It never leaves a server-owned session channel open,
and teardown caused by an existing peer CLOSE or transport failure remains idempotent.
Session hooks also cover ptyRequest, envRequest, and subsystemRequest. Accepted values are
available in channel.pty and channel.env; the corresponding pty, env, and subsystem events
are emitted after acceptance. Every decision-bearing handler must complete without rejection before
its success is retained or any accepted state or event is published. Session command, terminal, and
environment text must be valid UTF-8; malformed values are rejected before any policy hook runs.
channel.env returns a readonly snapshot, so observation cannot mutate later session state. A
server retains at most maxSessionEnvironmentVariables entries and
maxSessionEnvironmentBytes combined UTF-8 name/value bytes per session (256 and 64 KiB by
default). Replacing an existing name is charged for its new value rather than as another entry.
Requests that would exceed either limit fail before application policy runs; one-way requests are
silently ignored because RFC 4254 provides no reply for them.
Runtime windowChange and signal notifications first run ordered, awaited hooks and are then
exposed as observation events:
channel.hooker.hook("windowChange", async (_hook, dimensions) => {
await pty.resize(dimensions.columns, dimensions.rows)
})
channel.hooker.hook("signal", async (_hook, { signal }) => {
await processController.signal(signal)
})These runtime controls are one-way notifications: conforming senders never request a reply. PTY
mode payloads are parsed and validated before a policy hook runs. Signal names must be exact ASCII
bytes and omit the SIG prefix; malformed one-way signals are ignored before policy without
closing the otherwise healthy connection. After an accepted end-of-write, window-change, or signal
reaches its synchronous observation event, a thrown listener error follows the owning connection's
error path without generating a forbidden reply. Malformed setup requests that ask for a reply
receive channel failure.
RFC 4335 BREAK is denied unless the session has started a program and an awaited breakRequest
policy hook confirms that the operation was performed. The hook receives the requested duration
unchanged so device-specific code can apply its own default and safe limits:
channel.hooker.hook("breakRequest", async (_hook, context, decision) => {
const duration = context.duration === 0 ? 500 : Math.min(3_000, Math.max(500, context.duration))
decision.success = await serialConsole.sendBreak(duration)
})Treat BREAK authorization as security-sensitive: consoles may interpret it as a request to halt a
system or enter privileged configuration. Every breakRequest handler must complete successfully;
a later contained failure discards an earlier approval and suppresses the break event. After
accepting a PTY, the server may also call
stream.setXonXoff(clientCanDo) to send RFC 4254's one-way local-flow-control notification. Clients
emit xonXoff with the boolean value and never reply to this notification.
Agent forwarding is separately denied by default even for an allowed session. The server must
approve agentForwardRequest; every handler must complete successfully before the authorization is
registered. After approval it may open one or more bounded agent channels:
channel.hooker.hook("agentForwardRequest", (_hook, decision) => {
decision.success = connection.username === "deploy"
})
const agentChannel = await connection.forwardAgent()
agentProtocolStream.pipe(agentChannel.stream).pipe(agentProtocolStream)The request creates a transitive trust relationship: the server can ask the user's agent to sign
arbitrary supported data for the rest of the SSH connection. Only enable it for fully trusted
servers and authenticated principals. A modernssh server advertises RFC 9987 version 0, accepts
both request forms, and opens the channel form corresponding to the accepted request. The explicit
connection.openssh_forwardAgent() helper remains available when a server must force the
pre-standardization channel name for a known compatibility peer.
Concurrent forwarding calls on one session share the same in-flight peer request. Every caller
observes its success or failure, and a rejected request may be retried without sending duplicate
requests while the first result is pending.
X11 forwarding
RFC 4254 X11 forwarding is also disabled by default. A client requests it on a session before
starting a program. Each incoming connection then passes through the awaited x11Connection
Hooker policy and is denied by default. The hook receives the proposed channel, whose immutable
details identify the originator. Set allowOpen only after authorization and local display setup
complete; the passive x11 event runs after channel confirmation.
client.hooker.hook("x11Connection", async (_hook, channel, decision) => {
if (!(await authorizeX11Origin(channel.details.originatorAddress))) {
return
}
const display = net.connect({ host: "127.0.0.1", port: 6000 })
channel.pipe(display).pipe(channel)
decision.allowOpen = true
})
const session = await client.openSession()
const request = await session.requestX11({
single: false,
screen: 0,
protocol: "MIT-MAGIC-COOKIE-1",
cookie: process.env.X11_COOKIE,
})
await session.exec("xeyes")If no cookie is supplied, the client generates a random 128-bit fake cookie as recommended by RFC 4254. Applications that pipe to a real X server must replace that fake cookie in the initial X11
setup packet with the real cookie; the normalized request returned by requestX11() exposes the
generated value for this purpose. Alternatively, explicitly supply the real cookie and accept the
greater exposure. Cookies are validated as non-empty hexadecimal data. single: true authorizes
exactly one incoming channel, and all unused authorization is removed when its session closes.
The session reserves X11 forwarding while its request is awaiting a reply, so a concurrent request
is rejected without sending a duplicate. A failed peer request releases that reservation and may
be retried.
Local argument-validation and channel-capacity failures occur before an outgoing X11 open and do
not consume that single authorization.
Policy may set decision.rejection to a ChannelOpenError with a specific failure reason,
description, and language tag. A decision completed after transport teardown is discarded and its
proposed channel is destroyed. Destroying a proposal during policy denies it even if a later hook
sets allowOpen.
On a server, x11Request receives the requested single-connection flag, authentication protocol,
hex cookie, and screen. Every handler must complete successfully before the authorization is
registered. After explicit approval, connection.x11() opens a bounded channel back to the client:
channel.hooker.hook("x11Request", (_hook, request, decision) => {
decision.success = request.protocol === "MIT-MAGIC-COOKIE-1" && connection.username === "deploy"
})
const x11 = await connection.x11("127.0.0.1", 60_000)
xApplicationSocket.pipe(x11.stream).pipe(xApplicationSocket)The server refuses x11() unless an active session approved forwarding. Single-connection requests
are consumed by the first open, while already-open X11 channels remain independent when the session
closes, as required by RFC 4254.