@cloakcode/gateway
v1.0.0
Published
Standalone CloakCode gateway — the run-it-yourself hub that serves the PWA and multiplexes phone (operator) + extension (provider) connections. NO vscode import.
Readme
@cloakcode/gateway
The standalone CloakCode gateway — a run-it-yourself hub that serves the CloakCode phone app
(PWA) and connects your phone (operator) and your VS Code extensions (providers) over two
role-scoped listeners — a loopback operator listener (PWA + phone) and a dedicated provider
listener (extensions, wss:// by default). Run it when you want several VS Code windows or machines
to share one phone endpoint, or to keep the hub outside the editor.
Pairs with the CloakCode VS Code extension: the extension observes and steers your Copilot sessions; the gateway is the shared hub your phone connects to.
Needs Node ≥ 20. When the gateway is exposed (a wide
0.0.0.0bind or a live tunnel) it requires operator TOTP by default (secure-by-exposure — see Operator auth and the step-by-step setup); still prefer a private tunnel over a wide bind on an untrusted network (see Security).
Deployment shapes at a glance
Pick a row; each links to the section with the detail.
| Goal | Command | Operator auth (phone) | Provider transport (extensions) |
| ------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------ | ----------------------------------------- |
| Try it locally, everything on one machine | npx @cloakcode/gateway | Off (pure loopback isn't exposed) | wss://127.0.0.1:3544, self-signed + pin |
| Phone access from anywhere | CLOAKCODE_TUNNEL=devtunnel npx @cloakcode/gateway | TOTP (auto — the tunnel exposes it) | unchanged — extensions stay on loopback |
| Extensions on other machines | Docker (publishes 3544), or CLOAKCODE_TLS_HOST=0.0.0.0 | as above | wss://<host>:3544 + fingerprint pin |
| You already run Tailscale / WireGuard / ssh -L | npx @cloakcode/gateway (keep both listeners on loopback) | CLOAKCODE_MFA=required if you want it | the overlay encrypts; pin still applies |
| Trusted LAN, no TLS wanted | CLOAKCODE_PROVIDER_INSECURE=1 npx @cloakcode/gateway | as above | plain ws:// — warned, no pin |
The two listeners are independent: exposing the phone endpoint never exposes the extension one, and vice versa. See Two listeners for the binding and certificate rules, and docs/07 — Deployment for network-level detail (dev containers, WSL, firewall lockdown).
Run it — npx (no install)
npx @cloakcode/gatewayServes the PWA (phone) on the operator listener at http://127.0.0.1:3543 and accepts
extensions on the dedicated provider listener at wss://127.0.0.1:3544 (wss by default),
printing separate browser and extension URL lists.
There are no CLI flags — the published bin (cloakcode-gateway) is configured entirely by
environment variables set on the command line. The common ones:
# phone access via a private Dev Tunnel (needs the devtunnel CLI, signed in) — prints a phone URL
CLOAKCODE_TUNNEL=devtunnel npx @cloakcode/gateway
# pick a fixed port (default 3543; a fixed value keeps the phone/tunnel URL stable)
CLOAKCODE_GATEWAY_PORT=8080 npx @cloakcode/gateway
# require a provider token and host a phone tunnel — extensions reach the provider
# listener (wss on 3544) directly; the phone reaches the PWA via the tunnel
CLOAKCODE_GATEWAY_TOKEN=<shared-secret> \
CLOAKCODE_TUNNEL=devtunnel \
npx @cloakcode/gatewaySee all options below.
Run it — Docker
# extensions connect to the provider listener (wss) on 3544; the phone reaches the
# PWA via the gateway's own private tunnel (enable it below)
docker run --rm -p 3544:3544 ghcr.io/lsiddiquee/cloakcode-gateway:latest
# pin a version: ...cloakcode-gateway:v0.1.2In the image the operator listener (PWA + phone) binds loopback 127.0.0.1:3543 — reach it via
the gateway's own private Dev Tunnel (below), or front it with your own ingress by setting
-e CLOAKCODE_GATEWAY_HOST=0.0.0.0 and publishing -p 3543:3543. The provider listener binds
0.0.0.0:3544 (wss); publish it with -p 3544:3544 so extensions can connect. Configure with the
same environment variables via -e:
docker run --rm -p 3544:3544 \
-e CLOAKCODE_GATEWAY_TOKEN=<shared-secret> \
-e CLOAKCODE_TUNNEL=devtunnel \
-v cloakcode-devtunnel:/home/app/.local/share/DevTunnels \
ghcr.io/lsiddiquee/cloakcode-gateway:latestPhone tunnel from the container
The image bundles the devtunnel CLI (inert unless you enable it). To host a private Dev Tunnel
straight from the container, enable it and mount a volume for the token so you only sign in once:
docker run -p 3544:3544 \
-e CLOAKCODE_TUNNEL=devtunnel \
-v cloakcode-devtunnel:/home/app/.local/share/DevTunnels \
ghcr.io/lsiddiquee/cloakcode-gateway:latestOn first run it prints a device code + URL to the console (docker logs) — open the URL in any
browser and enter the code. The sign-in is device-code, so -it is not needed (it runs fully
detached); it blocks until you finish, and if the code expires the container exits — just restart. The
token lives in the mounted volume, so later runs sign in silently. Sign-in defaults to GitHub; set
-e CLOAKCODE_TUNNEL_PROVIDER=microsoft for a Microsoft account. The container runs as a non-root user
(app). Prefer your own ingress instead? Leave the tunnel off and front the published port with
Cloudflare Tunnel / Tailscale / a reverse proxy.
Persisting state across container upgrades (volumes)
A container is ephemeral — without volumes, replacing it (an image upgrade, docker rm, a
recreate) regenerates the operator TOTP secret (so every paired phone must re-enrol), drops the
Dev Tunnel sign-in, and discards the action log. Mount a volume for each piece of state you want
to keep — and you can relocate the files with env vars if you'd rather point them at one shared
volume:
| State | Default path in the container | Relocate with | Keep it with |
| -------------------------------------- | ------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------- |
| Operator TOTP secret (+ confirmed) | /home/app/.cloakcode/operator-totp.secret | CLOAKCODE_MFA_SECRET_FILE | -v cloakcode-mfa:/home/app/.cloakcode |
| Dev Tunnel sign-in token | /home/app/.local/share/DevTunnels | (fixed — mount the path) | -v cloakcode-devtunnel:/home/app/.local/share/DevTunnels |
| Action log (JSONL) | /app/cloakcode-gateway.jsonl | CLOAKCODE_GATEWAY_LOG_FILE ("" = off) | point it into a mounted dir (see below) |
All three at once — TOTP secret survives upgrades, tunnel signs in once, and the action log lands on a named volume:
docker run -p 3544:3544 \
-v cloakcode-mfa:/home/app/.cloakcode \
-v cloakcode-devtunnel:/home/app/.local/share/DevTunnels \
-v cloakcode-logs:/data \
-e CLOAKCODE_GATEWAY_LOG_FILE=/data/gateway.jsonl \
-e CLOAKCODE_TUNNEL=devtunnel \
ghcr.io/lsiddiquee/cloakcode-gateway:latestPrefer one volume for everything? Relocate the secret + log into it and mount it once:
-e CLOAKCODE_MFA_SECRET_FILE=/data/totp.secret -e CLOAKCODE_GATEWAY_LOG_FILE=/data/gateway.jsonl -v cloakcode-data:/data
(the Dev Tunnel token dir is fixed, so mount it separately if you use the built-in tunnel).
Full setup — exposed gateway with MFA (step by step)
When the gateway is exposed — a live tunnel (the phone path) or a wide 0.0.0.0 operator bind —
operator TOTP is on automatically (secure-by-exposure). Here's the whole flow: gateway → phone →
extension. Force it on/off with CLOAKCODE_MFA=required / CLOAKCODE_MFA=off.
1. Start the gateway
# The phone reaches the operator via a private tunnel, which EXPOSES it → MFA turns
# on. Mount a volume so the TOTP secret (and the tunnel sign-in) survive container
# replacement. Extensions connect to the provider listener on 3544.
docker run -p 3544:3544 \
-e CLOAKCODE_TUNNEL=devtunnel \
-v cloakcode-mfa:/home/app/.cloakcode \
-v cloakcode-devtunnel:/home/app/.local/share/DevTunnels \
ghcr.io/lsiddiquee/cloakcode-gateway:latestOn first run the console prints the instance name + the connect URLs and reports that enrolment is
required — until you pair an authenticator the hub serves only the pairing screen, no session
data. (No tunnel? The operator stays on loopback with MFA off; front it with your own private tunnel /
ingress, or set -e CLOAKCODE_GATEWAY_HOST=0.0.0.0 -p 3543:3543 to expose the PWA directly.)
2. Enrol an authenticator (operator — one time)
Open the gateway app — easiest on a desktop browser so you can scan the on-screen QR with your phone's authenticator app:
- Local / LAN:
http://<gateway-host>:<gateway-port> - Tunnel: the phone URL printed in the console (
docker logs).
The app shows a QR code. Scan it into an authenticator app (Google Authenticator, 1Password, …),
then enter the current 6-digit code to confirm. The secret is generated once and stored 0600
(in the mounted volume). Once confirmed, the gateway is active and serves normally.
Desktop vs phone. Opening the page on a desktop lets you scan the QR with your phone's authenticator. If you open it on the phone itself, you can't scan a QR on the same screen — tap/copy the shown secret into your authenticator instead.
Lock it down with strict enrolment. By default (
CLOAKCODE_MFA_ENROL=browser) the QR + secret are served to whoever opens the gateway during enrolment — convenient, but it means anyone who reaches the gateway before you've paired could enrol their own authenticator and take over. SetCLOAKCODE_MFA_ENROL=strictso the secret is never sent over the wire — the QR is shown only on an interactive TTY (never the persistentdocker logsstream, drift audit S7). Scan it from an attached terminal, then enter a code in the app; a headless run instead points at the0600secret file (retrieve it once out-of-band, e.g.docker exec … cat) or use browser enrolment.
Every later phone/browser logs in with the current 6-digit code and gets a session token (12 h, or 30 days with "remember this device"), so reconnects don't re-prompt. Replayed and repeatedly wrong codes are rejected.
3. Connect your VS Code extension (provider)
In each VS Code window point the extension at the gateway:
"cloakcode.gatewayUrl": "ws://<gateway-host>:<gateway-port>"The extension connects, and because the gateway requires auth it asks the extension to sign in (it does not fall back to an embedded bridge). Click the Sign In prompt, or run CloakCode: Sign in to Gateway from the Command Palette, and enter a current 6-digit code from the same authenticator you enrolled in step 2. The extension stores the issued provider token (per gateway URL) and reconnects. (Set the URL after activation? Run CloakCode: Reconnect or reload the window.)
Headless/automation instead of interactive sign-in? Present a static machine-to-machine secret:
CLOAKCODE_GATEWAY_TOKENon the gateway +cloakcode.gatewayTokenon the extension (Provider token).
4. Validate
Refresh the gateway in your browser/phone — the Copilot sessions from that VS Code window now appear. Open one to see the live transcript; a blocked session shows a "Needs your input" card you can answer remotely.
Connect your VS Code extension
In VS Code settings, point the extension at the gateway's provider listener (several windows can share one):
"cloakcode.gatewayUrl": "wss://<gateway-host>:3544"Also set cloakcode.gatewayCertFingerprint to the pin from the gateway's Connect an extension view
(all a self-signed gateway needs). An insecure gateway (CLOAKCODE_PROVIDER_INSECURE=1) uses a plain
ws:// URL instead.
If you started the gateway with a token, set the same value on the extension so it can register as a provider — see Provider token below.
If the gateway requires operator TOTP (the default when exposed), the extension connects but the gateway asks it to sign in — it does not fall back to an embedded bridge. Click the Sign In prompt (or run CloakCode: Sign in to Gateway) and enter a current 6-digit code from the authenticator you enrolled on the gateway; the extension stores the issued provider token per URL and reconnects. Full walkthrough: Full setup.
For a gateway on another machine or container, bind the provider listener wide
(CLOAKCODE_TLS_HOST=0.0.0.0 — the Docker image already does), then use that host's IP with the
provider port in gatewayUrl (e.g. wss://192.168.1.10:3544) plus the
cloakcode.gatewayCertFingerprint pin from its Connect an extension view. (The operator/PWA
listener stays on loopback — reach the phone via the gateway's tunnel.)
Provider token (shared secret)
The gateway and every extension that connects to it authenticate the provider↔gateway link with one shared secret. When you run the gateway separately, the token must be identical on both sides and configured in both places — otherwise the gateway rejects the extension and its sessions never reach your phone.
Set the same value on the gateway and on every VS Code window that connects:
# gateway (env) — npx
CLOAKCODE_GATEWAY_TOKEN=<shared-secret> npx @cloakcode/gateway
# gateway (env) — Docker
# gateway (env) — Docker (publish the provider listener so extensions can reach it)
docker run --rm -p 3544:3544 -e CLOAKCODE_GATEWAY_TOKEN=<shared-secret> ghcr.io/lsiddiquee/cloakcode-gateway:latest// VS Code settings — must match the gateway's token exactly
"cloakcode.gatewayToken": "<shared-secret>"- Machine-to-machine only. The token is never sent to or shown on the phone (operator auth is separate).
- Both unset = no auth (fine for loopback dev). If the gateway has a token and the extension
doesn't — or they differ — the gateway logs
provider.auth_rejectand closes the connection. - Use any hard-to-guess value; e.g.
openssl rand -hex 32. TheCLOAKCODE_GATEWAY_TOKENenv var overrides thecloakcode.gatewayTokensetting on the extension side.
Two listeners: operator (loopback) + provider (wss)
The gateway binds two role-scoped listeners:
- the operator listener — the PWA + phone — on
CLOAKCODE_GATEWAY_HOST(default127.0.0.1), fronted by your private Dev Tunnel (which supplies TLS). Operators only. - the provider listener — the dedicated endpoint extensions connect to — on
CLOAKCODE_TLS_HOST(default127.0.0.1; the Docker image sets0.0.0.0):CLOAKCODE_TLS_PORT(default3544). Providers only; a provider is never served on the operator bind.
The provider listener is wss:// by default — with no BYO cert the gateway generates and persists
a self-signed pair under ~/.cloakcode (key 0600, never logged) and prints its SHA-256
fingerprint — the pin. BYO a real CA / mkcert / corporate cert with CLOAKCODE_TLS_CERT_FILE +
CLOAKCODE_TLS_KEY_FILE:
# wss on the default provider port (3544), auto self-signed cert:
npx @cloakcode/gateway
# a fixed provider port + BYO cert/key:
CLOAKCODE_TLS_PORT=7443 CLOAKCODE_TLS_CERT_FILE=./gw.crt CLOAKCODE_TLS_KEY_FILE=./gw.key npx @cloakcode/gateway
# INSECURE plain ws (trusted network only — warned in console + UI):
CLOAKCODE_PROVIDER_INSECURE=1 npx @cloakcode/gatewayAn encrypted overlay / reverse proxy (Tailscale, WireGuard, ssh -L, Caddy/nginx) with both
listeners on loopback is still the lowest-friction path when you already have one.
Pair an extension from the app: open the PWA (behind your tunnel + TOTP) → Settings → Connect an
extension. It shows one pairing URL to paste into the extension's cloakcode.gatewayUrl — the
reachable wss:// address with this gateway's fingerprint attached as a #fp=… fragment, so the
address and its pin can never drift apart (the fragment is never transmitted; the extension splits it
off locally). The extension then fetches the certificate, accepts it only if it matches that pin,
and uses it as the trust anchor — failing closed on a mismatch, never downgrading to
trust-on-first-use. The bare pin is shown too for anyone who prefers cloakcode.gatewayCertFingerprint
as a separate setting. A gateway whose certificate a real authority already vouches for (a public
CA, or your org's root deployed to the device) needs no setting beyond the URL. The console printout
is the fallback when no tunnel is up. The fingerprint is public (an integrity pin, not a secret);
the private key never leaves the gateway.
Operator auth (TOTP)
The phone → gateway boundary is gated by a time-based one-time code (RFC 6238 TOTP) whenever the
hub is exposed — a wide bind or a live tunnel. Force it with CLOAKCODE_MFA=required, turn it
off with CLOAKCODE_MFA=off; unset means secure-by-exposure (off for pure loopback dev). For the
end-to-end walkthrough see Full setup.
Pair once (enrolment). On first run the gateway generates a secret and persists it 0600 to
CLOAKCODE_MFA_SECRET_FILE (default ~/.cloakcode/operator-totp.secret). A fresh secret is
unconfirmed — the hub runs in enrolment mode, serving only the pairing screen until you verify
a code. Default (browser): open the gateway URL and the app shows the QR — scan it into an
authenticator app, then enter a code to confirm. Strict (CLOAKCODE_MFA_ENROL=strict): the
secret is never sent over the wire — the QR + otpauth URI are printed to the console instead; scan
there and verify in the app. Either way the secret is shown once; later runs reuse the file.
Each phone logs in with the current 6-digit code; the gateway returns a signed session token (12h, or 30d with “remember this device”) so reconnects don't re-prompt until it expires. A reused code (replay) and repeated bad codes (lockout) are rejected. The secret is never sent to the phone or written to the action log.
Identifying a gateway (CLOAKCODE_INSTANCE_ID). Each gateway has an instance id used as
its authenticator label (the otpauth account — so the app shows CloakCode: <id>), its Dev-Tunnel
name seed, and the name shown to the phone (in the app header). It defaults to the machine
hostname (the Windows computer/NetBIOS name, or the Unix hostname) — printed at startup as
[cloakcode-gateway] instance: <id> — so gateways on different machines are already distinguishable
with no configuration.
Running more than one gateway on one machine (e.g. office + home)? Set a distinct
CLOAKCODE_INSTANCE_ID on each (office, home, …) so the authenticator entries read
CloakCode: office / CloakCode: home and the phone shows which one you're connected to, instead of
two identical hostnames. The VS Code extension stores each gateway's issued token separately (per
URL), so switching cloakcode.gatewayUrl between them never re-pairs.
In Docker, mount -v cloakcode-mfa:/home/app/.cloakcode so the TOTP secret survives container
replacement (the image runs as app, so its home is /home/app), or relocate it with
CLOAKCODE_MFA_SECRET_FILE — see
Persisting state across container upgrades for
all the volumes (secret, tunnel token, action log).
Configuration (environment variables)
| var | default | meaning |
| --------------------------- | --------------------------- | ----------------------------------------------------------------------- |
| CLOAKCODE_GATEWAY_HOST | 127.0.0.1 | operator listener bind (PWA + phone); keep loopback and front it with a private tunnel |
| CLOAKCODE_GATEWAY_PORT | 3543, else a free port | operator port — also the port segment of the Dev Tunnel URL. Unset ⇒ try 3543 and fall back to a free port; 0 ⇒ always ephemeral; a value ⇒ lock it |
| CLOAKCODE_TUNNEL | (off) | devtunnel → auto-host a private tunnel and print the phone URL |
| CLOAKCODE_TUNNEL_PROVIDER | github | Docker only: github or microsoft for the container's device-code sign-in; defaults to GitHub |
| CLOAKCODE_INSTANCE_ID | (machine hostname) | tunnel-name seed and authenticator label (e.g. office/home, so multiple gateways are distinguishable in your app) |
| CLOAKCODE_GATEWAY_TOKEN | (off) | provider↔gateway shared secret; extensions must present the same value |
| CLOAKCODE_MFA | (secure by exposure) | operator TOTP: required to force it, off to disable; unset ⇒ on when the hub is exposed (wide bind / live tunnel), off for pure loopback |
| CLOAKCODE_MFA_SECRET_FILE | ~/.cloakcode/operator-totp.secret | where the base32 TOTP secret persists (0600); mount it as a volume in Docker |
| CLOAKCODE_MFA_ENROL | browser | strict never sends the pairing secret over the wire (console QR only) |
| CLOAKCODE_MFA_RESET | (off) | 1 regenerates the secret (lockout recovery) and re-enters enrolment |
| CLOAKCODE_TLS_HOST | 127.0.0.1 (0.0.0.0 in Docker) | provider listener bind — the dedicated endpoint extensions connect to (separate from the operator listener) |
| CLOAKCODE_TLS_PORT | 3544, else a free port | provider listener port (always on); same rule as the operator port (0 = ephemeral, a value locks it). Pair extensions via Connect an extension in the app |
| CLOAKCODE_TLS_CERT_FILE | (auto self-signed) | BYO PEM cert for the wss provider listener (with _KEY_FILE); unset ⇒ an auto self-signed pair persisted under ~/.cloakcode |
| CLOAKCODE_TLS_KEY_FILE | (auto self-signed) | BYO PEM private key for wss (with _CERT_FILE); a 0600 secret, never logged |
| CLOAKCODE_PROVIDER_INSECURE | (off) | 1 ⇒ serve the provider listener as insecure plain ws:// (no cert) — trusted-network only; warned in console + UI |
| CLOAKCODE_GATEWAY_LOG_FILE| ./cloakcode-gateway.jsonl | on-disk action log (JSONL); set empty to disable |
| CLOAKCODE_WEB_DIR | bundled web/ | PWA directory to serve (defaults to the bundled app) |
| CLOAKCODE_LOG_LEVEL | info | trace/debug/info/warn/error (CLOAKCODE_VERBOSE=1 ⇒ debug) |
| CLOAKCODE_VERBOSE | (off) | 1 ⇒ shorthand for CLOAKCODE_LOG_LEVEL=debug (per-RPC detail: relay routing, sessions.list) |
The gateway logs provider / operator connect + disconnect by default; raise the level (or
CLOAKCODE_VERBOSE=1) for per-RPC detail.
Security
The two trust boundaries are authenticated separately:
- Operator (phone) → gateway: TOTP (F2a). On by default whenever the hub is exposed (wide
bind or a live tunnel); force it with
CLOAKCODE_MFA=required, disable withCLOAKCODE_MFA=off. See Operator auth (TOTP) — pair once, then each phone logs in with a 6-digit code and resumes with a 12h/30d session token. - Provider (extension) → gateway: shared token. Set
CLOAKCODE_GATEWAY_TOKENso only extensions holding the secret can register (machine-to-machine; never shown to the phone).
Both set together? The two hops authenticate independently — enabling one never disables the other:
- Phone (operator) → gateway: always TOTP when MFA is on. The static token is never used on this hop.
- Extension (provider) → gateway:
verifyProviderCredentialaccepts either a TOTP-issued token or the static token — an OR, not a priority or override. With MFA only, the extension must sign in with a code to obtain a token, so TOTP gates this hop too; adding a static token just supplies a second accepted credential (the headless escape hatch). On its side, the extension prefers its stored sign-in token and falls back to the static one.
Still prefer loopback + a private tunnel (default host 127.0.0.1; the Dev Tunnel is private,
sign-in required) over a wide 0.0.0.0 bind on an untrusted network — TOTP gates control, but a
private tunnel keeps the surface off the open internet.
Build from source
Requires the monorepo checkout (lsiddiquee/CloakCode):
# assemble a copy-ready folder (main.mjs + web/ + run.sh) into dist/gateway/
pnpm --filter @cloakcode/gateway assemble
cd dist/gateway && ./run.sh --tunnel # run.sh is a flag-driven launcher (--host/--port/--tunnel…)dist/gateway/ is self-contained — copy it to any host with Node ≥ 20 and run ./run.sh there.
run.sh --help lists the flags (each maps to a CLOAKCODE_* env var above).
