Vault
How mikan stores credentials in the state directory and injects env or file mounts by sandbox mode.
State directory and vault location
Section titled “State directory and vault location”The default state directory is:
~/.mikan/Important contents include:
~/.mikan/├── settings.json├── conversations/│ └── <office-key>/│ └── settings.json└── vaults/ ├── <office-key>/ # one conversation's credentials ├── shared/<name>/ # shared login profiles └── extensions/<slug>/ # extension secrets (host-side only)You can also specify it with --state-dir:
mikan --state-dir=/secure/mikan-state --sandbox=container:mikan-tools /path/to/workspaceCredentials are then stored in:
/secure/mikan-state/vaults/The global settings file is at <state-dir>/settings.json. Conversation overrides are host-only at <state-dir>/conversations/<office-key>/settings.json. A legacy <working-directory>/<conversationId>/settings.json is migrated once, then ignored — conversation directories are mounted read-write into the sandbox, so settings deliberately live outside them.
At startup, mikan refuses a --state-dir that is world-writable or not owned by the current user. Newly created state/vault directories and credential files use private modes, but an existing group/world-readable state directory is not automatically tightened; use chmod 0700 <state-dir>.
Vault contents
Section titled “Vault contents”Each vault is a directory under vaults/ and may contain:
envfile: environment variables inKEY=valueform- file credentials: for example
gws.json,.ssh/config
mikan infers mount targets from file names/paths — gws.json → /root/.config/gws/credentials.json, gcloud-adc.json → /root/.config/gcloud/application_default_credentials.json, .ssh/ → /root/.ssh, .kube/ → /root/.kube, .config/gh/ → /root/.config/gh — and anything else defaults to /root/<relative-path>. The target is derived from the file name every time the vault is resolved; it is not stored as metadata, so renaming a credential file changes where it lands. The built-in OAuth flows pick names that already infer to the right place and set the matching env var (for example GOOGLE_APPLICATION_CREDENTIALS) to it.
In image mode these are bind mounts and are writable from inside the sandbox, so tools may update them — keep backups for credentials whose mutation would matter. In gondolin:default the files are copied into the guest with owner-only permissions and are not written back, so a guest-side edit is discarded when the runtime is recreated.
Example:
~/.mikan/vaults/└── v1-slack-c0123456789-1a2b3c4d5e6f7a8b/ ├── env └── gws.jsonenv example:
GH_TOKEN=ghp_xxxGITHUB_OAUTH_ACCESS_TOKEN=gho_xxxWhat reaches the sandbox
Section titled “What reaches the sandbox”Vault material is not one undifferentiated class of secret:
- A conversation’s own vault is meant to reach the guest. Its
enventries become environment variables for tool commands, and its credential files are projected to their target paths (by default under/root). That is the whole point: the agent runsgh,gcloud, orsshas the person who logged in. - The vault directory itself is never bulk-mounted. Only the individual credential files that a resolved vault declares are projected, one mount per file, and only for the conversation whose key resolved.
- Daemon tokens never reach the guest. Platform bot tokens (
SLACK_BOT_TOKEN, the GitHub App private key, and friends) are read by the mikan host process and are not part of any vault injection. - Extension secrets never reach the guest.
vaults/extensions/<slug>/envis read host-side through the extension API; it is not a user vault and is not mounted or injected.
This is a data boundary, not an execution boundary. Anything the conversation’s own credentials can do, its agent can do — scope the credentials you store accordingly.
Sandbox behavior
Section titled “Sandbox behavior”| Sandbox mode | Vault env injection | File credentials | Vault key |
|---|---|---|---|
host | not injected | refused | derived from the platform user |
container:<name> | injected | refused | derived from the container name |
image:<image> | injected | projected (bind mounts) | the office key |
gondolin:default | injected | projected (copied in) | the office key |
firecracker:* | injected | refused | the office key |
cloudflare:* | injected | refused | the office key |
Refused means the run fails, not that the file is quietly ignored. A vault whose directory holds
any file other than env resolves to a file mount, and a mode that cannot mount files raises
Sandbox type "<type>" does not support vault file mounts instead of running with an incomplete
credential set. So on those modes, keep credentials in env only — a stray gws.json left in the
vault from an earlier image deployment will stop the conversation from running.
The office key is derived by hashing the platform name together with the platform’s raw conversation id, so two platforms that share a raw id cannot resolve each other’s credentials. Conversation vault directories created under the older raw-id scheme are renamed to office keys by the boot-time migration; a conflict (both directories present) stops boot for manual merge instead of picking one.
Shared vaults
Section titled “Shared vaults”sandbox.defaultSharedVault names a profile under vaults/shared/ that is copied into a new conversation’s vault on first use. That ambient copy only happens for membership-gated platforms (Slack, Discord, Telegram) on the isolated image and cloudflare topologies. Open-trigger surfaces such as GitHub never inherit it — an admin can still provision a vault for a specific GitHub conversation explicitly.
/pi-login
Section titled “/pi-login”In a DM / private message, run:
/pi-login/login is accepted as the same command; Slack registers the /pi- spelling.
mikan creates a 15-minute onboarding link. In the web page, users can store:
- arbitrary API keys / env vars
- GitHub OAuth credentials
- Google Cloud SDK OAuth credentials
- Google Workspace CLI OAuth credentials
The command only works in DMs / private messages so other people in shared channels cannot obtain a credential onboarding link. The link is a bearer capability and is consumed once a credential write or OAuth callback completes — see Portal auth and capability model.
Enable the link server
Section titled “Enable the link server”For production deployments, set the public URL:
export LINK_URL="https://mikan.example.com"If LINK_PORT is not set, mikan defaults to port 8181 when LINK_URL exists.
You can also set it explicitly:
export LINK_PORT=8181For local testing, you can set only:
export LINK_PORT=8181The /login link will then use:
http://localhost:8181OAuth callback URL is:
<LINK_URL>/oauth/callback