commit 96285967b26774f9e17c96380dcfc20ab3375880 Author: Luxit Date: Mon Aug 24 15:36:01 2026 +0200 The self-hosting stack diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bd3c49b --- /dev/null +++ b/.env.example @@ -0,0 +1,237 @@ +# Jarvis — optional configuration. +# +# THERE IS NOTHING YOU HAVE TO FILL IN. You do not need this file at all: +# +# docker compose up -d +# +# then open the address in a browser and the install screen asks the questions. It creates the first +# administrator, checks the deployment, and stores the address, the model and everything else as +# settings you can change later from a screen. +# +# THIS FILE USED TO BE MANDATORY, and that is the change. It asked for six values before the +# container would start, four of them secrets to be generated with `openssl` — and it failed in the +# worst available way, because an invalid one made the API exit 1 and the restart policy turned a +# typo into a crash loop, with the reason on line 40 of a log nobody had a reason to open. +# +# What is left below is for deployments that prefer to state things in writing: pinning image +# versions, running the agent overlay, choosing a host port. Every value here is optional, and the +# ones that overlap with the install screen SEED it — they are read once, when nothing is stored +# yet, and never again. Change them afterwards and nothing happens; change the setting instead. +# +# cp .env.example .env # only if you want any of this + +# --------------------------------------------------------------------------- +# Where your instance lives +# --------------------------------------------------------------------------- + +# Host port the web container publishes. Put your TLS terminator in front of it. +# +# The one value on this page that genuinely cannot move to a screen: it is a Docker fact, decided +# before anything in the application is running. +JARVIS_PORT=8080 + +# The address a browser reaches Jarvis on. ASKED BY THE INSTALL SCREEN, which pre-fills it with the +# address you are already reading it at — so setting it here is only useful if you want it stated in +# writing, or if you are upgrading a stack that already had it. +# +# It decides CORS, what enrolled machines dial, the base of every invitation link, and the WebAuthn +# relying party. Changing it later orphans every passkey already enrolled, so the screen warns you. +#WEB_ORIGIN=https://jarvis.example.com + +# PUBLIC_URL IS GONE. It was a second address for the same thing, kept separate for a split-hostname +# deployment nobody ran; the install screen asks once and every consumer reads that one value. An +# instance that still sets it is not broken — it is simply ignored. + +# How many proxies rewrite X-Forwarded-For before a request reaches the API. +# +# ASKED BY THE INSTALL SCREEN, which is the only place this can honestly be answered: it shows you +# the header chain your own request actually carried and the address the API resolved from it, and +# you confirm what you see. Nobody knows this number in advance. +# +# One is the web container's own nginx, which is always there; two if your own reverse proxy fronts +# it, which is the usual case. Raising it past the real number is the dangerous direction, because +# the API then trusts that many hops of a header the client can forge — and a caller can choose the +# address that lands in your audit log and in the session list. +#TRUST_PROXY_HOPS=2 + +# --------------------------------------------------------------------------- +# Secrets — there is nothing to fill in here any more +# --------------------------------------------------------------------------- +# +# This section used to hold four values and four `openssl rand` invocations. They are generated for +# you now, on your first `docker compose up`, by the `init` service — into the `jarvis_secrets` volume, +# where the database password, the two session-signing secrets and the vault master key live. +# +# WHY THEY LEFT THIS FILE. A vault key is not a preference. Asking for one put the single most +# consequential value in the deployment — everything in the vault is encrypted under it — in front of +# whoever was least equipped to look after it, at the moment they were least interested in it, in a +# file they were trying to get through. Nobody ever chose a better key than `openssl rand` would have. +# +# WHAT YOU STILL HAVE TO DO, AND IT IS THE IMPORTANT ONE: +# +# BACK UP THE `jarvis_secrets` VOLUME, SEPARATELY FROM THE DATABASE. +# +# The vault key is in there and NOWHERE ELSE. A database backup does not save you — the backup holds +# the ciphertext. An instance whose key is gone keeps LOOKING configured, with every row in place, +# and fails on every reveal. It is a separate volume from `postgres_data` precisely so that the two +# can be, and must be, backed up to different places: a dump that travelled with the key that opens +# it is a dump that opens itself. +# +# The setup screen shows you the key once, on your first visit, and will not let you finish until you +# have put it somewhere. That is the moment to do this. +# +# UPGRADING FROM A .env THAT ALREADY HAS THESE? Leave them exactly where they are. The generator +# ADOPTS an existing value rather than replacing it, and never overwrites a secret it has already +# written — so your instance keeps its own keys and nothing about your vault changes. You can delete +# them from this file once the stack has come up once, or leave them; they are read only when the +# corresponding file does not exist yet. +# +# Pointing at secrets of your own instead? Every one of them also accepts a `_FILE` variable +# (VAULT_MASTER_KEY_FILE, JWT_ACCESS_SECRET_FILE, …), which is the convention the postgres image and +# most others already use — so a `docker secret` can be mounted at those paths with nothing else +# changing. + +# --------------------------------------------------------------------------- +# The model — asked by the install screen, with a test button +# --------------------------------------------------------------------------- +# +# All four moved. The install screen asks for the endpoint and the key, lists the models the endpoint +# actually serves, and ASKS THE MODEL FOR A TOKEN before it saves anything — so a key that is +# well-formed and wrong, an account out of credit or a model id the provider does not serve is a +# sentence on screen instead of a fault in somebody's first conversation. +# +# The key is stored encrypted under this instance's vault key, exactly like the outbound mail secret, +# and is never shown again. Change any of it later under Settings -> Platform -> Model. +# +# Setting them here still works and seeds an instance that has nothing stored — for a deployment +# that would rather state its model in a file. They are read once and never again. + +#OPENAI_BASE_URL=https://api.openai.com/v1 +#OPENAI_API_KEY= +#OPENAI_MODEL=gpt-4o +# minimal | low | medium | high. Higher costs latency and tokens and is worth it for real work. +#OPENAI_THINKING_LEVEL=medium + +# --------------------------------------------------------------------------- +# Unattended installation (for fleets and for CI) +# --------------------------------------------------------------------------- +# +# Everything above is optional because the install screen asks for it. This is the other direction: +# somebody rolling out fifty instances from a template has answered those questions once already, +# and making them answer each one in a browser is exactly what this product stopped doing to people. +# +# Set this and the instance installs itself at boot from whatever the variables above provide, marks +# itself installed, and never shows the wizard. +# +#JARVIS_UNATTENDED=on +# +# The first administrator. REQUIRED when JARVIS_UNATTENDED is on and the database is empty; the +# password must be at least 12 characters. +# +# If they are missing or too short the instance says so loudly in its logs and leaves setup +# OUTSTANDING rather than completing — an instance marked installed with no account is one nobody +# can ever get into, and the claim is gated on setup being unfinished, so there would be no +# way back. Falling through to the ordinary install screen is strictly better than that. +# +#JARVIS_ADMIN_EMAIL= +#JARVIS_ADMIN_PASSWORD= +#JARVIS_ADMIN_NAME=Administrator +# +# Worth setting alongside them, since nobody will be there to be asked: WEB_ORIGIN, OPENAI_API_KEY +# and OPENAI_MODEL above, and JARVIS_LICENSE_KEY below. Each is optional even here — an unattended +# instance with no model comes up and reports that it has none, rather than refusing to start. + +# --------------------------------------------------------------------------- +# Channels and pinning (optional, recommended in production) +# --------------------------------------------------------------------------- + +# Every image tracks the `stable` channel unless you set these, so `docker compose pull && up -d` +# upgrades you to whatever has most recently been promoted. `stable` moves only after a build has +# run on the publisher's own instance; `dev` moves on every build and nothing has tried it yet. +# `latest` is a second name for `stable`, kept so nothing that already used it has to change. +# +# Which channel you are on is yours to state, because nothing in the image knows it — a channel is +# decided after a build and moves afterwards. Set it and the app shows it beside the version +# numbers; leave it empty if you pin below, because then you follow no channel. +#JARVIS_CHANNEL=stable + +# Pinning makes an upgrade a decision instead of a side effect of pulling. The app reports its real +# version either way — a channel tag is a second name on the same image, not a build that forgot +# its number. +# +# THERE IS NO EXAMPLE NUMBER HERE ON PURPOSE. Pin the version you are ALREADY RUNNING, which the app +# footer shows as `web … · api …`. Nothing in the publishing path bumps a number written into this +# file, so any number printed here is one that went stale while nobody was looking — and moving the +# api pin BACKWARDS runs an old build against a schema that has already been migrated forward. +#JARVIS_IMAGE_API=git.luxit.be/luxit/jarvis-api: +#JARVIS_IMAGE_WEB=git.luxit.be/luxit/jarvis-web: + +# Only read when the agent overlay is enabled, just below. Its version is the AGENT's, and moves +# independently of the two above — a Jarvis release usually does not change the agent at all. +# +# PIN THIS ONE FIRST if you pin only one. The two above change what your own server runs; this one +# changes what runs on every machine you administer. Pinning it decides which build your instance +# publishes; a separate UPDATE POLICY decides when a machine takes it, and its default is "let the +# agent decide" — which in practice means the next time its service starts. Set that policy +# instance-wide under Settings → Platform → General, per organization under Settings → Organization → +# Agent updates. An enrolled agent also refuses any version that is not strictly newer, so moving +# this back stops a rollout rather than reversing it on machines that already took the update. +# +# The app footer does not carry this number. Each enrolled machine reports the build it runs, on the +# Agents page — that is the one to pin. +#JARVIS_IMAGE_AGENT=git.luxit.be/luxit/jarvis-agent-dist: + +# --------------------------------------------------------------------------- +# The Jarvis agent (optional) +# --------------------------------------------------------------------------- + +# Enrolling a machine downloads a compiled binary, which the api serves from a directory it can +# only read. `docker-compose.agent.yml` supplies that directory as a pullable image, so the release +# arrives the same way the rest of the stack does. Uncomment this and every later `docker compose` +# command picks up both files with no extra flags: +# +#COMPOSE_FILE=docker-compose.yml:docker-compose.agent.yml +# +# Leaving it off is a supported state, not a broken one: everything except the agent works, and the +# installer answers 503 saying no build is published. The SSH, Proxmox, Microsoft 365 and MikroTik +# connectors all reach machines without it. +# +# AGENT_RELEASE_DIR is set by that overlay and should NOT be set here — a value in this file would +# point the api at a path nothing populates, turning the honest 503 into a 404 per platform. + +#AGENT_HEARTBEAT_INTERVAL_SEC=30 +#RUN_SHUTDOWN_GRACE_SEC=25 + +# How long a sign-in lasts, in seconds. Both were fixed in the compose file until now, which made +# them the only tuning values on this page you could read about and not change. +# +# JWT_ACCESS_TTL is how long an access token stays valid — and therefore how long a revoked session +# keeps working before it notices. Lower it if that window matters to you; the cost is a refresh +# round trip more often. +# +# JWT_REFRESH_TTL IS AN IDLE TIMEOUT, NOT A SESSION LIFETIME, and the difference will matter to you +# if you are here because of a policy. The refresh token rotates on every use and the new one starts +# its full term from that moment, so nothing anywhere measures how old a sign-in is: somebody who +# keeps a tab open stays signed in indefinitely. What this value really sets is how long a session +# survives being left alone. Fourteen days is the default. To end sessions by age rather than by +# idleness, revoke them — Settings → Security lists them and kills them individually. +#JWT_ACCESS_TTL=900 +#JWT_REFRESH_TTL=1209600 + +# --------------------------------------------------------------------------- +# Licence (optional — without one you get the community edition) +# --------------------------------------------------------------------------- + +# Leave this empty and the instance runs the COMMUNITY EDITION: 1 organization, 3 users, 5 agents, +# 10 assets, no expiry and nothing to renew. Every feature works inside +# those numbers. A full limit refuses the NEXT thing of that kind and touches nothing already there. +# +# A key raises the limits — ask antoine@luxit.be for one. It is verified offline, on this machine. A +# licensed instance then reports counts and its own address to your provider; the README lists every +# field it sends. A key that expires or is withdrawn drops back to the allowances above, never to +# nothing. +# +# This value SEEDS the database on first boot and does not govern it afterwards: a renewal arrives +# through the check-in and is stored, so leaving an old key here cannot roll you back. To replace a +# key, use Settings → Licence. +#JARVIS_LICENSE_KEY= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6453818 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# These files are read by Docker on a Linux host, whatever the machine that cloned them. +# +# Without this, a clone on Windows checks them out with CRLF, and the carriage return rides into +# `.env` as part of a VALUE — Compose passes it through verbatim, so it ends up inside the database +# password, the JWT secrets and the vault master key. The failure then surfaces as Postgres refusing +# the connection, or as a vault that cannot decrypt what it wrote yesterday, with nothing anywhere +# naming a line ending as the cause. +* text=auto eol=lf + +# And the screenshots are bytes, not text. `text=auto` above already detects that correctly, but +# saying so costs one line and removes the question before somebody adds a JPEG. +*.png binary +*.jpg binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d63272 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Your instance's configuration, which holds every secret it has: the vault master key, the JWT +# secrets, the database password and your AI provider's API key. NEVER commit it. `.env.example` is +# the template and is the only one of these that belongs in a repository. +.env +.env.* +!.env.example + +# Database dumps, which the README tells you to take before an upgrade. They contain every +# conversation, asset and audit row on the instance — and the vault's ciphertext, which is one +# leaked .env away from being plaintext. +*.sql +*.sql.gz diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8cb2f5d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,561 @@ +# Changelog + +What each published build changes for you, newest first. + +The three images are versioned independently and their numbers are not meant to match — usually only +one of them changed. An entry says which artefact and which version, so `web 0.102.0` and +`api 0.81.0` sitting under the same date is the ordinary case, not a mismatch. + +Every version published to a channel appears here. Nothing is written from memory afterwards: a build +cannot be published without its entry. + +## 2026-08-24 — Reporting is no longer optional `api 0.90.0` `web 0.114.0` + +**`JARVIS_TELEMETRY` is gone, and with it the ability to stop your instance checking in.** Three days +ago this repository told you the variable worked, and apologised for the fact it had not. We are +withdrawing the option rather than leaving you to discover it stopped working: it is removed from the +compose file, from `.env.example`, from the licence screen and from the install wizard, which is +nine steps now instead of ten. + +The reason is the one already written down for why a community instance reports at all. A free tier +the publisher cannot see is a free tier nobody can warn — and the instances that opt out are exactly +the ones most likely to be running an old build unattended, which is the population a warning is for. + +**What has not changed is what leaves your network, or your ability to see it.** The field list below +is complete and unchanged: your instance id and public key, the version and contract hash it runs, +its public address, and counts of organizations, users, agents and assets. No names, no conversation +content, nothing about the machines you administer. **Settings → Licence** shows you the address and +the contents resolved by your own running instance, which is the answer that matters rather than +anything this document says. + +If that does not suit your deployment, tell us before you deploy — we would rather have the +conversation than have you find out from a packet capture. + +### Also in this release + +**The sign-in field is off on a new installation.** The orb you press and hold to reveal the sign-in +form is a deliberate piece of the product, and it is no longer what a brand-new instance greets you +with — a first-time deployer meeting an unlabelled glowing hexagon has a puzzle rather than a login. +Turn it on under **Settings → Platform → Sign-in**; instances that already have it keep it. + +## 2026-08-24 — "Open Jarvis" sent you back to the install screen `web 0.113.0` + +**If you installed on web 0.112.0, the last click of the wizard put you in a loop.** You finished, +pressed **Open Jarvis**, and landed back on the install screen reading "This instance already has an +administrator" — with a button to sign in that bounced you to the console, which sent you back to the +install screen again. Nothing was wrong with your instance and nothing was lost; a reload of the page +was enough to break out of it. It was still the worst possible moment for it, on the one screen a +new deployer meets exactly once. + +The console asks "does this instance need installing" once, when the page first loads, and it had no +way to learn the answer had changed under it — so at the end of the wizard it was still acting on +what it had been told before you started. The wizard now tells it. + +Upgrade with the usual two lines: + +```sh +docker compose pull +docker compose up -d +``` + +## 2026-08-24 — The claim no longer expires `api 0.89.0` `web 0.112.0` + +**The thirty-minute window on the install screen is gone.** There is no countdown, no "the window +has closed", and no more restarting the api container to open another one. The first account created +still owns the instance; it can now be created whenever you get to it. + +**Which means the exposure is yours to manage, and we would rather say so than let a timer imply it.** +Until somebody creates that first account, anyone who reaches this instance can. The gap that matters +is between `docker compose up -d` and you opening a browser — so keep it short, and prefer pointing +a public hostname at the instance _after_ you have installed rather than before. + +Everything else about the claim is unchanged, including the part that protects you where a timer +never did: an instance whose database has gone missing still costs the last characters of your vault +master key to claim, because from the outside it is indistinguishable from a new one. + +## 2026-08-24 — Losing your database no longer puts your instance up for grabs `api 0.88.0` `web 0.111.0` + +**If your database is ever lost or replaced, your instance was claimable by whoever loaded the page +first.** From the outside it looked exactly like a brand-new deployment — no accounts, no completed +setup — so the install screen opened its 30-minute window and offered a super-admin account. Except +it was not a new deployment: it was yours, on a hostname already in DNS, already in people's browser +history, and already dialled by every agent you have enrolled. + +We found this the way these things get found. A `docker compose down -v` meant for a throwaway test +stack landed on a production one, because the compose file that had been copied into that directory +carried its own project name. The database went. What was left was an instance offering itself to the +internet. + +**What changes.** An instance that cannot prove it is new still opens the window — you need a way back +in — but creating the first administrator now costs the last characters of your vault master key. +You have it, in your `.env` or on the `jarvis_secrets` volume you were told to back up separately. +Nobody else does. + +**A genuine first installation is never asked for it**, and neither is a restart during a slow one. +The test is the age of the vault key at the moment your database first saw it: seconds on a real +first boot, months on a replacement. It is recorded once, so restarting the api to re-open a window +does not change the answer. + +**Nothing to do.** No new variable, no migration step. Instances that upgrade into this keep working +exactly as they are — the rule only ever applies to an instance with an empty database, which yours +is not. + +## 2026-08-24 — A friendlier install, and a vault key you can only be handed once `api 0.87.0` `web 0.110.0` + +**The install screen leads with Jarvis now.** The mark, the name and a welcome come before anything +is asked of you — the first thing this product ever showed a new deployer used to be a password +field with no explanation attached. Ten steps, about five minutes, and the road map on the first +screen is honest about which ones you can skip. + +**Only the address really needs you.** The model step used to refuse to continue without a model id +_and_ a key, which trapped anybody evaluating Jarvis without a provider key in hand — on a product +whose remote terminal, agent fleet and vault all work perfectly well without one. An untouched form +now continues and saves nothing. A half-filled one still refuses: that is a mistake rather than a +decision. + +**The wizard now asks who may create an account.** Self-registration defaults to open, and nothing +in the install had ever mentioned it — so an instance on a public hostname would accept an account +from anybody who found it, the moment you finished. It is a switch on the address step, beside the +field that decides who can reach the sign-in page at all. Change it later under **Settings → +Platform → Sign-in**. + +**The vault key is shown, and downloadable, exactly once.** The install screen hands you the file +rather than telling you to go and run `docker compose exec` — that command is a wall for anybody +deploying through Coolify, Portainer or a managed host, and what is behind the wall is the one value +no backup can reconstruct. The screen says so before you click. Afterwards, and any time later, the +key is on the host: + +```sh +docker compose exec api cat /var/lib/jarvis/secrets/vault-master-key +``` + +### Fixed + +- **An interrupted install could not be finished.** Coming back to the wizard after the organization + step showed you an empty form and demanded a name, and the request behind it could only fail — + the community edition allows one organization and you already had it. There was no way forward and + no way around. It now recognises the organization you already have and moves on. +- **A first boot with no configuration printed a red `ERROR` about `JARVIS_UNATTENDED`** on + instances where it was not set. Cosmetic, but it was the first thing a new deployer read in + `docker compose logs`. +- **`JARVIS_ADMIN_EMAIL` and `JARVIS_ADMIN_PASSWORD` alone would install the instance silently**, + with no `JARVIS_UNATTENDED=on`, and skip the wizard entirely. Those variables are what an + unattended install needs _in addition to_ the flag, never a second way of asking for one. If you + keep them in a shared shell or a fleet template, this is the release where they stop acting on + their own. +- **Installing in German or French left you in an English console** — the account the wizard creates + now records the language you installed in. + +### Changed + +- `JWT_ACCESS_TTL` and `JWT_REFRESH_TTL` are read from `.env` like every other tuning value + instead of being fixed in the compose file. `.env.example` documents what each actually does — + the refresh one is an idle timeout, not the session lifetime its name suggests. +- `PUBLIC_URL` is gone. It was a second variable for the same address as `WEB_ORIGIN` and had not + been read by anything for two releases; it is the stored instance address now, on a settings + screen. Remove it from your `.env` if it is still there — nothing breaks either way. + +### New in the manual + +The published README grew the procedures it was missing: restoring the vault key onto a new host +(**do it before the first `up -d`** — a generated key is never overwritten, so bringing the stack up +first makes yours unusable), getting back in when you are locked out of the only administrator +account, moving an instance to a new address and what that does to enrolled agents, and which +generated secrets can be rotated. + +## 2026-08-24 — `JARVIS_TELEMETRY=off` did not work, and now does + +**If you set `JARVIS_TELEMETRY=off` in `.env`, your instance has been reporting anyway.** We are +sorry. The variable never reached the container: compose reads `.env` to fill in `${...}` +placeholders inside the compose file, it does not hand that file to the services, and the `api` +service never listed this one. So the API saw nothing, applied its documented default of "on", and +checked in — while the README, the changelog and `.env.example` all told you that one line was +enough. + +What was sent is what the README has always listed and nothing more: your instance id and public +key, the version and contract hash it runs, its public address, and counts of organizations, users, +agents and assets. No names, no conversation content, nothing about the machines you administer. + +**The fix is in the compose file, not in the application**, so pulling this repository again is the +whole of it: + +```sh +git pull # or re-download docker-compose.yml +docker compose up -d +``` + +Then confirm it took, from the instance itself: + +```sh +docker compose exec api printenv JARVIS_TELEMETRY +``` + +No output means the fix has not landed yet; `off` means you are silent. **Settings → Licence** says +the same thing on screen, and that is the answer to trust — it reads what the API actually resolved +rather than what a file claims. + +If you would like the entries a silenced instance should never have created removed from our side, +write to and quote the instance id from that screen. + +## web 0.109.0 — 2026-08-24 + +**The welcome now comes before the account.** The very first thing anybody saw of Jarvis was a form +asking for a password — no name, no explanation, no idea what they were about to be given +administrator rights over. The introduction needs no account, so it goes first, and creating the +administrator is step two with a way back to it. + +**Mail and the licence are two steps.** They shared a page and have nothing to do with each other — +one is an app registration in your Entra tenant, the other a key from your supplier — which made one +long screen out of two short questions. The licence step now leads with the thing most people need +to hear: you almost certainly do not need a key, and the community edition is perpetual. + +## web 0.108.0 — 2026-08-24 + +**The install opens on a welcome rather than on a checklist.** It used to begin with "Checks: +database, Redis, vault key" — which tells somebody who has just deployed a product they have never +run that they are already in the middle of something. The first screen now introduces Jarvis, says +what the next steps will ask for, roughly how long it takes, and which of them you can skip. + +**The checks moved to the end, and that is a correction rather than a preference.** Run first, they +had nothing to look at: your public address had not been chosen, so there was no URL to probe and no +hostname to judge security keys against — three of the six findings were about a deployment that did +not exist yet. They now run immediately before the screen that commits the install, against the one +you have just described. + +**Reporting has a step of its own.** It shared a page with mail and the licence, where a question +about what leaves your network sat underneath two forms. It is the only decision in the wizard we +benefit from, which is exactly why it gets a page you cannot scroll past. + +**The step markers no longer collide.** Eight labels never fit the wizard column at any screen width +and ran into each other. The strip is markers now — filled where you are, ticked behind you — with +the step named in full underneath and the whole road laid out on the welcome screen. + +## api 0.86.0 · web 0.107.0 — 2026-08-24 + +**Mail and the licence are configured IN the wizard now, not somewhere else.** That step used to +show two paragraphs whose only affordance was a link to the settings screens — and those links could +not work: an instance that has not finished installing sends every other address back to the install +screen, so clicking one opened a tab that bounced straight back. The real forms are on the step now, +the same ones you meet under Settings afterwards, and nothing on it is required. + +**You can decide there whether this instance reports to us.** Reporting used to be an environment +variable and nothing else, so a fresh install had no way to answer the question without editing a +compose file. It is a switch on that step and on Settings → Licence. + +`JARVIS_TELEMETRY=off` keeps its authority: an instance whose host has switched reporting off shows +the control locked and says why. Deciding what leaves your network stays with whoever runs the host, +which is exactly why it was a variable in the first place — the switch adds the case where the person +installing the instance IS that person, and the case where you want to fall silent later without a +restart. + +**Your vault key is a download.** The last step used to print `docker compose exec api cat …` and ask +you to go and run it. If you deployed through Coolify, Portainer or a managed host, that was a wall +in front of the one thing here that no backup can reconstruct. Now the key is shown on the page with +a button that saves it as a file, and the command is still underneath for anyone who would rather +take it off the host. + +That view exists only while the install is unfinished. Once you press Finish it is refused for good, +and every time it was used is in the audit trail. + +**Unattended installation, for fleets and for CI.** Set `JARVIS_UNATTENDED=on` with +`JARVIS_ADMIN_EMAIL` and `JARVIS_ADMIN_PASSWORD` and the instance installs itself at boot from the +variables you already provide, and never shows the wizard. Everything else stays optional. + +If those credentials are missing or the password is under twelve characters it says so loudly and +leaves the install UNFINISHED rather than completing. An instance marked installed with no account +is one nobody can ever get into, and no restart recovers it — falling through to the ordinary install +screen is strictly better. + +## web 0.106.3 — 2026-08-24 + +**The install screen tells you where you are.** Its step list was six labels in a row where the +current one differed by a font weight — and the difference did not even arrive, because the class +meant to dim the others named a colour this console does not define. Every label rendered +identically, so the one screen whose whole job is to walk you through six steps could not say which +of them you were on. + +The stepper now carries that three ways at once, none of them colour alone: a tick for what is done +and a filled number for where you are, connectors that fill in behind you, and a line that names the +step — "Step 3 of 6 · The model" — which is also what makes it readable on a phone, where the labels +step aside. + +The same missing colour had flattened the rest of the wizard: hints and explanations rendered as +bright as the headings above them, error messages were not red, and the boxes around the warnings +had no edge. All of it reads properly now. + +## api 0.85.3 — 2026-08-24 + +**An instance still being installed no longer reports itself.** It used to check in thirty seconds +after boot, before anybody had chosen its address — so a fresh install announced itself as +`http://localhost:3000`, with every count at zero. That is worse than silence: the address exists in +the report so that your provider can tell one installation from another, and a list of identical +localhost entries answers nothing. + +It now waits until the install screen is finished, then reports normally with the address you chose. +Nothing is lost, only deferred. `JARVIS_TELEMETRY=off` still silences it entirely. + +## web 0.106.2 — 2026-08-23 + +**A language picker on the install screen.** Every other page that can be reached without an account +has one — the sign-in page, a shared transcript — and the install wizard, which is the longest piece +of reading this product puts in front of somebody who has not yet decided to trust it, did not. + +It follows your browser as before; the picker is for the case the browser is wrong, which is +routinely: installing from a colleague's laptop, or on a server whose locale nobody set. Before the +first account exists the choice is remembered in this browser; after it, it is saved to your account +as well — so it also corrects the language the claim guessed. + +## api 0.85.2 · web 0.106.1 — 2026-08-23 + +**Installing in German now ends in a German console.** The install screen has always followed your +browser, in English, French or German — but the account it created took the database default of +English, and the console adopted that the moment the wizard handed over. Somebody who had just read +seven screens in their own language was greeted in another and left to find the language switcher. + +The account now records the language the wizard was read in. Change it whenever you like under +Settings → Profile; this only decides where you start. + +## api 0.85.1 — 2026-08-23 + +**A fresh install no longer stores an address nobody chose.** api 0.85.0 wrote +`http://localhost:3000` as the public address of any instance that started with no configuration — +the schema default for `WEB_ORIGIN`, saved as though somebody had decided it. The boot banner then +told an operator on a remote host to open a URL that leads nowhere. + +The address is now left unset until you choose it, which is what the install screen already assumed: +it fills the field from the address you are reading it at, and the checks say plainly that nothing is +set yet. The banner names the path and not the host — you know how you reach the machine, you were +only missing `/install`. + +Nothing to do if you set `WEB_ORIGIN` yourself: it is still read, once, exactly as before. + +## api 0.85.0 · web 0.106.0 — 2026-08-23 + +**Installing Jarvis no longer starts with a text editor.** + +```sh +docker compose up -d +``` + +Then open it in a browser. An install screen creates the first administrator, checks the deployment +and asks for the rest. **The `.env` step is gone** — it demanded six values before the stack would +start, four of them secrets you had to generate with `openssl`, and the whole file is optional now. + +**A wrong model key is a message instead of a crash loop.** This is the change underneath all the +others. The api validated its configuration at boot and called `process.exit(1)` when anything was +missing — so a mistyped key, an account out of credit or an endpoint that had moved presented as a +container that would not stay up, with the actual reason on line 40 of a log you had no reason to +open. An unconfigured instance now starts, says what it needs, and the model screen has a **test +button that asks the model for a token before saving**, showing the provider's own words when it +refuses. + +**A brand-new instance can be claimed for 30 minutes, and this closes a real hole.** Jarvis used to +accept exactly one registration on an instance with no users and make that account super-admin — so +a freshly deployed instance reachable from the internet belonged to whoever found it first, on a +product that holds SSH keys and opens shells on your clients' servers. The install screen shows a +countdown; `docker compose restart api` opens another window if you miss it. + +**Everything the wizard asks has a permanent screen.** Settings → Platform gained two tabs: **Model** +— endpoint, key, model and thinking level, with the same test button the wizard uses — and +**Address**, for your public address and proxy-hop count. The wizard is a first-run convenience, not +the only way in: an instance whose OpenAI key has been rotated, or whose hostname has moved, says so +on a settings page rather than being re-installed. + +**The proxy setting is now something you are shown rather than asked to guess.** `TRUST_PROXY_HOPS` +decided which address landed in your audit trail, and nobody can know it in advance. The install +screen displays the `X-Forwarded-For` chain your own request actually carried and the address the +API resolved from it, and you confirm what you see. + +**Your secrets are generated for you, into a new `jarvis_secrets` volume.** Back it up, **separately +from the database** — the vault key lives there and nowhere else, and a database backup holds only +ciphertext. The install screen will not finish until you have read the key and typed its last +characters back. The key never leaves the server: there is deliberately no endpoint that returns it. + +### Upgrading from an earlier build? There is nothing to do + +Leave your `.env` exactly as it is. Your secrets are **adopted** into the new volume rather than +replaced, so the vault is untouched. `WEB_ORIGIN` becomes the stored public address, your `OPENAI_*` +values become the stored model settings, and an instance that already has an administrator is marked +installed by a backfill — **you will never see the install screen**. Everything those variables used +to govern is now a settings page; the variables still seed a fresh install and are otherwise ignored. + +One thing did change for you: **`PUBLIC_URL` is no longer read.** It was a second variable for the +same address. There is one now, seeded from `WEB_ORIGIN` and edited under Settings → Platform. + +**Changing your public address orphans enrolled passkeys.** It always did — a passkey is bound to the +hostname it was created under — but it was previously a variable nobody edited twice. Now that it is +a field on a screen, the screen warns you before it saves. + +## api 0.84.0 · web 0.105.0 — 2026-08-21 + +**Ask for a licence from inside Jarvis.** Settings → Licence has a form: your company, somebody to +answer, roughly what you need. Send it and your provider gets it with your instance's version and +counts attached, so the conversation starts at "here is what fits" instead of "how many machines do +you have". + +**An approved licence installs itself.** The request is signed by your installation, so the licence +is issued for that exact deployment and arrives on it at the next report — no key to copy, nothing to +paste, and no way to paste it into the wrong instance. You get the key by email as well, for your +records and in case you rebuilt the host while you waited. A decline comes back the same way, with +your provider's reason on the licence screen and in your inbox. You can withdraw a request you no +longer want, and ask again whenever something changes. + +**Community instances now report, and this reverses what we told you this morning.** api 0.83.0 said +— here, and in the README — that an instance without a licence contacts nobody at all. That is no +longer true. A community instance now sends the same fields a licensed one does: counts, the version +it runs, its contract checksum and its public address, to `checkin.luxit.be`. No names, no +conversation content, nothing about what you administer; the README lists every field, and it is the +same list as before. + +Why: we could not tell whether the community edition had reached anybody, or which builds were +running when we needed to warn people about one — and the request form above needs the same channel +to work at all. + +**`JARVIS_TELEMETRY=off` in `.env` stops it completely**, and costs you nothing else: every +community allowance, every feature and every connected machine stay exactly as they are. What you +lose is the licence request form, which has nowhere to send. A licensed instance reports regardless — +that has always been part of what a licence is, and the licence screen says so. + +If you would rather not report, set that variable before you upgrade. Nothing is sent until the +instance's first check-in after start-up. + +**On the licence screen**: what leaves your network, the exact address it goes to, and whether +reporting is on at all — all three visible without opening a compose file. + +## api 0.83.0 · web 0.104.0 — 2026-08-21 + +**Jarvis no longer needs a licence key to be useful.** An instance without one now runs the +**community edition** — 1 organization, 3 users, 5 agents, 10 assets — perpetually, free, with every +feature working and nothing reported to anybody. Install it, connect it to something real, and see +what it does before there is anyone to talk to. A licence raises those limits; it does not switch the +product on. + +This replaces the behaviour of the last three days, where an instance with no key came up, accepted +its first administrator account and then refused everything else. **If you installed Jarvis and found +it would not let you create anything, this is the release that fixes it** — upgrade, and the +allowances above apply immediately. Nothing needs to be reset, re-run or re-entered. + +**A licence that stops applying now falls back to the same allowances**, instead of refusing every +creation. Expired, withdrawn, or a key that will not verify: your instance keeps running everything +already set up and may still create up to the community numbers. There is no state left in which +Jarvis stops being usable over a billing question. + +**Fixed: an unreadable licence key blocked every creation.** The licence screen said nothing was +being withheld, and something was — a key that lost a character in a paste resolved every limit to +zero. It now falls back to the community allowances, which is what the screen always claimed. + +**On the licence screen**, a fresh install no longer shows a red "No licence" badge. It says +Community edition, lists what that allows against what you use, and states plainly that nothing is +reported. The refusal you get when a limit is full names the edition and what raises it. + +Nothing else changes. No configuration, no migration, no new variable — `JARVIS_LICENSE_KEY` is now +documented as optional, and it always could be left empty. + +## api 0.82.0 · web 0.103.0 · agent 0.27.0 — 2026-08-21 + +**Nothing changes in what these builds do.** They are identical in behaviour to api 0.81.0, +web 0.102.0 and agent 0.26.0, rebuilt so that every published image carries the same set of +registry labels: what the image is, where its documentation lives, and a link to this file. The +package page in the registry now says something useful to somebody who lands on it without +context. + +Upgrading is worthwhile only for that. If you are running the previous three and reading this, +you already have everything they do. + +**The earlier version tags have been removed from the registry.** Every build before these three +is gone; the `stable`, `dev` and `latest` channels point at them. If you had pinned an exact +older version in `.env`, that pin no longer resolves and `docker compose pull` will fail — pin +one of the versions above instead, or drop the pin to follow `stable`. + +## api 0.81.0 — 2026-08-21 + +**Approving a plan now actually authorises it.** When Jarvis asks for consent on a whole plan and you +approve it, the steps that touch the machines the plan names are no longer confirmed one at a time. +Previously the approval bound nothing at all — the assistant was asked to honour it and the executor +never checked — so an approved plan still stopped at every step, and a read-only conversation could +be granted a plan it had no way to carry out. + +The clearance is deliberately narrow. It applies only in the conversation the plan was raised in, +only to the machines the plan names, only up to a risk ceiling, and only for four hours. Anything +outside it still asks. It can never be wider than the approver's own access to those machines. + +**Two ways somebody could get more than they should, closed.** + +- Approving used to lend the approver's own permissions to the plan for the rest of the turn. Work + now runs as whoever asked for it, which is what the audit trail already claimed. +- Deciding a plan asked nothing about whether the decider may touch the machines it names — the + per-command confirmation had checked this for months and this path had not. It does now, for every + machine in the plan. Refusing is never restricted: anyone who can see a plan is wrong may stop it. + +**Wording that was not true.** A command that was waiting for a human when the server restarted used +to be reported as possibly having taken effect, and told you to go and check the machine. It never +ran — the confirmation happens before anything is sent — so it now says so. + +If you have approval requests older than this release, they are recorded as expired. They were made +under the old behaviour, where approving authorised nothing; treating them as live permissions would +grant something nobody was asked for. + +## web 0.102.0 — 2026-08-21 + +**The approval panel says what approving does.** It used to promise "Jarvis runs the steps below" and +"Nothing runs until you decide". Neither was true: a pending plan blocks nothing, and approving did +not run anything. It now describes the clearance you are actually granting — which machines, what +ceiling, how long — and the rejection notice no longer claims that nothing ran. + +The per-command confirmation is unchanged. That one does hold the command until you answer. + +## api 0.80.0 · web 0.101.0 — 2026-08-21 + +**The footer says which channel this instance follows**, beside the version numbers, when you set +`JARVIS_CHANNEL` in `.env`. Leave it empty if you pin exact versions — you then follow no channel, +and the footer shows nothing rather than a label that stopped being true. + +## api 0.78.2 · web 0.100.2 — 2026-08-21 + +**Distribution channels.** Images now carry `stable` and `dev` tags as well as their version. +`stable` moves only after a build has run on the publisher's own instance; `dev` moves on every +build. `latest` is a second name for `stable`, so nothing that already used it has to change, and +the compose files default to `stable`. + +A channel is a pointer and a version is a fact. Promotion copies the manifest of an image that has +already been published and run — it never rebuilds — so the bytes you receive are the bytes that were +tested, not a fresh build of the same source. + +**Fixed: some builds reported their version as `dev`.** The version is stamped into the image and +shown in the footer; a build published without it said `dev` while being tagged, pinned and run +correctly. Affected api 0.78.0 and 0.78.1 and web 0.100.0 and 0.100.1. + +## web 0.100.0 — 2026-08-21 + +**Recorded terminal sessions can be replayed** at the pace they happened, not only read as a finished +transcript. Idle time is compressed so a session that was mostly waiting is watchable, and how much +was skipped is stated. Sessions recorded before this release can still be read; they carry no timing, +so they cannot be replayed, and the panel says so rather than hiding the control. + +## agent 0.26.0 — 2026-08-21 + +**The command is now `jarvis`.** `jarvis-agent` keeps working and will continue to: both names are +present on every machine, and an agent that updates itself gives itself the missing one. Uninstalling +is a verb — `jarvis uninstall` — beside `status`, `start` and `stop`. The old +`jarvis-agent-uninstall` command is unchanged. + +`jarvis uninstall` refuses to run from inside a Jarvis terminal on the machine it is removing. +Stopping the service would kill the command mid-way, leaving the agent stopped and nothing +uninstalled, with no connection left to fix it through. Run it from an ordinary SSH session. + +## agent 0.25.0 — 2026-08-21 + +Internal: the agent asks the machine which name its service is registered under rather than assuming +one. No visible change; it is what makes a later rename survivable. + +## agent 0.24.0 — 2026-08-21 + +**Remote terminal on macOS.** Interactive shells now work on managed Macs, as they already did on +Linux and Windows. The session opens a login shell so the PATH matches what an administrator expects. + +**Fixed: sessions were recorded as "closed" instead of "exited".** Typing `exit` ended the session +correctly but filed it as though it had been taken away, with no exit code. Sessions from before this +release keep the reason they were given. + +**Fixed: a file descriptor and a thread leaked on every clean exit.** Only visible on machines that +stay up for a long time with many sessions. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed8bb15 --- /dev/null +++ b/README.md @@ -0,0 +1,759 @@ +

