Port Forwarding — Library & Inspector Port Links
A shared @kolu/port-forward library and live openable port chips for every terminal in the Inspector — no new daemon.
Agents constantly start dev servers, and the URL scrolls away into the
scrollback. This feature answers the one question kolu can’t answer today —
where is the thing the agent just ran? — with exactly one concept:
listening TCP ports, attributed to their terminal, one click from your
browser.The inspiration, kero.sh, also shows a
process list with CPU% and memory. We drop processes from the plan entirely
(KISS): CPU% is the most expensive part to build for the least payoff, and a
name+pid list is decoration. The process-subtree walk survives only inside
the scanner, as the mechanism that attributes a port to a terminal.
It ships as a shared library — @kolu/port-forward — consumed by kolu’s
Inspector. PRT0 first proved the library with a standalone TUI (vazhi; since
removed); that second consumer is what forced the extraction to stay free of
kolu types.
The plan is built for kolu’s real deployment shape: the server runs on a
headless Linux box (pureintent) while the browser runs elsewhere (zest, a
macbook on the tailnet). Everything below follows from one fact: “localhost”
in a link means the viewer’s machine, so the only universally reachable
place is the kolu server host — the name already in your address bar. Every
port is made to answer there, and the client builds URLs from
window.location.hostname, never a literal “localhost”.
How a click becomes a page — the three cases
| where the server listens | how it becomes reachable | what the chip opens |
|---|---|---|
kolu host, 127.0.0.1:5173 |
TCP relay: listen on 0.0.0.0:61000, pipe to loopback |
http://pureintent:61000 |
remote host, 127.0.0.1:5173 |
ssh -L *:61000:127.0.0.1:5173 on a connection of its own |
http://pureintent:61000 |
kolu host, 0.0.0.0:5173 |
already reachable — nothing to do | http://pureintent:5173 |
In plain words: a dev server on pureintent’s loopback is invisible to your
macbook (loopback never leaves a machine), so the library opens a door on
pureintent’s network side and copies bytes across. A dev server on a remote
host is one hop further: the same door on pureintent, with the ssh connection
kolu already holds to that host carrying the last leg. And a server that
bound 0.0.0.0 on pureintent needs no door at all — the scanner records the
bind address, so kolu tells these cases apart
automatically.Sub-case of case 2: the remote host is the viewer’s
own machine (zest as a kolu remote host, viewed from zest). The plan first
called the loop “roundabout but correct, not worth a special case”; field use
proved it baffling, and PRT2 shipped the recognition — see “When the viewer
IS the host” below.
One library, two apps — and no third daemon
kaval and padi remain the only daemons. The reusable capability is a
library package, packages/port-forward/ (@kolu/port-forward): the forward
map ((host, remotePort) → local listener, N hosts × N ports), one dedicated
ssh -L process per remote forward, the loopback TCP relay, port picking, and
teardown. For a remote target the local port is the remote port whenever that
number is free here — pu-dev:4123 answers on 0.0.0.0:4123, a port you can
predict and bookmark — and only when the number is taken does the kernel pick a free one
instead; a forward is never refused merely because its number is busy, and
there is no knob either way.Read the rule as remote-only: a
local (loopback-relay) target never gets the preference and cannot, because
both of its ends are on this machine — a listener on 0.0.0.0:<port> relaying
to 127.0.0.1:<port> is pointed at ITSELF, and one connection to it opened
~29,000 file descriptors in 1.5s before the build caught it. The kernel always
picks for a relay.
A forward lives exactly as long as the process that opened it. That is the
one property the mechanism is chosen for, and it is why each forward gets its
own ssh connection (ControlPath=none, so it can neither ride nor create a
shared master) rather than a listener on a master shared with kolu. Sharing was
the original design and it was wrong: OpenSSH gives a master’s forwards a
lifetime of their own — a listener added with -O forward outlives its
requester until the master’s ControlPersist idle timer reaps it, killing a mux
client does not take its forward down, and there is no -O list, so a
restarted process can neither see nor adopt what it left behind. An empty list
beside live ports is the worst possible pair, and it is what the shared design
actually produced.Both facts measured on a live sshd before the
rewrite: forwards survived their creator by the full ControlPersist window, and
a SIGKILLed mux client left its listener serving. Sharing a master and
kernel-tied lifetime are mutually exclusive in OpenSSH; lifetime wins.
The connection is held open by a remote command (cat) reading a pipe the
library never writes to, so the kernel closing that pipe — on quit, on crash, on
kill -9 — ends the session and with it the listener, with no timer in the
loop.-N (no remote command) does NOT have this property: measured,
an ssh -N -L child SURVIVES its parent’s SIGKILL and keeps serving the port.
The hold-open command is what makes the lifetime kernel-tied, and a library test
pins it — open a forward in a child process, SIGKILL it, require the port to
refuse. The costs, stated: one ssh handshake per forward instead of a
channel on a warm master, and a SIGKILL leaves the ssh children visible for as
long as it takes their connections to notice — they die with those connections,
they do not linger on a timer.
The per-forward connection also needs its own authentication, and that costs
nothing new: kolu’s mirror already requires non-interactive ssh
(BatchMode=yes and the rest of SSH_OPT_PAIRS), so any host kolu can mirror
is a host a dedicated forward connection can reach with the same keys and the
same ~/.ssh/config. host here is just an ssh destination — user@box, an
alias, whatever kolu’s remote hosts already say — with no kolu-specific host
model inside the library. And the ForwardManager API is the PRT2
contract: create (idempotent by target) · cancel · list · dispose ·
onLost is exactly what kolu’s Inspector will wire against, so the mechanism
could be replaced under it — as it just was — without either app noticing.
Mechanism in the library, policy in the consumers.Two shape questions
PRT2 was to settle, and both settled as NO CHANGE — recorded because “we looked
and the second consumer wanted the same thing” is a different fact from “nobody
got round to it”. (1) list() stays pull-only. The prediction was that a second
consumer would want a subscription; kolu instead re-reads the map after every
act — and it does so because the surface CELL it feeds is itself a re-read on a
change edge. A second independent consumer converging on pull is evidence the
pull shape is right, not that the subscription is overdue.
(2) create still rejects with the mechanism’s own message. The prediction was
that the Inspector would branch on a kind (retry, pick another port, mark the
host down); it branches on none of them, because the map ALREADY handles the only
recoverable case — a taken local port falls back to a free one inside
openPreferringPort, so it never reaches a consumer as an error. What is left is
genuinely terminal (no ssh, host unreachable, tunnel never came up) and its one
honest treatment is to show the user what happened. A tag with no reader would be
speculative structure.
One thing DID change, and it went the other
way — into the library rather than into a consumer: a target that comes back gets
the local port it had before. That is what makes a link survive a dev-server
restart, and it belongs to the MAP because it is bookkeeping the map already
holds. Left to consumers, each would have kept the same table beside the same
map and got its eviction rules subtly different.
One production consumer, the library still shaped as a shared capability (shared libraries, independent apps — the drishti pattern):
- kolu embeds the library in the server process it already runs. Forward state is a cell on kolu’s existing surface — no new socket, no IPC, no supervision. Kolu’s forwards live and die with kolu’s server, which is correct: reachability was tied to that process anyway.
- PRT0’s standalone TUI (vazhi) was the second consumer that proved the library stood alone; it is removed now that the Inspector is the only product surface for forwards. The library boundary stays: a future app can open the same doors without dragging kolu’s world in.
How the tunnel works, from zero
No new machinery, no daemon, nothing installed on either machine: a forward is
one ordinary ssh process — the same tool that logs you in — told to hold a
door open. Here is the actual command kolu runs, taken apart:
A dev server bound to loopback answers only its own machine — 127.0.0.1
and ::1 both just mean “this computer”.Two spellings because there
are two loopbacks, IPv4’s and IPv6’s — and a server listening on one is NOT
reachable on the other, which is why the command dials the exact loopback the
scanner saw the server bind. Dialling the wrong one was a real shipped bug in
this very track. The tunnel lends that private server a public door:
ssh listens on the kolu host, and everything that knocks is carried — inside
the same encrypted connection ssh always uses — to the far machine, where
sshd knocks on the server’s own loopback on your behalf. Your browser never
learns any of this happened; it just loads a URL on the kolu
host.The unlabelled flags in the real invocation are the mechanism
section’s story in option form: BatchMode (keys only, never prompt),
ExitOnForwardFailure (if the door can’t open, fail loudly instead of
half-connecting), ControlPath=none (never share a connection — the lifetime
argument above).
That is the entire protocol: one READY line at birth, a cat that waits
forever, and a kernel that closes the listener the instant the process ends —
which is why there is no cleanup code to forget and no way to leak a port. The
mechanism section above carries the expert-level detail (why each forward gets
its own connection, what -N would break, the measured evidence).
Port links in the Inspector
A Ports section joins the existing blocks in
packages/client/src/right-panel/MetadataInspector.tsx (pure rendering — the
data arrives on the terminal’s metadata like everything else). The flows:
- Open. Click a chip →
window.openonhttp://${location.hostname}:…, matching the Code tab’s external-open idiom (CodeTab.tsx:1114). Case-3 ports open directly; cases 1–2 call the embedded library’screate, get the listener port, open it, and the chip gains a⇄ :61000badge. Forwarding is lazy — first click only, never eager for every detected port.An agent session can hold six listening ports; auto-tunneling all of them VS-Code-style keeps idle listeners alive for ports nobody opens. Lazy costs one ssh handshake on first click — the price of a forward that dies with the process that opened it, and the reason to pay it once per opened port rather than once per detected one. - Manage, in ONE section — and one colour per meaning. The Inspector shows a single Ports list, and a forwarded port is ONE row: the number, the program, a teal
⇄ :localPortpill whose tooltip is the address you can paste, copy⧉and cancel⨯revealed on hover or keyboard focus, and↗ open. Three row endings that look different on purpose — forwarded, quietly offerable (“↗ forward & open”, muted until you reach for it), and plainly open. It used to be two stacked groups with a forwarded port in both, and a bare⇄that answered no question: forwarded where? What the second group was RIGHT about is kept — a forward is a fact about the HOST, so a ⌘K forward or one whose listener has died trails the same section under “also forwarded on this host”. The host tab’s dropdown keeps its own list (no hostname repeated inside that host’s own panel, a count on the header), and the host tab’s connection dot wears a hairline teal ring while doors are open there.The binding rule the pass settled: green is connection health, teal is open doors, and neither may borrow the other’s colour — a third meaning would get a third colour, never a second green. Getting the marker right took three cuts, and the arc is worth recording because two of them failed for opposite reasons. The first drew a ring in the accent, which read as a focus highlight — a quieter way of saying “connected” rather than a fact of its own. The second fixed the colour and the field rejected it as jarring: a thick teal stroke with an offset is heavy-handed at that size and reads as a treatment applied TO the dot. The third replaced the shape with a corner badge carrying a ⇄ glyph, which failed the other way — at tab scale the glyph is illegible mush. So the shape was never the problem; the weight was. What ships is a one-pixel ring sitting tight against the pip (which keeps the extra diameter the badge needed — a hairline reads better around a bigger dot), carrying no glyph and no count: both are unreadable there, and the tooltip and accessible label have carried “N forwarded ports — click to manage” through every cut. Every version obeyed the same constraint: the marker composes with the pip and never touches the pip’s own colour, which is painted from the health fact and nothing else. Display vocabulary moved too: amanualforward reads “pinned” — the user knows they made it; what they need to predict is that it stays — whileautosurvived only by earning a tooltip, since it was jargon with no explanation anywhere on the surface. The wire field names do not change. - Manual forward. The
+ Forward a port…row and a ⌘+K command accepthost:port— for a port the scanner missed or one outside any terminal’s subtree. - The unit is the TILE, not the pane. A tile is one thing to the user and several PTYs to the daemon, so the scanner attributes a port to whichever pane’s subtree holds it — correct and unavoidable. But a dev server almost always runs in a split, so a section that read only the main pane showed nothing in the common case (observed on a live deployment during PRT1, not in review). Every ports surface therefore reads main + splits, the same unit the Attach section already documents, merging rather than grouping: which pane a listener sits in is a detail of how the user split their tile. PRT2’s forward rows and auto-cancel inherit this — a forward belongs to the tile that opened it, not to a pane that may be closed while the server keeps running.
- Discoverability. One tip in
settings/tips.ts: “Click a port in the Inspector to open the server an agent just started.”
Detection: ports, attributed to the terminal
The scanner is padi’s port detection (packages/padi/src/ports/ —
scan.ts + sampler.ts), driven by a padi sensor, not a kaval module. The OS
volatility that once justified @kolu/port-scan as its own package now lives
in osfacts; the TypeScript face is
osfacts-client (that repo’s client-ts/), and kolu policy (bind classification,
U-row blindness, fold) sits beside the sampler in padi. Two separate calls,
and both matter.
Why not a package any more: the OS axis of change is osfacts’s job. padi
owns the cadence and the policy; the client is the tool’s own leaf (and
drishti’s next import). Test ③ (a second consumer) is met by
osfacts-client + the imminent drishti adoption.
Why padi drives it: the repo’s own taxonomy draws the line. Facts that require the
PTY itself live in kaval (foreground needs tcgetpgrp on the PTY fd, cwd is
parsed from OSC sequences), while facts derived by OS inspection from a
snapshot key live in padi (git status is derived from cwd; kaval knows
nothing about git by design). The port scan needs zero PTY access — its only
input is each shell’s root pid, which padi already reads off kaval’s list
surface — so it sits beside the git sensor, keeps subprocess-spawning out of
the memory-sensitive kaval, and changes no kaval wire contract (contract
skew on remote hosts has its own bug note; not growing ptyHostSurface is a
feature). One host-wide pass every 5 secondsCadence and the
unref’d, non-overlapping poll shape copy
packages/padi/src/memorySampler.ts and its derived.cell(source({ read, install: everyMsOr(…) })) wiring in servePadi.ts:247 — a padi idiom,
which is one more reason the scanner lives there. builds the
pid→ppid table once, partitions it into per-shell subtrees, and joins
listening TCP sockets against each subtree:
- both platforms — one spawn of the baked osfacts binary
(
KOLU_OSFACTS_BIN, viaosfacts-client) with--rootsover the terminal root pids. Versioned TSV (V 2, thenP/L/Urows); raw network-order bind-address hex; padi’saddressBindis the single scope/family judge. AUon a requested root is a blind scan (never an empty port list); aUon a descendant is skipped (the sudo lesson). Non-root sees own-uid pids only on darwin — sufficient by construction, since a terminal’s subtree is padi’s own uid.
The successor: osfacts
The shipped readers are deliberately not the destination. The destination —
one scoped, single-pass Rust binary serving kolu and drishti — was proposed,
gated on measurements, refuted, reopened on new evidence, and is now built as
osfacts; OSF2 migrates this very scanner onto
it. The candidate scoreboard, the gate results, the listeners bug we
fixed upstream, and every number behind those verdicts live in that note and
in git history, not here.The bar the incumbents set, kept for
context: the darwin C helper measured 8.45–10.6 ms per pass and the linux
/proc reader 14–18 ms, both parity-checked against lsof/ss on live
boxes. osfacts clears it: ~5 ms darwin, ~8 ms scoped linux.
Attribution is the live ppid subtreeAttribution is the live ppid subtree — and nothing more.** Backgrounded
(&) processes, pipelines, and grandchildren all keep the shell as an
ancestor, so the walk sees them; a true daemon (setsid/double-fork,
reparented to init) leaves the subtree and is deliberately out of scope — no
session-id heuristics, no host-wide orphan matching. If you daemonized it, it
is no longer “this terminal’s server”.
Cadence: a 5-second baseline, nudged by output. The tick alone means up to ~5 s between a dev server’s “ready” banner and its chip — the one UX cost the spike numbers don’t excuse. So terminal-output bursts and OSC 633 command marks (the same signals the foreground sampler keys on) trigger an immediate off-schedule scan — single-flight, ≥1 s floor between passes — while the tick remains the baseline that catches quiet binds and port death (which auto-cancel in PRT2 rides on). Output is only ever a hint about when to look; the socket table stays the sole source of facts — a printed URL never creates a chip.VS Code’s output mode is the cautionary tale for crossing that line: its terminal-URL regex creates forwards with no liveness check, and its own settings text documents the consequence — bogus URLs yield stale forwards that survive until reload, and its process scanner exists only on Linux remotes (macOS/Windows get the regex, full stop). Most of its knob matrix — autoForwardPortsSource, the 20-forward hybrid fallback, per-port action overrides, focus-gated toasts — compensates for eager forwarding on uncertain evidence. Kolu’s lazy, scanner-derived design needs none of it.
Measured end-to-end through the shipped scanner (non-root): a complete linux
scan is ~3 ms (~0.06% of a core at the 5 s cadence); the macOS pair is
17 ms on a quiet box and 93 ms on a busy one (~1.9% of a core). The
5-second cadence has room to spare on both.Parser subtleties, all
fixture-worthy: /proc/net/tcp addresses are byte-reversed hex and IPv6 words
are little-endian per 32-bit word, not across the address; v4-mapped IPv6
must be normalized (::ffff:0.0.0.0 is a wildcard, ::ffff:127.0.0.1 is not,
and they differ in four bytes) and dual-stack rows deduplicated on the final
structural PortInfo; a fork-inherited socket maps one inode to several pids,
so attribution folds at subtree level before dedup; lsof brackets IPv6 hosts
([::1]:5173), which is what makes “port after the last colon” unambiguous,
and spells the wildcard bind *. One more, found live: /proc/<pid>/stat’s
comm is the THREAD name, and Node renames its main thread — a plain
node dev server reports MainThread, so the displayed name comes from
/proc/<pid>/cmdline’s argv[0] instead.
Each terminal’s sample — PortInfo { port, name, scope, family } in
@kolu/terminal-vocab (ports.ts), beside ForegroundSchema:145 — is
deduped by structural equality before it touches the snapshot, so an
unchanged scan emits nothing: the churn guard that keeps a 5-second ticker
from thrashing SolidJS. From there every layer exists: the sensor set folds
it into TerminalSnapshot, the surface serves it
(snapshot-then-deltas invariant, .claude/rules/streaming.md), and the
reconcile-backed store in useTerminalMetadata.ts:180 delivers leaf-grained
ticks to the section.
Build plan — the PRT track
What each phase changes for the user — the sentence you’d put in a release note:
- PRT0 (shipped, #1975) —
@kolu/port-forwardlands as a runnable library, first proven by a standalone TUI (vazhi, later removed) that could open a forward on a real remote box with no kolu running. Kolu’s own UI changes not at all in this phase. - PRT1 (shipped, #1982) — the Inspector answers “what is this terminal serving?”: a live Ports section lists every listening port of the session within seconds of it appearing. No forwarding yet — but a port bound to
0.0.0.0on the kolu server host needs none (it already answers on the name in your address bar), so those chips open in one click; loopback-bound and remote-host ports are listed but wait for PRT2. - PRT2 (shipped, #1991) — every port is one click from a page, wherever it lives: loopback and remote-host ports open via automatic forwards, one Ports list shows and cancels them (plus a manual ⌘K “Forward a port…”), and forwards clean themselves up when the server dies. This is the headline: the agent started something → you’re looking at it.
- PRT3 (design seeded) — the page opens inside kolu instead of a browser tab.
| id | ships | needs | proof |
|---|---|---|---|
| PRT0 ✅ | @kolu/port-forward library (standalone TUI proof, since removed) — #1975 |
— | standalone forward on a real remote box, no kolu running |
| PRT1 ✅ | padi port sensor · Ports section · direct open — #1982 | — | e2e: spawned listener’s chip appears + opens |
| PRT2 ✅ | kolu embeds the library — #1991: lazy forward · merged Ports UI · manual · auto-cancel | PRT0 + PRT1 | real remote box: chip → tunneled HTTP 200; kill the server → ports refuse |
| PRT3 | embedded in-kolu preview | PRT2 | parked — #2001 closed at the HTTPS-page × HTTP-door browser wall |
| PRT4 ✅ | printed URLs join the scanner — click localhost:5173, get the door — #2004 |
PRT2 | e2e: printed URL → door → path preserved; live card upgrade; join unit table |
PRT0 shipped in #1975 and PRT1 in #1982; PRT2 shipped in #1991; PRT3 is parked at a browser wall recorded in its section below; PRT4 shipped in #2004.
PRT0 — the library, proven standalone (shipped: #1975)
packages/port-forward/, first proven by a standalone TUI consumer against a
real remote box with no kolu running. Two structural facts remain
load-bearing: that consumer imported only @kolu/port-forward — the proof that
made PRT2’s embed mechanical — and kill -9 leaves no port answering, the
property the per-forward connection exists to give. The TUI itself is gone;
the library and those proofs remain.
PRT1 — detection + Ports section (shipped: #1982)
Scanner (padi portScan + osfacts-client), the ports schema field, the sensor fold,
the Inspector section, direct-open chips, the tip. What remains load-bearing:
the pass holds no OS state between ticks (parse, join, emit the
structural PortInfo, drop everything else); it repartitions from the
current terminal root pids every tick, so a closed or re-keyed terminal
never leaves a stale subtree; and it is single-flight (the output-nudge
and the baseline tick share one non-overlapping runner). Fixtures alone
proved insufficient twice on darwin, so a live suite — real listeners,
the real scanner, both platforms — rides CI beside them.The
reading mechanism itself is OSF2’s to replace; the discipline above outlives
the swap.
PRT2 — kolu embeds the library (shipped: #1991)
What ships, at a glance — detail lives in the footnotes:
- Every chip opens. Click → the embedded library opens a door → new tab at
http://${location.hostname}:<port>. Lazy, first click only.Case-3 (wildcard on the kolu host) keeps opening directly with no door. Cases 1–2 callcreateand the chip gains a⇄ :<localPort>badge. Lazy because an agent session can hold six listening ports; eager VS-Code-style tunneling keeps doors open for ports nobody visits, and their knob matrix exists to walk that back. - Two door mechanisms, both shipped in PRT0 and proven standalone: an in-process TCP relay for the kolu host’s own loopback ports, one dedicated
ssh -Lchild per remote forward.The ssh child runs withControlPath=noneplus a hold-open remote command — it can neither ride nor create a shared master, so the forward’s lifetime IS the child’s lifetime. The shared-master design was measured and rejected in PRT0:-O forwardlisteners outlive their requester by the full ControlPersist window, and OpenSSH has no-O listto re-adopt them. - Same port when free:
box:5173answers at:5173, predictable and bookmarkable; the kernel picks only on collision — and a restarted listener gets its old local port back.The restart rule is one of two details adopted from VS Code’s Ports view; the other is copy-address on every row. Both exist so links survive a dev-server restart. - Forwards, three surfaces: the Inspector’s single Ports list (a forwarded port is one row, not a second entry in a second group), the same rows in the host tab’s dropdown, and a hairline teal ring on the host tab’s connection dot whenever that host has live forwards — glanceable from anywhere.Forwards are host-scoped facts, so the host popover is their natural home; the Inspector shows the same rows joined to what the active terminal serves, with host-scoped doors that match no scanned port trailing under “also forwarded on this host”. A row carries
port · <the terminal serving it, as a link> · ⇄ :localPort · auto|pinned · ⧉ copy · ⨯ cancel. The plan through PRT2’s first cut said a⇄ nbadge here; it was built, and cut — at tab scale a glyph is illegible mush, and the ring that replaced it is recorded above. - ⌘K: “Forward a port…” — accepts
host:port, for anything the scanner missed or outside any terminal’s subtree. - Doors close themselves. Scanner sees the port die →
autoforwards cancel;manualones persist until cancelled or the host disconnects; kolu killed → the kernel closes everything, instantly.The SIGKILL criterion from PRT0, and it matters MORE here: the server restarts on every deploy. The old shared-master design would have orphaned every forward on each release while the restarted UI showed an empty list.
The port fact, as shipped: scope + family, one judge
PortInfo carries scope: "any" | "loopback" | "interface" — a boolean
could not spell the interface-bind case, which answers at its own address
and which no door can reach, so portReach has an honest no-mechanism
arm — and family beside it, because 127.0.0.1 and ::1 are both
loopback and are not the same address: scope decides whether a door is
needed, family decides what it dials.One classifier
(addressScope) stays the single judge both platforms reach through their
own decoders, so they cannot disagree about ::ffff:0.0.0.0; the fold
widens (any > interface > loopback), keeping it order-independent so a
5-second scanner never republishes a phantom change. The dial family is read
SERVER-side off kolu’s own scan — a client’s stale copy would open a door
onto nothing — and the one unobservable case (a manual forward to a port no
terminal serves) assumes v4 at a named constant, loud at the point of use.
Both fields were bought by field defects: wildcard lost the interface
case, scope-alone lost the family — each a fact the OS handed us, collapsed
below the consumer’s need, invisible until something dialled the
answer. scope is an OBSERVATION, not a verdict: whether a port
answers for a given viewer also needs the host, so the judgment stays
portReach(scope, onKoluHost) in kolu’s own vocabulary. And ports is a
discriminated known / unknown two-way — a terminal that has never been
successfully scanned is distinguishable on the wire from one that serves
nothing, with three verified routes to blindness that never touch the
shell.A first-pass lsof timeout on darwin, a transient
/proc/net/tcp read failure on linux, any unexpected /proc errno. Blind
holds a last-good sample only for what it can actually cover.
Embedded, and field-hardened
The kolu server embeds @kolu/port-forward: forward state is a cell on the
existing surface, chip click → lazy create, one merged Ports list, ⌘K manual
forward, auto-cancel — with two UX details adopted from VS Code’s Ports view
(re-forwarding prefers the previous local port; every row carries
copy-address). The first deploy surfaced three field defects — a
fused-cadence publish loop that froze the whole server, the missing address
family, and forwarding offered to the machine the viewer was sitting at —
each fixed in the same PR with a regression test written red
first.The freeze’s general lesson outlived its fix and went
upstream (next section): neither module was wrong in isolation, so only a
test of the JOIN — reproducing the fused wiring and asserting a bounded read
count — could catch it. The reconciliation now reports by returning, so
“reap and announce” is no longer spellable; the reactor’s provenance guard
makes the whole class crash loudly instead of freezing.
**When the viewer IS the host.**When the viewer IS the host. A machine in kolu’s fleet can be the machine you are reading kolu from — the plan’s own footnote called this “roundabout but correct, and not worth a special case in v1”, and in practice it was baffling: every chip on zest’s terminals, viewed from zest, offered to open a door on pureintent so that zest’s browser could reach a port on zest. So kolu now recognises it, from the one fact that can settle it: the peer address of the viewer’s own connection, which only the server can see, compared against the addresses the host resolves to.
Two things make an inexact comparison acceptable here, and neither is incidental.
First, the failure direction is safe: a match opens the port directly on the
viewer’s own loopback, and every way of failing to recognise the viewer — a NAT,
a proxy, an ssh alias DNS cannot resolve — leaves the forward exactly as it was,
which works. Second, a direct port is never rewritten to localhost: it
already answers on a name that means the same thing from every machine, and
localhost is the one hostname that does not — trading the first for the second
would undo the rule this whole feature is built on.
It rides a ROOT rpc rather than a surface member, and that is structural rather than convenient: the answer differs per viewer, and a surface cell is broadcast, so it has no shape that can carry a different answer to each connected browser.The reliable version of this needs a fact kolu does not have — the host’s own interface addresses, which padi could report but does not — so what ships resolves the ssh destination instead. That is genuinely weaker for alias-named hosts, where it simply never fires. Recorded as a limit rather than hidden, because the alternative was to add a padi surface field inside an incident fix.
The viewer’s address is taken from the X-Forwarded-For header when,
and only when, the direct peer is a trusted local hop — loopback, or one of
this host’s own addresses, which is exactly what a local reverse proxy looks
like (kolu’s real deployment is reached through tailscale serve, whose
dial arrives from the host’s own address — the reason a bare TCP-peer
comparison never fired once in the field). A header on an untrusted
connection is ignored entirely (anyone who can reach kolu directly can
invent one); the last entry wins, because each proxy appends the address
it received from while the leftmost is whatever the client typed; and there
is no trusted-proxy list, because the trust condition is a fact about the
connection rather than a knob.A deployment shape that hides the viewer more thoroughly —
a proxy on a different machine, or one that strips the header — lands on the same
no-match side as before, which keeps the forward that works. The mechanism is
allowed to not fire; it is not allowed to be wrong.
What the automated suite actually covers, and what it deliberately does not.
The e2e scenarios drive the relay case end to end — a real loopback listener
in a real PTY, a real click, and the listener’s own body arriving in the opened
tab through a port it never bound — plus the cancel twin, which proves the door
is shut by dialling the recorded URL from node rather than by watching a row
disappear. The ssh -L case is not simulated: it needs a second machine, and
a loopback ssh hop standing in for one would exercise a different code path from
the one it claimed to cover, so it stays with the remote-host-testing harness
above. Stated rather than left implicit, because “the port forwarding suite is
green” would otherwise read as covering both mechanisms.
PRT3 — embedded preview (parked at a browser wall)
An implementation was completed and closed unmerged (#2001):
kolu in production always runs behind tailscale serve HTTPS, and browsers
categorically forbid an HTTP frame inside an HTTPS page — so a Preview pane
cannot show door content on any real deployment. No workaround survived
scrutiny.The options, each found wanting: honest-degrade ships an
empty pane on 100% of real deployments; a same-origin subpath proxy breaks
any app using absolute asset URLs (Vite’s own /@vite/client included) —
which is why the industry uses per-port subdomains with wildcard certs,
unavailable on ts.net; per-door TLS via runtime tailscale serve mutation
makes serve config outlive the process, the orphan class PRT0 made
unspellable; a statically declared HTTPS door pool (serve entries + door
range baked in one home-manager module) was judged unsatisfactory. Full
record on the closed PR.
What the attempt proved out survives as salvage on its branch
(port-forwarding-prt3), beside #111’s: the per-terminal
server chrome seam, the previewOpen/previewClose procedures with
server-side no-raw-URLs validation, the MCP door-verbs with
navigate-on-repeat, and the pane components. The wall is only the framing
scheme. Parked until a design exists that is not a compromise; when one
does, this section is rewritten, not layered.
PRT4 — the printed URL joins the scanner (shipped: #2004)
Agents print http://localhost:5173/ constantly, and “localhost” in the
viewer’s browser is the wrong machine — the fact this whole note opened on.
Clicking such a URL now raises a small card at the cursor that answers
through the door machinery. The printed URL is an entry point, never a
fact: every affordance is earned by a click-time JOIN against the
scanner’s observations and the live forwards — nothing is created from
text.VS Code’s regex-created forwards are this note’s standing
cautionary tale (see the cadence footnote in the detection section): its
terminal-URL regex creates forwards with no liveness check, and its knob
matrix exists to walk that back. The join inverts the direction — text may
only ever LOOK UP what observation already established. This
lands what PRT3 died reaching for — the printed URL becomes one correct
click — with zero iframes, so the HTTPS wall never enters the picture.
- Entry. Click a loopback URL (
localhost·127.*·[::1]·0.0.0.0) → the card, anchored at the cursor. ⌘-click opens the raw URL with no card. Non-loopback URLs keep today’s behavior untouched. - Three honest card states. Joined (the scanner sees the port on this
terminal → “forward & open”, copy, and the “localhost here means…”
sentence); unbacked (recognized but unobserved → “nothing is listening
yet”, open-raw only); blind (the scan could not look → “can’t tell right
now” —
unknownis never “no”).An open card is LIVE, not a snapshot: the join is a reactive derivation over the ports and forwards stores, evaluated only while a card is open — so a card upgrades itself the tick a listener becomes real, and degrades when auto-cancel closes the door behind it. Computing it frozen-at-click would have been the displaySuffix mistake: inputs on the scan and act clocks, output pinned to a moment. - One open-flow, three layers. The chip’s inline forward-and-open logic
is extracted and split by kind:
urlForPort(decision),ensureDoor(act),window.openat the component edge — chip, ⌘K, and the card all compose the same pieces, and “copy door URL” is the first two without the third.The extraction must NET-DELETE: the chip’s inline flow inPortsSectionis replaced, not wrapped. Braiding decide/act/effect into oneopenPortwas caught in design review as a complection — the copy action would have re-implemented half of it. - Homes, by volatility — no new packages. The loopback-URL grammar (a
web fact) joins the existing
@kolu/url-shapeleaf; the join policy (kolu’s opinions about scans and doors) is a client module besideuseForwards;xterm-kitgains only an injected handler seam, kolu policy never leaks into the generic terminal package — thefileRefLinkProviderprecedent exactly.xterm’s link API can toggle underlines but not style them per-link, so the artifact’s solid-vs-dotted underline is not implementable; the join state lives in the card instead. Recorded as the one fidelity deviation from the approved mockup.
Done-criteria — met in #2004. (1) e2e
printed-url.feature: a real PTY prints a real URL, the click lands the
page’s own body through the door, path preserved (coordinate click on the
xterm link held). (2) open card upgrades live when the listener appears
after the card opened. (3) join decision table in unit, including
blind-vs-unbacked. (4) ⌘-click bypasses. (5) the chip’s
e2e passes through the extracted flow; PortsSection net-negative after
extraction.
What this track sent upstream
PRT2’s incident drove four dispatches into @kolu/surface; three shipped,
one was withdrawn when verification refuted its premise. Shipped: the
reactor’s loop guard built on provenance — an AsyncLocalStorage read
context per cell; three consecutive self-caused ticks crash naming the
cellTiming-plus-value-equality heuristics were adversarially
refuted in both directions first: they crash an honest poll slower than its
interval, and they miss a real cycle whose read does work. Causation, not
timing — the repo’s own echo-loop doctrine. — with
assertCellConverges as its authoring-time twin; viewer identity,
graduated to @kolu/surface/viewerIdentity; and the doctrine written
down: a per-viewer answer is a root RPC and never a broadcast cell; a poll
read may reconcile but publishes only by returning. Withdrawn:
Observed<T> — its “same shape three times” premise was factually wrong
(ProcessRss is a load-bearing three-way; the honest duplication elsewhere
is ~6 lines of loop skeleton), recorded so the roadmap does not claim work
it did not do. Revisit only if osfacts’ kolu-side adoption ever creates real
duplicated fold logic.