Appearance
Factotum / secrets service
system.factotum is the one actor on a Device that reads or writes provider credential files. Per CLAUDE.md Rule 4, no other code path opens <matrix-home>/credentials.json. Other actors use the lease/materialize discipline.
On-disk layout
Source: projects/matrix-3/packages/system-factotum/dist/index.js, CredentialFileStore at lines 6-100.
<matrix-home>/
└── credentials.json (mode 0o600 typically; chmod is best-effort)
│
└── credentials.json.bak (backup of last successful write)
└── credentials.json.tmp (atomic-write temp file)The file shape:
json
{
"providers": {
"anthropic": {
"type": "oauth",
"accessToken": "...",
"refreshToken": "...",
"expiresAt": 1717200000000,
"scopes": ["..."],
"source": "oauth"
},
"codex": { "type": "oauth", "accessToken": "...", "source": "import-codex" },
"openai": { "type": "api-key", "apiKey": "...", "source": "manual" },
"ollama": { "type": "local", "baseUrl": "http://localhost:11434", "source": "env" }
}
}credential.list returns metadata (provider, type, hasToken, expiresAt, source), never the secret values. Secret retrieval is via the lease/materialize pattern.
Atomic writes
CredentialFileStore.write():
ts
write(data) {
// 1. mkdir -p the parent dir
// 2. cp credentials.json -> credentials.json.bak (best-effort)
// 3. write credentials.json.tmp
// 4. rename credentials.json.tmp -> credentials.json (atomic on POSIX)
}Atomic rename guarantees readers see either the old or new file, never a half-written one. The .bak file is restored on read if the primary file is corrupt.
Op surface
FactotumActor.accepts (system-factotum/dist/index.js:222-340):
| Op | Purpose | Caller |
|---|---|---|
credential.store | Store an OAuth token, API key, or local base URL | UI, manual entry, OAuth callback handler |
credential.get | Retrieve full credential (raw secrets) | (intended internal-only; lease/materialize is the public path) |
credential.list | List metadata for all providers (no secrets) | UI for "what providers are connected" |
credential.delete | Remove a credential | UI "Disconnect provider" |
credential.refresh | Refresh an OAuth token (today: partial impl) | Background refresh task; emits factotum.credential-expired on failure |
credential.import-detected | Scan for known credential locations (~/.claude/.credentials.json, ~/.codex/auth.json, env vars) | First-run UI |
credential.import | Import a previously detected credential | First-run UI after the user picks one |
credential.validate | Test a credential against the provider's API | Pre-flight before store |
credential.lease | Issue a lease (Phase B) | Inference adapters |
credential.materialize | Exchange lease → headers (Phase B) | Trusted system actors |
The "Phase B" lease/materialize ops are the canonical caller path. The older credential.get is intended for system-internal use only.
Events
| Event | When | Subscribers |
|---|---|---|
factotum.credential-stored { provider } | After successful credential.store | InferenceCatalog (to know a new provider exists) |
factotum.credential-deleted { provider } | After credential.delete | Same |
factotum.credential-refreshed { provider, newExpiresAt } | After successful refresh | Caller hooks |
factotum.credential-expired { provider } | When refresh fails (token revoked) | UI prompts user to reconnect |
factotum.needkey { provider, requestedBy } | When credential.lease fails because no credential exists | UI prompts the user to add a credential for this provider |
State
| State | Subject | Purpose |
|---|---|---|
factotum.provider-status | <provider, status, lastChecked> | Last-known status per provider, updated on store/refresh/expire |
Subscribes
| Event | Source | Reaction |
|---|---|---|
system.inference.$events (with provider + 401 error) | inference actor | trigger credential.refresh for that provider |
Importer
CredentialImporter.detectAll() scans:
~/.claude/.credentials.json— Claude Code CLI OAuth credentials.~/.codex/auth.json— Codex CLI OAuth credentials.ANTHROPIC_API_KEY,OPENAI_API_KEYenv vars.OLLAMA_BASE_URL/OLLAMA_HOSTenv vars.
Returns a list of { source, provider, description, hasToken } descriptors WITHOUT importing. The user chooses which to import; then credential.import({ source, provider }) actually copies the credential into Factotum's store.
This is the "first-run" UX path: a user already has Claude Code or Codex set up; Factotum can pick up their OAuth credentials without the user re-running OAuth.
Validation
credential.validate({ provider, type, apiKey?, accessToken? }):
- For
provider: 'anthropic'— POST a tinyclaude-haiku-4-5-20251001message tohttps://api.anthropic.com/v1/messageswith the appropriate auth header. 200 means valid. - For
provider: 'openai'— GEThttps://api.openai.com/v1/models. 200 means valid. - For others — returns
{ ok: true }(no validation; treat as valid).
Use this BEFORE credential.store to avoid storing dead keys.
Idempotency
credential.store accepts an idempotencyKey. The actor caches recent results by key (max 100 entries, GC at 10 min) and replays the same result for repeated stores with the same key. This is a per-CLAUDE.md Rule 9 requirement (every write op accepts optional idempotencyKey).
Cross-Device behaviour
Factotum's store is per-Device. Credentials do NOT replicate across a principal's Devices. Each Device must add credentials independently. This is the right behaviour given the no-raw-OAuth-tokens-on-local-host rule: replicating provider tokens across Devices via the cloud would violate it. Cross-Device sync, if it ever happens, must use end-to-end encryption with keys that never visit the cloud.
See also
- Credential references — lease/materialize.
- Secret rotation — refresh runbook.
- No raw OAuth tokens on local host — the boundary rule.
- Authentication / OAuth providers — provider-by-provider detail.
Source:
projects/matrix-3/packages/system-factotum/dist/index.js(full FactotumActor implementation).