Skip to content

Latest commit

 

History

History
234 lines (190 loc) · 13 KB

File metadata and controls

234 lines (190 loc) · 13 KB

First-run setup

One flow that configures an instance, at /api/v1/setup and — in the console — the setup wizard (frontend/src/views/setup/).

What it exists for

Everything an operator has to decide to get a spun-up harness running used to live in four places that never met:

Decision Where it lived
Host settings (bind, auth_mode, brain_mode, credentials, [workspace]) a hand-edited config.toml
Which company template to start from a serve --company <dir> flag, or the desktop's hardcoded DEFAULT_PRESET_ID
Per-company settings (inference, MCP, team, connections) six Settings sub-pages
Whether any of it had happened nowhere

A serve host with no --company booted an empty registry, and the console dead-ended on "No companies are running on this host." That is the hole this fills. It is the static-template half of guided company blueprints; the conversational Architect interview in agentic/setup.md is separate and still to come.

The two routes

GET /api/v1/setup returns everything the wizard needs to draw itself: each configurable field with the layer that owns it, the shipped template catalog, the sign-in modes this host accepts, which optional surfaces are compiled into the build, and — in mail — what this host can do with a mailbox.

mail is two booleans, wired and echoes_code, computed from the very predicates the login route branches on rather than re-read from the environment here. It answers a different question from auth_modes: that list says which modes are legal, and email stays on it whatever mail this host has, because hub OAuth and passwords sign people in without a transport. mail says which sign-in the wizard can honestly offer today — mailed on wired, handed back as a link on echoes_code, and neither on a routable host that has configured no transport.

That last case is why it is reported at all. The console used to infer mail from whether the sign-in request echoed a code back, and a code is only ever echoed on a loopback bind — so a routable host with no transport finished setup by telling its operator to check an inbox that would stay empty forever. The echoed code still sources the link it hands over; it no longer decides whether one was sent. The sign-in step says the same thing before the choice is made, and /auth/config carries it to the login screen as magicLink, which is the only place a returning visitor could ever be told: auth/request answers sent: true on a host that delivered nothing exactly as on one that did.

POST /api/v1/setup applies a completed wizard: writes config.toml, seeds the chosen template when the registry is empty, and stamps setup_completed_at. Validation happens before anything is written — a partial apply is worse than a refused one, because nothing tells the operator which half landed.

The Business step renders that catalog as a dropdown. The selected preset is sent with the optional details to the roster pass, so matching is anchored to a real shipped company type instead of inferred from a free-text keyword alone.

GET /spec additionally carries setup_complete. It is reported on that unauthenticated handshake because an instance nobody has configured has nobody who can sign in; gating the answer behind auth would make the wizard unreachable exactly when it is needed. The boolean is the whole disclosure — the configuration itself stays behind /api/v1/setup.

The order the wizard asks in

Six steps: model, business, sign-in, you, advanced, review.

Model is first because its failure is silent everywhere else — the design pass falls back to a curated team on a missing or bad credential, so an untested key produces a plausible company and the operator finds out several screens later, if at all. It is a gate, not a wall: skipping is a first-class answer.

For a local OpenAI-compatible provider, setup accepts the address local model apps normally display (localhost:6969), normalizes it to http://localhost:6969/v1, reads /models, and probes a concrete model id. That provider, normalized endpoint, model mapping, and optional write-only key are then persisted on the company the wizard creates. The roster-design pass uses the same tested provider before the company exists; a green test is not a temporary connection that setup forgets at Finish.

Sign-in comes before the address, and that is the point of it having a step. It used to be the first card inside Advanced — one screen after the wizard asked for an email address, under copy inviting the operator to press straight past the lot — so someone setting up on a laptop was asked for an address they need never have supplied, by a flow that already knew it might not want one. A question with a consequence does not sit behind "press on if none of it matters to you"; what stays in Advanced is settings that already work.

A skipped step gets no slot in the bar. Choosing none removes the address step outright rather than making it optional, and the progress bar renumbers with it — a five-step flow that says "step 3 of 6" is counting a screen that will never arrive. The console holds its position as a step id rather than an index for the same reason: a list that changes length behind the operator would otherwise silently mean a different screen.

Every field carries its layer

Resolution is env ⟵ config.toml ⟵ manifest ⟵ default (config.md), and this flow can only write the second layer. A hosted tenant has OPENCOMPANY_BIND, OPENCOMPANY_DATA_DIR and friends injected by the control plane, so a wizard that accepted an edit to bind there would write a file, report success, and change nothing at the next boot.

So each field reports its ConfigLayer and an editable flag, the console renders an env-owned field read-only with the reason, and the route refuses a write to one (409) rather than pretending. Silently ignored configuration is the failure mode this surface exists to prevent.

The layer attribution is not computed here: it is the same ConfigProvenance that opencompany doctor already reports (src/app/doctor.rs).

Applied, or only staged

Host-level fields are read once, at boot — bind binds a socket, [workspace] decides the data-dir lifecycle. Writing those is a staged change, and each says so through requires_restart.

