Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/live-container-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"fiber": minor
---

Signing in again now reaches a running containerised MCP server.

Under ToolHive the server took its credentials from `FIBER_SECRETS`, read once
at startup, so a container held whatever was true when it began: you signed in,
the keychain got the new token, and the server went on presenting the expired
one until someone re-exported the secrets and replaced the workload.

Credentials can now travel through the collections directory the container
already mounts. The app rewrites that file whenever a credential changes, the
server re-reads it, and the 401 retry that was already there picks the new value
up — no re-export, no restart.

The file is sealed with XChaCha20-Poly1305 and the key stays out of the mount:
in the keychain on the app's side, in ToolHive's encrypted store on the
container's. Its existence is the opt-in, so a desktop-only install never has
credentials on disk. New: `fiber mcp file-key` and `fiber mcp export-secrets
--to <path>`; `scripts/toolhive.sh` wires both up for you.

Bearer collections needed a second fix to benefit: a static token cannot be
refreshed by replaying a request, so a 401 never dropped it, and a zero-TTL
cache entry has nothing else to expire it — a container would have presented
the token it started with for the life of the workload. A rejected credential
is now dropped from the cache whenever it came from a source that can change
underneath the process, so the next call reads the new one. The desktop app is
unaffected: it has no such source, and the same line there would have cost a
keychain prompt per 401.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ ARG TARGETARCH
# --no-default-features drops Tauri entirely (see Cargo.toml [features]) — and
# with it the Linux keychain, whose D-Bus backend needs libdbus here and a
# session bus at runtime, neither of which a container has. Secrets come from
# FIBER_SECRETS instead. The musl target links the CRT statically by default, so
# FIBER_SECRETS, or from the FIBER_SECRETS_FILE the app keeps current, instead. The musl target links the CRT statically by default, so
# the result needs no libc.
#
# The binary is copied to a fixed path because the target triple is not known to
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,10 @@ in memory from then on.
The single exception is `fiber mcp export-secrets`, which exists so that a
containerised copy can be given the credentials it cannot fetch itself — see
[`deploy/toolhive.md`](deploy/toolhive.md). It covers only the collections you
have shared over MCP, it writes to a pipe and refuses a terminal, and you have
to run it deliberately.
have shared over MCP, it writes to a pipe or an encrypted file and refuses a
terminal, and you have to run it deliberately. Once you have set that up, the
app keeps the file current as you sign in, so a container stops going stale;
without it, nothing is ever written to disk.

A header you type on a request beats the collection's auth — for "just this
once, use a different token". `Cookie` is the exception, and has to be: cookies
Expand Down
108 changes: 75 additions & 33 deletions deploy/toolhive.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ rather than the desktop app's own collections, name it:
curl -fsSL .../toolhive.sh | bash -s -- ~/work/api-collections
```

The script finds your collections directory, moves the credentials for the
collections you have shared into ToolHive's secret store, and starts the server.
Rerunning it replaces the workload and refreshes the credentials, which is what
you want after signing in again.
The script finds your collections directory, sets up the credentials for the
collections you have shared, and starts the server. Signing in again in Fiber
reaches the running server on its own — see [Credentials](#credentials) — so
rerunning this is for changing what you serve, not for refreshing a token.

The image is published by the release workflow for `linux/amd64` and
`linux/arm64`, so there is nothing to build and nothing to push. ToolHive pulls
Expand Down Expand Up @@ -64,6 +64,9 @@ section files. Two things a section needs to be usable over MCP:
- for authenticated sections, a `secretRef` — the app writes `"<sectionId>:auth"`.
That exact string is the key you provide below.

`/data` is also where the credentials file lives when the app is keeping one
current, which is why that half needs no separate mount.

`/data` should be **writable and persistent**: loader caches, request history and
spilled response bodies are written there, and `query_response` reads a stored
body back, so it needs to survive between tool calls. The image runs as the
Expand All @@ -81,58 +84,97 @@ image.

The desktop app keeps secrets in the OS keychain and the section file holds only
a reference. A container can reach neither, so the headless build takes them
from the environment instead: `FIBER_SECRETS` is a JSON object of
`reference → value`, and `FIBER_SECRETS_FILE` is a path to a file holding the
same. Both are unset in the desktop app, which still uses only the keychain.
from the environment instead. There are two ways in, and they differ in one
thing that matters a lot in practice: whether signing in again reaches a server
that is already running.

### The file the app keeps current (what the script sets up)

Building that map by hand is the one genuinely tedious part, so the app will
write it for you:
`FIBER_SECRETS_FILE` points at a file of `reference → value`, and the server
re-reads it whenever it needs a credential. Put that file in the directory you
already mount and the desktop app will keep it up to date as you work: sign in
again, and the next tool call picks the new token up. No re-export, no restart.

That mount is the only channel the two halves share — the app cannot write to
ToolHive's secret store, and the container cannot read the keychain — so the
file is encrypted rather than plain, with `FIBER_SECRETS_KEY`. The key stays out
of the mount: in the keychain on the app's side, in ToolHive's encrypted store
on the container's. A copy of the file on its own is inert, and a tampered one
fails to open rather than decrypting to something else.

```sh
/Applications/Fiber.app/Contents/MacOS/fiber mcp export-secrets |
thv secret set fiber-secrets
/Applications/Fiber.app/Contents/MacOS/fiber mcp file-key | thv secret set fiber-key
/Applications/Fiber.app/Contents/MacOS/fiber mcp export-secrets --to \
"$HOME/Library/Application Support/dev.fiber.app/mcp-secrets.enc"

