commit a96c277011a67c30a7da8e7ccaff5f7d19237204 Author: Luxit Date: Fri Aug 21 14:40:30 2026 +0200 The self-hosting stack diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..89b6234 --- /dev/null +++ b/.env.example @@ -0,0 +1,154 @@ +# Jarvis — configuration. Copy to `.env` beside the compose file and fill in. +# +# cp .env.example .env +# +# Nothing here has a safe placeholder value: the four secrets must be generated, and the two URLs +# must be yours. The compose file refuses to start rather than booting with something wrong. + +# --------------------------------------------------------------------------- +# Where your instance lives +# --------------------------------------------------------------------------- + +# The address a browser reaches Jarvis on, scheme included and no trailing slash. +# +# This is the CORS decision. It accepts exactly ONE origin — not a list — and every request from +# anywhere else is refused, which presents as a sign-in page that cannot sign in. +WEB_ORIGIN=https://jarvis.example.com + +# The address enrolled machines dial. Defaults to WEB_ORIGIN, which is right for a single-origin +# deployment. Set it separately only if the app and the API answer on different hostnames. +#PUBLIC_URL=https://jarvis.example.com + +# Host port the web container publishes. Put your TLS terminator in front of it. +JARVIS_PORT=8080 + +# How many proxies rewrite X-Forwarded-For before a request reaches the API. +# +# One is the web container's own nginx, which is always there. So: 1 if nothing else fronts it, 2 if +# your own reverse proxy does — which is the usual case and the default. Raising it 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 the audit log and in the session list. +TRUST_PROXY_HOPS=2 + +# --------------------------------------------------------------------------- +# Secrets — generate every one of these, never copy them from anywhere +# --------------------------------------------------------------------------- + +# Postgres. Only ever used inside the compose network. +# openssl rand -base64 24 +POSTGRES_PASSWORD= + +# Session signing. Two different values, at least 16 characters each. +# openssl rand -base64 48 +JWT_ACCESS_SECRET= +JWT_REFRESH_SECRET= + +# THE ONE YOU CANNOT LOSE. +# +# Must decode to exactly 32 bytes: +# openssl rand -base64 32 +# +# Every credential in the vault — SSH keys, API secrets, Microsoft 365 client secrets — is encrypted +# under this key with AES-256-GCM, and so is everything Jarvis seals OUTSIDE the vault: the Microsoft +# Graph client secret it sends its own mail with, this instance's licence identity key, every +# authenticator-app secret, and every terminal recording. It is not stored anywhere but here. +# +# Change it or lose it and none of that data can be read again, by you or by anyone. A database backup +# does not save you: the backup holds the ciphertext. Back this value up separately from the database, +# somewhere you would still have it if this host were gone. An instance whose key has changed keeps +# LOOKING configured — the rows are all there — and fails on every reveal. +VAULT_MASTER_KEY= + +# --------------------------------------------------------------------------- +# The model +# --------------------------------------------------------------------------- + +# Any OpenAI-compatible endpoint: OpenAI, a self-hosted gateway, a local server. +#OPENAI_BASE_URL=https://api.openai.com/v1 + +# Required. Jarvis will not start without it. +OPENAI_API_KEY= + +# Must be a model your endpoint actually serves, and it should be a good one — this model is deciding +# what to run on production infrastructure. +#OPENAI_MODEL=gpt-4o + +# minimal | low | medium | high. Higher costs latency and tokens and is worth it for real work. +#OPENAI_THINKING_LEVEL=medium + +# --------------------------------------------------------------------------- +# 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 + +# --------------------------------------------------------------------------- +# Licence (required to create anything) +# --------------------------------------------------------------------------- + +# Your licence key. REQUIRED to create anything new. Ask antoine@luxit.be for one. +# +# Without it, an instance keeps running everything already set up and refuses to create new +# organizations, users, agents and assets — so an existing deployment does not stop working when +# this reaches it, and a fresh install gets as far as its first administrator and then needs a key. +# An unlicensed instance also contacts nobody at all. See the README for exactly what a licensed one +# reports. +# +# 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. +#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/README.md b/README.md new file mode 100644 index 0000000..f2852f2 --- /dev/null +++ b/README.md @@ -0,0 +1,478 @@ +