+ Jarvis +

+ +

Jarvis

+ +

AI-assisted infrastructure administration for MSPs.

+ +An operator opens a conversation and asks for the work in words. Jarvis does it: a command over SSH +or through an enrolled agent, a Proxmox guest powered on, a Microsoft 365 account blocked, a MikroTik +firewall read back — on the machines of one client organization, with credentials it decrypts from +the vault and shows nobody. Every call is judged before it runs, and the ones that cannot be undone +stop and ask a human in the thread. + +The difference from an assistant that writes commands for you to paste is that these run. + +This repository runs Jarvis from published container images — no source, no build, no account with +the project. + +**And no licence key.** A fresh install comes up on the **community edition**: one organization, +three users, five agents, ten assets, no expiry, nothing to renew. Everything Jarvis does works +inside those numbers — every connector, the agent, the remote terminal, the assistant itself. A +licence from your provider raises the limits; it does not switch anything on. + +When you outgrow it, **ask for a licence from inside Jarvis** — Settings → Licence — and the key +arrives on your instance by itself. A community instance reports its version and its counts so that +we can see which builds are in the field; [what it sends](#licence-keys-and-what-your-instance-reports) +is listed in full below. + +- [What it does](#what-it-does) +- [What decides whether a tool call runs](#what-decides-whether-a-tool-call-runs) +- [What it looks like](#what-it-looks-like) +- [How it works](#how-it-works) +- [What you need](#what-you-need) +- [Install](#install) +- [Your reverse proxy has two requirements](#your-reverse-proxy-has-two-requirements) +- [Upgrading](#upgrading) +- [The agent](#the-agent) +- [Things worth knowing before you trust it with production](#things-worth-knowing-before-you-trust-it-with-production) +- [When it does not come up](#when-it-does-not-come-up) +- [Backups, and restoring one](#backups-and-restoring-one) +- [Removing it](#removing-it) +- [The community edition](#the-community-edition) +- [Licence keys, and what your instance reports](#licence-keys-and-what-your-instance-reports) +- [Asking for a licence](#asking-for-a-licence) +- [Getting a licence, and getting help](#getting-a-licence-and-getting-help) + +## What it does + +**Five connectors reach a managed system.** Each one is a set of tools the assistant may call, and +what it may do with them is decided per call — see the next section. + +| Connector | Reaches | +| --- | --- | +| **SSH** | Anything with a shell — Linux, Windows, and CLI-driven network gear. An appliance that serves no SFTP is offered the command tool alone, rather than four that would fail at the handshake. | +| **Jarvis agent** | A machine running the enrolled agent, which dials out — so it works behind NAT, on a dynamic address, with no inbound rule and no SSH exposed. Commands, files, services, processes, system facts. | +| **Proxmox VE** | The cluster API. Read and power only — no create, clone, snapshot, backup or migrate. | +| **MikroTik RouterOS** | The native binary API, or the RouterOS 7 REST API. One identical tool surface either way; the connection decides the transport. | +| **Microsoft 365** | Graph, app-only, on any Microsoft cloud, authenticating with a client secret or a certificate. Named tools for identity, licences, groups, admin roles, Exchange Online, Intune and the audit logs, plus one that reaches every remaining Graph endpoint. | + +**An asset is the managed thing; a connection is a way of reaching it.** A Linux host commonly +answers on OpenSSH *and* through an enrolled agent; a Proxmox node has a cluster API, a shell and an +agent. Each route carries its own address, its own credentials and its own health — so a vendor API +that stops answering no longer makes a device unmanageable while its console is up. + +**Documents.** A conversation accumulates what the assistant writes: reports and runbooks in +Markdown, diagrams in Mermaid, tabular data as a workbook. What it writes is always a *source*, and +Jarvis renders the file on download — PDF and Word from prose, Excel and CSV from a workbook, HTML +from either. Keeping the source is what leaves a document revisable instead of a dead binary. + +**And what an MSP has to administer about itself**: organizations and members under a role ceiling, +invitations, an encrypted vault with its own folder tree and its own grants, an audit trail, +passkeys and TOTP, a notification centre, and a console in English, French or German — a property of +the account, not of the browser. + +## What decides whether a tool call runs + +Three things, resolved on every single call. The most restrictive wins. + +**Risk is a property of the operation.** A tool declares a baseline and may escalate per invocation: +a shell command is mutating in general and destructive for `rm -rf`, `mkfs`, `shutdown`, +`iptables -F` and about two dozen other patterns. Escalation is one-way — a per-call assessment can +raise the risk, never lower it. + +**Autonomy is a property of the conversation**, chosen by the operator: + +| | Safe | Mutating | Destructive | +| --- | --- | --- | --- | +| **Read-only** | run | refuse | refuse | +| **Ask before every change** | run | ask | ask | +| **Ask before destructive changes** (default) | run | run | ask | +| **Full access** | run | run | run | + +"Ask" raises an approval request in the conversation, and the run parks until a human answers. The +level is re-read on every call, so lowering it takes effect on the very next tool call of a run +already in flight. **Full access removes the last in-chat gate for every participant** — destructive +operations then run immediately, with no prompt and no second pair of eyes. Make it a deliberate +choice. + +**Permission is a property of the person.** A grant says which slice of the asset tree somebody may +operate, through which connectors, up to which risk, and whether the tools whose operation the +assistant *composes* — a shell command, an arbitrary Graph request, the contents of a file — are +admitted at all. Grants resolve by walking outward from the asset: the most specific level that says +anything decides entirely, and if nothing has spoken by the root the answer is no. Absent means +nothing, so a forgotten grant fails closed rather than open. + +> The conversation's autonomy is a **floor the operator imposes on themselves**; the grant is the +> **ceiling imposed on them**. What runs is whichever binds. + +A conversation also carries a **scope** — any mix of assets and folders, a folder granting its whole +subtree. Scope is checked *before* the asset is resolved, so a machine out of scope never has its +vault secrets decrypted. + +## What it looks like + +

+ The Jarvis console, on a freshly installed instance +

+ +The console on a fresh install. The footer names the two builds you are running, which is the first +thing to quote when something is wrong. + +

+ One asset, its two routes, its agent and its inventory +

+ +One asset, and the distinction the model rests on: **two routes to the same machine**, each with its +own address, its own credentials and its own health. One is preferred and untested; the other is +disabled without being deleted, so its settings and its history survive and no tool may use it. + +## How it works + +Four containers. **Only `web` publishes a port**: its nginx serves the console and reverse-proxies +`/api` and the websocket to `api` over the internal network, so your TLS terminator has exactly one +target and the API is never reachable from outside the compose network. + +| Service | What it is | +| --- | --- | +| `web` | nginx serving the React console. The only published port. | +| `api` | REST, auth, the vault, the connectors, the tool-calling loop, and the websocket. | +| `postgres` | Everything except the vault master key. | +| `redis` | Socket fan-out, nonces and rate-limit counters. | + +The assistant streams a turn, executes the tool calls the model asked for **strictly after the +stream is fully drained**, then feeds the results back — up to fifty rounds per user turn. That +ordering is what makes retrying a broken stream safe: at the moment a stream fails, no tool of that +round has run, so replaying it re-generates intent and never re-runs an operation. + +**A run that was in flight when the api stopped is picked up when it comes back.** A shutdown aborts +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. + +**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 +inventory on every heartbeat. + +## What you need + +- Docker with Compose v2, on **x86-64 Linux**. The api and web images are published for + `linux/amd64` only, so an arm64 host — a Pi, an Ampere, a Graviton — fails at `docker compose pull` + with no matching manifest. The machines you *administer* have no such limit: the agent ships arm64 + builds for Linux, macOS and Windows. +- A hostname and a TLS terminator in front of it. Jarvis speaks plain HTTP and does not manage + certificates. +- An API key for an OpenAI-compatible endpoint. The install screen asks for it and tests it before + saving; you do not need it in hand before you start. +- **No licence key.** Jarvis runs the [community edition](#the-community-edition) out of the box; a + key raises the limits when you outgrow them. +- Roughly 2 GB of RAM for the stack and room for Postgres to grow. + +## Install + +```sh +curl -O https://git.luxit.be/Luxit/jarvis-selfhost/raw/branch/main/docker-compose.yml +docker compose up -d +``` + +That is the whole first deployment. **There is no `.env` step.** First boot generates the stack's +secrets, syncs the database schema and runs its data backfills before the API listens, so give it +about a minute. Then point your reverse proxy at port 8080 and open the address in a browser. + +An install screen takes it from there. It welcomes you, creates the first administrator inside a +bounded window, then asks for the address this instance answers on and who may create an account on +it, the model, your first organization, and — every one of them skippable — mail, a licence and +whether this instance reports anything about itself. It finishes by showing you what the deployment +actually looks like from inside, and by making you take a backup of the one value nothing can +reconstruct. Ten screens, about five minutes, and **only the address really needs you**: everything +else has a "later" that costs nothing. + +Everything it asks is a setting you can change afterwards from an ordinary screen. Nothing it asks +needs a container restart to change. + +### Install it when you deploy it + +**Until somebody creates the first account, anyone who reaches this instance can.** There is no +deadline on that and no token to find: the first account created owns the instance, and the claim is +open for as long as nobody has taken it. + +So the gap that matters is between `docker compose up -d` and you opening a browser. Keep it short. +If you are pointing a public hostname at this, point it after you have installed, or install through +the host's own address first — the setting is editable afterwards. + +If you would rather it were not open at all until you say so, do not publish the address yet: the +install screen is served by the same web container as everything else, and a Jarvis nobody can reach +is a Jarvis nobody can claim. + +**Losing your database does not put your instance up for grabs.** An instance whose database has gone +missing looks exactly like a brand-new one from the outside — no accounts, no completed setup — while +sitting on a hostname the world already knows. On that one the claim still opens, so you can get back +in, but creating the first administrator costs the last characters of your vault master key. You have +it; nobody else does. A genuine first installation is never asked for it. + +### The one thing to do afterwards + +**Back up the `jarvis_secrets` volume, and not to the same place as your database.** + +It holds this instance's vault master key, which is generated on first boot and exists nowhere else. +Every credential in the vault, the licence identity, every authenticator secret and every terminal +recording is encrypted under it. **A database backup does not save you** — the backup holds the +ciphertext. An instance whose key is gone keeps looking configured, with every row in place, and +fails on the first reveal. + +The install screen shows you the key, offers it as a file to download, and will not let you finish +until you have typed its last characters back. **Take the download while it is on screen** — that is +the easiest moment this value will ever be available to you. + +Afterwards, and any time later, read it from the host: + +```sh +docker compose exec api cat /var/lib/jarvis/secrets/vault-master-key +``` + +### If you prefer to configure it in writing + +`.env.example` is still there and every value in it is optional — image pinning, the host port, +the agent overlay. The ones that overlap with the install screen SEED it: they are read once, when +nothing is stored yet, and never again. Change them afterwards and nothing happens; change the +setting instead. + +**Upgrading from a stack that already has a `.env`?** Leave it exactly as it is. Your secrets are +adopted into the volume rather than replaced, your `WEB_ORIGIN` becomes the stored public address, +your model settings are adopted the same way, and an instance that already has an administrator is +marked installed by a backfill — so you will never see the install screen. Nothing to do. + +## Your reverse proxy has two requirements + +Both are the kind that produce confusing symptoms rather than clean errors. + +- **Forward the WebSocket upgrade.** Two separate sockets ride `JARVIS_PORT`: the chat, on + `/socket.io/`, and enrolled agents, on `/api/agents/ws`. Neither falls back to plain HTTP. Without + the upgrade the chat does not lose streaming — it never connects at all, and since the prompt + itself travels over that socket, nothing sends. Presence and in-chat approvals go with it, and no + agent can connect. +- **Give it a long read timeout** — 300s or so. A reasoning model can go 90+ seconds without emitting + a byte, and a 60s default cuts the response mid-stream. The client sees a connection reset rather + than a timeout, which reads like a bug in Jarvis. + +Forward `X-Forwarded-For` too. How many proxies rewrite it is a setting, and the install screen is +where you answer it — **it shows you the chain your own request actually carried and the address the +API resolved from it**, so you confirm what you see rather than counting hops from memory. That is +what puts real client addresses in the audit trail and the session list instead of your proxy's, and +setting it too high is the dangerous direction: the API would then believe that many hops of a +header a caller can forge. Change it later under **Settings → Platform**. + +## Upgrading + +```sh +docker compose pull && docker compose up -d +``` + +That is the whole upgrade: the api, the web, and — with the agent overlay on — the agent release all +track the **`stable`** channel by default. Postgres and Redis are not on a Jarvis channel; they +follow their own upstream tags, `postgres:16-alpine` and `redis:7-alpine`. Schema changes apply +themselves when the api starts, and the api and the web are versioned independently — their numbers +are not meant to match, because usually only one side changed. + +**A schema change is one-way.** Jarvis has no migration history: each boot force-syncs the database +to the schema its image carries, adding what a release added and dropping what it removed, without +prompting. Pulling an older api image does not undo that — unlike the image itself, the schema stays +where the newer build left it. Restoring a dump is the only way back, which is what makes the one +below a prerequisite rather than a precaution. + +```sh +docker compose exec -T postgres pg_dump -U jarvis jarvis | gzip > jarvis-$(date +%F).sql.gz +``` + +### Channels + +| Channel | What it means | +| -------- | ---------------------------------------------------------------------------------- | +| `stable` | Promoted after running on the publisher's own instance. **The default, and what you want.** | +| `dev` | Every build, as soon as it is published. Nothing has tried it yet. | +| `latest` | A second name for `stable`, kept so nothing that already used it has to change. | + +A channel is a **pointer** and a version number is a **fact**. `0.78.2` means one specific set of +bytes for ever; `stable` means whichever set we currently stand behind, and it moves. A build only +reaches `stable` by being promoted — and promotion copies the manifest of an image that has already +been published and already run. It never rebuilds, so the bytes you receive are the same bytes that +were tested, not a fresh build of the same source. + +**Channels do not mean the app forgets which build it is.** The version is stamped into the image +when it is built, so the footer in the app, `/version.json` and the agent manifest keep reporting the +real number whichever name you pulled it under. That is what lets you tell somebody which build you +are on when something goes wrong. Set `JARVIS_CHANNEL` in `.env` and the footer names your channel +beside those numbers; leave it empty if you pin, because then you follow no channel. + +Once you are in production, consider pinning: set `JARVIS_IMAGE_API`, `JARVIS_IMAGE_WEB` and +`JARVIS_IMAGE_AGENT` in `.env` to explicit version tags. It makes an upgrade a decision rather than a +side effect of pulling. Pin the version you are *already running* — the footer shows it — rather than +one copied from a document, and remember that moving the api pin backwards runs an old build against +a schema that has already moved forward. + +**Pin the agent one first if you pin only one.** The api and the web change what your own server +runs; the agent changes what runs on every machine you administer. Pinning the image decides which +build your instance publishes; a separate **update policy** decides when a machine takes it — +*As soon as available*, *On a schedule*, *Manually only*, *Let the agent decide* (the default, which +means on its next service start, and on a server that can be months) or *Never*. The instance-wide +answer is on **Settings → Platform → General**; an organization overrides it under **Settings → +Organization → Agent updates**, and a single machine overrides that. An enrolled agent also refuses +any version that is not strictly newer, so moving that pin back stops a rollout rather than reversing +it on machines that already took the update. + +## The agent + +**The Jarvis agent is an optional overlay, off by default.** Enrolling a machine downloads a compiled +binary that the api serves from `AGENT_RELEASE_DIR`, and a compose-only deployment has no way to +produce one. `docker-compose.agent.yml` supplies it as a pullable image instead. Leaving it off is a +supported state rather than a broken one: everything else works, the manifest and download endpoints +answer 503 saying no build is published, and the SSH, Proxmox, Microsoft 365 and MikroTik connectors +all reach machines without it. + +Turn it on by adding one line to `.env`, so that every later `docker compose` command picks up both +files with no extra flags: + +```sh +COMPOSE_FILE=docker-compose.yml:docker-compose.agent.yml +``` + +then `docker compose pull && docker compose up -d`. A one-shot `agent-releases` service copies the +release into a volume the api reads, and exits. From there, the **Agents** page in the sidebar issues +the install command: click **New install command**, pick Linux, macOS or Windows, and copy the one +line. (Settings → Organization → Agent updates is a different screen — it schedules how +already-enrolled agents take new builds.) + +Four things worth knowing about it: + +- **Until that publisher exits cleanly, the api does not start.** That is deliberate — a release that + failed to arrive should stop the deploy loudly rather than leave you handing 404s to every installer + you run this week. The cost is that an unreachable registry blocks the whole stack. The comment in + the file names the three lines to drop if you would rather it degraded quietly. +- **Upgrading it does not restart anything.** The api re-checks the file on disk on every download + request and re-hashes it whenever its size or timestamp has changed, so a new release in the volume + is served immediately and the published checksum always describes the bytes actually being served. +- **The agent version is its own number.** It moves independently of the api and the web, and a Jarvis + release usually does not touch it at all. The app footer does not carry it either — each enrolled + machine reports the build it runs, on the Agents page. Pin it with `JARVIS_IMAGE_AGENT`. +- **It carries an interactive shell.** Beyond what the assistant can do with it, a person gets a real + terminal on an enrolled machine from the browser — Linux, macOS and Windows alike. Sessions are + recorded by default, encrypted under `VAULT_MASTER_KEY` and deleted on a retention policy you set + under **Settings → Organization → Terminal sessions**. Turning recording off stops the transcript, + never the audit entry. + +**What an enrolled agent can do.** Inventory the machine, run commands, read, write and fetch files, +list and control services, list processes, update itself, and carry that shell. It runs as root on +Linux and macOS and as LocalSystem on Windows, deliberately — its purpose is to administer the +machine. + +What the **assistant** does with that reach is bounded by the autonomy policy and the approval gates +above. **The interactive shell is not.** There is no command to inspect before a shell opens, so the +risk ceiling has nothing to weigh; it is gated instead by a permission and a per-machine switch on +the grant. Decide who holds those before enrolling anything you care about. + +## Things worth knowing before you trust it with production + +These are deliberate and documented rather than surprises waiting to be found. + +- **The vault key has no recovery.** It is generated on first boot into the `jarvis_secrets` volume + and exists nowhere else. A database backup does not protect what it seals — the backup holds + ciphertext encrypted under that key, and that covers more than the vault: the outbound-mail client + secret, this instance's licence identity key, every TOTP secret and every terminal recording go + with it. The install screen makes you read it and type its last characters back before it will + finish, which is the only reason anybody would. See [Backups](#backups-and-restoring-one). + **It also cannot be rotated.** There is no procedure that re-wraps existing data under a new key, + so if this value is disclosed — read out on a screen share, pasted into a ticket, on a laptop that + walked — the answer is a new instance and a fresh set of credentials, not a rotation. The other + three generated secrets are ordinary: delete the file from `jarvis_secrets` and restart, and the + init service writes a new one. Doing that to `jwt-access-secret` or `jwt-refresh-secret` signs + everybody out, which is usually the point; doing it to `postgres-password` needs the database's own + 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. +- **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. +- **An access token dies with its session, with one exception.** Every access token carries the id of + the session that issued it and every request re-checks that the session is live, so revoking a + session, signing other devices out or deactivating an account cuts that token off on its next + request. The exception is changing your own password: it revokes every *other* session and keeps + the one you are changing it from, so a token stolen from that session stays valid until it expires + — 15 minutes by default (`JWT_ACCESS_TTL`). An admin-forced reset drops every session. +- **The api container runs as root**, and so does nginx's master process in the web container, + though its workers drop privileges. Neither image declares a `USER`. +- **`/api/health` answers 200 with `status: "degraded"`** when the database is unreachable, so the + container healthcheck alone is not a liveness signal for the database. +- **Outbound mail is Microsoft Graph only.** There is no SMTP and no environment variable for any of + it: an app registration with `Mail.Send` and a shared mailbox, set up on the **Mail** tab of + **Settings → Platform → General**. Without it, invitations still work — the link comes back to the + admin who created it instead of being emailed. Nothing else is emailed: there is no password reset + and no address verification. +- The assistant executes real operations on real infrastructure. What stops for a human is the + conversation's autonomy level, and at *Full access* nothing does. See + [What decides whether a tool call runs](#what-decides-whether-a-tool-call-runs). + +## When it does not come up + +```sh +docker compose ps # who is running, and who is restarting +docker compose logs -f api # the api says why it refused to start +``` + +**An unconfigured instance now starts and says so** rather than refusing to boot. That is the +change: a wrong model key used to make the api exit 1, and the restart policy turned it into a crash +loop with the reason buried in a log. It comes up, shows the install screen, and tells you what is +wrong on the screen that asks for it. + +| Symptom | Cause | +| --- | --- | +| Every page redirects to an install screen | This instance has not been installed yet. That is the wizard, not an error. | +| The assistant answers "this instance has no model configured" | Exactly that. **Settings → Platform → Model**, where the test button will tell you what the provider thinks. | +| api restarts in a loop, logs `Invalid environment configuration` | A value you set yourself is wrong. Only the database, Redis and the vault key are validated at boot now. | +| The sign-in page loads but cannot sign in | The stored public address is not the one the browser used, scheme included. Change it under **Settings → Platform**. | +| Chat never answers and nothing streams | The WebSocket upgrade is not being forwarded. | +| An answer dies part-way through, every time | The proxy's read timeout is too short. | +| Everything works but one kind of thing cannot be created | A limit is full — the community edition's, or your licence's. The message names which. See below. | +| An agent installer answers 503 | No agent build is published — the overlay is off. That is a supported state. | + +The api takes about a minute on first boot, syncing the schema before it listens. `docker compose ps` +showing `health: starting` for that long is expected, not a fault. + +### When you are locked out of the only administrator account + +There is no password reset in this product — no email flow, no "forgot password" link. That is +deliberate, and it means the single super-admin the wizard creates is a single point of failure until +you do something about it. + +**Do this now, not later: make a second super-admin.** **Settings → Users → New user**, platform role +*Super admin*, and set their password on the same screen. Outbound mail is not required for it. Two +minutes, and it turns every case below into somebody else clicking a button. + +**If another account can still sign in**, promote it and let it fix the first: + +```sh +docker compose exec -T postgres psql -U jarvis -d jarvis \ + -c "UPDATE \"User\" SET \"platformRole\" = 'SUPER_ADMIN' WHERE email = 'colleague@example.com';" +``` + +They then reset the locked-out password under **Settings → Users**. + +**If no account can sign in at all**, set a password hash directly. Jarvis stores argon2id, and the +api image carries the library that makes one — so the hash is generated by the same code that will +check it: + +```sh +docker compose exec api node -e \ + "const a=require('argon2');a.hash(process.argv[1],{type:a.argon2id}).then(h=>console.log(h))" \ + 'the-new-password-at-least-12-characters' +``` + +Then write it, and make sure the account is active and privileged: + +```sh +docker compose exec -T postgres psql -U jarvis -d jarvis -c \ + "UPDATE \"User\" SET \"passwordHash\" = '', \"isActive\" = true, + \"platformRole\" = 'SUPER_ADMIN' WHERE email = 'you@example.com';" +``` + +Quote the hash in single quotes — it contains `$` characters your shell would otherwise eat. + +If the account also holds a second factor you no longer have: when another administrator exists, they +do it properly from **Settings → Users**, which strips the factors, ends every open session and files +an audit entry. With nobody left to click it, clear all three parts by hand — the passkeys, the +authenticator secret, and the flag that says a factor is expected — or the account will keep +demanding one: + +```sh +docker compose exec -T postgres psql -U jarvis -d jarvis <<'SQL' +DELETE FROM "WebAuthnCredential" WHERE "userId" = (SELECT id FROM "User" WHERE email = 'you@example.com'); +DELETE FROM "UserTotpCredential" WHERE "userId" = (SELECT id FROM "User" WHERE email = 'you@example.com'); +UPDATE "User" SET "mfaEnabled" = false, "mfaEnabledAt" = NULL WHERE email = 'you@example.com'; +SQL +``` + +Enrol a new factor as soon as you are back in, and note that this leaves no audit trail of its own — +the trail is your shell history. + +**Re-running the install wizard is not a recovery route.** The claim is open only on an instance with +no users and no completed setup, so on a working deployment it refuses — and making it refuse less by +clearing those columns by hand would hand your live instance to whoever reaches it first. + +### Moving the instance to a new address + +Changing the public address under **Settings → Platform → Instance** is supported, and it has two +consequences that are not obvious and not reversible by changing it back. + +**Enrolled agents keep dialling the old one.** Each machine stores the address it was enrolled with +and does not learn a new one from the server. After a move they go OFFLINE and stay there. Every +agent has to be re-enrolled against the new address, so a move is a job scheduled with whoever +administers those machines rather than a settings change made on a Friday. + +**Every passkey stops being offered.** A passkey is bound to the hostname it was enrolled under. They +do not fail loudly — the browser simply stops presenting them — so accounts quietly fall back to +passwords, and anyone who set up a passkey and never learned their password is locked out. Make sure +the people who use passkeys know their passwords, or enrol them again afterwards, before you move. + +Nothing else moves: conversations, credentials, assets and recordings are rows and do not know the +address. Update your reverse proxy and the `WEB_ORIGIN`-shaped world outside first, then the setting, +then the agents. + +## Backups, and restoring one + +Postgres holds everything except the vault key — avatars, documents, exports and terminal recordings +are all rows, not files on disk. **Two volumes are worth backing up, and they must go to different +places:** `postgres_data` and `jarvis_secrets`. `redis_data` carries nonces, rate-limit counters and the +socket fan-out, all of which rebuild themselves; `agent_releases` is refilled by the next +`docker compose pull`. + +They are separate volumes precisely so that they can be, and must be, backed up separately. A dump +that travelled with the key that opens it is a dump that opens itself. + +```sh +docker compose exec -T postgres pg_dump -U jarvis jarvis | gzip > jarvis.sql.gz +``` + +Plus the vault key, stored somewhere that is not this host and not beside the dump: + +```sh +docker compose exec api cat /var/lib/jarvis/secrets/vault-master-key +``` + +A dump without the key is a database whose credentials cannot be read — and an instance restored +under a *different* key keeps **looking** configured, because nothing on a settings page decrypts +anything. It fails on every reveal instead. + +To restore, stop the api so nothing writes while you work, then load the dump into an empty database: + +```sh +docker compose stop api web +docker compose exec -T postgres psql -U jarvis -d postgres \ + -c 'DROP DATABASE IF EXISTS jarvis;' -c 'CREATE DATABASE jarvis;' +gunzip -c jarvis.sql.gz | docker compose exec -T postgres psql -U jarvis -d jarvis +docker compose start api web +``` + +Restore under the **same `VAULT_MASTER_KEY`** the dump was taken with. That value is not in the dump, +and no part of the restore will warn you that it differs. + +### Putting the key back on a new host + +**Do this before the first `docker compose up -d`, not after.** The generator writes each secret with +`O_EXCL` and never overwrites one that exists, which is what stops an upgrade quietly replacing the +key your vault is sealed under. On a fresh host it cuts the other way: bring the stack up first and +a brand-new key is written, after which anything you put in `.env` is ignored for good. + +If you kept the `jarvis_secrets` volume, restore it and nothing else is needed. If all you have is +the base64 string, put it in `.env` before the first start: + +```sh +# On the NEW host, in the directory holding docker-compose.yml — before any `up -d`. +echo 'VAULT_MASTER_KEY=' >> .env +docker compose up -d +``` + +The `init` service adopts that value into the volume on the first run and the api reads it from +there afterwards, so the line in `.env` is a seed rather than a permanent setting — you may remove it +once the stack is up. + +If you have already started the stack and a wrong key was generated, delete the file and let the init +service run again. **Only ever do this on a host whose vault you are deliberately re-keying** — on a +working instance it destroys every credential, the mail client secret, this instance's licence +identity, every TOTP secret and every terminal recording: + +```sh +docker compose down +docker run --rm -v jarvis_secrets:/s alpine rm -f /s/vault-master-key +# then the `.env` line above, then `docker compose up -d` +``` + +To check you restored under the right one, reveal a stored credential in **Settings → Vault**. The +rows are all there under a wrong key; only a reveal tells you the truth. + +## Removing it + +```sh +docker compose down # stops everything, keeps the data +docker compose down -v # also deletes the volumes — every conversation, asset and credential +``` + +`down -v` is not recoverable from anything but a dump you already took. + +## The community edition + +**Jarvis runs without a licence key, for as long as you like.** An instance with no key runs the +community edition: + +| | Community edition | +| --- | --- | +| Organizations | 1 | +| Users | 3 | +| Agents | 5 | +| Assets | 10 | +| Term | Perpetual. No expiry, nothing to renew, no key to lose | +| Reporting | Version, counts and address — the same fields a licensed instance sends. Switchable off | +| Features | All of them — every connector, the agent, the remote terminal, the assistant | + +Nothing is disabled, watermarked or time-limited. What a licence buys is a **higher ceiling**, not +the product: the same images, the same code, larger numbers. This is deliberate and it is the reason +the images are public — you should be able to install Jarvis, connect it to something real, and find +out whether it earns a place in your work before there is anybody to talk to. + +When a limit is full, Jarvis refuses **the next** thing of that kind and says which limit it was. +Nothing already there is touched: no organization is closed, no user locked out, no agent +disconnected, and the assistant keeps working on everything you have. So an instance that grows past +the community numbers — through an imported estate, say — keeps running in full; it simply cannot +add to that meter until a key raises it. + +### What changed on 21 August 2026, and why we are saying it loudly + +**Until this release, a community instance contacted nobody at all**, and this file said so in those +words. That is no longer true: an instance without a licence now reports the same fields a licensed +one does — counts, version, contract checksum and its public address — to `checkin.luxit.be`, which +is compiled into the build because an instance with no licence has no address in one to read. + +We changed it for two reasons, and neither of them is nicer for you than for us. We could not tell +whether the community edition was reaching anybody, or which builds were running when we needed to +warn people about one. And the licence request below needs the same channel — a form that could not +reach us would just be an email with extra steps. + +**Reporting is part of running Jarvis, and there is no switch.** Every instance checks in — +community or licensed, the same fields either way — because it is the only way we see which builds +are in the field when one of them turns out to need a warning, and because the licence request form +below travels the same channel. What leaves is listed field by field in the next section, and +**Settings → Licence** shows you the exact address and the exact contents from the running instance +rather than from this document. + +Everything in the next section applies to a community instance as well: the field list is the whole +field list, and it is the same one. + +To go further — more organizations for a real client base, more seats, more machines — ask for a +licence from **Settings → Licence** inside your own instance. That form is signed by your +installation, so an approved licence lands on it by itself, with nothing to paste. + +## Licence keys, and what your instance reports + +**A licence key raises the limits.** Ask for one (see +[below](#getting-a-licence-and-getting-help)) and put it in `.env` as `JARVIS_LICENSE_KEY`, or paste +it under **Settings → Licence**. The key is a signed token your instance verifies **offline** — it +carries your term and your limits, and it needs no network to be checked. + +**A licence that stops applying drops back to the community edition**, and never to nothing. Expired, +withdrawn, or a key that will not verify: the instance keeps every allowance in the table above and +keeps running everything already set up. There is no state in which Jarvis stops being usable because +of a billing question, and there never will be. + +**Every instance reports**, licensed or not. A licensed one reports to the address written into its +key; a community one to `checkin.luxit.be`, compiled into the build. The interval is your provider's +to set — every ten minutes on the current arrangement, so that a renewal or a revocation reaches you +promptly rather than tomorrow. + +This is everything either of them sends, in full: + +| Field | What it is | +| ------------------------ | ------------------------------------------------- | +| Product | The literal string `jarvis` | +| Licence id | Which licence this is. **Absent on a community instance** — there is none to quote | +| Instance id + public key | A key pair your instance generated, identifying it | +| Version | Which Jarvis build you are running | +| Contract version | Which set of limits this build understands — a checksum, not a document | +| Counts | How many organizations, users, agents and assets | +| Public URL | Your instance's address — always sent, see below | +| Timestamps | When the process started, and when it reported | +| Signature + nonce | Proof the message came from this instance, and a one-time value so an old one cannot be replayed. Carries nothing about you. | + +**Counts, not contents.** No names, no email addresses, no conversation text, no asset inventory, no +credentials, nothing about what you administer. + +**The public URL is the one field that names your network rather than measuring something, and it is +sent.** It used to be a switch on the licence screen; it is not any more, because an installation the +publisher can identify only by a fingerprint is one where "which of these is the customer calling +about" has no answer. The screen shows you the exact address that leaves, under **Settings → +Licence**. + +**There is no way to switch this off**, on a community instance or a licensed one. It is the same +report either way, and the licence screen states it rather than offering a control that would make +the arrangement a matter of opinion. If that does not suit your deployment, take it up with us before +you deploy anything. + +The reply can carry a renewed key, which your instance adopts on its own — so a renewal reaches you +without anybody re-pasting anything. + +**Your platform does not stop working because of a licence.** Expiry gives you a grace period — as +long as your key says, which on the current plans is 30 days, and none at all on a trial. After it, +the instance returns to the community edition's allowances: everything already set up keeps running, +the assistant included, and only creating something beyond those numbers is refused. There is no +state in which Jarvis disables, deletes or locks you out of something you are already using. If the +check-in cannot reach the server, nothing changes at all: the key you hold is what governs, and it is +checked without a network. + +## Asking for a licence + +**From inside your own instance: Settings → Licence → Ask for a licence.** Fill in who you are — a +company name and somebody to answer are the only required fields — roughly what you need, and send. + +What makes this worth doing from in there rather than by email: the form is **signed by your +installation**, so when your provider approves it, the licence is issued for that exact deployment +and **lands on it by itself at the next report**. There is no key to copy, nothing to paste, and no +way to paste it into the wrong instance. You get the key by email as well, for your records and in +case you rebuild the host before the answer arrives. + +Sent with the form: your instance's id, its version, what it currently counts, and its address. That +is the same information it already reports, so the form adds only what you typed. + +While it is pending, the licence screen says so. If your provider declines, their reason appears on +that screen and in your inbox — and you can ask again from the same button whenever something has +changed. You can also withdraw a request you no longer want. + +If you have turned reporting off, the form cannot reach anybody and says so. Write to the address +below instead, quoting the instance id from the licence screen. + +## Getting a licence, and getting help + +**Antoine Cavelier — .** Licence keys, pricing, and anything wrong with the +product. + +When something is broken, the two facts worth putting in the first message are the build you are on +and what the api said: + +```sh +curl -s https://your-jarvis.example.com/version.json # the web and api versions +docker compose logs --tail=100 api +``` + +`/version.json` is public on purpose, so you can quote it without signing in. + +## Licence + +The images are provided as-is with no warranty, no support and no commitment to future availability. +The source is not public and no rights to it are granted. Ask before deploying this commercially or +for third parties. diff --git a/docker-compose.agent.yml b/docker-compose.agent.yml new file mode 100644 index 0000000..3f7c7c8 --- /dev/null +++ b/docker-compose.agent.yml @@ -0,0 +1,45 @@ +# Jarvis — the agent release, as a pullable image. +# +# OPTIONAL OVERLAY. The base stack runs perfectly without it; what it adds is the one feature a +# self-hosted instance cannot otherwise have, because enrolling a machine downloads a compiled +# binary and there is nowhere for a compose-only deployment to get one. This carries that release +# as an OCI image, so it arrives through the same `docker compose pull` as the api and the web. +# +# Turn it on by naming both files. Either spelling works: +# +# docker compose -f docker-compose.yml -f docker-compose.agent.yml up -d +# +# or, so that a plain `docker compose ...` keeps working for every later command, put this in .env: +# +# COMPOSE_FILE=docker-compose.yml:docker-compose.agent.yml +# +# Upgrading is unchanged: `docker compose pull && docker compose up -d`. The publisher re-runs, +# replaces the release in the volume, and the api picks it up WITHOUT a restart — digests are +# computed from the bytes on disk on every request, not cached at boot. +services: + # Runs once per `up`, copies its payload into the shared volume, exits. Not a server. + agent-releases: + image: ${JARVIS_IMAGE_AGENT:-git.luxit.be/luxit/jarvis-agent-dist:stable} + # Explicit, because the default would be wrong the moment somebody copies this block: a + # restarting one-shot is an infinite loop, and Compose's own default policy is already "no". + restart: "no" + volumes: + - agent_releases:/out + + api: + # NOTE THE COUPLING: until the publisher has exited 0, the api does not start. That is + # deliberate — a release that failed to arrive should stop the deploy and say so, rather than + # leave an instance quietly handing 404s to every installer somebody runs this week. The cost + # is that an unreachable registry now blocks the whole stack, so if that trade is wrong for + # you, drop these three lines and the api will simply serve no build until the volume fills. + depends_on: + agent-releases: + condition: service_completed_successfully + volumes: + # Read-only: the API serves these bytes to every managed machine and never writes here. + - agent_releases:/srv/agent-releases:ro + environment: + AGENT_RELEASE_DIR: /srv/agent-releases + +volumes: + agent_releases: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ab06d40 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,240 @@ +# Jarvis — self-hosting stack. +# +# Everything runs from published images; nothing is built here and no source is needed. +# +# docker compose pull +# docker compose up -d +# +# That is the whole first deployment. Open the address in a browser and an install screen asks the +# questions: it creates the first administrator, checks what this deployment actually looks like +# from inside, and stores the address, the model and the rest as settings you can change later. +# +# THERE IS NO .env STEP ANY MORE. This file used to demand six variables before it would start, +# four of them secrets you had to generate with `openssl` — and it failed badly when one was wrong, +# because the API exited 1 and the restart policy turned a typo into a crash loop. `.env.example` +# still exists and every value in it is optional: pinning, the host port, the agent overlay. +# +# THE ONE THING TO DO AFTERWARDS: back up the `jarvis_secrets` volume, separately from the database. +# The install screen shows you why and will not finish until you have. See the `init` service below. +# +# Only the `web` service publishes a port. Its nginx serves the app and reverse-proxies /api and the +# websocket to the internal `api` service, so your own TLS terminator has exactly one target and the +# API is never reachable from outside this compose network. +# +# UPGRADING: `docker compose pull && docker compose up -d`. Every Jarvis image here tracks `stable` by +# default, so that is the whole upgrade. Postgres and Redis are not on a Jarvis channel — they follow +# their own upstream tags. Schema changes apply themselves when the api starts, and they are ONE-WAY: +# there is no migration history, so pulling an older api image does not put the schema back. Take a +# dump first. See the README. +# +# A channel tag does NOT mean the app forgets which build it is: `stable` is a second name on the same +# image as its version tag, and the version is stamped INTO the image when it is built. The footer in +# the app and /version.json keep reporting the real number whichever name you pulled it under — which +# is what lets you tell somebody which build you are on when something goes wrong. +# +# To pin instead — recommended once you are in production, because it makes an upgrade a decision rather +# than a side effect of pulling — set JARVIS_IMAGE_API and JARVIS_IMAGE_WEB (and JARVIS_IMAGE_AGENT, if +# you run the agent overlay) in .env to explicit version tags. +# +# No version number is written in this comment on purpose. Nothing in the publishing path would ever +# bump one, so a number here is a number that goes stale while nobody is looking. +name: jarvis + +services: + # Writes the stack's secrets, once, into a volume the other services read. Runs to completion + # before anything else starts and then exits — `docker compose ps` shows it as `Exited (0)`, which is + # what success looks like and not something to fix. + # + # THIS IS WHY .env NO LONGER ASKS FOR FOUR openssl INVOCATIONS. A vault key, two signing secrets + # and a database password are values no person should be choosing, and asking for them put the + # most consequential one — the vault key, which everything in the vault is sealed under — in the + # hands of whoever was least equipped to look after it, at the moment they were least interested. + # + # It never overwrites. On an upgrade it ADOPTS whatever is still in your .env, so a stack that has + # been running for a year keeps its own keys and this service is inert from its second run onward. + # + # BACK THIS VOLUME UP, AND SEPARATELY FROM THE DATABASE. It is deliberately not `postgres_data`: + # every credential in the vault is encrypted under a key that lives here, so a database dump that + # travelled with its own key would be a dump that decrypts itself. The setup screen shows you the + # key once and will not let you past until you have put it somewhere. + init: + image: ${JARVIS_IMAGE_API:-git.luxit.be/luxit/jarvis-api:stable} + entrypoint: ["node", "/app/apps/api/init-secrets.cjs"] + restart: "no" + environment: + # Only read when the corresponding file does not exist yet — the upgrade path for a stack + # whose .env already holds these. Empty on a fresh install, which is the ordinary case now. + VAULT_MASTER_KEY: ${VAULT_MASTER_KEY:-} + JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:-} + JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-} + volumes: + - jarvis_secrets:/var/lib/jarvis/secrets + + postgres: + image: postgres:16-alpine + restart: unless-stopped + depends_on: + init: + condition: service_completed_successfully + environment: + POSTGRES_USER: jarvis + # The file, never the variable. The two are mutually exclusive in this image — it refuses to + # start when both are set — which is exactly why `init` adopts an existing .env value into the + # file instead of the compose file trying to choose between them. + POSTGRES_PASSWORD_FILE: /var/lib/jarvis/secrets/postgres-password + POSTGRES_DB: jarvis + volumes: + - postgres_data:/var/lib/postgresql/data + # Read-only: postgres consumes this secret and has no business ever writing one. + - jarvis_secrets:/var/lib/jarvis/secrets:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U jarvis -d jarvis"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + # Append-only so a restart does not lose the queue and the socket fan-out state. + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + api: + image: ${JARVIS_IMAGE_API:-git.luxit.be/luxit/jarvis-api:stable} + restart: unless-stopped + # The assistant runs long operations, and a deploy is the most common thing that interrupts one. + # Given room to stop, the API aborts each loop, writes the partial answer with a note saying why + # the transcript ends there, and marks the run interrupted so the next process picks it up. + # Docker's 10s default is not enough. RUN_SHUTDOWN_GRACE_SEC must stay the smaller of the two. + stop_grace_period: 60s + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + init: + condition: service_completed_successfully + volumes: + # Read-only. The api reads these keys and must never be the thing that creates one: a + # container that could write here is a container whose bug can replace the key the vault is + # sealed under, and nothing about that failure is visible until somebody opens a credential. + - jarvis_secrets:/var/lib/jarvis/secrets:ro + environment: + NODE_ENV: production + API_PORT: "4000" + + # A SEED, not a requirement. The install screen asks for this address and pre-fills it with the + # one you are reading the page at; whatever is stored wins from then on. Passed through so a + # stack that already set it upgrades with its origin intact, and empty on a fresh install — + # which is a supported state, not a broken one. + # + # PUBLIC_URL is deliberately no longer passed. It was a second variable for the same address, + # and every consumer now reads the single stored value. + WEB_ORIGIN: ${WEB_ORIGIN:-} + + # Assembled by the entrypoint from POSTGRES_PASSWORD_FILE, because a password that lives in a + # file cannot be interpolated into a URL by compose. Set DATABASE_URL in .env to override it + # outright — pointing at a managed postgres outside this stack, say. + DATABASE_URL: ${DATABASE_URL:-} + POSTGRES_PASSWORD_FILE: /var/lib/jarvis/secrets/postgres-password + REDIS_URL: redis://redis:6379 + + # Delivered as files rather than values, by the `init` service above. The `_FILE` suffix is the + # convention this postgres image and most others already use, so a `docker secret` of your own + # can be pointed at these paths instead with nothing here changing. + JWT_ACCESS_SECRET_FILE: /var/lib/jarvis/secrets/jwt-access-secret + JWT_REFRESH_SECRET_FILE: /var/lib/jarvis/secrets/jwt-refresh-secret + # Interpolated like every other tuning value, rather than frozen here. These two were the only + # ones written as literals, which meant the one knob an operator under a session policy asks + # for was also the one they could not reach from `.env`. See `.env.example` for what each + # actually does — the refresh one is not the session lifetime it looks like. + JWT_ACCESS_TTL: ${JWT_ACCESS_TTL:-900} + JWT_REFRESH_TTL: ${JWT_REFRESH_TTL:-1209600} + + # THE ONE YOU CANNOT LOSE. Every credential in the vault is encrypted under it, and so is this + # instance's licence identity, every authenticator secret and every terminal recording. It is + # generated into `jarvis_secrets` on your first boot and exists NOWHERE ELSE — back that volume + # up separately from the database. The setup screen shows it to you once. + VAULT_MASTER_KEY_FILE: /var/lib/jarvis/secrets/vault-master-key + + # Any OpenAI-compatible endpoint. All four of these are seeds for the stored settings. + OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} + # NO LONGER REQUIRED, and that is the point of the change. This used to be validated at boot, + # so a key that was merely wrong made the API exit 1 and the restart policy turned it into a + # crash loop. The install screen asks for it, tests it against the provider, and stores it + # encrypted; what is passed here only seeds an instance that has nothing stored yet. + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_MODEL: ${OPENAI_MODEL:-gpt-4o} + OPENAI_THINKING_LEVEL: ${OPENAI_THINKING_LEVEL:-medium} + + # How many proxies sit in front and rewrite X-Forwarded-For. ONE is the web container's own + # nginx, which is always there — so 1 is right when nothing else fronts it, and 2 when your own + # TLS terminator does. Raising it is the dangerous direction: the API trusts that many hops of a + # header the client can forge, and too high lets a caller choose the IP that lands in the audit + # log, in the session list and in the enrollment rate limit. + TRUST_PROXY_HOPS: ${TRUST_PROXY_HOPS:-2} + + # Required to create new organizations, users or agents. Without it an instance keeps running + # everything it already has, creates nothing new, and contacts nobody. See the README. + JARVIS_LICENSE_KEY: ${JARVIS_LICENSE_KEY:-} + + # Which distribution channel this instance follows, shown to signed-in operators beside the + # version numbers. Set `JARVIS_CHANNEL=stable` (or `dev`) in .env if you track a channel; + # LEAVE IT EMPTY IF YOU PIN EXACT VERSIONS, because then you follow no channel — you follow a + # decision — and the footer shows nothing rather than a label that stopped being true. + # + # It is not baked into the image, and cannot be: a channel is decided after a build and moves + # afterwards, so the same image is `dev` one week and `stable` the next. Only you know which + # one you are on. + APP_CHANNEL: ${JARVIS_CHANNEL:-} + + # Install from the environment and never show the wizard. For fleets and for CI — see + # .env.example. Off means the ordinary install screen, which is what one deployment wants. + JARVIS_UNATTENDED: ${JARVIS_UNATTENDED:-off} + JARVIS_ADMIN_EMAIL: ${JARVIS_ADMIN_EMAIL:-} + JARVIS_ADMIN_PASSWORD: ${JARVIS_ADMIN_PASSWORD:-} + JARVIS_ADMIN_NAME: ${JARVIS_ADMIN_NAME:-} + + AGENT_HEARTBEAT_INTERVAL_SEC: ${AGENT_HEARTBEAT_INTERVAL_SEC:-30} + RUN_SHUTDOWN_GRACE_SEC: ${RUN_SHUTDOWN_GRACE_SEC:-25} + + # 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. + AGENT_RELEASE_DIR: ${AGENT_RELEASE_DIR:-} + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://localhost:4000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + interval: 10s + timeout: 5s + retries: 12 + # First boot syncs the schema and runs the data backfills before it listens. + start_period: 40s + + web: + image: ${JARVIS_IMAGE_WEB:-git.luxit.be/luxit/jarvis-web:stable} + restart: unless-stopped + depends_on: + - api + 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" + +volumes: + postgres_data: + redis_data: + # The keys, deliberately apart from postgres_data. Back it up, and not to the same place: a + # database dump is worthless to a thief without this, and worthless to YOU without it either. + jarvis_secrets: diff --git a/docs/img/asset.png b/docs/img/asset.png new file mode 100644 index 0000000..88da904 Binary files /dev/null and b/docs/img/asset.png differ diff --git a/docs/img/console.png b/docs/img/console.png new file mode 100644 index 0000000..dd290ce Binary files /dev/null and b/docs/img/console.png differ diff --git a/jarvis.svg b/jarvis.svg new file mode 100644 index 0000000..cc0332e --- /dev/null +++ b/jarvis.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + +