Repository Public
1# @aos/server23Fastify server of Agent OS (agent.spboucher.ai): authentication, REST + events WebSocket under4`/api/v1`, static console, Postgres. Adapted from the Claude Remote server (auth, sessions, CSRF,5throttling, CSP, audit and janitor are unchanged in behaviour).67The server **never runs agent code**: it reads the kernel's tables with plain SQL and delegates8every write the kernel owns to `@aos/kernel` through a narrow port. It talks to the worker only9through Postgres. See `docs/CONTRACT.md` §1, §2, §8 (binding).1011## Layout1213| Path | Role |14|---|---|15| `src/app.ts` | `buildServer(config, {kernel, providers})`: the whole server as a factory |16| `src/main.ts` | entry point: config → `migrate` (kernel) → adapters → listen |17| `src/kernel-port.ts` | `KernelPort`: the six kernel functions the server calls |18| `src/kernel-adapter.ts` | the only file touching `@aos/kernel` (typed imports) |19| `src/providers-port.ts` / `providers-adapter.ts` | same pattern for `@aos/providers` (catalogue only) |20| `src/dto-auth.ts` | auth / notification / audit DTOs + branding (**the file the web app imports**) |21| `src/dto.ts` | server-local DTOs: `SystemStatus`, `UpdateProject`, `ArtifactDto`, `ModelInfoEx`, `SteeringMessage` |22| `src/events-hub.ts` | kernel event feed → WebSockets (replay, hand-over, slow consumers, notifications) |23| `src/paths.ts` | path safety under `AOS_WORKSPACE_ROOT` |24| `src/security/*`, `src/modules/auth` | password, TOTP, WebAuthn, sessions, CSRF/Origin guard, throttle, setup token |25| `src/modules/*` | projects, models, missions (+ runs, steps, artifacts), waits, policy, spend, system, events-ws, notifications, settings, openapi |26| `src/browser-view.ts` | strips the provider-native transcript from what the browser receives |27| `src/env-file.ts` | loader of the file named by `AOS_ENV_FILE` |28| `src/testkit/*` | harness, `fake-kernel.ts`, software passkey |2930## Environment3132| Variable | Default | Notes |33|---|---|---|34| `NODE_ENV` | development | `production` enforces the three requirements marked * |35| `AOS_PORT` / `AOS_HOST` | 8640 / 127.0.0.1 | |36| `AOS_PUBLIC_ORIGIN` * | http://localhost:8640 | exact origin; https in production. Cookie scope, Origin check, WebAuthn RP ID (= hostname) |37| `DATABASE_URL` * | postgres://localhost/agent_os_dev | |38| `AOS_SECRET_KEY` * | dev: generated into `$AOS_DATA_DIR/dev-secret-key` | 64 hex chars; AES-256-GCM key for TOTP secrets |39| `AOS_DATA_DIR` | `~/agent-os-data` | holds `setup-token` (0600) until the administrator exists; artifacts under `artifacts/` (kernel) |40| `AOS_WORKSPACE_ROOT` | `~/workspace` | projects live in `<root>/<rel_path>` |41| `AOS_TRUSTED_PROXIES` | `127.0.0.1,::1,10.67.0.1` | IPs/CIDRs whose `X-Forwarded-For` is honoured (tunnel gateway) |42| `AOS_WEB_DIR` | `../web/out` | static export; missing ⇒ API only |43| `AOS_LOG_LEVEL` | info | pino JSON |44| `AOS_ENV_FILE` | – | optional `KEY=VALUE` file loaded at start; never overrides the environment |45| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` | – | the server only records their **presence** (booleans) and masks their values in everything it sends or logs; it never calls a model |4647## API (`/api/v1`)4849Every route needs a full session (password + second factor) except the six public / pre-auth ones50(`GET /health`, `GET /auth/status`, `POST /auth/setup`, `POST /auth/login`, passkey login51options / verify). Mutations need `X-CSRF-Token` (from `GET /auth/status`) and `Origin` =52`AOS_PUBLIC_ORIGIN`. Errors are `{error:{code,message,details?}}`. Creations answer 201.5354| Area | Endpoints |55|---|---|56| health | `GET /health` → `{ok, version, agent:'aos'}` · `GET /system/status` → `SystemStatus` (`dto.ts`) · `GET /system/audit` |57| auth | unchanged from Claude Remote; `GET /auth/status` adds `branding:{agent:'aos', productName:'Agent OS', agentName:'Agent'}` |58| projects | `GET/POST /projects` · `GET/PATCH/DELETE /projects/:id` |59| models | `GET /models` → `ModelInfo[]` (+ optional `stale`, `note`) |60| missions | `GET /missions?status=&projectId=&limit=` · `POST /missions` · `GET /missions/:id` · `POST /missions/:id/cancel` · `GET /missions/:id/runs` |61| runs | `GET /runs/:id` · `GET /runs/:id/steps?after=&limit=` · `POST /runs/:id/message` `{text}` · `GET /runs/:id/artifacts` · `GET /artifacts/:sha256?name=` |62| waits | `GET /waits?status=&kind=&missionId=` · `POST /waits/:id/resolve` |63| policy | `GET/POST /policy/rules` · `DELETE /policy/rules/:id` |64| spend | `GET /spend` → `SpendSummary` |65| misc | `GET/PUT /settings` (`AosSettings`) · `GET /notifications` · `POST /notifications/read` · `GET /openapi.json` |66| events | WS `/events?cursor=<lastEventId>&missionId=<optional>` |6768Behaviour worth knowing:6970- **`POST /missions`** — the project must belong to the caller and its directory must exist71 (`409 workspace_missing`); provider / model / effort / budget default to the owner's72 `AosSettings`; the model must be in the provider catalogue (`400 unknown_model`, never a silent73 substitute) and the provider key configured (`409 provider_not_configured`); refused with74 **`409 daily_cap_reached`** when `spend_days` for `CURRENT_DATE` ≥ `dailyBudgetUsd`.75 `workspace_abs` = `realpath(AOS_WORKSPACE_ROOT/rel_path)`. The kernel builds the root76 `DelegationContract`; **done criteria may be empty** and are passed through as an empty list77 (`contract.doneCriteria = []`, `missions.done_criteria = []`) — that is the signal for the engine78 to propose criteria first. No placeholder criterion is used.79- **Steps** — `request`, `result` and `error` are each bounded to 20 000 characters. The JSON80 structure is kept (long strings are cut with a `…[truncated N chars]` marker, surplus array81 items / object keys are dropped with a marker) and the step carries `truncated: true`.82 `idem_key`, `request_hash`, leases, fencing tokens and `workspace_abs` are never sent.83- **Artifacts** — reachable only through a run of a mission the caller owns. Always84 `Content-Type: application/octet-stream`, `Content-Disposition: attachment`, `nosniff`,85 `Content-Security-Policy: default-src 'none'; sandbox`: nothing renders inline.86- **Waits** — default listing: pending approvals and questions, oldest first (`kind=all` includes87 timers / child joins). Resolving goes through the kernel; a wait that is no longer pending is88 `409 wait_not_pending`. `remember: true` is passed in the resolution (approve only): the89 **engine** turns it into an allow rule; the server inserts no rule.90- **Projects** — `create` = mkdir + `git init`; `existing` = register a directory; `clone` =91 `git clone` (whitelist `https://`, `ssh://`, `git@host:path`, `gitsrv:path`; argv array, no92 shell; minimal environment with `GIT_TERMINAL_PROMPT=0` and no API key; 10 min timeout; the93 partial directory is removed on failure). The clone runs inside the request. Paths are clean94 relative paths under `AOS_WORKSPACE_ROOT`; the realpath of every existing component must stay95 inside the root (symlink escapes ⇒ `400 path_outside_root`); `.git` / `.aos` segments are96 refused. Credentials embedded in a remote URL are stripped before it is stored or shown.97 `DELETE` unregisters only (refused with `409 project_busy` while a mission is live).98- **Models** — live listing per provider when its key is configured, cached 10 min (1 min after a99 failure), 10 s timeout. A provider without a key, or whose listing fails, is answered from the100 static catalogue with `available:false, stale:true, note`.101- **System status** — `db {ok, latencyMs}`, `worker {alive, lastSeenAt, source, workers[]}` (table102 `workers` first, a row < 60 s old; otherwise the lease heartbeat of a running run;103 `alive: null` when nothing can tell), `providers {anthropic, openai}` as booleans, counts.104- **Spend** — `byModel` covers the last 7 days, from the `usage` of `model_call` steps.105106### Events WebSocket107108Same authentication as every other route (cookie + second factor, Origin checked at upgrade;109query validated before the upgrade). Server → browser only, one JSON `AosEvent` per text frame.1101111. With `cursor`, persisted events with `id > cursor` are replayed from Postgres (owner-scoped,112 optionally one mission), at most 5 000. If more are missing, the first 5 000 are followed by113 `system.notice {code:'gap', message:'gap, refetch', latestId}`. Without `cursor`: live only.1142. Live events from the kernel's `listenEvents` are buffered during the replay and flushed after115 it; ids are monotonic per socket, so the hand-over neither loses nor repeats an event.1163. Transient `model.delta` events are forwarded with `id: -1` and never stored.1174. A consumer with more than 1 MiB buffered is dropped (close 4429, then terminated).1185. When the auth session ends (logout, revocation, expiry — re-checked every 60 s) the socket119 receives `system.notice {code:'auth.expired'}` and is closed with **4401**.120121Notices use `id: -1` and `missionId: ''`. `wait.created` (approval / question) and a terminal122`mission.status` also create an in-app notification (title only; mission title when123`notifications.detail = 'titles'`).124125## Security model in one page126127- Route auth levels (`config.auth`, default `full`): `public` (GET /health only), `preauth`128 (auth/status, setup, login, passkey login), `restricted` (valid session, second factor129 pending: status, logout, TOTP step, enrolment), `full`. One global `onRequest` guard130 (`src/security/guard.ts`) enforces them, also for WebSocket upgrades. `http.test.ts` iterates131 the live route table: every route answers 401 without a session unless it is on the documented132 list, and every mutating route refuses a missing CSRF token or a foreign Origin.133- Sessions: random 32-byte cookie token (`__Host-aos_sid`; `aos_sid` only outside production on134 http://localhost), sha256 stored, idle 12 h, absolute 30 d, 15 min before the second factor,135 rotated on upgrade. Pre-auth passkey challenge cookie: `__Host-aos_pre`.136- Request rate limits (per minute, `config.rateLimit`): credential checks share a strict per-IP137 bucket (20); anonymous `GET /auth/status` per IP (240); `GET /health` per IP (600); everything138 else per session cookie (600).139- Login throttling in `login_attempts`: user 5, IP 10, global 100 failures / 15 min; lock 60 s140 doubling per extra failure (cap 15 min).141- CSP on every response; `script-src` never contains `unsafe-inline` (per-document sha256 hashes142 of the static export's inline scripts).143- **Secrets**: the model API keys are read by `@aos/providers` / the worker, not used here. The144 server keeps only booleans, and a scrubber replaces any occurrence of the key values with145 `[redacted]` in every API response body and header, every WebSocket frame and every log line146 (step bodies are scrubbed before truncation). Artifact *content* is streamed as stored.147 `leak.test.ts` pollutes the journal with the keys and greps every GET route of the route table.148- Owner scoping: every read joins on `owner_id`; another owner's rows are 404.149- Audit on every mutation; never objectives, answers, steering text or URLs with credentials.150151## Kernel and providers integration152153`KernelPort` (`createMission`, `requestCancel`, `resolveWait`, `postOwnerMessage`, `listenEvents`,154`openArtifact`) is implemented by `kernel-adapter.ts` with ordinary typed imports of `@aos/kernel`155(a workspace dependency): a signature change in the kernel is a compile error here. The adapter creates156its own kernel handle (`createKernel({databaseUrl, dataDir})`) and passes it to every call. Migrations157belong to the kernel: `main.ts` calls its `migrate(databaseUrl)` at startup, and the test harness uses158the same function. The server inserts no event itself (notices sent to a socket are never persisted).159`providers-adapter.ts` does the same for `@aos/providers` (catalogue and live model listing only).160161`src/kernel-adapter.test.ts` runs in the default suite: the adapters against the real kernel and162providers (vitest resolves both packages to their sources, `vitest.config.ts`; no model call): migrate,163create mission, live events, steering, wait resolution, artifact download, cancel.164165`browser-view.ts` removes the provider-native transcript (`native`) from `model_call` step results and166from `step.completed` events before anything is sent to the browser.167168`main.ts` first loads the file named by `AOS_ENV_FILE`, if set (`env-file.ts`: `KEY=VALUE` lines, never169overriding a variable already in the environment) — this is how production gets `DATABASE_URL`,170`AOS_SECRET_KEY` and the presence of the provider keys without putting them in the PM2 environment.171172## Run173174```bash175pnpm --filter @aos/shared build && pnpm --filter @aos/kernel build && pnpm --filter @aos/providers build176pnpm --filter @aos/server build177node dist/main.js # migrates (kernel), then listens on AOS_PORT178```179180First run: read the one-time token on the host (`cat $AOS_DATA_DIR/setup-token`; it is never181logged), open the site, create the administrator, then enrol TOTP or a passkey. A second factor182is mandatory: until one exists the session can only reach the enrolment endpoints.183184## Tests185186```bash187pnpm --filter @aos/server test # needs local Postgres with createdb/dropdb on PATH, and git188```189190Each test file creates its own database `aos_test_<random>`, applies `db/migrations` and drops it191afterwards (also when the harness fails to start). The kernel is `src/testkit/fake-kernel.ts`:192SQL-backed, it writes the rows the real kernel writes for the six port functions and plays the193worker through `seed*` / `emit*` fixtures. Migrations are applied by the kernel's `migrate`. The model catalogue in tests is a194labelled fixture (`FIXTURE_CATALOGUE`). `src/testkit/soft-authenticator.ts` is a software passkey195(ES256, real signatures).196