auth_mode is deliberately not in that category, though it is also resolved at build and cached on the runtime (auth-modes.md). Choosing a sign-in mode and then being shown a sign-in form is the most confusing thing this flow could do, and "restart the host yourself" is not an answer on a first run. So the apply makes the mode live on AppState before it builds anything — the seeded company is built with it — and then rebuilds any company that was already registered, through runtime::rebuild_company. AppState::auth_mode_override exists for exactly this: AppConfig's field is the value boot resolved and can never change, and a rebuild reading that frozen value would re-apply the mode the process started with.

A host with no RuntimeRebuilder wired is the only case that still needs a restart for it, and it is reported per company rather than assumed either way. restart_required in the response names what is genuinely still pending — the console shows that answer rather than its own prediction, and its button is labelled "Open the console anyway" when something is outstanding, because that button re-enters the console and cannot restart a process.

Where a restart genuinely is needed, it has to actually work. serve therefore adopts the companies its data root already holds (desktop::adopt_companies). Before, only --company put a company in the registry, so an operator who finished setup, was told to restart, and did, came back to an empty host with their company sitting unread on disk. Adopting is not seeding: an empty root still starts empty. That registration path also applies the host-wide auth_mode override, which it previously ignored.

Per-company settings (inference, MCP servers, team) are not written here at all — they go through the existing ops routes, which apply live.

Writing config.toml

app::config::write_config_toml merges through toml_edit rather than serializing a ConfigFile. The shipped file carries commented [[default_mcp_server]] documentation that an operator is meant to read and uncomment, and a struct round-trip would delete it along with every other comment and any key ordering. Only the named keys are touched.

The write is atomic — rendered to config.toml.tmp in the same directory, then renamed — so a crash mid-write cannot leave a config the next boot refuses to parse. A malformed existing file is a hard error, matching ConfigFile::load: merging into a document that could not be parsed would overwrite whatever the operator actually had.

Clearing a field removes the key rather than writing "". An absent key falls through to the next layer; a blank string is a set-but-empty value that shadows it.

POST /api/v1/setup requires an application/json body: axum's Json extractor rejects anything else, so a plain HTML form cannot submit to this route the way a CSRF attack against a cookie-authenticated endpoint normally would. A cross-origin page can still ask the browser to send JSON, but the browser sends a CORS preflight first, and this host only answers one for an origin on the exact OPENCOMPANY_CORS_ORIGINS allowlist — unset, the default, answers none. That allowlist is this route's actual anti-forgery boundary; see config.md for how it's configured.

Who may call it

Unauthenticated access is loopback-only, and only in exactly two situations, both meaning "there is nobody who could authorize it":

  1. setup has never completed, or
  2. the host has no companies, so there is no roster to hold an admin.

"Loopback-only" here means the configured bind and public_url say this host is unreachable from outside and the request itself backs that up: its actual TCP peer is loopback and it carries none of the proxy-forwarding headers (X-Forwarded-For, X-Forwarded-Host, Forwarded, X-Real-IP) that would mean something in front of this process terminated a connection it did not. Both checks matter — a loopback-bound listener sitting behind an undeclared reverse proxy would otherwise pass the first and fail only the second.

Openness on a routable host would let whoever reached a fresh deployment first configure it; openness on a configured laptop would let any page in the browser rewrite its settings. The second condition is not a nicety either — setup can complete without seeding a company (an operator who only changes host settings), and gating that host behind an admin check would leave it with no company to sign in to and no way back into setup to create one, which is this flow's own dead end reintroduced one step later.

Outside those two unauthenticated cases — a configured host with exactly one company — the ordinary admin check applies instead: a live session belonging to an admin of the sole company, resolved the same way server/users/admin.rs resolves it. This path accepts an authenticated human session from anywhere, not just loopback; it is require_admin, not the loopback gate, that authorizes it.

Two states reach neither the anonymous branch nor require_admin, and both answer the same 409: a host serving several companies, which has no single roster that could speak for the instance, and a routable host with none, where the loopback condition above did not apply and there is no roster at all. registry().sole() is None for both, so the refusal names what is true of either rather than a count that might be wrong. The recovery is the one the response itself gives — edit config.toml directly and restart — because the alternative on a routable host is an unauthenticated write, which is exactly what the loopback gate exists to prevent.

The console's 401 handling is excluded while the wizard is showing: on an unconfigured host every authenticated route answers 401, and letting that swap the wizard for a sign-in form would restore the dead end this flow removes.

What it does not configure

ACP is a cargo feature (acp), not a setting. With the feature on, the host mounts the authenticated HTTP JSON-RPC transport at /acp (src/server/acp/); without it only the session and permission model plus the reserved path compile (src/server/routes.rs). The flow therefore reports acp_in_build and acp_transport_mounted separately and offers no switch, which is the difference between telling an operator "not available" and sending a client at an endpoint that 404s. The same reporting-not-writing rule covers mcp, openhuman and oauth.

data_dir is excluded too: a running host has already opened and locked its data root, so writing a new one into the file that lives inside that root would produce a config nothing reads. Moving a data root is a relocation, not a setting.