)}
diff --git a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx
index 31a97830b..477aea46a 100644
--- a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx
+++ b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx
@@ -24,10 +24,12 @@ vi.mock('../../../lib/bootCheck', () => ({
runBootCheck: (...args: unknown[]) => mockRunBootCheck(...args),
}));
+const mockTestCoreRpcConnection = vi.fn();
vi.mock('../../../services/coreRpcClient', () => ({
callCoreRpc: vi.fn(),
clearCoreRpcUrlCache: vi.fn(),
clearCoreRpcTokenCache: vi.fn(),
+ testCoreRpcConnection: (...args: unknown[]) => mockTestCoreRpcConnection(...args),
}));
vi.mock('../../../utils/configPersistence', () => ({
@@ -186,6 +188,144 @@ describe('BootCheckGate — picker (unset mode)', () => {
});
});
+describe('BootCheckGate — picker test connection', () => {
+ beforeEach(() => {
+ mockTestCoreRpcConnection.mockReset();
+ });
+
+ function fillCloudInputs(url = 'https://core.example.com/rpc', token = 'tok-abc') {
+ fireEvent.click(screen.getByText('Cloud'));
+ fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), {
+ target: { value: url },
+ });
+ fireEvent.change(screen.getByPlaceholderText(/Bearer token/i), { target: { value: token } });
+ }
+
+ it('shows Connected on a 200 response', async () => {
+ mockTestCoreRpcConnection.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ result: { ok: true } }),
+ } as unknown as Response);
+
+ renderGate();
+ fillCloudInputs();
+ fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('test-status-ok')).toBeInTheDocument();
+ });
+ expect(mockTestCoreRpcConnection).toHaveBeenCalledWith(
+ 'https://core.example.com/rpc',
+ 'tok-abc'
+ );
+ });
+
+ it('shows Auth failed on a 401 response', async () => {
+ mockTestCoreRpcConnection.mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: async () => ({ error: 'unauthorized' }),
+ } as unknown as Response);
+
+ renderGate();
+ fillCloudInputs();
+ fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('test-status-auth')).toBeInTheDocument();
+ });
+ });
+
+ it('shows Auth failed on a 403 response', async () => {
+ mockTestCoreRpcConnection.mockResolvedValue({
+ ok: false,
+ status: 403,
+ json: async () => ({}),
+ } as unknown as Response);
+
+ renderGate();
+ fillCloudInputs();
+ fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('test-status-auth')).toBeInTheDocument();
+ });
+ });
+
+ it('shows Unreachable when fetch rejects', async () => {
+ mockTestCoreRpcConnection.mockRejectedValue(new Error('network down'));
+
+ renderGate();
+ fillCloudInputs();
+ fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('test-status-unreachable')).toBeInTheDocument();
+ });
+ expect(screen.getByTestId('test-status-unreachable').textContent).toMatch(/network down/);
+ });
+
+ it('shows Unreachable on non-2xx non-auth response', async () => {
+ mockTestCoreRpcConnection.mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: async () => ({}),
+ } as unknown as Response);
+
+ renderGate();
+ fillCloudInputs();
+ fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('test-status-unreachable')).toBeInTheDocument();
+ });
+ expect(screen.getByTestId('test-status-unreachable').textContent).toMatch(/HTTP 500/);
+ });
+
+ it('does not call the test endpoint when URL is missing', () => {
+ renderGate();
+ fireEvent.click(screen.getByText('Cloud'));
+ fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
+
+ expect(mockTestCoreRpcConnection).not.toHaveBeenCalled();
+ expect(screen.getByText('Please enter a core URL.')).toBeInTheDocument();
+ });
+
+ it('does not call the test endpoint when token is missing', () => {
+ renderGate();
+ fireEvent.click(screen.getByText('Cloud'));
+ fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), {
+ target: { value: 'https://core.example.com/rpc' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
+
+ expect(mockTestCoreRpcConnection).not.toHaveBeenCalled();
+ expect(screen.getByText(/Please enter the core auth token/i)).toBeInTheDocument();
+ });
+
+ it('clears a stale ok status when the user edits inputs again', async () => {
+ mockTestCoreRpcConnection.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({}),
+ } as unknown as Response);
+
+ renderGate();
+ fillCloudInputs();
+ fireEvent.click(screen.getByRole('button', { name: 'Test connection' }));
+ await waitFor(() => {
+ expect(screen.getByTestId('test-status-ok')).toBeInTheDocument();
+ });
+
+ fireEvent.change(screen.getByPlaceholderText(/Bearer token/i), {
+ target: { value: 'tok-def' },
+ });
+
+ expect(screen.queryByTestId('test-status-ok')).not.toBeInTheDocument();
+ });
+});
+
describe('BootCheckGate — checking state', () => {
it('shows checking spinner while boot check is in flight', async () => {
// Never resolves during this test
diff --git a/gitbooks/features/cloud-deploy.md b/gitbooks/features/cloud-deploy.md
index e8cb9f72b..2e2335706 100644
--- a/gitbooks/features/cloud-deploy.md
+++ b/gitbooks/features/cloud-deploy.md
@@ -27,6 +27,41 @@ in `app/.env.local` and launch.
---
+## Single source of truth for the bearer token
+
+Every `/rpc` call carries `Authorization: Bearer `. The core has two
+ways to load that token at startup ([`src/core/auth.rs`](../../src/core/auth.rs)):
+
+1. **`OPENHUMAN_CORE_TOKEN` environment variable** — pre-seeded by the caller
+ (Tauri shell, Docker, App Platform, systemd unit, …). The core uses this
+ value as-is and **never** writes a file.
+2. **`{workspace}/core.token` file** — generated by the core on first boot
+ *only when `OPENHUMAN_CORE_TOKEN` is unset*. Standalone `openhuman core run`
+ uses this so CLI clients can `cat` the file.
+
+**Rule of thumb for any remote / dockerized deploy: always set
+`OPENHUMAN_CORE_TOKEN`.** Do not rely on `core.token` in a container —
+ephemeral filesystems lose it on redeploy, and any client trying to read the
+file from outside the container will get a stale or empty value. The two
+paths are deliberately mutually exclusive at startup; mixing them is the most
+common reason behind "the dashboard gets 401 after I redeployed".
+
+To check what the *running* core is using, run [`scripts/print-core-token.sh`](../../scripts/print-core-token.sh)
+on the host (or inside the container with `docker compose exec`):
+
+```bash
+scripts/print-core-token.sh --where # prints 'env' or 'file:/path'
+scripts/print-core-token.sh --redact # first 8 hex chars + '…' (safe for logs)
+scripts/print-core-token.sh # full value (pipe straight into a client)
+```
+
+The desktop app's first-run picker also exposes a **Test connection** button
+next to the Core RPC URL + token fields, which fires `core.ping` against the
+URL with the typed token and reports `Connected ✓` / `Auth failed` /
+`Unreachable` inline before persisting the configuration.
+
+---
+
## What you need before you start
| Setting | Required | Notes |
@@ -197,6 +232,35 @@ docker compose up -d
docker compose logs -f openhuman-core
```
+### Rotating the bearer token
+
+`OPENHUMAN_CORE_TOKEN` is the only thing standing between the public internet
+and full RPC access. Rotate it on a schedule and after any suspected leak:
+
+```bash
+# 1. Generate a new token and update the server-side .env.
+openssl rand -hex 32 > /tmp/new-token
+sed -i.bak "s|^OPENHUMAN_CORE_TOKEN=.*|OPENHUMAN_CORE_TOKEN=$(cat /tmp/new-token)|" .env
+rm /tmp/new-token .env.bak
+
+# 2. Restart the container so the new value reaches the core process.
+docker compose up -d --force-recreate openhuman-core
+
+# 3. Confirm the running container is using the new token (redacted).
+docker compose exec openhuman-core /bin/sh -c \
+ 'echo -n "$OPENHUMAN_CORE_TOKEN" | head -c 8; echo "…"'
+
+# 4. Update every desktop client (Switch mode → re-paste in the picker, or
+# edit OPENHUMAN_CORE_TOKEN in app/.env.local and relaunch). Clients that
+# still hold the old token will get HTTP 401 on the next /rpc call — that
+# is expected, not a regression.
+```
+
+For App Platform, do the same in **Settings → App-Level Environment
+Variables**: edit the `OPENHUMAN_CORE_TOKEN` secret and let App Platform
+redeploy. There is no separate token file to delete; the env var is the only
+state.
+
### Putting it behind TLS
Use Caddy, nginx, or Traefik as a reverse proxy in front of `:7788`. A minimal
diff --git a/scripts/print-core-token.sh b/scripts/print-core-token.sh
new file mode 100755
index 000000000..3d16ea9ae
--- /dev/null
+++ b/scripts/print-core-token.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+#
+# print-core-token.sh — print the active core RPC bearer token for the
+# current deploy mode, so operators don't have to remember which side of the
+# Tauri-vs-CLI / Docker-vs-binary split owns the value.
+#
+# Resolution order (matches src/core/auth.rs::init_rpc_token):
+# 1. $OPENHUMAN_CORE_TOKEN if set and non-empty (Tauri / Docker / cloud)
+# 2. ${OPENHUMAN_WORKSPACE:-$HOME/.openhuman}/core.token (standalone CLI)
+#
+# Usage:
+# scripts/print-core-token.sh # print full token to stdout
+# scripts/print-core-token.sh --redact # print first 8 hex chars + '…' only
+# scripts/print-core-token.sh --where # print the source (env|file:path)
+# and exit without revealing the value
+#
+# Exit codes:
+# 0 success
+# 1 no token configured (neither env nor file)
+# 2 file exists but is unreadable / empty
+#
+# This script is read-only and never logs the token to syslog or to debug
+# files. When invoked from CI, prefer --redact + --where so logs stay safe.
+
+set -euo pipefail
+
+mode="full"
+for arg in "$@"; do
+ case "$arg" in
+ --redact)
+ mode="redact"
+ ;;
+ --where)
+ mode="where"
+ ;;
+ -h|--help)
+ sed -n '2,21p' "$0" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ *)
+ echo "print-core-token: unknown argument '$arg'" >&2
+ exit 64
+ ;;
+ esac
+done
+
+env_token="${OPENHUMAN_CORE_TOKEN:-}"
+workspace_dir="${OPENHUMAN_WORKSPACE:-$HOME/.openhuman}"
+file_path="$workspace_dir/core.token"
+
+source="" # one of: env | file
+token=""
+
+if [ -n "$env_token" ]; then
+ source="env"
+ token="$env_token"
+elif [ -f "$file_path" ]; then
+ if [ ! -r "$file_path" ]; then
+ echo "print-core-token: $file_path exists but is not readable by $USER" >&2
+ exit 2
+ fi
+ token="$(tr -d '\n\r' < "$file_path" || true)"
+ if [ -z "$token" ]; then
+ echo "print-core-token: $file_path is empty" >&2
+ exit 2
+ fi
+ source="file:$file_path"
+else
+ cat >&2 <