thv run --name fiber --transport stdio -v /path/to/your/collections:/data \
--secret fiber-key,target=FIBER_SECRETS_KEY \
--env FIBER_SECRETS_FILE=/data/mcp-secrets.enc \
ghcr.io/mathiaswp/fiber-mcp:latest
```

It emits `{"<secretRef>": "<value>"}` for every collection you have shared over
MCP — and only those, so it hands out nothing an agent could not already use. It
writes to stdout and refuses to run into a terminal, so the credentials go down
the pipe into ToolHive's encrypted store without touching a file, a shell
variable or your scrollback. It is the only thing in Fiber that reads a secret
back out of the keychain; macOS may ask you to approve each one.
`file-key` creates the key on first use and returns the same one thereafter, so
rerunning any of this is safe: the key is long-lived and the values rotate
underneath it.

If you have no app on the machine, the same map typed by hand does the same job:
**The file's existence is the opt-in.** The app writes to it only if it is
already there, so a desktop-only user never has credentials on disk, and
deleting the file opts back out.

```sh
thv secret set fiber-secrets
# paste, e.g.: {"acme-api:auth":"eyJhbGciOi...","stripe:auth":"sk_live_..."}
```
Both commands refuse to run into a terminal, so the key and the credentials go
down a pipe or into a `0600` file rather than into your scrollback. Reading
secrets back out of the keychain is the one thing nothing else in Fiber does;
macOS may ask you to approve each one.

Either way, one flag on the run command uses it:
### The snapshot (`FIBER_SECRETS`)

`FIBER_SECRETS` is a JSON object of `reference → value` in the environment. It
is simpler, and it is what to use when there is no app on the machine to keep a
file current — a collections repo on a server, say.

```sh
/Applications/Fiber.app/Contents/MacOS/fiber mcp export-secrets |
thv secret set fiber-secrets

