Sandboxes run in isolated containers that cannot access the host's credential stores (e.g., macOS Keychain, Linux secret-service). This page explains how forage-ctl bridges that gap for each authentication method.
There are three ways to authenticate agents in sandboxes, in order of preference:
| Method | Scope | Token lifetime | Best for |
|---|---|---|---|
| Long-lived token | Claude-specific | 1 year | Production use, long-running sandboxes |
| Keychain passthrough | Claude-specific | ~8 hours | Quick experiments, no setup needed |
| Secret files | Any agent | Indefinite | API key auth (Anthropic API, OpenAI, etc.) |
For Claude Code with a Max/Pro subscription (OAuth authentication), generate a long-lived token that forage-ctl stores and injects into every sandbox.
-
Generate a token (opens browser for OAuth):
claude setup-token
-
Copy the displayed token and store it:
forage-ctl claude token store <token>
-
Verify:
forage-ctl claude token status
That's it. All future sandboxes with a Claude agent will automatically pick up this token.
forage-ctl claude token store <token>
│
▼
<stateDir>/tokens/claude-oauth.json ← token + creation time + expiry
│
│ (on forage-ctl up)
▼
CLAUDE_CODE_OAUTH_TOKEN env var ← injected into container
│
▼
Claude Code reads env var ← authenticates without keychain
The token file is stored at <stateDir>/tokens/claude-oauth.json (typically /var/lib/firefly-forage/tokens/claude-oauth.json) with mode 0600. It contains:
{
"token": "sk-ant-...",
"createdAt": "2026-03-19T21:00:00Z",
"expiresAt": "2027-03-19T21:00:00Z"
}- Valid: token is used silently, no output.
- Expiring (< 30 days remaining): token is used but forage-ctl prints a warning during
forage-ctl upsuggesting renewal. - Expired: forage-ctl falls back to keychain passthrough and warns. Renew with
claude setup-token+forage-ctl claude token store. - Missing: same behavior as expired — keychain fallback with instructions.
forage-ctl claude token store <token> # Store a new token
forage-ctl claude token status # Show token state and expiry
forage-ctl claude token remove # Delete stored tokenWhen using a long-lived token, the template only needs hostConfigDir — no secrets or API key env vars:
services.firefly-forage.templates.claude = {
description = "Claude Code sandbox";
network = "full";
agents.claude = {
package = pkgs.claude-code;
hostConfigDir = "~/.claude";
};
};hostConfigDir mounts the host ~/.claude directory into the container. This provides Claude Code with its configuration, project history, and settings. The OAuth token is injected separately via CLAUDE_CODE_OAUTH_TOKEN, not through the mounted directory.
When no long-lived token is stored, forage-ctl automatically reads the OAuth access token from the host's credential store and injects it. This requires no setup but has limitations.
On macOS, Claude Code stores OAuth credentials in the login keychain under the service name Claude Code-credentials. At sandbox creation time, forage-ctl:
- Reads the keychain entry via
security find-generic-password - Parses the JSON to extract the access token
- Checks the token hasn't expired
- Injects it as
CLAUDE_CODE_OAUTH_TOKEN
- macOS only — Linux keychain support is not yet implemented.
- Short-lived — access tokens expire in ~8 hours. A token extracted at sandbox creation may expire during a long session.
- No refresh — once injected, the token cannot be refreshed inside the container. When it expires, Claude Code will report authentication errors.
- Requires active session — the host must have a valid Claude Code login (i.e., you've used
claudeon the host recently).
The keychain passthrough is a convenience for quick experiments. For anything beyond that, use a long-lived token.
With -v, forage-ctl logs which token source was used:
level=DEBUG msg="using stored long-lived Claude OAuth token"
or:
level=DEBUG msg="read OAuth token from keychain" expiresIn=7h25m0s
level=DEBUG msg="using short-lived OAuth token from host keychain ..."
For agents that authenticate via API keys (not OAuth), use the secrets mechanism. This works for any agent type — Claude with an API key, OpenAI, or custom agents.
Define secrets as a mapping from names to file paths:
services.firefly-forage = {
secrets = {
anthropic = config.sops.secrets.anthropic-api-key.path;
openai = "/run/secrets/openai-api-key";
};
templates.claude = {
agents.claude = {
package = pkgs.claude-code;
secretName = "anthropic";
authEnvVar = "ANTHROPIC_API_KEY";
};
};
};- The Nix module validates that each agent's
secretNameexists in the top-levelsecretsmap. - At sandbox creation, forage-ctl copies the secret file into a per-sandbox directory under
<secretsDir>/<sandbox-name>/. - The secret directory is bind-mounted read-only into the container at
/run/secrets/. - The agent's wrapper script reads the file and exports it as the specified
authEnvVar.
Secrets should come from a proper secret manager, not plain files:
# sops-nix (recommended)
secrets.anthropic = config.sops.secrets.anthropic-api-key.path;
# agenix
secrets.anthropic = config.age.secrets.anthropic-api-key.path;When a Claude agent is configured with hostConfigDir (OAuth flow) and no secretName, forage-ctl resolves the token in this order:
- Token store —
<stateDir>/tokens/claude-oauth.json. If valid, use it. - Token store (expiring) — if the stored token has < 30 days remaining, use it but warn.
- Token store (expired) — skip, warn, fall through.
- Host keychain — extract the short-lived access token from the macOS Keychain.
- No token — warn with setup instructions. The sandbox is created but Claude Code will report "Not logged in".
When secretName is set, the secret file path is used directly and none of the above applies.
Check which token source forage-ctl is using:
forage-ctl up mysandbox --template=claude --repo=. -v 2>&1 | grep -i "oauth\|token\|keychain"Common causes:
- No long-lived token and no keychain entry: log in on the host with
claude auth login, then either use the keychain passthrough or generate a long-lived token. - Expired keychain token: run
claudeon the host to trigger a refresh, then recreate the sandbox. - Expired long-lived token:
forage-ctl claude token statuswill confirm. Regenerate withclaude setup-token.
forage-ctl exec mysandbox -- claude auth statusExpected output for a working setup:
{
"loggedIn": true,
"authMethod": "oauth_token",
"apiProvider": "firstParty"
}Verify the env var is set:
forage-ctl exec mysandbox -- printenv CLAUDE_CODE_OAUTH_TOKENIf empty, check forage-ctl up -v output for token resolution messages.