Imported from amirtuval/openharness (
apps/server/AGENTS.md). Install upstream withnpx skills add amirtuval/openharness --skill server. Copyright stays with the author.
@openharness/server
The runnable openharness server: the HTTP API the protocol describes, the SSE stream over a
session's event log, sign-in (Better Auth), and the scheduler that runs brains against it.
Hono app served by @hono/node-server; the store is Postgres when there is a DATABASE_URL
and in-memory when there is not.
# Three variables are required: see "Authentication" below and `.env.example` at the root.
BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
OPENHARNESS_SECRETS_KEY=$(openssl rand -base64 32) \
BETTER_AUTH_URL=http://localhost:3000 OPENHARNESS_DEV_LOGIN=1 \
DATABASE_URL=postgres://localhost/openharness yarn dev # http://localhost:3000
Commands
Run from this folder (apps/server):
| command | what it does |
|---|---|
yarn build |
builds src/ to dist/ with tsdown (.js + .d.ts) |
yarn build:deps |
builds only this package's workspace dependencies (turbo filter ^...) |
yarn dev |
rebuilds on change and restarts the server (http://localhost:3000) |
yarn typecheck |
tsc --noEmit |
yarn lint |
ESLint over this folder |
yarn format |
Prettier --write |
yarn format:check |
Prettier --check |
yarn test |
Vitest, single run |
yarn build:deps matters when you work in isolation: it builds this package's workspace
dependencies (from the repo root's installed node_modules) without touching the rest of the
repo.
Routes
Everything under API_VERSION_PREFIX (/v1). Bodies and queries are validated by the
protocol's schemas, so the shapes are not repeated here — see
packages/protocol/AGENTS.md.
| method | path | body / query | answers |
|---|---|---|---|
GET |
/health |
— | liveness: { status: 'ok' }; never needs a session |
GET |
/ready |
— | readiness (#151): { status: 'ok' }, or 503 while draining or when the store does not answer; never needs a session |
GET |
/v1/auth-config |
— | { providers, dev_login }; never needs a session |
GET |
/v1/me |
— | the signed-in User |
GET |
/v1/me/preferences |
— | the caller's UserPreferences plus the defaults its nulls mean (#282), unwrapped |
PUT |
/v1/me/preferences |
PutPreferencesRequestSchema |
the stored preferences, merged; 400 for a malformed id or an out-of-range number |
GET |
/v1/me/tools |
ListToolSettingsQuerySchema (mode_id) |
{ data: ToolSettingEntry[] } — the effective settings, as a chat on mode_id would see them: this process's tools, then the in-force MCP servers' (#307; #312) |
PUT |
/v1/me/tools |
PutToolSettingsRequestSchema |
the stored settings, merged per tool; 400 for a permission or shape the schema refuses |
POST |
/v1/me/modes |
CreateModeRequestSchema |
201, the Mode; 409 for a duplicate name (per user) or the twentieth-plus-one mode (#245, M6) |
GET |
/v1/me/modes |
— | { data: Mode[] }, the caller's own; no pagination (a user holds at most 20) |
GET |
/v1/me/modes/{mode_id} |
— | the Mode, or 404 for another user's or an unknown id |
POST |
/v1/me/modes/{mode_id} |
UpdateModeRequestSchema |
the updated Mode, or 404; 409 for a rename onto a name the caller has |
DELETE |
/v1/me/modes/{mode_id} |
— | 204; lands the chats that followed the mode on the model they last ran; 404 for another user's |
POST |
/v1/agents |
CreateAgentRequestSchema |
201, the Agent |
GET |
/v1/agents |
ListAgentsQuerySchema |
{ data, next_page } |
GET |
/v1/agents/{agent_id} |
— | the Agent, or 404 |
POST |
/v1/agents/{agent_id} |
UpdateAgentRequestSchema |
the updated Agent, or 404 |
POST |
/v1/sessions |
CreateSessionRequestSchema |
201, the Session; 404 for an unknown agent or mode; 400 for none of an agent, a model or a mode; 422 for a mode whose model cannot be used. Takes interactive (epic #340, S1); the tree fields are stripped (#341) |
GET |
/v1/sessions |
ListSessionsQuerySchema |
{ data, next_page } — the caller's top-level chats, or ?parent_session_id= one session's children (epic #340, S3; #341) |
GET |
/v1/sessions/{session_id} |
— | the Session, or 404 |
DELETE |
/v1/sessions/{session_id} |
— | 204; hard delete (U5) — the session and its descendants (epic #340, S7; #341); 404 for another owner's or an unknown session |
POST |
/v1/sessions/{session_id}/events |
SendEventsRequestSchema |
{ data: user event[] }; then signals, and a title; 409 for a rewind while running, 400 for a batch whose rewind is not its only first event (#238) or for a user.tool_confirmation naming a call that is not waiting (#309) |
GET |
/v1/sessions/{session_id}/events |
ListEventsQuerySchema |
{ data, next_page } |
GET |
/v1/sessions/{session_id}/events/stream |
StreamEventsQuerySchema |
the SSE stream; 404 for an unknown session |
POST |
/v1/sessions/{session_id}/compact |
CompactSessionRequestSchema |
{ data: session.compact } — stores a manual-compaction request, or returns the one already pending; 400 for over-long instructions, 404 for another owner's (#283) |
POST |
/v1/sessions/{session_id}/ai-sdk/chat |
the AI SDK useChat request (see below) |
an AI SDK UI message stream — an extension |
GET |
/v1/models |
ListModelsQuerySchema (refresh) |
{ data, providers }; 429 for a refresh inside the minute |
GET |
/v1/sessions/{session_id}/usage |
— | what one session spent: totals, cost and the per-model breakdown; 404 for another owner's |
GET |
/v1/me/usage |
UserUsageQuerySchema (from, to, tz) |
the caller's own usage: totals, cost, by model and by day; 400 for a zone or range it cannot read |
PUT |
/v1/provider-credentials/{name} |
PutProviderCredentialRequestSchema |
the credential's metadata; 422 if it is refused, 400 for a name its type may not take |
GET |
/v1/provider-credentials |
— | { data: ProviderCredential[] }, metadata only |
DELETE |
/v1/provider-credentials/{name} |
— | 204; never an error for one that is not there |
POST |
/v1/me/mcp_servers |
CreateMcpServerRequestSchema |
201, the McpServer; the auth type is detected from the URL (#314); 409 for a duplicate name or the twenty-first server (#303, X10); 400 for a URL the guard refuses; 422 never (a check that fails stores status: error) |
GET |
/v1/me/mcp_servers |
— | { data: McpServer[] }, metadata only; no pagination (a user holds at most 20) |
GET |
/v1/me/mcp_servers/{mcp_server_id} |
— | the McpServer, or 404 |
POST |
/v1/me/mcp_servers/{mcp_server_id} |
UpdateMcpServerRequestSchema |
the updated McpServer, or 404; headers replaces the sealed map, omitted keeps it |
DELETE |
/v1/me/mcp_servers/{mcp_server_id} |
— | 204; deletes the server and its pending OAuth states |
POST |
/v1/me/mcp_servers/{mcp_server_id}/test |
— | the refreshed McpServer after the connection check; 400 for a refused URL, 404 for another owner's |
POST |
/v1/me/mcp_servers/{mcp_server_id}/connect |
ConnectMcpServerRequestSchema (client) |
{ authorization_url }; 422 mcp_connection_error for a discovery/registration failure, 400 for a non-OAuth server |
POST |
/v1/me/mcp_servers/{mcp_server_id}/disconnect |
— | the McpServer with its tokens dropped, on needs_reconnect |
GET |
/v1/me/mcp_servers/oauth/callback |
code, state |
the provider's redirect back — no session; 302 to the app (web) or an HTML page (CLI), 400 with a page for a bad, expired or misused state |
Every /v1 route except auth-config and the MCP OAuth callback requires a session (see
"Authentication" and "Remote MCP servers"), and every resource is scoped to its owner. The two
exceptions are registered ahead of the guard, in app.ts, and are the exact method and path:
GET /v1/auth-config is read before sign-in (#62), and GET /v1/me/mcp_servers/oauth/callback
is a browser navigation that authenticates by its state (#311). /api/auth/* is Better Auth's
own surface: sign-in, sign-out, the device flow, /api/auth/error. Anything else answers 404 in
the protocol's error envelope.
POST …/events is the only way user input enters the system, and it does two things in a
fixed order: it stores the events (processed_at: null, which is what makes them queued)
and only then tells the scheduler. The store call is what makes the request durable; the
signal is a latency optimization the scheduler can afford to lose (see "Signals are hints" in
packages/session).
The body carries the user's own events, and one instruction that is not one: a
session.rewind (#238), "edit and resend". It names the user.message the reader edited,
travels with the replaced message in the same batch — so the two are one append, and a
rewind the log cannot honour stores neither — and the server writes the event (session.* is
the session's domain: it is not queued and never claimed, unlike user.*). The append records
the range it replaces, from that message through the end of the log as it stood; the response
carries the stored user events alone, and a client reads the rewind back from the log or
the stream. A rewind is accepted only while the session is idle: anything else — running,
and unfinished too, since the next brain to take the partition over will run the inherited
turn — is the 409 conflict_error requireIdleSession raises. The store refuses a brain
that wins the race anyway, because a claim naming a superseded message is not a claim the log
accepts, so that turn ends at its next write instead of appending into the range. A from_seq
that names no user.message this session still shows is the store's RangeError, which the
app maps to the 400 invalid_request_error every bad-argument refusal gets. The batch carries
at most one rewind, and only as its first event: SendEventsRequestSchema refuses
[user.message, session.rewind] and [session.rewind, session.rewind] in parseBody, before
the route reads anything, with the same 400 and nothing stored — a message the rewind's range
would swallow is a message no turn would answer.
Creating a session with initial_events goes through the same rules: the protocol says those
events are stored "before it starts running", so a user.message among them signals work and
a user.interrupt signals interrupt — exactly what the same events would do posted to
POST …/events afterwards.
POST …/compact is a second client-requested event (epic #277, K8; #283), routes/compact.ts.
/compact [instructions] — from the web composer or oh — asks the brain to summarize the older
history now. The route is the scoped read (another user's session is the 404 an id nothing has
gets), then one owner-scoped read of the newest of the pair of compaction events, then the
append and the signal. That read is what makes the route idempotent while a request is
pending: a session.compact there is one nobody has answered yet, and the route returns it
rather than appending a second; anything else — a session.compaction, or nothing — means a new
request is due. The optional instructions (at most COMPACT_INSTRUCTIONS_MAX_LENGTH, 2000
characters) are refused as the protocol's 400 if too long, before anything is stored. The request
is a session.* event written by the server, not a queued user event, so it is the log — around
the request boundary the brain already reads — that the brain picks it up from; a turn that is
running folds it in at its next request, and an idle session's work signal starts a turn that
answers it and makes no model reply (runner.ts passes compaction to every turn, which is
where the manual read lives). Its outcome — session.compaction, summarized |
nothing_to_summarize | failed — is the clear, stored answer a client shows; the summary
itself is a session.context_summary with reason manual.
Environment variables
| variable | default | what it does |
|---|---|---|
DATABASE_URL |
— | run on Postgres, migrating on boot; unset means in-memory |
SCHEDULER |
local |
local, or postgres for the multi-instance scheduler |
BETTER_AUTH_SECRET |
— (required) | signs sessions and cookies |
BETTER_AUTH_URL |
— (required) | the public URL: Better Auth's base, the one trusted origin (CSRF) |
OPENHARNESS_SECRETS_KEY |
— (required under local) |
base64 32-byte master key the vault seals credentials with; not needed under gcp-kms |
OPENHARNESS_KEY_PROVIDER |
local |
local (the environment key) or gcp-kms (Cloud KMS): who wraps the vault's data keys (#150) |
OPENHARNESS_KMS_KEY |
— (required under gcp-kms) |
the Cloud KMS projects/…/cryptoKeys/… key; unused under local |
OPENHARNESS_KEY_CACHE_TTL_MS |
300000 |
how long unwrapped data keys stay cached in memory; 0 disables the cache |
OPENHARNESS_DEV_LOGIN |
off | 1 enables the local dev login; localhost URLs only (A7); the way in when no provider is set |
GOOGLE_CLIENT_ID/_SECRET |
— | enable Google sign-in (both, or neither; one provider or the dev login is required) |
GITHUB_CLIENT_ID/_SECRET |
— | enable GitHub sign-in |
MICROSOFT_CLIENT_ID/_SECRET |
— | enable Microsoft sign-in |
MICROSOFT_TENANT_ID |
common |
the Entra tenant the Microsoft provider authenticates against |
PORT |
3000 |
the port to listen on |
OPENHARNESS_TEST_MODEL |
— | mock swaps in the deterministic test model |
OPENHARNESS_WEB_DIR |
— | a built web app to serve at / |
OPENHARNESS_ALLOW_PRIVATE_PROVIDER_URLS |
off | 1 lets a custom OpenAI-compatible credential or a remote MCP server reach a private/loopback address (epic #245, M4; #249; #303, X10); never Azure; off in staging and production |
OPENHARNESS_TRUSTED_PROXY_HOPS |
0 |
how many proxies append to x-forwarded-for; 0 trusts no forwarding header (#151, see below) |
OPENHARNESS_CORS_ORIGINS |
— | comma-separated origins to allow; unset means no CORS headers |
OPENHARNESS_MAX_CONCURRENT_SESSIONS |
4 |
how many sessions may be running at once |
OPENHARNESS_DRAIN_TIMEOUT_MS |
5000 |
how long shutdown waits for a turn in flight |
OPENHARNESS_INSTANCE_ID |
hostname + pid + random suffix | this instance's id in the lease table |
OPENHARNESS_PARTITIONS |
64 (the protocol's) |
how many partitions the session space has |
OPENHARNESS_LEASE_TTL_MS |
30000 |
how long a partition lease lasts before it must be renewed |
OPENHARNESS_HEARTBEAT_MS |
10000 |
how often leases are renewed and free partitions taken |
OPENHARNESS_SWEEP_MS |
60000 |
how often owned partitions are re-scanned for missed work |
OPENHARNESS_DELTA_RETENTION_MS |
3600000 |
how long superseded chunks are kept before compaction deletes them |
OPENHARNESS_COMPACT_INTERVAL_MS |
300000 |
how often the compaction job runs; 0 disables it |
OPENHARNESS_COMPACTION_THRESHOLD |
0.7 |
the share of the chat model's context budget at which older history is summarized (epic #277, K2; #279), and the default a user who has not chosen one follows (C3, #282); a fraction in 0..1 |
OPENHARNESS_CLEAR_THRESHOLD |
0.5 |
the soft share of the chat model's budget at which old tool results are cleared before anything is summarized (#331), below OPENHARNESS_COMPACTION_THRESHOLD; a fraction in 0..1, and the default needs no setting |
OPENHARNESS_TODO_NUDGE |
off | 1 nudges the model to write a todo list before its first tool call (#331); off by default, because the benefit is small and a line on every session's first request is not free |
OPENHARNESS_MAX_TOOL_STEPS |
50 (the brain's) |
the most model requests one turn may make (epic #303, X2); past it the turn ends with a tool_steps_exhausted_error notice and goes idle, and a value below 1 fails the boot |
OPENHARNESS_MAX_AGENT_DEPTH |
3 (the protocol's) |
how deep a tree of helpers may get (epic #340, S6; #344): a session at this depth is not offered the tools that start another one, so a top-level chat (depth 0) may nest two levels below it; a value below 1, or above MAX_DESCENDANT_DEPTH (64, the ceiling every tree walk stops at, #361), fails the boot |
OPENHARNESS_MAX_LIVE_TASKS |
10 (the protocol's) |
how many of one root chat's live descendants there may be (S6; #344) before an agent call is answered with an is_error the model acts on; a value below 1 fails the boot |
OPENHARNESS_LOG_FORMAT |
text |
text (readable) or json (Cloud Logging): what stdout carries (#158) |
OPENHARNESS_TRACING |
off |
off, or cloud-trace to export spans to Cloud Trace (#158) |
OPENHARNESS_TRACE_SAMPLE_RATE |
0.1 |
the fraction of root traces kept when tracing is on; 0 keeps none, 1 keeps all (#158) |
GOOGLE_CLOUD_PROJECT |
— | the project a JSON log line's trace id is qualified with; Cloud Logging resolves a bare id (#158) |
<NAME>_FILE |
— | for any secret above: read the value from this path instead of <NAME> (#154, see below) |
Every secret in that table — DATABASE_URL, BETTER_AUTH_SECRET,
OPENHARNESS_SECRETS_KEY, and each provider's *_CLIENT_SECRET — can be delivered as a file
instead, by setting <NAME>_FILE to a path that holds the value (#154). That is how the Helm
chart mounts them from Secret Manager: the container gets
DATABASE_URL_FILE=/var/run/secrets/openharness/database-url, not DATABASE_URL. The file's
value has one trailing newline removed, so printf '…' and echo '…' are the same
secret. Setting both <NAME> and <NAME>_FILE fails the boot (they could disagree); so does a
file that cannot be read, with a message naming the variable and the path and never the
content. The rule that an empty value is unset holds here too: an empty file is an unset
setting. Local dev and docker compose are unaffected — with no _FILE variable, nothing reads
a file. OPENHARNESS_KMS_KEY is a resource name, not a secret, and stays inline.
On GKE the DATABASE_URL the chart mounts names 127.0.0.1, not a database host: the
Cloud SQL Auth Proxy runs as a sidecar in the app's own pod and makes the TLS connection to
the instance itself (#159), so the server's sslmode=disable there is about a loopback hop
and not about the connection that leaves the pod. See
docs/DEPLOYMENT.md.
Nothing in this package changes because of it — readServerConfig treats the URL as opaque.
Provider credentials (ANTHROPIC_API_KEY, OPENAI_API_KEY, …) are not read at all
(epic #65, A5), and the server keeps none of its own: every model request is made with the
credential the session-bound resolver answered — the owner's stored, sealed key, opened per
request (credentials.ts). A session whose owner has no key for the model's provider ends the
turn with the brain's missing_provider_credential session.error; the environment is never
a fallback. The mock kind resolves a placeholder the deterministic model ignores.
The vault's key provider (#150, deployment epic #148 decision D6) is
OPENHARNESS_KEY_PROVIDER. local — the default — wraps each credential's data key with
OPENHARNESS_SECRETS_KEY, so dev, docker compose and CI are unchanged; gcp-kms wraps it
with a Cloud KMS key (OPENHARNESS_KMS_KEY), authenticated by Application Default
Credentials — Workload Identity on GKE — and keeps no key material in the environment at all.
main.ts builds the one vault every credential path shares from the configuration
(createConfigVault), and the Cloud KMS client is loaded on the first wrap or unwrap: a
local server never loads @google-cloud/kms. Unwrapped data keys are cached in memory for
OPENHARNESS_KEY_CACHE_TTL_MS (0 disables) so a model request does not always make a KMS
round trip; the cache is bounded, nothing else is cached — never a plaintext, never the
master key — and a cached key is zeroed when it expires or is evicted. Each stored row
records which provider wrapped it, so a secret is refused by a provider that did not wrap it
with an error naming both — switching providers means re-saving credentials.
A variable that is set but empty counts as unset. A value that cannot be what it claims — a
PORT that is not a port, an OPENHARNESS_TEST_MODEL that is not mock — fails the boot
with a message naming the variable, rather than coming up in a state nobody asked for. The
boot also needs a way to sign in: with no *_CLIENT_ID/*_SECRET pair configured and
OPENHARNESS_DEV_LOGIN off, readServerConfig refuses to start — every route is behind a
session nobody could create — and says which variables to set.
The store. With DATABASE_URL, main.ts builds a pool, runs migrate() (idempotent, so
two instances starting together are safe) and hands the pool to createPostgresSessionStore.
Without it, the server runs on InMemorySessionStore and says so loudly at startup: that mode
is for local development and quick trials, and nothing survives a restart.
Authentication and ownership (epic #65)
Better Auth (1.7.6 — one version for the whole repo, apps/web included) is mounted at
/api/auth/* in this app (app.ts), configured in
auth.ts and run against the same database the log uses — the Kysely handle main.ts already
built for the migrations, or Better Auth's in-memory adapter when there is no DATABASE_URL.
Its own migrator is never called: the tables (user, session, account, verification,
deviceCode) are created by @openharness/session's migrations 0011–0013, generated from
exactly this plugin list — core + google/github/microsoft + device-authorization +
bearer — so the configuration and the schema have to keep matching; a Postgres test asserts
Better Auth's own schema check passes on the migrated database.
- Providers are enabled only when their
*_CLIENT_ID/*_SECRETare set — both, or neither; with both empty (i.e. unset) the provider has no button — and/v1/auth-configreports the list (the interface agreed with the web app, #62). At least one provider, or the dev login, has to be configured: with neither the boot fails, naming the variables to set, because nobody could ever sign in. - Identity is the verified email (A3), enforced by
auth-profile.ts: Google'semail_verified, GitHub's primary verified address, and Microsoft's claims (personal accounts,xms_edov, the verified lists) — the nOAuth guard. The two boolean-shaped flags (email_verified,xms_edov) are read throughaffirmativeClaim: Microsoft documentsxms_edovas a Boolean but a token carries it as a string ("1"/"0"), sotrue,1, and (trimmed, case-insensitively)"true"/"1"are affirmative and everything else —false,0,"0","false", empty, any other string, absent — is not. A personal Microsoft account is vouched for byxms_edovalone (theverified_*lists are an Entra work/school thing), which the app registration must request as an optional ID claim alongsideemail(docs/DEPLOYMENT.md). A provider that cannot prove the address refuses the sign-in (email_not_verified, 403) before a user or a link is created;databaseHooks.user.create.before(refuseUnverifiedUser) is the last gate, and implicit linking follows the same email with no trusted-provider shortcut. A Microsoft refusal also logs oneWARNING(MICROSOFT_REFUSAL_LOG, via the loggercreateAuthpasses intoproviderOptions): the decoded token's claim names (sorted), thetidandissvalues, the type of each claim the rule reads (absent/boolean/string/array(n)), whether the lowercasedemailis in either verified list, andhasEmail— plus the one claim value that is a flag and never personal data,xmsEdovValue: the rawxms_edovvalue when it is a boolean, a number, or a string of at most eight characters, and'<omitted>'otherwise (an absent claim included). No address, name or token ever reaches the line, which is what tells the "the optional claim is configured in the portal" case (a name present, a type that is notabsent) from the "Microsoft never sent it" one. - Sessions are opaque tokens in the database: 7 days, sliding at most once a day, and
fresh (created within a day) for credential writes. The device-authorization plugin
accepts the CLI's
openharness-cliclient id and approves at the web app's hash route<BETTER_AUTH_URL>/#/device—verification_uriis exactly that, andverification_uri_completecarries?user_code=…inside the fragment, where the app's router reads it (auth.tsrewrites the field Better Auth built, whose query lands before the#; see the after-hook there). Its codes live ten minutes. Rate limiting is on, with counters owned by each instance. /v1needs a session (cookie or bearer), else 401:auth-guard.tsresolves it through Better Auth and putsuser/sessionon the request. A cookie-authenticated write also needs anOriginofBETTER_AUTH_URL's origin — CSRF, because a browser attaches cookies to any page's request; bearer requests are exempt, since a page cannot set that header cross-origin./v1/auth-configis registered ahead of the guard;/api/auth/*is Better Auth's own.- Better Auth's own origin check is on outside a test process.
/api/auth/*refuses a sign-in POST whoseOriginis nottrustedOrigins' entry — the public URL — and a cookieless one that carries Fetch-Metadata headers (Node'sfetchsendssec-fetch-mode: cors) with noOriginat all (MISSING_OR_NULL_ORIGIN, 403). Better Auth skips the whole check whenNODE_ENV=test— which is what vitest sets — soAuthConfig.enforceOriginCheck(tests only; nothing sets it in production, and nothing anywhere turns the check off) forces it on forauth.test.ts, and the e2e suite runs its servers withNODE_ENV=productionfor the same reason (#79). - Revocation is immediate, and reaches open responses (A2; issue #76). The guard validates
once per request, and an SSE stream is one long request — so the stream and the AI SDK
adapter watch their own session.
session-watch.tsholds both halves:createSessionRevocationskeys every open response by the session id (session.id, never the token) and is the app'sRouteDeps.revocations;startSessionRecheckre-validates the session with Better Auth (auth.api.getSessionon the caller's own headers — the row exists and has not expired) everyDEFAULT_SESSION_RECHECK_MS(15 s, the "at most 30 s" backstop), which is what closes a stream for an expired session and for a notification that was missed. A revocation is published on the store's revocation channel (notifyAuthSessionRevoked) by two sources: Better Auth'sdatabaseHooks.session.delete.after— sign-out, beareroh logout,revoke-other-sessions, every Better Auth deletion — wired inauth.ts/main.ts, and on Postgres a trigger on"session"(packages/sessionmigration0014), which is what catches an operator's plaindelete. Postgres announces withNOTIFY, so every instance closes its streams whichever instance handled the revocation; the in-memory store does the same in-process. Payloads and logs carry the session id only. - Ownership (A4): routes create with
c.get('user').idand read through the store's required owner scope (getAgent(…, { ownerId }),listAgents({ ownerId }), …); another user's resource is a 404. The brain and the scheduler use the explicitly unscoped methods.apps/server/src/isolation.test.tssweeps every/v1route with a second user. - Provider credentials (A5) live in
credentials.ts: aPUTvalidates the credential with one cheap call (provider-validation.ts, injectable so tests never hit a network), seals the request body with@openharness/vaultunder AADuserId|name, and stores it throughCredentialStore.createSessionCredentialResolver— the resolver the runner hands the brain, bound to the session — looks up the session's owner (unscoped read: a turn acts for a session), opens the sealed row for the one request, and answers the brain's(provider) => …question. Nothing caches a plaintext; nothing echoes one. Both credential writes also maintain the caller's automatic default model (epic #116, U4) — see "Preferences and the automatic default" — andGET/PUT /v1/me/preferencesare that default's settings screen. - Dev login (A7):
OPENHARNESS_DEV_LOGIN=1seedsdev@localhost/dev(DEV_LOGIN_EMAIL). Better Auth's email validation refuses a dotless domain, so the row is stored asdev@localhost.localdomainand a dev-login-only shim (rewriteDevLoginRequest) maps the documented spelling onto it; sign-up stays disabled, so those are the only password credentials that exist. The boot refuses the flag unlessBETTER_AUTH_URLis localhost.
The model catalogue (epic #92)
GET /v1/models answers the chat models the caller's own provider credentials can use,
one entry per model and one status per provider — the list the clients' model pickers offer
(#91/#92, model-first chat), and the context windows the per-model context budget resolves
against (#246, below). catalog/ is the whole of it; the route (routes/models.ts) only
parses the query and maps the one error it can raise.
-
C1 — the list comes from the provider, with the caller's key. Per provider the server calls that provider's own list endpoint with the credential stored for the caller, decrypted for that call only (
openApiKey, the same vault path the brain's resolver uses). The endpoints are a fixed table incatalog/adapters.ts— OpenAIGET /v1/models, AnthropicGET /v1/models(x-api-key+anthropic-version), GeminiGET /v1beta/models(the key in thex-goog-api-keyheader, never the URL), OpenRouterGET /api/v1/models, and the OpenAI-compatible family (GET <base>/models, bearer) for Groq, DeepSeek, Fireworks, Mistral, Together, xAI and Cerebras. Every URL is a constant of that module: no request ever supplies a URL, so there is no SSRF surface. (The one exception is a custom OpenAI-compatible credential'sbase_url, which is the user's; it never goes through this table —openAICompatibleCatalogcalls it throughsafeFetch, the guard built for exactly that case, and only through it.) Each call has a 5-second deadline (AbortSignal.timeout), shared by all pages of one provider. The catalogue asks for a page size of 1000 and follows Anthropic'shas_more/last_idand Gemini'snextPageToken; OpenRouter and the OpenAI-compatible family answer in one page. A provider that pages forever stops atMAX_PAGES. The two URL-less named types have calls of their own rather than entries in that table, because neither has a URL a user typed nor a bearer key: anazure_openaicredential contributes the deployments the user named (nothing is fetched), and abedrockone contributes what two SigV4-signed control-plane reads answer in its own region —ListFoundationModelsandListInferenceProfiles(#274), the host coming from the region, which the protocol's list validated — so "no request ever supplies a URL" holds for all three. -
C2 — the registry join and the filter.
catalog/registry.tsreadsapps/server/src/catalog/models-dev.json, a snapshot of models.dev committed to this package and bundled intodist/index.js— never read from the network, and never from a path that could be missing at runtime. The exact rule, per provider-listed model:- An explicit non-chat verdict drops it — the registry's classification, or the
provider's own capability data (Gemini's
supportedGenerationMethodswithoutgenerateContent). - An explicit chat verdict keeps it — the registry's classification, OpenRouter's
chat-only catalogue, Gemini with
generateContent. - Otherwise the conservative name filter decides (
catalog/filter.ts): an id naming a known non-chat family is dropped, every other id is kept. The families areembed,tts,whisper,transcri*,speech,dall-e/dalle,gpt-image,image,imagen,moderation,realtime,audio,search,rerank,sora,babbage/davinci, andinstruct— word-like patterns, sogpt-4o-search-previewgoes ando3-deep-researchstays. The principle is asymmetric on purpose: an unfamiliar id is kept, because hiding a usable chat model is the failure the epic is about.
The price comes from the snapshot alone (
coston aModelEntry, #247): no provider's list-models payload carries what it charges. models.dev publishes rates per million tokens, and the refresh script keeps the ones it has —inputandoutputfor every priced model, the two cache rates where they exist. It is what a client prices a reply with, so a model the snapshot does not price reportscost: nulland its requests keep their tokens and report no money. The name, context window and max output of an entry come from the provider's own payload where it has them (Gemini'sdisplayName/inputTokenLimit/outputTokenLimit, OpenRouter'sname/context_length/top_provider.max_completion_tokens), from the snapshot where it has them, and from the model id otherwise;nullis a legitimate value for the two limits. What the snapshot actually carries — the model's ownname,limit.context,limit.output, its price, its reasoning data (reasoning, and the effort levels models.dev lists for the model's own knob — #252's follow-up) and whether it can call tools (tool_call, kept only where it isfalse— epic #303, X2), for the 11 providers whose keys this server can validate; it is keyed by our provider ids, sofireworks/togetherare models.dev'sfireworks-ai/togetheraimapped at generation time. It carries no chat flag, and could not: models.dev has none, and the fields it does have are not one —modalities.outputis["text"]fortext-embedding-3-smalltoo, andfamilyis a name family. So step 3 is what classifies, and its principle is unchanged.RegistryModelstill carriesname/contextWindow/maxOutput/chat, so a registry that finds a real chat signal is a one-place change; the tests inject a stub with all four to pin the join itself. The limits the snapshot supplies are what fill thenulls the provider's own list leaves for OpenAI, Anthropic and the rest (model-catalog.test.tspins the OpenAI and Anthropic entries end to end), andtoolCallis what an entry reports astool_call—falsefor the models models.dev marks unable to call tools,truefor everything else, including a model the registry does not know (the never-hide rule again).Regenerate it with
yarn workspace @openharness/server catalog:refresh(scripts/refresh-models-dev.mjs), which fetches https://models.dev/api.json, maps the provider keys and writes the file; thenyarn formatit, which the script's plainJSON.stringifyoutput needs for the per-model effort arrays. The snapshot's date is in the file (SNAPSHOT_DATE). The providers and their models.dev keys are no longer restated there: since #245 the script readsPROVIDERSfrom@openharness/protocol's build output — the same list the validating and model-list tables are keyed by — so build the workspace (yarn buildat the root, oryarn build:depshere) before refreshing. It is a source file like any other — nothing fetches at build, test or boot time, and a sandbox with no network still builds. The data is only as fresh as a commit. - An explicit non-chat verdict drops it — the registry's classification, or the
provider's own capability data (Gemini's
-
C3 — fallback is visible, never silent. A provider that times out (5 s), fails (non-2xx, an unreadable body, a transport error), cannot have its credential opened, or has no adapter at all is answered from the registry instead:
status: "fallback",fetched_at: null, and amessagesaying why — the provider's status and a bounded snippet of what it said, or the plain reason. The registry's own list goes through the same C2 filter (it lists embeddings too), and its entries carrysource: "registry". -
C4 — the cache.
catalog/cache.tsholds one entry per (user, provider) in this process for an hour (CatalogCache,DEFAULT_CATALOG_TTL_MS), nothing in Postgres. Saving or deleting a credential callsModelCatalog.invalidate(userId, provider)from the PUT/DELETE routes — this instance only; other instances expire by TTL.refresh=truebypasses the cache for every provider the caller has a key for and is rate-limited to once a minute per user (RefreshLimiter): inside the window the request is the protocol's 429rate_limit_error, raised asCatalogRefreshLimitedErrorinside the catalogue and mapped in the route. -
C5 — only providers with a key are listed. The providers come from
credentials.list({ userId }); a caller with none gets{ data: [], providers: [] }and not one outbound request. Another user's keys are never read: the owner isc.get('user').id, always, andmodel-catalog.test.tshas the case — two callers, one key each, every recorded provider request carrying the right one. -
Security. The key is opened per provider call and lives for that call; nothing caches a plaintext. Anything the provider said is scrubbed with
redactSecret(the brain's redaction: the whole key, minus four leading or trailing characters) before it reaches amessageor a log line.provider-validation.tsgained the family's providers (Mistral, Together, xAI, Cerebras) so a key that can be stored can also be listed. Since #245 both tables — the validating requests and the model-list adapters — areRecord<ProviderId, …>over the protocol's provider list, so "every validatable provider has an adapter" is a compile error rather than an assertion. -
The egress proxy. Provider calls go through
catalog/provider-fetch.ts: Node'sfetchover undici'sEnvHttpProxyAgent, which readsHTTP_PROXY/HTTPS_PROXY/NO_PROXY— the documented egress-proxy variables (e2e/AGENTS.md) — so a deployment behind a proxy works without also rememberingNODE_USE_ENV_PROXY=1, which plainfetchwould need. With no proxy configured it is an ordinary direct connection.provider-validation.ts(the call a saved key is checked with) goes through the same client: one outbound path for the whole server.ProviderFetchis the one seam the tests replace: no test reaches a provider, and the harness's default catalogue is inert (an empty registry and a fetch that refuses).
The context budget, per model (#246)
Every model request is trimmed to a history budget, and the budget is the request's model's
— not one number for every model, and not the session's. Before #246 the brain trimmed
everything to DEFAULT_CONTEXT_TOKEN_BUDGET (32,768): a 200k-token model forgot a long chat
early, and a model smaller than the default could be handed more than it can take. The rule is
budget = contextWindow − min(maxOutput, 25% of contextWindow)
— what is subtracted is room for the reply: the model's own output ceiling when the registry has
one, and 25% of the window when it does not. The numbers come from the bundled models.dev
registry (catalog/registry.ts, the same snapshot the catalogue joins for its model picker
Truncated - read the full file at https://github.com/amirtuval/openharness/blob/d3a27981374df0b78dff963ea00d75e9cbb4251b/apps/server/AGENTS.md.