thv run --name fiber --transport stdio -v /path/to/your/collections:/data \
--secret fiber-secrets,target=FIBER_SECRETS \
ghcr.io/mathiaswp/fiber-mcp:latest
```

A process's environment cannot change under it, so this is a **snapshot taken
when the workload started**. Sign in again and the container will go on
presenting the old credential until you re-export and replace the workload —
rerunning `toolhive.sh` does both. That is the behaviour the file above exists
to avoid.

If you have no app on the machine, the same map typed by hand does the same job:

```sh
thv secret set fiber-secrets
# paste, e.g.: {"acme-api:auth":"eyJhbGciOi...","stripe:auth":"sk_live_..."}
```

For a login-request section the value is the request body (`{"user":"…","password":"…"}`);
for bearer/browser sections it's the token or cookie string — exactly what the
app would have put in the keychain.

If you'd rather not manage a JSON blob, mount a file and set
`--env FIBER_SECRETS_FILE=/run/secrets/fiber.json` instead of the `--secret`
line.
`FIBER_SECRETS` wins over the file if you somehow set both. An unencrypted
`FIBER_SECRETS_FILE` still works when `FIBER_SECRETS_KEY` is unset, for a file
you manage yourself; setting the key and pointing it at a plaintext file is an
error rather than a silent downgrade, and so is an encrypted file with no key.

### Why this is not as good as the keychain

Inside a container, injected secrets live in the process environment (or a
mounted file) rather than the OS keychain — that's the unavoidable cost of a
Inside a container, injected secrets live in the process environment or a
mounted file rather than the OS keychain — that's the unavoidable cost of a
container that can't reach the keychain, and it's the standard container
pattern. ToolHive's encrypted secret store decrypts and injects them at runtime,
which is why `--secret` is preferable to a plain `--env`. The redaction guarantee
still holds: `authorization`, `cookie`, `set-cookie`, `proxy-authorization` and
`x-api-key` are stripped from every response the server returns, so an injected
credential can't be laundered back out through a tool result.
pattern. Encrypting the file narrows the gap: what is at rest in the mount is
ciphertext, and the key is held by ToolHive's encrypted secret store, which is
why `--secret` is preferable to a plain `--env` for it. The redaction guarantee
still holds either way: `authorization`, `cookie`, `set-cookie`,
`proxy-authorization` and `x-api-key` are stripped from every response the
server returns, so an injected credential can't be laundered back out through a
tool result.

## Building the image yourself

Expand Down
41 changes: 28 additions & 13 deletions scripts/toolhive.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ set -euo pipefail
# Overridable for a mirror, a pinned version, or a local build under test.
IMAGE="${FIBER_IMAGE:-ghcr.io/mathiaswp/fiber-mcp:latest}"
NAME="fiber"
SECRET="fiber-secrets"
# The sealing key lives in ToolHive's store; the credentials it seals live in
# the mounted collections directory, so the app can keep them current.
SECRET_KEY="fiber-key"
SECRETS_FILE="mcp-secrets.enc"

die() {
echo "$*" >&2
Expand Down Expand Up @@ -71,27 +74,39 @@ fi
secret_args=()
if [ -x "$app" ]; then
echo "Copying credentials out of the keychain..."
# Straight down a pipe into ToolHive's encrypted store — the JSON never
# reaches a file, a shell variable or the terminal. macOS may ask for
# permission once per credential; that prompt is the keychain doing its job.
# Two pieces, and the split is the point. The *key* goes into ToolHive's
# encrypted store, where it sits unchanged for the life of the workload. The
# *credentials* go into a file inside the collections directory we are about
# to mount, sealed with that key — so signing in again in Fiber rewrites the
# file, the running container reads it on its next 401, and nothing has to be
# re-exported or restarted. Before this, a container held whatever was true
# when it started.
#
# FIBER_DATA_DIR so it reports on the collections we are about to serve,
# which is not the app's own directory when a repo was named.
# The key never reaches the mount and the credentials never reach the
# terminal: each goes straight down a pipe or straight to a 0600 file.
#
# `< /dev/null` is not decoration. Under `curl | bash` this script *is*
# bash's stdin, so a child inherits the rest of it — and a copy of Fiber too
# old to know `export-secrets` would take that for MCP traffic and sit there
# old to know these commands would take that for MCP traffic and sit there
# reading. With stdin closed the worst case is an immediate empty result,
# which the check below turns into an explanation.
if FIBER_DATA_DIR="$data" "$app" mcp export-secrets < /dev/null |
thv secret set "$SECRET" > /dev/null; then
secret_args=(--secret "$SECRET,target=FIBER_SECRETS")
else
echo "Could not store the credentials." >&2
if ! "$app" mcp file-key < /dev/null | thv secret set "$SECRET_KEY" > /dev/null; then
echo "Could not store the sealing key." >&2
echo " - if ToolHive has no secrets provider yet: run 'thv secret setup'" >&2
echo " - if Fiber said nothing about export-secrets: update it, that command is newer" >&2
echo " - if Fiber said nothing about file-key: update it, that command is newer" >&2
exit 1
fi
# FIBER_DATA_DIR so it reports on the collections we are about to serve,
# which is not the app's own directory when a repo was named.
if ! FIBER_DATA_DIR="$data" "$app" mcp export-secrets --to "$data/$SECRETS_FILE" \
< /dev/null; then
echo "Could not write the credentials file." >&2
exit 1
fi
secret_args=(
--secret "$SECRET_KEY,target=FIBER_SECRETS_KEY"
--env "FIBER_SECRETS_FILE=/data/$SECRETS_FILE"
)
else
echo "Fiber is not installed here, so there are no credentials to copy."
echo "Authenticated collections will need FIBER_SECRETS — see deploy/toolhive.md."
Expand Down
Loading
Loading