Skip to content

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):

OpPurposeCaller
credential.storeStore an OAuth token, API key, or local base URLUI, manual entry, OAuth callback handler
credential.getRetrieve full credential (raw secrets)(intended internal-only; lease/materialize is the public path)
credential.listList metadata for all providers (no secrets)UI for "what providers are connected"
credential.deleteRemove a credentialUI "Disconnect provider"
credential.refreshRefresh an OAuth token (today: partial impl)Background refresh task; emits factotum.credential-expired on failure
credential.import-detectedScan for known credential locations (~/.claude/.credentials.json, ~/.codex/auth.json, env vars)First-run UI
credential.importImport a previously detected credentialFirst-run UI after the user picks one
credential.validateTest a credential against the provider's APIPre-flight before store
credential.leaseIssue a lease (Phase B)Inference adapters
credential.materializeExchange 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

EventWhenSubscribers
factotum.credential-stored { provider }After successful credential.storeInferenceCatalog (to know a new provider exists)
factotum.credential-deleted { provider }After credential.deleteSame
factotum.credential-refreshed { provider, newExpiresAt }After successful refreshCaller 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 existsUI prompts the user to add a credential for this provider

State

StateSubjectPurpose
factotum.provider-status<provider, status, lastChecked>Last-known status per provider, updated on store/refresh/expire

Subscribes

EventSourceReaction
system.inference.$events (with provider + 401 error)inference actortrigger credential.refresh for that provider

Importer

CredentialImporter.detectAll() scans:

  1. ~/.claude/.credentials.json — Claude Code CLI OAuth credentials.
  2. ~/.codex/auth.json — Codex CLI OAuth credentials.
  3. ANTHROPIC_API_KEY, OPENAI_API_KEY env vars.
  4. OLLAMA_BASE_URL / OLLAMA_HOST env 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 tiny claude-haiku-4-5-20251001 message to https://api.anthropic.com/v1/messages with the appropriate auth header. 200 means valid.
  • For provider: 'openai' — GET https://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

Source: projects/matrix-3/packages/system-factotum/dist/index.js (full FactotumActor implementation).