sugarcraft / candy-wish
SSH server middleware framework — port of charmbracelet/wish. Build TUIs that anyone can `ssh user@host` to run, with composable middleware (auth, logging, rate-limiting, ext-ssh2 client wrappers, mount a SugarCraft Program).
Requires
- php: >=8.1
- sugarcraft/candy-core: @dev
Requires (Dev)
- phpunit/phpunit: ^10.0
Suggests
- ext-ssh2: Enables the Ssh2 client middleware (forwarding, sftp helpers).
This package is auto-updated.
Last update: 2026-05-19 08:06:04 UTC
README
CandyWish
PHP port of charmbracelet/wish — an SSH server middleware framework that lets you build TUIs anyone can ssh user@host to run.
composer require sugarcraft/candy-wish
Architecture
CandyWish leans on the host's OpenSSH daemon rather than implementing the SSH wire protocol from scratch. Each SSH connection forks a fresh PHP process under sshd (via ForceCommand). What that PHP process does internally depends on the active transport:
InProcessTransport (default)
[client] ─ssh─▶ [sshd] ─ForceCommand──▶ [php supervisor] ──▶ [middleware stack]
│ │
└─pump bytes──┐ └─Spawn middleware
│ │
▼ ▼
[candy-pty master ◀──── slave / inner cmd]
(bash, vim, custom binary)
The supervisor allocates a candy-pty master/slave pair, spawns the user's cmd as a subprocess with full controlling-terminal semantics (Ctrl+C → SIGINT, SIGWINCH-driven resize, job control), and pumps bytes between the supervisor's STDIN/STDOUT (= sshd's PTY slave) and the candy-pty master. The terminal middleware is Spawn, which produces the cmd from the Session.
HostSshdTransport (legacy, opt-in)
[client] ─ssh─▶ [sshd] ─ForceCommand──▶ [php supervisor] ──▶ [middleware stack] ──▶ [SugarCraft Program reading STDIN, writing STDOUT]
The pre-PTY-upgrade architecture: middleware run inline in the supervisor, and the terminal middleware (BubbleTea) mounts a SugarCraft Program directly on the supervisor's STDIN/STDOUT. Pin via Server::new()->withTransport(new HostSshdTransport()). Use this if your existing entry script reads STDIN/echoes STDOUT directly without a subprocess.
Picking a transport
InProcessTransportwhen you want to spawn arbitrary shells (bash -i,zsh,fish), editors (vim,less), or compiled TUI binaries — anything that needs a controlling terminal. Subprocess overhead per connection (~50-200ms PHP cold start), but full PTY semantics.HostSshdTransportwhen your TUI is a SugarCraftProgramand you want zero subprocess overhead, or when you have an inline-STDIN-reading middleware (banner-style). No subprocess, but no controlling-terminal isolation.
Quickstart
1. Configure sshd
Add to /etc/ssh/sshd_config.d/wish.conf:
Match User wishuser
ForceCommand /usr/bin/php /opt/wish/server.php
AllowTcpForwarding no
PermitTTY yes
X11Forwarding no
Then systemctl reload sshd.
2. Write the entry script
InProcessTransport (default) — spawn an interactive shell:
<?php // /opt/wish/server.php require '/opt/wish/vendor/autoload.php'; use SugarCraft\Wish\Server; use SugarCraft\Wish\Middleware\Logger; use SugarCraft\Wish\Middleware\Auth; use SugarCraft\Wish\Middleware\RateLimit; use SugarCraft\Wish\Middleware\Spawn; use SugarCraft\Wish\Session; Server::new() ->use(new Logger('/var/log/wish.jsonl')) ->use(new RateLimit('/var/lib/wish/buckets.json', burst: 5, ratePerSec: 0.5)) ->use(new Auth(users: ['alice', 'bob'])) ->use(new Spawn(fn (Session $s) => [ 'cmd' => ['/bin/bash', '-l'], 'env' => [ 'TERM' => $s->term, 'USER' => $s->user, 'HOME' => "/home/{$s->user}", 'PATH' => '/usr/local/bin:/usr/bin:/bin', ], ])) ->serve();
HostSshdTransport (legacy) — mount a SugarCraft Program inline:
<?php // /opt/wish/server.php require '/opt/wish/vendor/autoload.php'; use SugarCraft\Wish\Server; use SugarCraft\Wish\Middleware\Logger; use SugarCraft\Wish\Middleware\Auth; use SugarCraft\Wish\Middleware\RateLimit; use SugarCraft\Wish\Middleware\BubbleTea; use SugarCraft\Wish\Transport\HostSshdTransport; Server::new() ->withTransport(new HostSshdTransport()) ->use(new Logger('/var/log/wish.jsonl')) ->use(new RateLimit('/var/lib/wish/buckets.json', burst: 5, ratePerSec: 0.5)) ->use(new Auth(users: ['alice', 'bob'])) ->use(new BubbleTea(fn ($session) => new MyApp($session))) ->serve();
3. Connect
ssh wishuser@your-host
Middleware
| Middleware | Transport | Purpose |
|---|---|---|
Logger |
both | One-line JSON event at session start + end, with elapsed time and connection meta. |
Auth |
both | Username allowlist, public-key fingerprint allowlist (or both). |
PasswordAuth |
both | Validates user+password against a caller-supplied callback (SSH_PASSWORD env var). |
CertificateAuth |
both | Validates X.509 peer certificate (SSL_CLIENT_CERT / SSH_CLIENT_CERT env vars). |
AuthMethods |
both | Declares accepted auth methods; writes SSH_AUTH_METHODS banner to STDOUT; stores list in Context. |
KeyboardInteractive |
both | Challenge-response — writes prompts to STDOUT, reads responses from STDIN (RFC 4256). |
RateLimit |
both | Per-IP token-bucket persisted to a JSON state file with flock(LOCK_EX). |
Keepalive |
both | Sends SSH-level keepalive messages at a configurable interval. |
Spawn |
InProcess only | Terminal — spawns a child cmd in a candy-pty controlled by the supervisor. |
BubbleTea |
HostSshd only | Terminal — mounts a SugarCraft Program inline reading STDIN, writing STDOUT. |
Subsystem |
both | Terminal — parses subsystem <name> from Session::command, dispatches to a registered SubsystemHandler. Non-subsystem requests pass through to $next. |
AsyncMiddleware |
both | Abstract base for middleware that needs async I/O (LDAP, OAuth, database auth) — return a PromiseInterface from handleAsync(). The transport waits for the promise to settle before continuing the chain. |
All middleware receives a {@see Context} as the first argument, along with
the {@see Session} and a $next continuation. Implement SugarCraft\Wish\Middleware:
use SugarCraft\Wish\Context; use SugarCraft\Wish\Middleware; use SugarCraft\Wish\Session; final class HelloBanner implements Middleware { public function handle(Context $ctx, Session $s, callable $next): void { echo "Welcome, {$s->user}!\n"; $next($ctx, $s); } }
Async middleware
Middleware handle() may return void (synchronous) or a
\React\Promise\PromiseInterface. The transport waits for the promise
to settle before continuing the chain, enabling async back-ends like
LDAP, OAuth, or database authentication.
Extend SugarCraft\Wish\Middleware\AsyncMiddleware to implement async
middleware. Override handleAsync() to perform async work and return
a promise; resolve the promise (or let it reject) to control whether
the chain continues.
use SugarCraft\Wish\Context; use SugarCraft\Wish\Middleware\AsyncMiddleware; use SugarCraft\Wish\Session; use React\Promise\PromiseInterface; final class LdapAuth extends AsyncMiddleware { protected function handleAsync(Context $ctx, Session $session, callable $next): PromiseInterface { return $this->ldap->verify($session->user)->then( fn () => $next($ctx, $session), fn (\Throwable $e) => throw new AuthFailedException($e->getMessage()), ); } }
The promise returned by handleAsync() resolves when async work is
done and the chain should proceed to $next; rejects to short-circuit
the chain. The 30-second timeout is enforced by
AsyncMiddleware::await().
Session metadata
Session::fromEnvironment() reads the standard sshd-supplied environment:
$s->user; // 'alice' $s->clientHost; // '203.0.113.7' $s->clientPort; // 54321 $s->term; // 'xterm-256color' $s->cols; // 120 $s->rows; // 40 $s->tty; // '/dev/pts/3' (null when non-interactive) $s->command; // SSH_ORIGINAL_COMMAND if set $s->isInteractive(); $s->toLogContext();
After the SSH handshake completes, transports call withProtocolMetadata()
to populate protocol-level fields:
$s->sessionId; // SSH session ID (hex string) $s->authMethod; // 'publickey' | 'password' | 'keyboard-interactive' | ... $s->keyFingerprint; // SHA256 host-key fingerprint of the connected client $s->clientVersion; // SSH client version string (e.g. 'SSH-2.0-OpenSSH_9.0') $s->serverVersion; // SSH server version string (e.g. 'SSH-2.0-OpenSSH_9.0') // Build a new Session with protocol metadata attached $s = $s->withProtocolMetadata( sessionId: $sessionId, authMethod: $authMethod, keyFingerprint: $keyFingerprint, clientVersion: $clientVersion, serverVersion: $serverVersion, );
Context propagation
Every request starts with a root {@see Context} created by Context::background().
The context is immutable — each with*() method returns a new derived
context that forms a parent chain. Middleware can attach key-value metadata
via withValue(), set a deadline via withDeadline(), or make the context
cancellable via withCancelable(). The terminal middleware (Spawn /
BubbleTea) never call $next, short-circuiting the chain.
| Context method | What it does |
|---|---|
Context::background() |
Root context — never done, no values, not cancelable |
->withValue(string $k, mixed $v) |
Return a new context with $k → $v attached |
->withDeadline(\DateTimeImmutable) |
Return a new cancelable context that is done when the deadline passes |
->withCancelable() |
Return a new cancelable context (no deadline; must call ->cancel() explicitly) |
->cancel(?\Throwable $reason) |
Mark the context (and all derived contexts) as cancelled |
->done() |
Returns true when cancelled or deadline-exceeded |
->err() |
Returns the cancellation error or DeadlineExceededException / CancellationException |
->value(string $k) |
Walk the parent chain looking for $k; returns null if not found |
use SugarCraft\Wish\Context; // Derive a cancelable context with a 30-second deadline $ctx = Context::background() ->withValue('requestId', $uuid) ->withDeadline(new \DateTimeImmutable('+30 seconds')); if ($ctx->done()) { throw $ctx->err(); // DeadlineExceededException or CancellationException }
Exceptions
| Exception | When it's thrown |
|---|---|
CancellationException |
Context->cancel() was called, or done() returns true with no deadline |
DeadlineExceededException |
The context deadline (withDeadline()) has passed |
Both extend \RuntimeException.
ext-ssh2
The PECL ssh2 extension is optional and used only if you want a middleware that opens outbound SSH connections from inside the session (e.g. SFTP file pickers, remote-control agents). Standard server-side use does not require it.
Channel handler (InProcessTransport)
The InProcessTransport dispatches SSH channel-level messages through a
ChannelHandler rather than handling them inline. This lets you replace the
default PTY/shell wiring with a custom implementation.
| Class | Purpose |
|---|---|
ChannelHandler |
Interface — implement to handle pty-req, window-change, shell, exec, signal, env, break |
ChannelMsg |
Abstract base for all channel messages (RFC 4254) |
DefaultChannelHandler |
Default impl — tracks PTY state, env vars, cols/rows, drives ChildSpawner on shell/exec |
PtyReqMsg |
wantPty, term, cols, rows, widthPx, heightPx |
WindowChangeMsg |
cols, rows, widthPx, heightPx |
ShellMsg |
wantShell, subsystem |
ExecMsg |
command (raw string — parsed by DefaultChannelHandler::parseCommandString()) |
SignalMsg |
signalName |
EnvMsg |
name, value |
BreakMsg |
Break request (no fields) |
use SugarCraft\Wish\Channel\ChannelHandler; use SugarCraft\Wish\Channel\ChannelMsg; use SugarCraft\Wish\Channel\Msg\PtyReqMsg; use SugarCraft\Wish\Channel\Msg\WindowChangeMsg; use SugarCraft\Wish\Channel\Msg\ShellMsg; use SugarCraft\Wish\Channel\Msg\ExecMsg; use SugarCraft\Wish\Channel\Msg\SignalMsg; use SugarCraft\Wish\Channel\Msg\EnvMsg; use SugarCraft\Wish\Channel\Msg\BreakMsg; use SugarCraft\Wish\Session; final class DebugChannelHandler implements ChannelHandler { public function handlePtyReq(PtyReqMsg $msg, Session $session): void { fwrite(STDERR, "pty-req: wantPty={$msg->wantPty} cols={$msg->cols} rows={$msg->rows}\n"); } public function handleWindowChange(WindowChangeMsg $msg, Session $session): void { fwrite(STDERR, "window-change: cols={$msg->cols} rows={$msg->rows}\n"); } public function handleShell(ShellMsg $msg, Session $session): void { fwrite(STDERR, "shell: wantShell={$msg->wantShell}\n"); } public function handleExec(ExecMsg $msg, Session $session): void { fwrite(STDERR, "exec: {$msg->command}\n"); } public function handleSignal(SignalMsg $msg, Session $session): void { fwrite(STDERR, "signal: {$msg->signalName}\n"); } public function handleEnv(EnvMsg $msg, Session $session): void { fwrite(STDERR, "env: {$msg->name}={$msg->value}\n"); } public function handleBreak(BreakMsg $msg, Session $session): void { fwrite(STDERR, "break\n"); } } // Pass to InProcessTransport new InProcessTransport($ptySystem, new DebugChannelHandler());
Subsystem middleware (InProcessTransport)
SSH clients can request a named subsystem by sending subsystem <name> as
the original command. The Subsystem middleware parses this prefix, looks up
a registered handler, invokes it, and stops the chain — subsystem handlers
are terminal by design.
| Class | Purpose |
|---|---|
Subsystem |
Middleware — parses subsystem <name>, dispatches to registered handler |
SubsystemHandler |
Interface — implement handle(Context, Session): void for a named subsystem |
SftpStub |
Example impl — stub demonstrating wiring; not a real SFTP server |
use SugarCraft\Wish\Middleware\Subsystem; use SugarCraft\Wish\Middleware\Subsystem\SftpStub; $subsystem = new Subsystem(); $subsystem->register('sftp', new SftpStub()); Server::new() ->use(new Logger('/var/log/wish.jsonl')) ->use(new Auth(['alice', 'bob'])) ->use($subsystem) // handles subsystem sftp; others pass through to Spawn ->use(new Spawn(fn (Session $s) => ['cmd' => ['/bin/bash', '-l']])) ->serve();
A production SFTP implementation would implement SubsystemHandler to speak
the SFTP protocol over the session's stdin/stdout after Subsystem
extracts the name and dispatches.
Status
Phase 9+ — with Context propagation + ChannelHandler dispatch. Seven middleware classes, ChannelHandler/ChannelMsg + 7 message classes, 25+ tests / 80+ assertions, ready for v0 deployment.
See examples/hello-server.php for a runnable banner-only stack you can ForceCommand against.