Architecture (user-level)
How agent-locksmith composes at runtime — the user-level mental model of what happens when you start the daemon and send it a request.
The daemon
locksmithd runs one process with two concurrent surfaces:
┌─────────────────────────────────────────┐
│ │
│ Agent listener (TCP) │
agent ──HTTPS──┤ :9200 (default) │
bearer/mtls │ • /api/{tool}/{*path} proxy hot path │
│ • /tools, /models discovery │
│ • /skill personalised │
│ • /livez, /readyz health probes │
│ │
│ Admin listener (UDS) │
operator ──UDS─┤ /var/run/locksmith/admin.sock │
bearer │ • /admin/operator/{agents, bootstrap_tokens, tools, models, infra, oauth, audit}
│ • /admin/agent/{status, rotate, register, deregister, tools}
│ │
│ Optional: admin HTTPS (:9201) │
│ │
└─────────────────────────────────────────┘
Both listeners share one process, one SQLite pool, one audit fanout.
The agent's request flow
When an agent calls POST /api/anthropic/v1/messages with
Authorization: Bearer lk_...:
1. axum routes the request to proxy::proxy_handler
↓
2. auth_middleware validates the bearer against AgentRepository.
Stamps AgentIdentity into request extensions.
Failure → 401 invalid_credential
↓
3. ACL gate: identity.allows_tool(name)
Failure → 403 tool_not_allowed + audit row
↓
4. Target resolution:
state.catalog.lookup_active(name) — registrations table cache
Failure → 404 unknown tool + audit row
↓
5. OAuth resolution (only if AuthSpec is OAuth):
oauth_runtime.sessions.get(name, label)
If degraded / missing → 503 oauth_refresh_failed + audit
If access token expiring → trigger inline refresh
↓
6. Header strip: agent's Authorization, x-api-key, host
plus the target's own auth header (defense-in-depth).
↓
7. Credential injection (per AuthSpec):
None → nothing.
Header → "<header>: <resolved_creds[name]>"
Bearer → "Authorization: Bearer <resolved_creds[name]>"
OAuth → "Authorization: Bearer <oauth_session.access_token>"
↓
8. Egress route:
egress: proxied → HTTP CONNECT through pipelock
egress: direct → straight to upstream
↓
9. Stream the response back to the agent.
↓
10. Apply response controls if configured:
max_size_bytes, content_type_allowlist, redaction_patterns.
↓
11. Emit one AuditEvent → SQLite + (optional) JSONL mirror.
Steps 1–11 happen per request. The audit row is the operator's single source of truth for who-called-what-when.
Composition root: daemon::run
src/daemon.rs::run is the only place where state gets wired up. Read it
first if you're contributing code. It does (in order):
- Parse + validate
AppConfig. - Construct
Arc<ArcSwap<AppConfig>>so config can hot-reload. - Resolve
tool.auth.value: SecretReffor legacy config.tools entries. - Build admin substrate (only when
listen.admin_socketis set):- Open SQLite pool, run migrations.
- Construct repositories (AgentRepository, BootstrapTokenRepository, AuditRepository, RegistrationRepository, OauthSessionRepository).
- Run the seed loader — populate registrations from
/etc/locksmith/seed/catalog.yaml. - Run legacy_bootstrap — migrate any pre-Phase-E
config.toolsentries into the registrations table. - Build the in-memory
Catalogcache from the registrations table. - Resolve registration env vars into the resolved_creds map.
- Build OAuth runtime (when
LOCKSMITH_OAUTH_SEALING_KEYis set). - Construct
AdminService+BearerAuthenticator+OperatorAuthenticator.
- Spawn the audit retention sweeper.
- Bind agent listener (TCP for bearer, TLS for mTLS).
- Bind admin UDS listener.
- Optionally bind admin HTTPS listener.
- Optionally bind bootstrap-only listener.
- Wait for SIGTERM/SIGINT; shut down both listeners within the drain window.
State that survives restarts
Anything that needs to outlive locksmithd lives in the SQLite DB:
locksmith.db
├── agents — per-agent identity + ACL + revocation
├── bootstrap_tokens — pre-issued enrollment tokens
├── audit — every request + admin write
├── registrations — kind-discriminated catalog
├── registrations_meta — seed catalog version pin
└── oauth_sessions — sealed OAuth tokens
The audit table can also mirror to a JSONL file (rotating, size-capped) so the audit log survives volume recreation.
What's transient (in-memory only)
resolved_creds— built at startup from env vars + sealed files. Refreshed when an admin write touches a registration.Catalog— in-memory mirror of the registrations table. Refreshed by admin writes.OauthRuntime— sealing key + session repo + refresh lock map + shared HTTP client.ResponseControlscache — compiled regex patterns per tool.ClientPool— cachedreqwest::Clientper (name, timeouts, egress).
Auth surfaces are independent
Three auth surfaces, each picks its own mode:
| Surface | Modes | Notes |
|---|---|---|
| Agent listener | bearer | mtls | both | listen.auth_mode |
| Admin UDS | bearer only | OS peer-uid is the gate |
| Admin HTTPS (optional) | bearer | mtls | both | listen.admin_https.auth_mode |
You can run agents on bearer + admin HTTPS on mTLS, or vice versa. The settings are independent.
See also
- Getting started — first-contact.
- CLI reference — every subcommand.
- Layer8 Proxy architecture — where locksmith sits in the broader stack (broker / ACL / egress model).