diff --git a/CHANGELOG.md b/CHANGELOG.md index 785d774..398e6a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,87 @@ one of them changed. An entry says which artefact and which version, so `web 0.1 Every version published to a channel appears here. Nothing is written from memory afterwards: a build cannot be published without its entry. +## 2026-08-27 — The console container listens on 8080 and runs unprivileged `web 0.120.0` + +**Check your port mapping before you pull.** The web container used to listen on **80** and now +listens on **8080**, because binding a port below 1024 needs root and the container no longer runs +as root. The compose file in this repository is already updated — `${JARVIS_PORT:-8080}:8080` — so +if you use it unchanged there is nothing to do. If you wrote your own mapping, or your reverse proxy +targets the container port directly rather than the published one, change `:80` to `:8080`. + +Nothing else about how you reach it changes: same URL, same paths, same TLS terminator in front. + +**The container's upstream is configurable.** `JARVIS_API_UPSTREAM` and `JARVIS_DNS_RESOLVER` now +say where the API is and who resolves that name; they default to what compose has always used, so an +unchanged stack behaves identically. They exist for anybody running these images somewhere the name +`api` and Docker's own DNS do not mean anything. + +**A terminal no longer opens a second shell when your connection drops.** Reconnecting used to +silently start a fresh login on the machine — a new session, a new audit entry, a new recording — +underneath a window still showing the old scrollback. It now tells you the connection was lost and +offers to open a new session, which is a decision rather than something that happened to you. + +**A part of the console that fails to load after an upgrade now says so.** The interface ships as +content-hashed files that are replaced on every upgrade, so a tab left open from before can reach +for something the server no longer has — which used to be a blank panel and a line in the browser +console. It now raises the same notice an update raises, with words, and offers the reload that +fixes it. + +## 2026-08-27 — An upgrade refuses to drop a column instead of doing it `api 0.93.0` + +**The one to read.** Until now, every boot ran the schema sync with `--accept-data-loss`, so an +upgrade that needed to remove a column simply removed it. That was survivable while one container +was the whole deployment and is the wrong default for anything else — and it meant a destructive +schema change happened with nobody having decided anything. + +It now **refuses**, prints which columns or tables it would have dropped, and stops. Nothing is +altered and the api does not start. If those drops are what you want, set +`SCHEMA_ACCEPT_DATA_LOSS=true` in `.env` for that one upgrade and put it back afterwards. Take a +dump first either way: there is still no migration history, so pulling an older image does not put +the schema back. + +The schema phase is also its own entrypoint now (`migrate.sh`) rather than part of the api's boot. +On compose nothing changes — the api still runs it, because one container is the whole deployment — +and it takes a database lock while it works, so `--scale api=2` waits rather than races. + +**Two health endpoints, for anything watching this instance.** `/api/health/live` says only that the +process is running, and `/api/health/ready` answers 503 when Postgres **or Redis** is unreachable. +`/api/health` is unchanged, including its 200-while-degraded behaviour, so whatever you already +point at it keeps working. + +**Two tabs no longer sign you out of everything.** Both tabs share one session cookie, so both +refreshing at the same moment looked exactly like a stolen cookie being replayed, and the protection +against that revoked every session you had. Concurrent refreshes are now told apart from a replay by +how long ago the token was rotated. A genuine replay still revokes the family, which is what it is +for. + +**An enrolment token's use limit is now actually a limit.** Two machines running the same install +command in the same second could both enrol on a token that permitted one — an Ansible play across a +fleet does exactly that. The check and the increment are one operation now. + +**A terminal transcript survives the process being killed.** Output used to live only in memory until +the session ended, so an out-of-memory kill or a host losing power took the whole recording of a root +shell with it. It is written down every few seconds now, and a session that ended that way hands back +what was captured, clearly marked as stopping early. A session with no transcript at all also says +which of the three reasons applies rather than guessing — recording was off, retention has passed, or +it was lost — because telling an auditor a recording was never made when it was made and lost is the +answer that stops them looking. + +**A restart no longer writes an ending into a session that is still open.** Starting up used to close +every terminal session row it found, on the reasoning that a row with no ending belongs to a process +that died. With one container that was true. It is now scoped to the process's own sessions. + +**The api container runs as a non-root user.** Nothing you do changes; it matters if you run these +images under a policy that refuses root containers. + +**Optional: `VAULT_KEY_CREATED_AT`.** Only needed where the vault key file is rewritten by your +platform rather than sitting still in a volume — on compose it is inferred correctly from the file +and you can ignore this. See `.env.example` for what it decides. + +**And the reason for most of the above: more than one api replica is now possible.** It is not yet +supported, and this compose file still runs one. Nobody has served real traffic on more than one, so +if you are considering it, read the note in the README first. + ## 2026-08-25 — A run gets 200 tool rounds instead of 50 `api 0.92.0` **If the assistant has been stopping mid-investigation with "the results above were not analysed", diff --git a/README.md b/README.md index 4c9e891..2a0a064 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,7 @@ round has run, so replaying it re-generates intent and never re-runs an operatio each loop and lets it write out what it had streamed with a note saying why the transcript ends there; the next process finishes that message, closes any tool call whose outcome is unknown saying in as many words that it is unknown, and resumes the run — instructed to read the current state -before repeating anything that writes. This is why the api asks for a stop grace period, and part of -why it must run as a single replica. +before repeating anything that writes. This is why the api asks for a stop grace period. **The agent dials out.** Nothing inbound is opened on a managed machine. It holds a websocket to your instance, signs each session with a key whose private half never leaves it, and reports its @@ -398,14 +397,28 @@ These are deliberate and documented rather than surprises waiting to be found. password changed to match, so plan that one. - **SSH host keys are not verified.** Every SSH connection trusts whatever key answers. This is the one gap in the execution path with no compensating control. -- **The api must run as a single replica.** In-flight runs, pending approvals, presence and the - websocket of every enrolled agent live in one process's memory. The Redis in this stack does not - lift that limit: it fans outgoing events out to other replicas, but an incoming one is only ever - handled by the replica holding that connection. So a cancel or an approval answered on the wrong - replica is silently dropped, and an agent tool call can land on a replica that does not hold the - target machine. Worse, a starting replica's recovery sweep claims every run it does not own — so a - second instance re-executes, against your real infrastructure, operations the first is still - running. +- **This compose file runs one api, and that is the shape it is written for.** It gives you one of + everything and generates the secrets onto a shared volume, which is right here and is exactly what + must not be done per replica anywhere else — each copy would mint its own vault key, and a + credential one process sealed would read as corrupt to another with nothing logged at boot. + + The images themselves no longer require a single replica. The web container's upstream and DNS + resolver are configurable, there are separate liveness and readiness endpoints, both containers run + unprivileged, and the schema is a separate entrypoint meant to run once rather than something every + container does at boot. Everything that used to do real damage across replicas is fixed: a starting + replica's recovery sweep once claimed every run it did not own, so a second instance re-executed — + against your real infrastructure — operations the first was still running. A run now holds its + conversation through a unique index in the database, a sweep claims only a run whose heartbeat has + gone cold, and a run that cannot write its heartbeat stops itself before anyone else may take over. + Agent tool calls, revocations, Stop, approvals and interactive terminals reach whichever process + holds the connection instead of failing where they land. A replica shutting down no longer marks + your whole fleet offline, and starting one no longer writes an ending into the record of a root + shell that is still open. + + **Nobody has run an estate on more than one replica yet**, so if you are the first, treat it as + something to watch rather than something to assume. Two API processes have been run together + against one database and one Redis, and the manifests are published, but that is not the same + thing as your machines depending on it. - **There is no rate limiting on sign-in.** No throttler, no account lockout, and authentication events are not audited. Credential stuffing is bounded only by the reverse proxy you put in front, which this repository does not ship. If your proxy can rate-limit one route, make it that one. diff --git a/docker-compose.yml b/docker-compose.yml index ab06d40..79eb0ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -206,6 +206,15 @@ services: AGENT_HEARTBEAT_INTERVAL_SEC: ${AGENT_HEARTBEAT_INTERVAL_SEC:-30} RUN_SHUTDOWN_GRACE_SEC: ${RUN_SHUTDOWN_GRACE_SEC:-25} + # This container brings the database up to its own schema before serving. Left on, because on + # compose one container IS the deployment. Turn it off only where something else runs + # `migrate.sh` first — an orchestrator with a run-once Job, say. + JARVIS_SKIP_MIGRATIONS: ${JARVIS_SKIP_MIGRATIONS:-false} + # Let that phase DROP columns and tables. OFF, and it should stay off between deliberate acts: + # without it the schema sync REFUSES and prints what it would have destroyed, which is the + # answer you want from an upgrade that was not expecting to lose anything. + SCHEMA_ACCEPT_DATA_LOSS: ${SCHEMA_ACCEPT_DATA_LOSS:-false} + # Where the agent binaries live, if you have them. Leaving this unset is a supported state: # everything except the agent installer works, and the installer answers 503 saying no build is # published. See the README — a self-hosted instance has no way to produce these. @@ -230,7 +239,7 @@ services: ports: # Put your own TLS terminator in front of this. Jarvis speaks plain HTTP here on purpose and # reads X-Forwarded-Proto to know what the browser actually used. - - "${JARVIS_PORT:-8080}:80" + - "${JARVIS_PORT:-8080}:8080" volumes: postgres_data: