mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
+8
-2
@@ -45,8 +45,14 @@ JWT_TOKEN=
|
|||||||
OPENHUMAN_CORE_PORT=7788
|
OPENHUMAN_CORE_PORT=7788
|
||||||
# [optional] Default: http://127.0.0.1:7788/rpc
|
# [optional] Default: http://127.0.0.1:7788/rpc
|
||||||
OPENHUMAN_CORE_RPC_URL=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;
|
# Core RPC bearer token. Single source of truth for /rpc auth.
|
||||||
# for standalone CLI use only)
|
# - 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=
|
# OPENHUMAN_CORE_TOKEN=
|
||||||
# [optional] Run mode: child (default, spawns sidecar) | inprocess
|
# [optional] Run mode: child (default, spawns sidecar) | inprocess
|
||||||
OPENHUMAN_CORE_RUN_MODE=child
|
OPENHUMAN_CORE_RUN_MODE=child
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import { type BootCheckResult, runBootCheck } from '../../lib/bootCheck';
|
import { type BootCheckResult, runBootCheck } from '../../lib/bootCheck';
|
||||||
import { bootCheckTransport } from '../../services/bootCheckService';
|
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 { type CoreMode, resetCoreMode, setCoreMode } from '../../store/coreModeSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||||
import {
|
import {
|
||||||
@@ -63,12 +67,98 @@ interface PickerProps {
|
|||||||
onConfirm: (mode: CoreMode) => void;
|
onConfirm: (mode: CoreMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TestStatus =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'testing' }
|
||||||
|
| { kind: 'ok' }
|
||||||
|
| { kind: 'auth' }
|
||||||
|
| { kind: 'unreachable'; reason: string };
|
||||||
|
|
||||||
function ModePicker({ onConfirm }: PickerProps) {
|
function ModePicker({ onConfirm }: PickerProps) {
|
||||||
const [selected, setSelected] = useState<'local' | 'cloud'>('local');
|
const [selected, setSelected] = useState<'local' | 'cloud'>('local');
|
||||||
const [cloudUrl, setCloudUrl] = useState('');
|
const [cloudUrl, setCloudUrl] = useState('');
|
||||||
const [cloudToken, setCloudToken] = useState('');
|
const [cloudToken, setCloudToken] = useState('');
|
||||||
const [urlError, setUrlError] = useState<string | null>(null);
|
const [urlError, setUrlError] = useState<string | null>(null);
|
||||||
const [tokenError, setTokenError] = 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 = () => {
|
const handleContinue = () => {
|
||||||
if (selected === 'local') {
|
if (selected === 'local') {
|
||||||
@@ -77,40 +167,15 @@ function ModePicker({ onConfirm }: PickerProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic URL validation: must be http(s)
|
const validated = validateInputs();
|
||||||
const trimmedUrl = cloudUrl.trim();
|
if (!validated) return;
|
||||||
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);
|
|
||||||
|
|
||||||
log(
|
log(
|
||||||
'[boot-check] picker — user selected cloud mode url=%s tokenLen=%d',
|
'[boot-check] picker — user selected cloud mode url=%s tokenLen=%d',
|
||||||
trimmedUrl,
|
validated.url,
|
||||||
trimmedToken.length
|
validated.token.length
|
||||||
);
|
);
|
||||||
onConfirm({ kind: 'cloud', url: trimmedUrl, token: trimmedToken });
|
onConfirm({ kind: 'cloud', url: validated.url, token: validated.token });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -162,6 +227,7 @@ function ModePicker({ onConfirm }: PickerProps) {
|
|||||||
onChange={e => {
|
onChange={e => {
|
||||||
setCloudUrl(e.target.value);
|
setCloudUrl(e.target.value);
|
||||||
setUrlError(null);
|
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"
|
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 => {
|
onChange={e => {
|
||||||
setCloudToken(e.target.value);
|
setCloudToken(e.target.value);
|
||||||
setTokenError(null);
|
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"
|
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.
|
<code>Authorization: Bearer …</code> on every RPC.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ vi.mock('../../../lib/bootCheck', () => ({
|
|||||||
runBootCheck: (...args: unknown[]) => mockRunBootCheck(...args),
|
runBootCheck: (...args: unknown[]) => mockRunBootCheck(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockTestCoreRpcConnection = vi.fn();
|
||||||
vi.mock('../../../services/coreRpcClient', () => ({
|
vi.mock('../../../services/coreRpcClient', () => ({
|
||||||
callCoreRpc: vi.fn(),
|
callCoreRpc: vi.fn(),
|
||||||
clearCoreRpcUrlCache: vi.fn(),
|
clearCoreRpcUrlCache: vi.fn(),
|
||||||
clearCoreRpcTokenCache: vi.fn(),
|
clearCoreRpcTokenCache: vi.fn(),
|
||||||
|
testCoreRpcConnection: (...args: unknown[]) => mockTestCoreRpcConnection(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../utils/configPersistence', () => ({
|
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', () => {
|
describe('BootCheckGate — checking state', () => {
|
||||||
it('shows checking spinner while boot check is in flight', async () => {
|
it('shows checking spinner while boot check is in flight', async () => {
|
||||||
// Never resolves during this test
|
// Never resolves during this test
|
||||||
|
|||||||
@@ -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
|
## What you need before you start
|
||||||
|
|
||||||
| Setting | Required | Notes |
|
| Setting | Required | Notes |
|
||||||
@@ -197,6 +232,35 @@ docker compose up -d
|
|||||||
docker compose logs -f openhuman-core
|
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
|
### Putting it behind TLS
|
||||||
|
|
||||||
Use Caddy, nginx, or Traefik as a reverse proxy in front of `:7788`. A minimal
|
Use Caddy, nginx, or Traefik as a reverse proxy in front of `:7788`. A minimal
|
||||||
|
|||||||
Executable
+97
@@ -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
|
||||||
Reference in New Issue
Block a user