docs(core): simplify remote core RPC token setup (#1438) (#1444)

This commit is contained in:
Steven Enamakel
2026-05-09 16:44:49 -07:00
committed by GitHub
parent 43bfef53a8
commit 87b4903ac8
5 changed files with 432 additions and 33 deletions
+8 -2
View File
@@ -45,8 +45,14 @@ JWT_TOKEN=
OPENHUMAN_CORE_PORT=7788
# [optional] Default: http://127.0.0.1:7788/rpc
OPENHUMAN_CORE_RPC_URL=http://127.0.0.1:7788/rpc
# [optional] Pre-seed the core RPC bearer token (Tauri sets this automatically;
# for standalone CLI use only)
# Core RPC bearer token. Single source of truth for /rpc auth.
# - Tauri desktop: set automatically by the shell — leave blank.
# - Docker / cloud / VPS: REQUIRED. Generate with `openssl rand -hex 32`.
# Same value goes in the desktop's app/.env.local (or paste into the
# first-run picker). See gitbooks/features/cloud-deploy.md.
# - Standalone `openhuman core run` on a workstation: leave blank — the
# core writes ${OPENHUMAN_WORKSPACE:-~/.openhuman}/core.token (0o600).
# Use scripts/print-core-token.sh on the host to inspect the active token.
# OPENHUMAN_CORE_TOKEN=
# [optional] Run mode: child (default, spawns sidecar) | inprocess
OPENHUMAN_CORE_RUN_MODE=child
@@ -14,7 +14,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { type BootCheckResult, runBootCheck } from '../../lib/bootCheck';
import { bootCheckTransport } from '../../services/bootCheckService';
import { clearCoreRpcTokenCache, clearCoreRpcUrlCache } from '../../services/coreRpcClient';
import {
clearCoreRpcTokenCache,
clearCoreRpcUrlCache,
testCoreRpcConnection,
} from '../../services/coreRpcClient';
import { type CoreMode, resetCoreMode, setCoreMode } from '../../store/coreModeSlice';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import {
@@ -63,12 +67,98 @@ interface PickerProps {
onConfirm: (mode: CoreMode) => void;
}
type TestStatus =
| { kind: 'idle' }
| { kind: 'testing' }
| { kind: 'ok' }
| { kind: 'auth' }
| { kind: 'unreachable'; reason: string };
function ModePicker({ onConfirm }: PickerProps) {
const [selected, setSelected] = useState<'local' | 'cloud'>('local');
const [cloudUrl, setCloudUrl] = useState('');
const [cloudToken, setCloudToken] = useState('');
const [urlError, setUrlError] = useState<string | null>(null);
const [tokenError, setTokenError] = useState<string | null>(null);
const [testStatus, setTestStatus] = useState<TestStatus>({ kind: 'idle' });
/**
* Validate the cloud URL + token inputs against a live core before we
* commit the mode. We hit the public `core.ping` (auth-bypass) to confirm
* reachability, then re-issue the same JSON-RPC envelope with the bearer
* token to confirm `/rpc` accepts it. This catches the two most common
* paste-time mistakes — wrong URL, wrong/missing token — with one click,
* before the user lands on the unreachable result screen.
*
* Tokens are never logged: only `tokenLen` is emitted via the existing
* picker debug line, and any error messages from the network/JSON parse
* paths are passed through verbatim without the bearer value.
*/
const validateInputs = (): { url: string; token: string } | null => {
const trimmedUrl = cloudUrl.trim();
if (!trimmedUrl) {
setUrlError('Please enter a core URL.');
return null;
}
try {
const parsed = new URL(trimmedUrl);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
setUrlError('URL must start with http:// or https://');
return null;
}
} catch {
setUrlError('Please enter a valid URL (e.g. https://core.example.com/rpc)');
return null;
}
setUrlError(null);
const trimmedToken = cloudToken.trim();
if (!trimmedToken) {
setTokenError('Please enter the core auth token.');
return null;
}
setTokenError(null);
return { url: trimmedUrl, token: trimmedToken };
};
const handleTestConnection = async () => {
const validated = validateInputs();
if (!validated) return;
setTestStatus({ kind: 'testing' });
log(
'[boot-check] picker — testing cloud connection url=%s tokenLen=%d',
validated.url,
validated.token.length
);
try {
const response = await testCoreRpcConnection(validated.url, validated.token);
if (response.status === 401 || response.status === 403) {
log('[boot-check] picker — test failed: auth (status=%d)', response.status);
setTestStatus({ kind: 'auth' });
return;
}
if (!response.ok) {
log('[boot-check] picker — test failed: HTTP %d', response.status);
setTestStatus({ kind: 'unreachable', reason: `HTTP ${response.status} from /rpc` });
return;
}
// Drain the body — response.ok with JSON-RPC error is still reachable.
try {
await response.json();
} catch {
// Non-JSON body is unusual but doesn't disprove reachability.
}
log('[boot-check] picker — test succeeded');
setTestStatus({ kind: 'ok' });
} catch (err) {
const reason = err instanceof Error ? err.message : 'Connection failed';
logError('[boot-check] picker — test errored: %o', err);
setTestStatus({ kind: 'unreachable', reason });
}
};
const handleContinue = () => {
if (selected === 'local') {
@@ -77,40 +167,15 @@ function ModePicker({ onConfirm }: PickerProps) {
return;
}
// Basic URL validation: must be http(s)
const trimmedUrl = cloudUrl.trim();
if (!trimmedUrl) {
setUrlError('Please enter a core URL.');
return;
}
try {
const parsed = new URL(trimmedUrl);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
setUrlError('URL must start with http:// or https://');
return;
}
} catch {
setUrlError('Please enter a valid URL (e.g. https://core.example.com/rpc)');
return;
}
setUrlError(null);
// Cloud cores require a bearer token (OPENHUMAN_CORE_TOKEN). Without one
// every /rpc call would 401 and the user would be stuck on the boot
// gate; require it up front rather than failing later.
const trimmedToken = cloudToken.trim();
if (!trimmedToken) {
setTokenError('Please enter the core auth token.');
return;
}
setTokenError(null);
const validated = validateInputs();
if (!validated) return;
log(
'[boot-check] picker — user selected cloud mode url=%s tokenLen=%d',
trimmedUrl,
trimmedToken.length
validated.url,
validated.token.length
);
onConfirm({ kind: 'cloud', url: trimmedUrl, token: trimmedToken });
onConfirm({ kind: 'cloud', url: validated.url, token: validated.token });
};
return (
@@ -162,6 +227,7 @@ function ModePicker({ onConfirm }: PickerProps) {
onChange={e => {
setCloudUrl(e.target.value);
setUrlError(null);
setTestStatus({ kind: 'idle' });
}}
className="rounded-lg border border-stone-600 bg-stone-800 px-3 py-2 text-sm text-white placeholder-stone-500 focus:border-ocean-500 focus:outline-none"
/>
@@ -180,6 +246,7 @@ function ModePicker({ onConfirm }: PickerProps) {
onChange={e => {
setCloudToken(e.target.value);
setTokenError(null);
setTestStatus({ kind: 'idle' });
}}
className="rounded-lg border border-stone-600 bg-stone-800 px-3 py-2 text-sm text-white placeholder-stone-500 focus:border-ocean-500 focus:outline-none"
/>
@@ -189,6 +256,31 @@ function ModePicker({ onConfirm }: PickerProps) {
<code>Authorization: Bearer </code> on every RPC.
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleTestConnection}
disabled={testStatus.kind === 'testing'}
className="rounded-lg border border-stone-600 px-3 py-1.5 text-xs text-stone-100 hover:bg-stone-800 disabled:opacity-60">
{testStatus.kind === 'testing' ? 'Testing…' : 'Test connection'}
</button>
{testStatus.kind === 'ok' && (
<span className="text-xs text-emerald-400" data-testid="test-status-ok">
Connected
</span>
)}
{testStatus.kind === 'auth' && (
<span className="text-xs text-coral-400" data-testid="test-status-auth">
Auth failed check the token (got 401/403).
</span>
)}
{testStatus.kind === 'unreachable' && (
<span className="text-xs text-coral-400" data-testid="test-status-unreachable">
Unreachable: {testStatus.reason}
</span>
)}
</div>
</div>
)}
</div>
@@ -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
+64
View File
@@ -27,6 +27,41 @@ in `app/.env.local` and launch.
---
## Single source of truth for the bearer token
Every `/rpc` call carries `Authorization: Bearer <token>`. 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
+97
View File
@@ -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 <<EOF
print-core-token: no core token configured.
Looked for:
1. \$OPENHUMAN_CORE_TOKEN environment variable (used by Tauri shell, Docker,
and any cloud deploy)
2. $file_path (standalone CLI 'openhuman core run' writes this on first
boot; override the directory with \$OPENHUMAN_WORKSPACE)
If you are running the dockerized core, set OPENHUMAN_CORE_TOKEN in your
.env (or the App Platform secrets UI) and bounce the container. See
gitbooks/features/cloud-deploy.md for the full single-source-of-truth setup.
EOF
exit 1
fi
case "$mode" in
full)
printf '%s\n' "$token"
;;
redact)
# Show enough to disambiguate two tokens without leaking the secret.
head_chars="${token:0:8}"
printf '%s…\n' "$head_chars"
;;
where)
printf '%s\n' "$source"
;;
esac