+ 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. + +- [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) +- [Licence keys, and what your instance reports](#licence-keys-and-what-your-instance-reports) +- [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 api will not start without one. +- A licence key. See [Licence keys](#licence-keys-and-what-your-instance-reports) — without one an + instance runs but creates nothing new. +- Roughly 2 GB of RAM for the stack and room for Postgres to grow. + +## Install + +```sh +git clone https://git.luxit.be/Luxit/jarvis-selfhost.git +cd jarvis-selfhost +cp .env.example .env +$EDITOR .env # every comment in it is load-bearing; the secrets note especially +docker compose pull +docker compose up -d +``` + +First boot 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 `JARVIS_PORT` and open the app. + +**Give Jarvis its public address with the right scheme.** `WEB_ORIGIN` is the CORS decision and it +accepts exactly one origin; `PUBLIC_URL` is what gets baked into agent install commands, invitation +links and share links. A wrong `WEB_ORIGIN` presents as a sign-in page that cannot sign in. A wrong +`PUBLIC_URL` does not fail at deploy time — it fails weeks later, on somebody else's server. + +**The first account created becomes super-admin.** Self-registration is open by default, so sign up +immediately after the stack is up and then close registration under **Settings → Platform → +General**. Leaving it open means anyone who reaches the sign-in page gets an account. + +## 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, and set `TRUST_PROXY_HOPS` to match how many proxies rewrite it — +that is what puts real client addresses in the audit trail and the session list instead of your +proxy's. + +## 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. + +- **`VAULT_MASTER_KEY` has no recovery.** Read the note in `.env.example`. 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. +- **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 +``` + +The api validates its configuration at boot and **fails fast rather than starting half-configured**, +so a first-boot failure is almost always one line of `.env`: + +| Symptom | Cause | +| --- | --- | +| `docker compose up` exits complaining about a variable | That variable is empty in `.env`. Compose refuses before any container starts. | +| api restarts in a loop, logs `Invalid environment configuration` | A value is present but wrong. `VAULT_MASTER_KEY` must base64-decode to exactly 32 bytes. | +| The sign-in page loads but cannot sign in | `WEB_ORIGIN` is not the address the browser used, scheme included. It accepts one origin, not a list. | +| 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 nothing can be created | No licence key. 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. + +## Backups, and restoring one + +Postgres holds everything except the vault key — avatars, documents, exports and terminal recordings +are all rows, not files on disk. **Of the stack's volumes, only `postgres_data` is worth backing +up.** `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`. + +```sh +docker compose exec -T postgres pg_dump -U jarvis jarvis | gzip > jarvis.sql.gz +``` + +Plus `VAULT_MASTER_KEY`, stored somewhere that is not this host. 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. + +## 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. + +## Licence keys, and what your instance reports + +**Jarvis needs a licence key.** Ask for one (see [below](#getting-a-licence-and-getting-help)) and +put it in `.env` as `JARVIS_LICENSE_KEY`. 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. + +Without one, an instance keeps running everything already set up — every organization, every user, +every agent, every asset, and the assistant itself — and refuses only to create NEW ones: no new +organization, user, agent or asset. So an existing deployment does not stop working when this reaches +it, and a fresh install gets as far as its first administrator account and then needs a key. + +**An unlicensed instance contacts nobody.** No check-in, no telemetry, nothing leaves your network at +all. The reporting below starts only once a key is in place. + +A licensed instance then reports to the address written into that key, at an interval your provider +sets — every ten minutes on the current arrangement, so that a renewal or a revocation reaches you +promptly rather than tomorrow. This is everything it sends, in full: + +| Field | What it is | +| ------------------------ | ------------------------------------------------- | +| Product | The literal string `jarvis` | +| Licence id | Which licence this is | +| 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 on a +licensed instance 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 → Platform → Licence**. If that is not acceptable for your deployment, the answer is +to run unlicensed — which contacts nobody at all — or to take it up with us before installing. + +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, +Jarvis refuses only the creation of new organizations, users, agents and assets; everything already +set up keeps running, and so does the assistant. 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. + +## 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..29eba1d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,155 @@ +# Jarvis — self-hosting stack. +# +# Everything runs from published images; nothing is built here and no source is needed. +# +# cp .env.example .env # then fill it in — see the comments in that file +# docker compose pull +# docker compose up -d +# +# 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: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: jarvis + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + POSTGRES_DB: jarvis + volumes: + - postgres_data:/var/lib/postgresql/data + 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 + environment: + NODE_ENV: production + API_PORT: "4000" + + # The address a browser reaches Jarvis on. This is the CORS decision: requests from any other + # origin are refused, so a wrong value here presents as a sign-in page that cannot sign in. + WEB_ORIGIN: ${WEB_ORIGIN:?set WEB_ORIGIN in .env} + # The address baked into agent install commands and dialled by every enrolled machine. Usually + # the same string as WEB_ORIGIN; separate because they answer different questions, and a wrong + # value here does not fail at deploy time — it fails weeks later, on somebody else's server. + PUBLIC_URL: ${PUBLIC_URL:-${WEB_ORIGIN}} + + DATABASE_URL: postgresql://jarvis:${POSTGRES_PASSWORD}@postgres:5432/jarvis?schema=public + REDIS_URL: redis://redis:6379 + + JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:?set JWT_ACCESS_SECRET in .env} + JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?set JWT_REFRESH_SECRET in .env} + JWT_ACCESS_TTL: "900" + JWT_REFRESH_TTL: "1209600" + + # READ THE NOTE IN .env.example BEFORE CHANGING THIS. Every credential in the vault is + # encrypted under it; lose it and they are gone, with no recovery of any kind. + VAULT_MASTER_KEY: ${VAULT_MASTER_KEY:?set VAULT_MASTER_KEY in .env} + + # Any OpenAI-compatible endpoint. Defaults to OpenAI itself. + OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} + OPENAI_API_KEY: ${OPENAI_API_KEY:?set OPENAI_API_KEY in .env} + 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:-} + + 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: 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 @@ + + + + + + + + + + + + + + + + + + +