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
|
||||
# [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
|
||||
|
||||
@@ -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
|
||||
|
||||
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