The station
The far side. A directory of plain text, planted into a private transport repo, that watches for a request, runs one step, and sends the log back.
It is the half of heliograph you cannot reach, so it is the half most worth reading before you run it. Everything here is text you can open in an editor on the machine it will run on.
Two stations, and a launcher
| what it is | |
|---|---|
station/bash/ | the station. Bash 4+, and what almost every host runs |
station/powershell/ | the twin, for a Windows estate with no bash and no permission to install any. Plant it with --flavour powershell |
station/bash/station.ps1 | a launcher, not a station. It finds the bash that Git for Windows installed and hands over to start.sh. Do not confuse it with station/powershell/station.ps1, which is the loop itself |
Prefer the bash station wherever it will run. One implementation of the capture is better than two, and a Windows box with Git for Windows should use the launcher rather than the twin.
A second implementation is permitted only while it passes the contract
That is the rule, and it replaced a blanket prohibition. The argument against a port was never about PowerShell - it was about untested drift, and a buffered port is the worst kind: it gives every line the same timestamp, which reads like a working log while destroying the only property the log is for.
So the rule is now a gate rather than a ban. station/powershell/ passes all ten properties of the capture contract, over both transports it ships, on Windows PowerShell 5.1 and on 7, in CI, on every change. If it ever stops passing, it stops shipping.
The twin carries the same request document, the same published status, the same four gates and the same exit codes. A control side reads one document and cannot tell which of them wrote it, and a test asserts exactly that.
What it does not have is recorded on Windows: a self-update that needs a restart, and Constrained Language Mode stops it entirely.
What it depends on
The bash station needs bash 4+ and GNU coreutils. The PowerShell station needs Windows PowerShell 5.1, which is in-box on Server 2016 and later. Then whatever the transport needs: git for the git transport, curl for blob and relay, nothing at all for a file share.
That is the whole list, and it is close to the entire proposition - on a locked-down box, installing anything is its own change request.
Which compiled things a station may need is a written policy, not a habit. station/FAR-SIDE-BINARIES is the list, CI reads that file, and a program under cmd/ referenced anywhere under station/ and absent from the list fails the build. No Go source may appear under station/ either; the only Go permitted there is station/embed.go, which lets the CLI carry the payload, and its test - neither ships anywhere.
The list has one entry and is meant to stay that way. The bash relay transport needs heliograph-seal, because its construction is X25519, HKDF-SHA256, ChaCha20-Poly1305 and Ed25519, and hand-assembling those in shell across openssl versions is where crypto bugs live and where they are silent. The trusted set needed Ed25519 verification on the far side too and reused the same binary rather than adding a second, which is the rule the list states: what matters to an estate is how many binaries it has to accept, not how many features wanted one.
Nothing else is compiled. Git, a file share, a bundle and an object store need no binary anywhere on the far side, on either station. An estate that forbids compiled code entirely loses the relay on a bash station, and the beam when it lands - two shapes, not the tool.
Listing a binary costs something and the list says so: "read it before you run it" stops working, and is replaced by "verify the binary matches the source you read". That is only true because packaging/reproduce.sh builds it, the checksum is published, and the station refuses a seal whose hash does not match.
The PowerShell relay needs no binary at all, and that is the better answer rather than a lucky one. An estate that will not let you install a native binary is precisely the estate this payload exists for, so the seal ships as managed C# source under lib/seal/ and is compiled by Add-Type at startup: X25519, Ed25519 and Poly1305 from a vendored Chaos.NaCl - djb's ref10, MIT - with ChaCha20, the RFC 8439 framing and HKDF written beside them. Still plain text an operator can read before running it, which is the property the list exists to protect.
Add-Type needs FullLanguage, so Constrained Language Mode rules the relay out. That costs nothing extra: CLM already stops the whole station, because the capture is mostly .NET calls, and start.ps1 checks for it before anything else.
The files
| bash | PowerShell | |
|---|---|---|
| the preflight, and the one command the operator types | start.sh | start.ps1 |
| the loop: poll, decide, dispatch, publish | station.sh | station.ps1 |
| the step runner. Owns the log, the timestamps and the delivery | run.sh | run.ps1 |
| the capture itself | caplib.sh | caplib.psm1 |
| one file per channel | transports/git.sh, share.sh, bundle.sh, blob.sh, relay.sh | transports/git.psm1, share.psm1 |
| one file per question, and a template to start from | steps/ | steps/ |
| helpers a step can use | lib/ | lib/ |
| where captured logs land | ops-logs/ | ops-logs/ |
| what to run, and what happened | station/request, station/status | the same |
Both payloads carry a service.ps1 for Windows, and they are different files: the bash one registers the launcher, the PowerShell one registers start.ps1 and carries the transport's variables into a restricted file, because a scheduled task inherits none of them. The bash payload also has caprun.sh - the same capture around an arbitrary command - and secret.sh; the PowerShell payload has neither yet.
Details of each: the runner, writing a step, Windows.
The loop, precisely
- Poll the transport for a request. Default every 5 seconds
- The trigger is the
id, never a new commit. Documentation and step edits
land constantly; if any change fired a run, the station would fire on all of them. A run is always something somebody asked for on purpose 3. Ask run.sh --mode what the step declares itself to be, and apply the gates 4. Publish running, then dispatch the step in its own process group 5. Keep polling while it works, so a cancel is heard and an hour-long step does not make the station deaf for an hour 6. Push a partial log every PROGRESS_EVERY seconds, so a long run can be watched rather than waited out 7. Deliver the finished log, then publish idle - or undelivered, if the log was captured and the transport would not take it
The states it publishes
| state | |
|---|---|
starting | the loop is up and its credential works. Nothing asked yet |
running | a step is in flight |
idle | the run finished and the log was delivered. Only a confirmed delivery earns it |
undelivered | the run finished and the log did not arrive - refused by the transport, skipped, or the runner never reached delivery. The reason is published with it |
refused | a gate said no, and says which one |
cancelled | signalled mid-run. The partial log is kept |
stopped | the loop ended, by stop: yes or by Ctrl-C |
Every status also carries host: and payload:. The payload is a digest of station.sh, run.sh and caplib.sh - what a step's behaviour actually rests on. HEAD cannot answer "which payload is running", because every status commit and every log advances it, so two stations on identical payloads report different revisions within a minute. Branches carry independent copies and self-update pulls only its own, so drift between stations is real, and worth seeing rather than discovering when a step behaves differently on one machine.
The steps themselves are deliberately not in the digest: they are supposed to differ per branch, and including them would make it change for the ordinary reason and stop meaning anything.
undelivered matters more than it looks. Without it, "the log exists and cannot be shipped" and "the step is still running" are the same silence from your side, and only one of them is worth waiting on.
The action mode it publishes
Every status also carries actions:, which is allowed or refused. It is not about the run. It says what this station will permit for the whole life of the process, and it is settled by --allow-actions at startup:
actions: allowed | started with --allow-actions. A step declaring action runs, still needing CONFIRM=yes on the request |
actions: refused | the default. A step declaring action is refused, and the refusal names the flag |
It is published because the alternative is to infer it, and inference is wrong in both directions. A station restarted without the flag still has action logs sitting in the transport repo, and a station started with the flag may never have been asked for one. Anything showing a column of stations - heliograph status, a fleet view, a dashboard of your own - reads this field and never guesses from history.
A station that publishes no actions: line is not read-only. It is a station planted before the field existed, and there is no way to ask it from your side. heliograph status says not reported for that case, which is a third answer and not a polite way of saying refused. Treating silence as read-only would tell somebody an estate is safe on the strength of a station that never said so.
actions: and trust: answer different questions and neither substitutes for the other. The trusted set says who may ask; the action mode says what this station will do when asked. They do not interact: a request signed by a key in the trusted set still gets no action out of a station started without --allow-actions, and the refusal is published exactly as it would be for an unsigned one. A station can be wide open to actions and verify every signature, or trust nobody and still be started with the flag, so a fleet view needs both columns.
Two properties everything else rests on
Every captured line carries a UTC timestamp, applied by a pure-bash read loop reading straight from the command, before any other stage. That ordering is load-bearing. It used to be applied last, which made the property depend on sed -u; a sed without it gave every line in a block the same time while the log still read perfectly. After the fact, in an untimed log, a hang and slow progress are indistinguishable.
A failed run still ships, and a failed delivery never loses the log. The log is delivered whether the step passed or failed, and the real exit code survives the pipeline via PIPESTATUS. Each round trip through an operator is expensive; none may be wasted by tooling that only reports success.
Both are asserted by the conformance suite, which is the executable form of the contract rather than a description of it.
Stopping, cancelling, and surviving a logout
stop: yes in the request ends the loop from your side, which matters because nobody is sitting at that terminal. cancel: yes kills the step running right now; cancel: <id> kills it only if that id is the one running, so a stale cancel cannot reap a later run. The partial log is always kept on the station, and on the git transport it is delivered with the cancellation; on blob and relay it currently is not, and stays local.
station.sh deliberately does not trap HUP. Its cleanup signals the running step's process group, so trapping HUP would kill an in-flight step every time a connection dropped. To survive a logout properly, see running it as a service.
Getting one onto the far side
Planting a station. The operator's whole job is: clone the repo, run ./start.sh, walk away.