Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails (#35094)

This commit is contained in:
Josh Avant
2026-03-05 12:53:56 -06:00
committed by GitHub
parent bc66a8fa81
commit 72cf9253fc
112 changed files with 5750 additions and 465 deletions

View File

@@ -18,6 +18,11 @@ Docs: https://docs.openclaw.ai
- Cron/job snapshot persistence: skip backup during normalization persistence in `ensureLoaded` so `jobs.json.bak` keeps the pre-edit snapshot for recovery, while preserving backup creation on explicit user-driven writes. (#35234) Thanks @0xsline.
- TTS/OpenAI-compatible endpoints: add `messages.tts.openai.baseUrl` config support with config-over-env precedence, endpoint-aware directive validation, and OpenAI TTS request routing to the resolved base URL. (#34321) thanks @RealKai42.
- Plugins/before_prompt_build system-context fields: add `prependSystemContext` and `appendSystemContext` so static plugin guidance can be placed in system prompt space for provider caching and lower repeated prompt token cost. (#35177) thanks @maweibin.
- Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails. (#35094) Thanks @joshavant.
### Breaking
- **BREAKING:** Gateway auth now requires explicit `gateway.auth.mode` when both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs). Set `gateway.auth.mode` to `token` or `password` before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant.
### Fixes

View File

@@ -24,6 +24,9 @@ Notes:
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
- Channel-oriented services (Slack/Discord/Matrix/Microsoft Teams) prompt for channel/room allowlists during setup. You can enter names or IDs; the wizard resolves names to IDs when possible.
- If you run the daemon install step, token auth requires a token, and `gateway.auth.token` is SecretRef-managed, configure validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, configure blocks daemon install with actionable remediation guidance.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, configure blocks daemon install until mode is set explicitly.
## Examples

View File

@@ -38,6 +38,13 @@ openclaw daemon uninstall
- `install`: `--port`, `--runtime <node|bun>`, `--token`, `--force`, `--json`
- lifecycle (`uninstall|start|stop|restart`): `--json`
Notes:
- `status` resolves configured auth SecretRefs for probe auth when possible.
- When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, install fails closed.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly.
## Prefer
Use [`openclaw gateway`](/cli/gateway) for current docs and examples.

View File

@@ -14,3 +14,9 @@ Open the Control UI using your current auth.
openclaw dashboard
openclaw dashboard --no-open
```
Notes:
- `dashboard` resolves configured `gateway.auth.token` SecretRefs when possible.
- For SecretRef-managed tokens (resolved or unresolved), `dashboard` prints/copies/opens a non-tokenized URL to avoid exposing external secrets in terminal output, clipboard history, or browser-launch arguments.
- If `gateway.auth.token` is SecretRef-managed but unresolved in this command path, the command prints a non-tokenized URL and explicit remediation guidance instead of embedding an invalid token placeholder.

View File

@@ -105,6 +105,11 @@ Options:
- `--no-probe`: skip the RPC probe (service-only view).
- `--deep`: scan system-level services too.
Notes:
- `gateway status` resolves configured auth SecretRefs for probe auth when possible.
- If a required auth SecretRef is unresolved in this command path, probe auth can fail; pass `--token`/`--password` explicitly or resolve the secret source first.
### `gateway probe`
`gateway probe` is the “debug everything” command. It always probes:
@@ -162,6 +167,10 @@ openclaw gateway uninstall
Notes:
- `gateway install` supports `--port`, `--runtime`, `--token`, `--force`, `--json`.
- When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `gateway install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, install fails closed instead of persisting fallback plaintext.
- In inferred auth mode, shell-only `OPENCLAW_GATEWAY_PASSWORD`/`CLAWDBOT_GATEWAY_PASSWORD` does not relax install token requirements; use durable config (`gateway.auth.password` or config `env`) when installing a managed service.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly.
- Lifecycle commands accept `--json` for scripting.
## Discover gateways (Bonjour)

View File

@@ -359,6 +359,7 @@ Options:
- `--gateway-bind <loopback|lan|tailnet|auto|custom>`
- `--gateway-auth <token|password>`
- `--gateway-token <token>`
- `--gateway-token-ref-env <name>` (non-interactive; store `gateway.auth.token` as an env SecretRef; requires that env var to be set; cannot be combined with `--gateway-token`)
- `--gateway-password <password>`
- `--remote-url <url>`
- `--remote-token <token>`

View File

@@ -61,6 +61,28 @@ Non-interactive `ref` mode contract:
- Do not pass inline key flags (for example `--openai-api-key`) unless that env var is also set.
- If an inline key flag is passed without the required env var, onboarding fails fast with guidance.
Gateway token options in non-interactive mode:
- `--gateway-auth token --gateway-token <token>` stores a plaintext token.
- `--gateway-auth token --gateway-token-ref-env <name>` stores `gateway.auth.token` as an env SecretRef.
- `--gateway-token` and `--gateway-token-ref-env` are mutually exclusive.
- `--gateway-token-ref-env` requires a non-empty env var in the onboarding process environment.
- With `--install-daemon`, when token auth requires a token, SecretRef-managed gateway tokens are validated but not persisted as resolved plaintext in supervisor service environment metadata.
- With `--install-daemon`, if token mode requires a token and the configured token SecretRef is unresolved, onboarding fails closed with remediation guidance.
- With `--install-daemon`, if both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, onboarding blocks install until mode is set explicitly.
Example:
```bash
export OPENCLAW_GATEWAY_TOKEN="your-token"
openclaw onboard --non-interactive \
--mode local \
--auth-choice skip \
--gateway-auth token \
--gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN \
--accept-risk
```
Interactive onboarding behavior with reference mode:
- Choose **Use secret reference** when prompted.

View File

@@ -35,7 +35,10 @@ openclaw qr --url wss://gateway.example/ws --token '<token>'
- `--token` and `--password` are mutually exclusive.
- With `--remote`, if effectively active remote credentials are configured as SecretRefs and you do not pass `--token` or `--password`, the command resolves them from the active gateway snapshot. If gateway is unavailable, the command fails fast.
- Without `--remote`, local `gateway.auth.password` SecretRefs are resolved when password auth can win (explicit `gateway.auth.mode="password"` or inferred password mode with no winning token from auth/env), and no CLI auth override is passed.
- Without `--remote`, local gateway auth SecretRefs are resolved when no CLI auth override is passed:
- `gateway.auth.token` resolves when token auth can win (explicit `gateway.auth.mode="token"` or inferred mode where no password source wins).
- `gateway.auth.password` resolves when password auth can win (explicit `gateway.auth.mode="password"` or inferred mode with no winning token from auth/env).
- If both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs) and `gateway.auth.mode` is unset, setup-code resolution fails until mode is set explicitly.
- Gateway version skew note: this command path requires a gateway that supports `secrets.resolve`; older gateways return an unknown-method error.
- After scanning, approve device pairing with:
- `openclaw devices list`

View File

@@ -14,6 +14,10 @@ Related:
- TUI guide: [TUI](/web/tui)
Notes:
- `tui` resolves configured gateway auth SecretRefs for token/password auth when possible (`env`/`file`/`exec` providers).
## Examples
```bash

View File

@@ -2431,6 +2431,7 @@ See [Plugins](/tools/plugin).
- **Legacy bind aliases**: use bind mode values in `gateway.bind` (`auto`, `loopback`, `lan`, `tailnet`, `custom`), not host aliases (`0.0.0.0`, `127.0.0.1`, `localhost`, `::`, `::1`).
- **Docker note**: the default `loopback` bind listens on `127.0.0.1` inside the container. With Docker bridge networking (`-p 18789:18789`), traffic arrives on `eth0`, so the gateway is unreachable. Use `--network host`, or set `bind: "lan"` (or `bind: "custom"` with `customBindHost: "0.0.0.0"`) to listen on all interfaces.
- **Auth**: required by default. Non-loopback binds require a shared token/password. Onboarding wizard generates a token by default.
- If both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs), set `gateway.auth.mode` explicitly to `token` or `password`. Startup and service install/repair flows fail when both are configured and mode is unset.
- `gateway.auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts.
- `gateway.auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)).
- `gateway.auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`.

View File

@@ -77,7 +77,7 @@ cat ~/.openclaw/openclaw.json
- Gateway runtime best-practice checks (Node vs Bun, version-manager paths).
- Gateway port collision diagnostics (default `18789`).
- Security warnings for open DM policies.
- Gateway auth warnings when no `gateway.auth.token` is set (local mode; offers token generation).
- Gateway auth checks for local token mode (offers token generation when no token source exists; does not overwrite token SecretRef configs).
- systemd linger check on Linux.
- Source install checks (pnpm workspace mismatch, missing UI assets, missing tsx binary).
- Writes updated config + wizard metadata.
@@ -238,9 +238,11 @@ workspace.
### 12) Gateway auth checks (local token)
Doctor warns when `gateway.auth` is missing on a local gateway and offers to
generate a token. Use `openclaw doctor --generate-gateway-token` to force token
creation in automation.
Doctor checks local gateway token auth readiness.
- If token mode needs a token and no token source exists, doctor offers to generate one.
- If `gateway.auth.token` is SecretRef-managed but unavailable, doctor warns and does not overwrite it with plaintext.
- `openclaw doctor --generate-gateway-token` forces generation only when no token SecretRef is configured.
### 13) Gateway health check + restart
@@ -265,6 +267,9 @@ Notes:
- `openclaw doctor --yes` accepts the default repair prompts.
- `openclaw doctor --repair` applies recommended fixes without prompts.
- `openclaw doctor --repair --force` overwrites custom supervisor configs.
- If token auth requires a token and `gateway.auth.token` is SecretRef-managed, doctor service install/repair validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, doctor blocks the install/repair path with actionable guidance.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, doctor blocks install/repair until mode is set explicitly.
- You can always force a full rewrite via `openclaw gateway install --force`.
### 16) Gateway runtime + port diagnostics

View File

@@ -46,11 +46,13 @@ Examples of inactive surfaces:
In local mode without those remote surfaces:
- `gateway.remote.token` is active when token auth can win and no env/auth token is configured.
- `gateway.remote.password` is active only when password auth can win and no env/auth password is configured.
- `gateway.auth.token` SecretRef is inactive for startup auth resolution when `OPENCLAW_GATEWAY_TOKEN` (or `CLAWDBOT_GATEWAY_TOKEN`) is set, because env token input wins for that runtime.
## Gateway auth surface diagnostics
When a SecretRef is configured on `gateway.auth.password`, `gateway.remote.token`, or
`gateway.remote.password`, gateway startup/reload logs the surface state explicitly:
When a SecretRef is configured on `gateway.auth.token`, `gateway.auth.password`,
`gateway.remote.token`, or `gateway.remote.password`, gateway startup/reload logs the
surface state explicitly:
- `active`: the SecretRef is part of the effective auth surface and must resolve.
- `inactive`: the SecretRef is ignored for this runtime because another auth surface wins, or
@@ -65,6 +67,7 @@ When onboarding runs in interactive mode and you choose SecretRef storage, OpenC
- Env refs: validates env var name and confirms a non-empty value is visible during onboarding.
- Provider refs (`file` or `exec`): validates provider selection, resolves `id`, and checks resolved value type.
- Quickstart reuse path: when `gateway.auth.token` is already a SecretRef, onboarding resolves it before probe/dashboard bootstrap (for `env`, `file`, and `exec` refs) using the same fail-fast gate.
If validation fails, onboarding shows the error and lets you retry.

View File

@@ -36,6 +36,7 @@ Scope intent:
- `tools.web.search.kimi.apiKey`
- `tools.web.search.perplexity.apiKey`
- `gateway.auth.password`
- `gateway.auth.token`
- `gateway.remote.token`
- `gateway.remote.password`
- `cron.webhookToken`
@@ -107,7 +108,6 @@ Out-of-scope credentials include:
[//]: # "secretref-unsupported-list-start"
- `gateway.auth.token`
- `commands.ownerDisplaySecret`
- `channels.matrix.accessToken`
- `channels.matrix.accounts.*.accessToken`

View File

@@ -7,7 +7,6 @@
"commands.ownerDisplaySecret",
"channels.matrix.accessToken",
"channels.matrix.accounts.*.accessToken",
"gateway.auth.token",
"hooks.token",
"hooks.gmail.pushToken",
"hooks.mappings[].sessionKey",
@@ -385,6 +384,13 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "gateway.auth.token",
"configFile": "openclaw.json",
"path": "gateway.auth.token",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "gateway.remote.password",
"configFile": "openclaw.json",

View File

@@ -71,6 +71,15 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
<Step title="Gateway">
- Port, bind, auth mode, tailscale exposure.
- Auth recommendation: keep **Token** even for loopback so local WS clients must authenticate.
- In token mode, interactive onboarding offers:
- **Generate/store plaintext token** (default)
- **Use SecretRef** (opt-in)
- Quickstart reuses existing `gateway.auth.token` SecretRefs across `env`, `file`, and `exec` providers for onboarding probe/dashboard bootstrap.
- If that SecretRef is configured but cannot be resolved, onboarding fails early with a clear fix message instead of silently degrading runtime auth.
- In password mode, interactive onboarding also supports plaintext or SecretRef storage.
- Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`.
- Requires a non-empty env var in the onboarding process environment.
- Cannot be combined with `--gateway-token`.
- Disable auth only if you fully trust every local process.
- Nonloopback binds still require auth.
</Step>
@@ -92,6 +101,9 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
- Wizard attempts to enable lingering via `loginctl enable-linger <user>` so the Gateway stays up after logout.
- May prompt for sudo (writes `/var/lib/systemd/linger`); it tries without sudo first.
- **Runtime selection:** Node (recommended; required for WhatsApp/Telegram). Bun is **not recommended**.
- If token auth requires a token and `gateway.auth.token` is SecretRef-managed, daemon install validates it but does not persist resolved plaintext token values into supervisor service environment metadata.
- If token auth requires a token and the configured token SecretRef is unresolved, daemon install is blocked with actionable guidance.
- If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, daemon install is blocked until mode is set explicitly.
</Step>
<Step title="Health check">
- Starts the Gateway (if needed) and runs `openclaw health`.
@@ -130,6 +142,19 @@ openclaw onboard --non-interactive \
Add `--json` for a machinereadable summary.
Gateway token SecretRef in non-interactive mode:
```bash
export OPENCLAW_GATEWAY_TOKEN="your-token"
openclaw onboard --non-interactive \
--mode local \
--auth-choice skip \
--gateway-auth token \
--gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN
```
`--gateway-token` and `--gateway-token-ref-env` are mutually exclusive.
<Note>
`--json` does **not** imply non-interactive mode. Use `--non-interactive` (and `--workspace`) for scripts.
</Note>

View File

@@ -51,6 +51,13 @@ It does not install or modify anything on the remote host.
<Step title="Gateway">
- Prompts for port, bind, auth mode, and tailscale exposure.
- Recommended: keep token auth enabled even for loopback so local WS clients must authenticate.
- In token mode, interactive onboarding offers:
- **Generate/store plaintext token** (default)
- **Use SecretRef** (opt-in)
- In password mode, interactive onboarding also supports plaintext or SecretRef storage.
- Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`.
- Requires a non-empty env var in the onboarding process environment.
- Cannot be combined with `--gateway-token`.
- Disable auth only if you fully trust every local process.
- Non-loopback binds still require auth.
</Step>
@@ -206,7 +213,7 @@ Credential and profile paths:
- OAuth credentials: `~/.openclaw/credentials/oauth.json`
- Auth profiles (API keys + OAuth): `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
API key storage mode:
Credential storage mode:
- Default onboarding behavior persists API keys as plaintext values in auth profiles.
- `--secret-input-mode ref` enables reference mode instead of plaintext key storage.
@@ -222,6 +229,10 @@ API key storage mode:
- Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise onboarding fails fast.
- For custom providers, non-interactive `ref` mode stores `models.providers.<id>.apiKey` as `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`.
- In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise onboarding fails fast.
- Gateway auth credentials support plaintext and SecretRef choices in interactive onboarding:
- Token mode: **Generate/store plaintext token** (default) or **Use SecretRef**.
- Password mode: plaintext or SecretRef.
- Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`.
- Existing plaintext setups continue to work unchanged.
<Note>

View File

@@ -72,8 +72,13 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
In interactive runs, choosing secret reference mode lets you point at either an environment variable or a configured provider ref (`file` or `exec`), with a fast preflight validation before saving.
2. **Workspace** — Location for agent files (default `~/.openclaw/workspace`). Seeds bootstrap files.
3. **Gateway** — Port, bind address, auth mode, Tailscale exposure.
In interactive token mode, choose default plaintext token storage or opt into SecretRef.
Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`.
4. **Channels** — WhatsApp, Telegram, Discord, Google Chat, Mattermost, Signal, BlueBubbles, or iMessage.
5. **Daemon** — Installs a LaunchAgent (macOS) or systemd user unit (Linux/WSL2).
If token auth requires a token and `gateway.auth.token` is SecretRef-managed, daemon install validates it but does not persist the resolved token into supervisor service environment metadata.
If token auth requires a token and the configured token SecretRef is unresolved, daemon install is blocked with actionable guidance.
If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, daemon install is blocked until mode is set explicitly.
6. **Health check** — Starts the Gateway and verifies it's running.
7. **Skills** — Installs recommended skills and optional dependencies.

View File

@@ -37,10 +37,15 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel.
- **Localhost**: open `http://127.0.0.1:18789/`.
- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); the UI stores a copy in localStorage after you connect.
- If `gateway.auth.token` is SecretRef-managed, `openclaw dashboard` prints/copies/opens a non-tokenized URL by design. This avoids exposing externally managed tokens in shell logs, clipboard history, or browser-launch arguments.
- If `gateway.auth.token` is configured as a SecretRef and is unresolved in your current shell, `openclaw dashboard` still prints a non-tokenized URL plus actionable auth setup guidance.
- **Not localhost**: use Tailscale Serve (tokenless for Control UI/WebSocket if `gateway.auth.allowTailscale: true`, assumes trusted gateway host; HTTP APIs still need token/password), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web).
## If you see “unauthorized” / 1008
- Ensure the gateway is reachable (local: `openclaw status`; remote: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`).
- Retrieve the token from the gateway host: `openclaw config get gateway.auth.token` (or generate one: `openclaw doctor --generate-gateway-token`).
- Retrieve or supply the token from the gateway host:
- Plaintext config: `openclaw config get gateway.auth.token`
- SecretRef-managed config: resolve the external secret provider or export `OPENCLAW_GATEWAY_TOKEN` in this shell, then rerun `openclaw dashboard`
- No token configured: `openclaw doctor --generate-gateway-token`
- In the dashboard settings, paste the token into the auth field, then connect.

View File

@@ -99,11 +99,13 @@ export async function downloadImageFeishu(params: {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const client = createFeishuClient({
...account,
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
});
const response = await client.im.image.get({
path: { image_key: normalizedImageKey },
timeout: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
});
const buffer = await readFeishuResponseBuffer({
@@ -135,12 +137,14 @@ export async function downloadMessageResourceFeishu(params: {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const client = createFeishuClient({
...account,
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
});
const response = await client.im.messageResource.get({
path: { message_id: messageId, file_key: normalizedFileKey },
params: { type },
timeout: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
});
const buffer = await readFeishuResponseBuffer({
@@ -180,7 +184,10 @@ export async function uploadImageFeishu(params: {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const client = createFeishuClient({
...account,
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
});
// SDK accepts Buffer directly or fs.ReadStream for file paths
// Using Readable.from(buffer) causes issues with form-data library
@@ -193,7 +200,6 @@ export async function uploadImageFeishu(params: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
image: imageData as any,
},
timeout: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
});
// SDK v1.30+ returns data directly without code wrapper on success
@@ -248,7 +254,10 @@ export async function uploadFileFeishu(params: {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const client = createFeishuClient({
...account,
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
});
// SDK accepts Buffer directly or fs.ReadStream for file paths
// Using Readable.from(buffer) causes issues with form-data library
@@ -265,7 +274,6 @@ export async function uploadFileFeishu(params: {
file: fileData as any,
...(duration !== undefined && { duration }),
},
timeout: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
});
// SDK v1.30+ returns data directly without code wrapper on success

View File

@@ -107,6 +107,27 @@ describe("gateway tool defaults", () => {
expect(opts.token).toBeUndefined();
});
it("ignores unresolved local token SecretRef for strict remote overrides", () => {
configState.value = {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" },
},
remote: {
url: "wss://gateway.example",
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
};
const opts = resolveGatewayOptions({ gatewayUrl: "wss://gateway.example" });
expect(opts.token).toBeUndefined();
});
it("explicit gatewayToken overrides fallback token resolution", () => {
process.env.OPENCLAW_GATEWAY_TOKEN = "local-env-token";
configState.value = {

View File

@@ -132,4 +132,29 @@ describe("ensureBrowserControlAuth", () => {
expect(result).toEqual({ auth: { token: "latest-token" } });
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("fails when gateway.auth.token SecretRef is unresolved", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" },
},
},
browser: {
enabled: true,
},
secrets: {
providers: {
default: { source: "env" },
},
},
};
mocks.loadConfig.mockReturnValue(cfg);
await expect(ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
/MISSING_GW_TOKEN/i,
);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
});

View File

@@ -87,7 +87,10 @@ export async function ensureBrowserControlAuth(params: {
env,
persist: true,
});
const ensuredAuth = resolveBrowserControlAuth(ensured.cfg, env);
const ensuredAuth = {
token: ensured.auth.token,
password: ensured.auth.password,
};
return {
auth: ensuredAuth,
generatedToken: ensured.generatedToken,

View File

@@ -0,0 +1,117 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const loadConfigMock = vi.hoisted(() => vi.fn());
vi.mock("../config/config.js", () => ({
loadConfig: loadConfigMock,
}));
const { resolveRelayAcceptedTokensForPort } = await import("./extension-relay-auth.js");
describe("extension-relay-auth SecretRef handling", () => {
const ENV_KEYS = ["OPENCLAW_GATEWAY_TOKEN", "CLAWDBOT_GATEWAY_TOKEN", "CUSTOM_GATEWAY_TOKEN"];
const envSnapshot = new Map<string, string | undefined>();
beforeEach(() => {
for (const key of ENV_KEYS) {
envSnapshot.set(key, process.env[key]);
delete process.env[key];
}
loadConfigMock.mockReset();
});
afterEach(() => {
for (const key of ENV_KEYS) {
const previous = envSnapshot.get(key);
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
}
});
it("resolves env-template gateway.auth.token from its referenced env var", async () => {
loadConfigMock.mockReturnValue({
gateway: { auth: { token: "${CUSTOM_GATEWAY_TOKEN}" } },
secrets: { providers: { default: { source: "env" } } },
});
process.env.CUSTOM_GATEWAY_TOKEN = "resolved-gateway-token";
const tokens = await resolveRelayAcceptedTokensForPort(18790);
expect(tokens).toContain("resolved-gateway-token");
expect(tokens[0]).not.toBe("resolved-gateway-token");
});
it("fails closed when env-template gateway.auth.token is unresolved", async () => {
loadConfigMock.mockReturnValue({
gateway: { auth: { token: "${CUSTOM_GATEWAY_TOKEN}" } },
secrets: { providers: { default: { source: "env" } } },
});
await expect(resolveRelayAcceptedTokensForPort(18790)).rejects.toThrow(
"gateway.auth.token SecretRef is unavailable",
);
});
it("resolves file-backed gateway.auth.token SecretRef", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-relay-file-secret-"));
const secretFile = path.join(tempDir, "relay-secrets.json");
await fs.writeFile(secretFile, JSON.stringify({ relayToken: "resolved-file-relay-token" }));
await fs.chmod(secretFile, 0o600);
loadConfigMock.mockReturnValue({
secrets: {
providers: {
fileProvider: { source: "file", path: secretFile, mode: "json" },
},
},
gateway: {
auth: {
token: { source: "file", provider: "fileProvider", id: "/relayToken" },
},
},
});
try {
const tokens = await resolveRelayAcceptedTokensForPort(18790);
expect(tokens.length).toBeGreaterThan(0);
expect(tokens).toContain("resolved-file-relay-token");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("resolves exec-backed gateway.auth.token SecretRef", async () => {
const execProgram = [
"process.stdout.write(",
"JSON.stringify({ protocolVersion: 1, values: { RELAY_TOKEN: 'resolved-exec-relay-token' } })",
");",
].join("");
loadConfigMock.mockReturnValue({
secrets: {
providers: {
execProvider: {
source: "exec",
command: process.execPath,
args: ["-e", execProgram],
allowInsecurePath: true,
},
},
},
gateway: {
auth: {
token: { source: "exec", provider: "execProvider", id: "RELAY_TOKEN" },
},
},
});
const tokens = await resolveRelayAcceptedTokensForPort(18790);
expect(tokens.length).toBeGreaterThan(0);
expect(tokens).toContain("resolved-exec-relay-token");
});
});

View File

@@ -60,20 +60,20 @@ describe("extension-relay-auth", () => {
}
});
it("derives deterministic relay tokens per port", () => {
const tokenA1 = resolveRelayAuthTokenForPort(18790);
const tokenA2 = resolveRelayAuthTokenForPort(18790);
const tokenB = resolveRelayAuthTokenForPort(18791);
it("derives deterministic relay tokens per port", async () => {
const tokenA1 = await resolveRelayAuthTokenForPort(18790);
const tokenA2 = await resolveRelayAuthTokenForPort(18790);
const tokenB = await resolveRelayAuthTokenForPort(18791);
expect(tokenA1).toBe(tokenA2);
expect(tokenA1).not.toBe(tokenB);
expect(tokenA1).not.toBe(TEST_GATEWAY_TOKEN);
});
it("accepts both relay-scoped and raw gateway tokens for compatibility", () => {
const tokens = resolveRelayAcceptedTokensForPort(18790);
it("accepts both relay-scoped and raw gateway tokens for compatibility", async () => {
const tokens = await resolveRelayAcceptedTokensForPort(18790);
expect(tokens).toContain(TEST_GATEWAY_TOKEN);
expect(tokens[0]).not.toBe(TEST_GATEWAY_TOKEN);
expect(tokens[0]).toBe(resolveRelayAuthTokenForPort(18790));
expect(tokens[0]).toBe(await resolveRelayAuthTokenForPort(18790));
});
it("accepts authenticated openclaw relay probe responses", async () => {
@@ -89,7 +89,7 @@ describe("extension-relay-auth", () => {
res.end(JSON.stringify({ Browser: "OpenClaw/extension-relay" }));
},
async ({ port }) => {
const token = resolveRelayAuthTokenForPort(port);
const token = await resolveRelayAuthTokenForPort(port);
const ok = await probeRelay(`http://127.0.0.1:${port}`, token);
expect(ok).toBe(true);
expect(seenToken).toBe(token);

View File

@@ -1,11 +1,26 @@
import { createHmac } from "node:crypto";
import { loadConfig } from "../config/config.js";
import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
const RELAY_TOKEN_CONTEXT = "openclaw-extension-relay-v1";
const DEFAULT_RELAY_PROBE_TIMEOUT_MS = 500;
const OPENCLAW_RELAY_BROWSER = "OpenClaw/extension-relay";
function resolveGatewayAuthToken(): string | null {
class SecretRefUnavailableError extends Error {
readonly isSecretRefUnavailable = true;
}
function trimToUndefined(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
async function resolveGatewayAuthToken(): Promise<string | null> {
const envToken =
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || process.env.CLAWDBOT_GATEWAY_TOKEN?.trim();
if (envToken) {
@@ -13,11 +28,36 @@ function resolveGatewayAuthToken(): string | null {
}
try {
const cfg = loadConfig();
const configToken = cfg.gateway?.auth?.token?.trim();
const tokenRef = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
}).ref;
if (tokenRef) {
const refLabel = `${tokenRef.source}:${tokenRef.provider}:${tokenRef.id}`;
try {
const resolved = await resolveSecretRefValues([tokenRef], {
config: cfg,
env: process.env,
});
const resolvedToken = trimToUndefined(resolved.get(secretRefKey(tokenRef)));
if (resolvedToken) {
return resolvedToken;
}
} catch {
// handled below
}
throw new SecretRefUnavailableError(
`extension relay requires a resolved gateway token, but gateway.auth.token SecretRef is unavailable (${refLabel}). Set OPENCLAW_GATEWAY_TOKEN or resolve your secret provider.`,
);
}
const configToken = normalizeSecretInputString(cfg.gateway?.auth?.token);
if (configToken) {
return configToken;
}
} catch {
} catch (err) {
if (err instanceof SecretRefUnavailableError) {
throw err;
}
// ignore config read failures; caller can fallback to per-process random token
}
return null;
@@ -27,8 +67,8 @@ function deriveRelayAuthToken(gatewayToken: string, port: number): string {
return createHmac("sha256", gatewayToken).update(`${RELAY_TOKEN_CONTEXT}:${port}`).digest("hex");
}
export function resolveRelayAcceptedTokensForPort(port: number): string[] {
const gatewayToken = resolveGatewayAuthToken();
export async function resolveRelayAcceptedTokensForPort(port: number): Promise<string[]> {
const gatewayToken = await resolveGatewayAuthToken();
if (!gatewayToken) {
throw new Error(
"extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)",
@@ -41,8 +81,8 @@ export function resolveRelayAcceptedTokensForPort(port: number): string[] {
return [relayToken, gatewayToken];
}
export function resolveRelayAuthTokenForPort(port: number): string {
return resolveRelayAcceptedTokensForPort(port)[0];
export async function resolveRelayAuthTokenForPort(port: number): Promise<string> {
return (await resolveRelayAcceptedTokensForPort(port))[0];
}
export async function probeAuthenticatedOpenClawRelay(params: {

View File

@@ -249,8 +249,8 @@ export async function ensureChromeExtensionRelayServer(opts: {
);
const initPromise = (async (): Promise<ChromeExtensionRelayServer> => {
const relayAuthToken = resolveRelayAuthTokenForPort(info.port);
const relayAuthTokens = new Set(resolveRelayAcceptedTokensForPort(info.port));
const relayAuthToken = await resolveRelayAuthTokenForPort(info.port);
const relayAuthTokens = new Set(await resolveRelayAcceptedTokensForPort(info.port));
let extensionWs: WebSocket | null = null;
const cdpClients = new Set<WebSocket>();

View File

@@ -0,0 +1,147 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { makeTempWorkspace } from "../../test-helpers/workspace.js";
import { captureEnv } from "../../test-utils/env.js";
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const serviceMock = vi.hoisted(() => ({
label: "Gateway",
loadedText: "loaded",
notLoadedText: "not loaded",
install: vi.fn(async (_opts?: { environment?: Record<string, string | undefined> }) => {}),
uninstall: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
restart: vi.fn(async () => {}),
isLoaded: vi.fn(async () => false),
readCommand: vi.fn(async () => null),
readRuntime: vi.fn(async () => ({ status: "stopped" as const })),
}));
vi.mock("../../daemon/service.js", () => ({
resolveGatewayService: () => serviceMock,
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: {
log: (message: string) => runtimeLogs.push(message),
error: (message: string) => runtimeErrors.push(message),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
},
}));
const { runDaemonInstall } = await import("./install.js");
const { clearConfigCache } = await import("../../config/config.js");
async function readJson(filePath: string): Promise<Record<string, unknown>> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as Record<string, unknown>;
}
describe("runDaemonInstall integration", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
let tempHome: string;
let configPath: string;
beforeAll(async () => {
envSnapshot = captureEnv([
"HOME",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_GATEWAY_TOKEN",
"CLAWDBOT_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD",
"CLAWDBOT_GATEWAY_PASSWORD",
]);
tempHome = await makeTempWorkspace("openclaw-daemon-install-int-");
configPath = path.join(tempHome, "openclaw.json");
process.env.HOME = tempHome;
process.env.OPENCLAW_STATE_DIR = tempHome;
process.env.OPENCLAW_CONFIG_PATH = configPath;
});
afterAll(async () => {
envSnapshot.restore();
await fs.rm(tempHome, { recursive: true, force: true });
});
beforeEach(async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
vi.clearAllMocks();
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
serviceMock.isLoaded.mockResolvedValue(false);
await fs.writeFile(configPath, JSON.stringify({}, null, 2));
clearConfigCache();
});
it("fails closed when token SecretRef is required but unresolved", async () => {
await fs.writeFile(
configPath,
JSON.stringify(
{
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
auth: {
mode: "token",
token: {
source: "env",
provider: "default",
id: "MISSING_GATEWAY_TOKEN",
},
},
},
},
null,
2,
),
);
clearConfigCache();
await expect(runDaemonInstall({ json: true })).rejects.toThrow("__exit__:1");
expect(serviceMock.install).not.toHaveBeenCalled();
const joined = runtimeLogs.join("\n");
expect(joined).toContain("SecretRef is configured but unresolved");
expect(joined).toContain("MISSING_GATEWAY_TOKEN");
});
it("auto-mints token when no source exists and persists the same token used for install env", async () => {
await fs.writeFile(
configPath,
JSON.stringify(
{
gateway: {
auth: {
mode: "token",
},
},
},
null,
2,
),
);
clearConfigCache();
await runDaemonInstall({ json: true });
expect(serviceMock.install).toHaveBeenCalledTimes(1);
const updated = await readJson(configPath);
const gateway = (updated.gateway ?? {}) as { auth?: { token?: string } };
const persistedToken = gateway.auth?.token;
expect(typeof persistedToken).toBe("string");
expect((persistedToken ?? "").length).toBeGreaterThan(0);
const installEnv = serviceMock.install.mock.calls[0]?.[0]?.environment;
expect(installEnv?.OPENCLAW_GATEWAY_TOKEN).toBe(persistedToken);
});
});

View File

@@ -0,0 +1,249 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonActionResponse } from "./response.js";
const loadConfigMock = vi.hoisted(() => vi.fn());
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789));
const writeConfigFileMock = vi.hoisted(() => vi.fn());
const resolveIsNixModeMock = vi.hoisted(() => vi.fn(() => false));
const resolveSecretInputRefMock = vi.hoisted(() =>
vi.fn((): { ref: unknown } => ({ ref: undefined })),
);
const resolveGatewayAuthMock = vi.hoisted(() =>
vi.fn(() => ({
mode: "token",
token: undefined,
password: undefined,
allowTailscale: false,
})),
);
const resolveSecretRefValuesMock = vi.hoisted(() => vi.fn());
const randomTokenMock = vi.hoisted(() => vi.fn(() => "generated-token"));
const buildGatewayInstallPlanMock = vi.hoisted(() =>
vi.fn(async () => ({
programArguments: ["openclaw", "gateway", "run"],
workingDirectory: "/tmp",
environment: {},
})),
);
const parsePortMock = vi.hoisted(() => vi.fn(() => null));
const isGatewayDaemonRuntimeMock = vi.hoisted(() => vi.fn(() => true));
const installDaemonServiceAndEmitMock = vi.hoisted(() => vi.fn(async () => {}));
const actionState = vi.hoisted(() => ({
warnings: [] as string[],
emitted: [] as DaemonActionResponse[],
failed: [] as Array<{ message: string; hints?: string[] }>,
}));
const service = vi.hoisted(() => ({
label: "Gateway",
loadedText: "loaded",
notLoadedText: "not loaded",
isLoaded: vi.fn(async () => false),
install: vi.fn(async () => {}),
uninstall: vi.fn(async () => {}),
restart: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
readCommand: vi.fn(async () => null),
readRuntime: vi.fn(async () => ({ status: "stopped" as const })),
}));
vi.mock("../../config/config.js", () => ({
loadConfig: loadConfigMock,
readConfigFileSnapshot: readConfigFileSnapshotMock,
resolveGatewayPort: resolveGatewayPortMock,
writeConfigFile: writeConfigFileMock,
}));
vi.mock("../../config/paths.js", () => ({
resolveIsNixMode: resolveIsNixModeMock,
}));
vi.mock("../../config/types.secrets.js", () => ({
resolveSecretInputRef: resolveSecretInputRefMock,
}));
vi.mock("../../gateway/auth.js", () => ({
resolveGatewayAuth: resolveGatewayAuthMock,
}));
vi.mock("../../secrets/resolve.js", () => ({
resolveSecretRefValues: resolveSecretRefValuesMock,
}));
vi.mock("../../commands/onboard-helpers.js", () => ({
randomToken: randomTokenMock,
}));
vi.mock("../../commands/daemon-install-helpers.js", () => ({
buildGatewayInstallPlan: buildGatewayInstallPlanMock,
}));
vi.mock("./shared.js", () => ({
parsePort: parsePortMock,
}));
vi.mock("../../commands/daemon-runtime.js", () => ({
DEFAULT_GATEWAY_DAEMON_RUNTIME: "node",
isGatewayDaemonRuntime: isGatewayDaemonRuntimeMock,
}));
vi.mock("../../daemon/service.js", () => ({
resolveGatewayService: () => service,
}));
vi.mock("./response.js", () => ({
buildDaemonServiceSnapshot: vi.fn(),
createDaemonActionContext: vi.fn(() => ({
stdout: process.stdout,
warnings: actionState.warnings,
emit: (payload: DaemonActionResponse) => {
actionState.emitted.push(payload);
},
fail: (message: string, hints?: string[]) => {
actionState.failed.push({ message, hints });
},
})),
installDaemonServiceAndEmit: installDaemonServiceAndEmitMock,
}));
const runtimeLogs: string[] = [];
vi.mock("../../runtime.js", () => ({
defaultRuntime: {
log: (message: string) => runtimeLogs.push(message),
error: vi.fn(),
exit: vi.fn(),
},
}));
const { runDaemonInstall } = await import("./install.js");
describe("runDaemonInstall", () => {
beforeEach(() => {
loadConfigMock.mockReset();
readConfigFileSnapshotMock.mockReset();
resolveGatewayPortMock.mockClear();
writeConfigFileMock.mockReset();
resolveIsNixModeMock.mockReset();
resolveSecretInputRefMock.mockReset();
resolveGatewayAuthMock.mockReset();
resolveSecretRefValuesMock.mockReset();
randomTokenMock.mockReset();
buildGatewayInstallPlanMock.mockReset();
parsePortMock.mockReset();
isGatewayDaemonRuntimeMock.mockReset();
installDaemonServiceAndEmitMock.mockReset();
service.isLoaded.mockReset();
runtimeLogs.length = 0;
actionState.warnings.length = 0;
actionState.emitted.length = 0;
actionState.failed.length = 0;
loadConfigMock.mockReturnValue({ gateway: { auth: { mode: "token" } } });
readConfigFileSnapshotMock.mockResolvedValue({ exists: false, valid: true, config: {} });
resolveGatewayPortMock.mockReturnValue(18789);
resolveIsNixModeMock.mockReturnValue(false);
resolveSecretInputRefMock.mockReturnValue({ ref: undefined });
resolveGatewayAuthMock.mockReturnValue({
mode: "token",
token: undefined,
password: undefined,
allowTailscale: false,
});
resolveSecretRefValuesMock.mockResolvedValue(new Map());
randomTokenMock.mockReturnValue("generated-token");
buildGatewayInstallPlanMock.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
workingDirectory: "/tmp",
environment: {},
});
parsePortMock.mockReturnValue(null);
isGatewayDaemonRuntimeMock.mockReturnValue(true);
installDaemonServiceAndEmitMock.mockResolvedValue(undefined);
service.isLoaded.mockResolvedValue(false);
});
it("fails install when token auth requires an unresolved token SecretRef", async () => {
resolveSecretInputRefMock.mockReturnValue({
ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
});
resolveSecretRefValuesMock.mockRejectedValue(new Error("secret unavailable"));
await runDaemonInstall({ json: true });
expect(actionState.failed[0]?.message).toContain("gateway.auth.token SecretRef is configured");
expect(actionState.failed[0]?.message).toContain("unresolved");
expect(buildGatewayInstallPlanMock).not.toHaveBeenCalled();
expect(installDaemonServiceAndEmitMock).not.toHaveBeenCalled();
});
it("validates token SecretRef but does not serialize resolved token into service env", async () => {
resolveSecretInputRefMock.mockReturnValue({
ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
});
resolveSecretRefValuesMock.mockResolvedValue(
new Map([["env:default:OPENCLAW_GATEWAY_TOKEN", "resolved-from-secretref"]]),
);
await runDaemonInstall({ json: true });
expect(actionState.failed).toEqual([]);
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
}),
);
expect(writeConfigFileMock).not.toHaveBeenCalled();
expect(
actionState.warnings.some((warning) =>
warning.includes("gateway.auth.token is SecretRef-managed"),
),
).toBe(true);
});
it("does not treat env-template gateway.auth.token as plaintext during install", async () => {
loadConfigMock.mockReturnValue({
gateway: { auth: { mode: "token", token: "${OPENCLAW_GATEWAY_TOKEN}" } },
});
resolveSecretInputRefMock.mockReturnValue({
ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
});
resolveSecretRefValuesMock.mockResolvedValue(
new Map([["env:default:OPENCLAW_GATEWAY_TOKEN", "resolved-from-secretref"]]),
);
await runDaemonInstall({ json: true });
expect(actionState.failed).toEqual([]);
expect(resolveSecretRefValuesMock).toHaveBeenCalledTimes(1);
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
}),
);
});
it("auto-mints and persists token when no source exists", async () => {
randomTokenMock.mockReturnValue("minted-token");
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
config: { gateway: { auth: { mode: "token" } } },
});
await runDaemonInstall({ json: true });
expect(actionState.failed).toEqual([]);
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
const writtenConfig = writeConfigFileMock.mock.calls[0]?.[0] as {
gateway?: { auth?: { token?: string } };
};
expect(writtenConfig.gateway?.auth?.token).toBe("minted-token");
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
expect.objectContaining({ token: "minted-token", port: 18789 }),
);
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
expect(actionState.warnings.some((warning) => warning.includes("Auto-generated"))).toBe(true);
});
});

View File

@@ -3,16 +3,10 @@ import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
isGatewayDaemonRuntime,
} from "../../commands/daemon-runtime.js";
import { randomToken } from "../../commands/onboard-helpers.js";
import {
loadConfig,
readConfigFileSnapshot,
resolveGatewayPort,
writeConfigFile,
} from "../../config/config.js";
import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js";
import { loadConfig, resolveGatewayPort } from "../../config/config.js";
import { resolveIsNixMode } from "../../config/paths.js";
import { resolveGatewayService } from "../../daemon/service.js";
import { resolveGatewayAuth } from "../../gateway/auth.js";
import { defaultRuntime } from "../../runtime.js";
import { formatCliCommand } from "../command-format.js";
import {
@@ -75,78 +69,29 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
}
}
// Resolve effective auth mode to determine if token auto-generation is needed.
// Password-mode and Tailscale-only installs do not need a token.
const resolvedAuth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
const tokenResolution = await resolveGatewayInstallToken({
config: cfg,
env: process.env,
explicitToken: opts.token,
autoGenerateWhenMissing: true,
persistGeneratedToken: true,
});
const needsToken =
resolvedAuth.mode === "token" && !resolvedAuth.token && !resolvedAuth.allowTailscale;
let token: string | undefined =
opts.token ||
cfg.gateway?.auth?.token ||
process.env.OPENCLAW_GATEWAY_TOKEN ||
process.env.CLAWDBOT_GATEWAY_TOKEN;
if (!token && needsToken) {
token = randomToken();
const warnMsg = "No gateway token found. Auto-generated one and saving to config.";
if (tokenResolution.unavailableReason) {
fail(`Gateway install blocked: ${tokenResolution.unavailableReason}`);
return;
}
for (const warning of tokenResolution.warnings) {
if (json) {
warnings.push(warnMsg);
warnings.push(warning);
} else {
defaultRuntime.log(warnMsg);
}
// Persist to config file so the gateway reads it at runtime
// (launchd does not inherit shell env vars, and CLI tools also
// read gateway.auth.token from config for gateway calls).
try {
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
// Config file exists but is corrupt/unparseable — don't risk overwriting.
// Token is still embedded in the plist EnvironmentVariables.
const msg = "Warning: config file exists but is invalid; skipping token persistence.";
if (json) {
warnings.push(msg);
} else {
defaultRuntime.log(msg);
}
} else {
const baseConfig = snapshot.exists ? snapshot.config : {};
if (!baseConfig.gateway?.auth?.token) {
await writeConfigFile({
...baseConfig,
gateway: {
...baseConfig.gateway,
auth: {
...baseConfig.gateway?.auth,
mode: baseConfig.gateway?.auth?.mode ?? "token",
token,
},
},
});
} else {
// Another process wrote a token between loadConfig() and now.
token = baseConfig.gateway.auth.token;
}
}
} catch (err) {
// Non-fatal: token is still embedded in the plist EnvironmentVariables.
const msg = `Warning: could not persist token to config: ${String(err)}`;
if (json) {
warnings.push(msg);
} else {
defaultRuntime.log(msg);
}
defaultRuntime.log(warning);
}
}
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port,
token,
token: tokenResolution.token,
runtime: runtimeRaw,
warn: (message) => {
if (json) {

View File

@@ -5,7 +5,10 @@ import { checkTokenDrift } from "../../daemon/service-audit.js";
import type { GatewayService } from "../../daemon/service.js";
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
import { resolveGatewayCredentialsFromConfig } from "../../gateway/credentials.js";
import {
isGatewaySecretRefUnavailableError,
resolveGatewayCredentialsFromConfig,
} from "../../gateway/credentials.js";
import { isWSL } from "../../infra/wsl.js";
import { defaultRuntime } from "../../runtime.js";
import {
@@ -299,8 +302,15 @@ export async function runServiceRestart(params: {
}
}
}
} catch {
// Non-fatal: token drift check is best-effort
} catch (err) {
if (isGatewaySecretRefUnavailableError(err, "gateway.auth.token")) {
const warning =
"Unable to verify gateway token drift: gateway.auth.token SecretRef is configured but unavailable in this command path.";
warnings.push(warning);
if (!json) {
defaultRuntime.log(`\n⚠ ${warning}\n`);
}
}
}
}

View File

@@ -123,12 +123,14 @@ describe("gatherDaemonStatus", () => {
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD",
"DAEMON_GATEWAY_TOKEN",
"DAEMON_GATEWAY_PASSWORD",
]);
process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-cli";
process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-cli/openclaw.json";
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
delete process.env.DAEMON_GATEWAY_TOKEN;
delete process.env.DAEMON_GATEWAY_PASSWORD;
callGatewayStatusProbe.mockClear();
loadGatewayTlsRuntime.mockClear();
@@ -218,6 +220,37 @@ describe("gatherDaemonStatus", () => {
);
});
it("resolves daemon gateway auth token SecretRef values before probing", async () => {
daemonLoadedConfig = {
gateway: {
bind: "lan",
tls: { enabled: true },
auth: {
mode: "token",
token: "${DAEMON_GATEWAY_TOKEN}",
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
};
process.env.DAEMON_GATEWAY_TOKEN = "daemon-secretref-token";
await gatherDaemonStatus({
rpc: {},
probe: true,
deep: false,
});
expect(callGatewayStatusProbe).toHaveBeenCalledWith(
expect.objectContaining({
token: "daemon-secretref-token",
}),
);
});
it("does not resolve daemon password SecretRef when token auth is configured", async () => {
daemonLoadedConfig = {
gateway: {

View File

@@ -9,7 +9,11 @@ import type {
GatewayBindMode,
GatewayControlUiConfig,
} from "../../config/types.js";
import { normalizeSecretInputString, resolveSecretInputRef } from "../../config/types.secrets.js";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
resolveSecretInputRef,
} from "../../config/types.secrets.js";
import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js";
import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
import { findExtraGatewayServices } from "../../daemon/inspect.js";
@@ -114,6 +118,61 @@ function readGatewayTokenEnv(env: Record<string, string | undefined>): string |
return trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN) ?? trimToUndefined(env.CLAWDBOT_GATEWAY_TOKEN);
}
function readGatewayPasswordEnv(env: Record<string, string | undefined>): string | undefined {
return (
trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD) ?? trimToUndefined(env.CLAWDBOT_GATEWAY_PASSWORD)
);
}
async function resolveDaemonProbeToken(params: {
daemonCfg: OpenClawConfig;
mergedDaemonEnv: Record<string, string | undefined>;
explicitToken?: string;
explicitPassword?: string;
}): Promise<string | undefined> {
const explicitToken = trimToUndefined(params.explicitToken);
if (explicitToken) {
return explicitToken;
}
const envToken = readGatewayTokenEnv(params.mergedDaemonEnv);
if (envToken) {
return envToken;
}
const defaults = params.daemonCfg.secrets?.defaults;
const configured = params.daemonCfg.gateway?.auth?.token;
const { ref } = resolveSecretInputRef({
value: configured,
defaults,
});
if (!ref) {
return normalizeSecretInputString(configured);
}
const authMode = params.daemonCfg.gateway?.auth?.mode;
if (authMode === "password" || authMode === "none" || authMode === "trusted-proxy") {
return undefined;
}
if (authMode !== "token") {
const passwordCandidate =
trimToUndefined(params.explicitPassword) ||
readGatewayPasswordEnv(params.mergedDaemonEnv) ||
(hasConfiguredSecretInput(params.daemonCfg.gateway?.auth?.password, defaults)
? "__configured__"
: undefined);
if (passwordCandidate) {
return undefined;
}
}
const resolved = await resolveSecretRefValues([ref], {
config: params.daemonCfg,
env: params.mergedDaemonEnv as NodeJS.ProcessEnv,
});
const token = trimToUndefined(resolved.get(secretRefKey(ref)));
if (!token) {
throw new Error("gateway.auth.token resolved to an empty or non-string value.");
}
return token;
}
async function resolveDaemonProbePassword(params: {
daemonCfg: OpenClawConfig;
mergedDaemonEnv: Record<string, string | undefined>;
@@ -124,7 +183,7 @@ async function resolveDaemonProbePassword(params: {
if (explicitPassword) {
return explicitPassword;
}
const envPassword = trimToUndefined(params.mergedDaemonEnv.OPENCLAW_GATEWAY_PASSWORD);
const envPassword = readGatewayPasswordEnv(params.mergedDaemonEnv);
if (envPassword) {
return envPassword;
}
@@ -145,7 +204,9 @@ async function resolveDaemonProbePassword(params: {
const tokenCandidate =
trimToUndefined(params.explicitToken) ||
readGatewayTokenEnv(params.mergedDaemonEnv) ||
trimToUndefined(params.daemonCfg.gateway?.auth?.token);
(hasConfiguredSecretInput(params.daemonCfg.gateway?.auth?.token, defaults)
? "__configured__"
: undefined);
if (tokenCandidate) {
return undefined;
}
@@ -290,14 +351,19 @@ export async function gatherDaemonStatus(
explicitPassword: opts.rpc.password,
})
: undefined;
const daemonProbeToken = opts.probe
? await resolveDaemonProbeToken({
daemonCfg,
mergedDaemonEnv,
explicitToken: opts.rpc.token,
explicitPassword: opts.rpc.password,
})
: undefined;
const rpc = opts.probe
? await probeGatewayStatus({
url: probeUrl,
token:
opts.rpc.token ||
mergedDaemonEnv.OPENCLAW_GATEWAY_TOKEN ||
daemonCfg.gateway?.auth?.token,
token: daemonProbeToken,
password: daemonProbePassword,
tlsFingerprint:
shouldUseLocalTlsRuntime && tlsRuntime?.enabled

View File

@@ -17,24 +17,45 @@ const ensureDevGatewayConfig = vi.fn(async (_opts?: unknown) => {});
const runGatewayLoop = vi.fn(async ({ start }: { start: () => Promise<unknown> }) => {
await start();
});
const configState = vi.hoisted(() => ({
cfg: {} as Record<string, unknown>,
snapshot: { exists: false } as Record<string, unknown>,
}));
const { runtimeErrors, defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture();
vi.mock("../../config/config.js", () => ({
getConfigPath: () => "/tmp/openclaw-test-missing-config.json",
loadConfig: () => ({}),
readConfigFileSnapshot: async () => ({ exists: false }),
loadConfig: () => configState.cfg,
readConfigFileSnapshot: async () => configState.snapshot,
resolveStateDir: () => "/tmp",
resolveGatewayPort: () => 18789,
}));
vi.mock("../../gateway/auth.js", () => ({
resolveGatewayAuth: (params: { authConfig?: { token?: string }; env?: NodeJS.ProcessEnv }) => ({
mode: "token",
token: params.authConfig?.token ?? params.env?.OPENCLAW_GATEWAY_TOKEN,
password: undefined,
allowTailscale: false,
}),
resolveGatewayAuth: (params: {
authConfig?: { mode?: string; token?: unknown; password?: unknown };
authOverride?: { mode?: string; token?: unknown; password?: unknown };
env?: NodeJS.ProcessEnv;
}) => {
const mode = params.authOverride?.mode ?? params.authConfig?.mode ?? "token";
const token =
(typeof params.authOverride?.token === "string" ? params.authOverride.token : undefined) ??
(typeof params.authConfig?.token === "string" ? params.authConfig.token : undefined) ??
params.env?.OPENCLAW_GATEWAY_TOKEN;
const password =
(typeof params.authOverride?.password === "string"
? params.authOverride.password
: undefined) ??
(typeof params.authConfig?.password === "string" ? params.authConfig.password : undefined) ??
params.env?.OPENCLAW_GATEWAY_PASSWORD;
return {
mode,
token,
password,
allowTailscale: false,
};
},
}));
vi.mock("../../gateway/server.js", () => ({
@@ -106,6 +127,8 @@ describe("gateway run option collisions", () => {
beforeEach(() => {
resetRuntimeCapture();
configState.cfg = {};
configState.snapshot = { exists: false };
startGatewayServer.mockClear();
setGatewayWsLogStyle.mockClear();
setVerbose.mockClear();
@@ -190,4 +213,30 @@ describe("gateway run option collisions", () => {
'Invalid --auth (use "none", "token", "password", or "trusted-proxy")',
);
});
it("allows password mode preflight when password is configured via SecretRef", async () => {
configState.cfg = {
gateway: {
auth: {
mode: "password",
password: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_PASSWORD" },
},
},
secrets: {
defaults: {
env: "default",
},
},
};
configState.snapshot = { exists: true, parsed: configState.cfg };
await runGatewayCli(["gateway", "run", "--allow-unconfigured"]);
expect(startGatewayServer).toHaveBeenCalledWith(
18789,
expect.objectContaining({
bind: "loopback",
}),
);
});
});

View File

@@ -9,6 +9,7 @@ import {
resolveStateDir,
resolveGatewayPort,
} from "../../config/config.js";
import { hasConfiguredSecretInput } from "../../config/types.secrets.js";
import { resolveGatewayAuth } from "../../gateway/auth.js";
import { startGatewayServer } from "../../gateway/server.js";
import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js";
@@ -308,9 +309,22 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
const passwordValue = resolvedAuth.password;
const hasToken = typeof tokenValue === "string" && tokenValue.trim().length > 0;
const hasPassword = typeof passwordValue === "string" && passwordValue.trim().length > 0;
const tokenConfigured =
hasToken ||
hasConfiguredSecretInput(
authOverride?.token ?? cfg.gateway?.auth?.token,
cfg.secrets?.defaults,
);
const passwordConfigured =
hasPassword ||
hasConfiguredSecretInput(
authOverride?.password ?? cfg.gateway?.auth?.password,
cfg.secrets?.defaults,
);
const hasSharedSecret =
(resolvedAuthMode === "token" && hasToken) || (resolvedAuthMode === "password" && hasPassword);
const canBootstrapToken = resolvedAuthMode === "token" && !hasToken;
(resolvedAuthMode === "token" && tokenConfigured) ||
(resolvedAuthMode === "password" && passwordConfigured);
const canBootstrapToken = resolvedAuthMode === "token" && !tokenConfigured;
const authHints: string[] = [];
if (miskeys.hasGatewayToken) {
authHints.push('Found "gateway.token" in config. Use "gateway.auth.token" instead.');
@@ -320,7 +334,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
'"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.',
);
}
if (resolvedAuthMode === "password" && !hasPassword) {
if (resolvedAuthMode === "password" && !passwordConfigured) {
defaultRuntime.error(
[
"Gateway auth is set to password, but no password is configured.",

View File

@@ -129,6 +129,16 @@ describe("registerOnboardCommand", () => {
);
});
it("forwards --gateway-token-ref-env", async () => {
await runCli(["onboard", "--gateway-token-ref-env", "OPENCLAW_GATEWAY_TOKEN"]);
expect(onboardCommandMock).toHaveBeenCalledWith(
expect.objectContaining({
gatewayTokenRefEnv: "OPENCLAW_GATEWAY_TOKEN",
}),
runtime,
);
});
it("reports errors via runtime on onboard command failures", async () => {
onboardCommandMock.mockRejectedValueOnce(new Error("onboard failed"));

View File

@@ -104,6 +104,10 @@ export function registerOnboardCommand(program: Command) {
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
.option("--gateway-auth <mode>", "Gateway auth: token|password")
.option("--gateway-token <token>", "Gateway token (token auth)")
.option(
"--gateway-token-ref-env <name>",
"Gateway token SecretRef env var name (token auth; e.g. OPENCLAW_GATEWAY_TOKEN)",
)
.option("--gateway-password <password>", "Gateway password (password auth)")
.option("--remote-url <url>", "Remote Gateway WebSocket URL")
.option("--remote-token <token>", "Remote Gateway token (optional)")
@@ -177,6 +181,7 @@ export function registerOnboardCommand(program: Command) {
gatewayBind: opts.gatewayBind as GatewayBind | undefined,
gatewayAuth: opts.gatewayAuth as GatewayAuthChoice | undefined,
gatewayToken: opts.gatewayToken as string | undefined,
gatewayTokenRefEnv: opts.gatewayTokenRefEnv as string | undefined,
gatewayPassword: opts.gatewayPassword as string | undefined,
remoteUrl: opts.remoteUrl as string | undefined,
remoteToken: opts.remoteToken as string | undefined,

View File

@@ -293,6 +293,30 @@ describe("registerQrCli", () => {
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
});
it("fails when token and password SecretRefs are both configured with inferred mode", async () => {
vi.stubEnv("QR_INFERRED_GATEWAY_TOKEN", "inferred-token");
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
token: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_TOKEN" },
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
},
},
});
await expectQrExit(["--setup-code-only"]);
const output = runtime.error.mock.calls.map((call) => String(call[0] ?? "")).join("\n");
expect(output).toContain("gateway.auth.mode is unset");
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
});
it("exits with error when gateway config is not pairable", async () => {
loadConfig.mockReturnValue({
gateway: {

View File

@@ -1,7 +1,7 @@
import type { Command } from "commander";
import qrcode from "qrcode-terminal";
import { loadConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js";
import { resolvePairingSetupFromConfig, encodePairingSetupCode } from "../pairing/setup-code.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime } from "../runtime.js";
@@ -81,11 +81,11 @@ function shouldResolveLocalGatewayPasswordSecret(
return false;
}
const envToken = readGatewayTokenEnv(env);
const configToken =
typeof cfg.gateway?.auth?.token === "string" && cfg.gateway.auth.token.trim().length > 0
? cfg.gateway.auth.token.trim()
: undefined;
return !envToken && !configToken;
const configTokenConfigured = hasConfiguredSecretInput(
cfg.gateway?.auth?.token,
cfg.secrets?.defaults,
);
return !envToken && !configTokenConfigured;
}
async function resolveLocalGatewayPasswordSecretIfNeeded(

View File

@@ -0,0 +1,168 @@
import { Command } from "commander";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../test-utils/env.js";
const loadConfigMock = vi.hoisted(() => vi.fn());
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789));
const copyToClipboardMock = vi.hoisted(() => vi.fn(async () => false));
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const runtime = vi.hoisted(() => ({
log: (message: string) => runtimeLogs.push(message),
error: (message: string) => runtimeErrors.push(message),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: loadConfigMock,
readConfigFileSnapshot: readConfigFileSnapshotMock,
resolveGatewayPort: resolveGatewayPortMock,
};
});
vi.mock("../infra/clipboard.js", () => ({
copyToClipboard: copyToClipboardMock,
}));
vi.mock("../runtime.js", () => ({
defaultRuntime: runtime,
}));
const { registerQrCli } = await import("./qr-cli.js");
const { registerMaintenanceCommands } = await import("./program/register.maintenance.js");
function createGatewayTokenRefFixture() {
return {
secrets: {
providers: {
default: {
source: "env",
},
},
defaults: {
env: "default",
},
},
gateway: {
bind: "custom",
customBindHost: "gateway.local",
port: 18789,
auth: {
mode: "token",
token: {
source: "env",
provider: "default",
id: "SHARED_GATEWAY_TOKEN",
},
},
},
};
}
function decodeSetupCode(setupCode: string): { url?: string; token?: string; password?: string } {
const padded = setupCode.replace(/-/g, "+").replace(/_/g, "/");
const padLength = (4 - (padded.length % 4)) % 4;
const normalized = padded + "=".repeat(padLength);
const json = Buffer.from(normalized, "base64").toString("utf8");
return JSON.parse(json) as { url?: string; token?: string; password?: string };
}
async function runCli(args: string[]): Promise<void> {
const program = new Command();
registerQrCli(program);
registerMaintenanceCommands(program);
await program.parseAsync(args, { from: "user" });
}
describe("cli integration: qr + dashboard token SecretRef", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
beforeAll(() => {
envSnapshot = captureEnv([
"SHARED_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_TOKEN",
"CLAWDBOT_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD",
"CLAWDBOT_GATEWAY_PASSWORD",
]);
});
afterAll(() => {
envSnapshot.restore();
});
beforeEach(() => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
vi.clearAllMocks();
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
delete process.env.SHARED_GATEWAY_TOKEN;
});
it("uses the same resolved token SecretRef for both qr and dashboard commands", async () => {
const fixture = createGatewayTokenRefFixture();
process.env.SHARED_GATEWAY_TOKEN = "shared-token-123";
loadConfigMock.mockReturnValue(fixture);
readConfigFileSnapshotMock.mockResolvedValue({
path: "/tmp/openclaw.json",
exists: true,
valid: true,
issues: [],
config: fixture,
});
await runCli(["qr", "--setup-code-only"]);
const setupCode = runtimeLogs.at(-1);
expect(setupCode).toBeTruthy();
const payload = decodeSetupCode(setupCode ?? "");
expect(payload.url).toBe("ws://gateway.local:18789");
expect(payload.token).toBe("shared-token-123");
expect(runtimeErrors).toEqual([]);
runtimeLogs.length = 0;
runtimeErrors.length = 0;
await runCli(["dashboard", "--no-open"]);
const joined = runtimeLogs.join("\n");
expect(joined).toContain("Dashboard URL: http://127.0.0.1:18789/");
expect(joined).not.toContain("#token=");
expect(joined).toContain(
"Token auto-auth is disabled for SecretRef-managed gateway.auth.token",
);
expect(joined).not.toContain("Token auto-auth unavailable");
expect(runtimeErrors).toEqual([]);
});
it("fails qr but keeps dashboard actionable when the shared token SecretRef is unresolved", async () => {
const fixture = createGatewayTokenRefFixture();
loadConfigMock.mockReturnValue(fixture);
readConfigFileSnapshotMock.mockResolvedValue({
path: "/tmp/openclaw.json",
exists: true,
valid: true,
issues: [],
config: fixture,
});
await expect(runCli(["qr", "--setup-code-only"])).rejects.toThrow("__exit__:1");
expect(runtimeErrors.join("\n")).toMatch(/SHARED_GATEWAY_TOKEN/);
runtimeLogs.length = 0;
runtimeErrors.length = 0;
await runCli(["dashboard", "--no-open"]);
const joined = runtimeLogs.join("\n");
expect(joined).toContain("Dashboard URL: http://127.0.0.1:18789/");
expect(joined).not.toContain("#token=");
expect(joined).toContain("Token auto-auth unavailable");
expect(joined).toContain("Set OPENCLAW_GATEWAY_TOKEN");
});
});

View File

@@ -1,6 +1,10 @@
import { resolveEnvApiKey } from "../agents/model-auth.js";
import type { OpenClawConfig } from "../config/types.js";
import { type SecretInput, type SecretRef } from "../config/types.secrets.js";
import {
isValidEnvSecretRefId,
type SecretInput,
type SecretRef,
} from "../config/types.secrets.js";
import { encodeJsonPointerToken } from "../secrets/json-pointer.js";
import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js";
import {
@@ -15,7 +19,6 @@ import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
import type { SecretInputMode } from "./onboard-types.js";
const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/;
const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/;
type SecretRefChoice = "env" | "provider";
@@ -127,7 +130,7 @@ export async function promptSecretRefForOnboarding(params: {
placeholder: params.copy?.envVarPlaceholder ?? "OPENAI_API_KEY",
validate: (value) => {
const candidate = value.trim();
if (!ENV_SECRET_REF_ID_RE.test(candidate)) {
if (!isValidEnvSecretRefId(candidate)) {
return (
params.copy?.envVarFormatError ??
'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).'
@@ -144,7 +147,7 @@ export async function promptSecretRefForOnboarding(params: {
});
const envCandidate = String(envVarRaw ?? "").trim();
const envVar =
envCandidate && ENV_SECRET_REF_ID_RE.test(envCandidate) ? envCandidate : defaultEnvVar;
envCandidate && isValidEnvSecretRefId(envCandidate) ? envCandidate : defaultEnvVar;
if (!envVar) {
throw new Error(
`No valid environment variable name provided for provider "${params.provider}".`,

View File

@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const withProgress = vi.hoisted(() => vi.fn(async (_opts, run) => run({ setLabel: vi.fn() })));
const loadConfig = vi.hoisted(() => vi.fn());
const resolveGatewayInstallToken = vi.hoisted(() => vi.fn());
const buildGatewayInstallPlan = vi.hoisted(() => vi.fn());
const note = vi.hoisted(() => vi.fn());
const serviceInstall = vi.hoisted(() => vi.fn(async () => {}));
const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../cli/progress.js", () => ({
withProgress,
}));
vi.mock("../config/config.js", () => ({
loadConfig,
}));
vi.mock("./gateway-install-token.js", () => ({
resolveGatewayInstallToken,
}));
vi.mock("./daemon-install-helpers.js", () => ({
buildGatewayInstallPlan,
gatewayInstallErrorHint: vi.fn(() => "hint"),
}));
vi.mock("../terminal/note.js", () => ({
note,
}));
vi.mock("./configure.shared.js", () => ({
confirm: vi.fn(async () => true),
select: vi.fn(async () => "node"),
}));
vi.mock("./daemon-runtime.js", () => ({
DEFAULT_GATEWAY_DAEMON_RUNTIME: "node",
GATEWAY_DAEMON_RUNTIME_OPTIONS: [{ value: "node", label: "Node" }],
}));
vi.mock("../daemon/service.js", () => ({
resolveGatewayService: vi.fn(() => ({
isLoaded: vi.fn(async () => false),
install: serviceInstall,
})),
}));
vi.mock("./onboard-helpers.js", () => ({
guardCancel: (value: unknown) => value,
}));
vi.mock("./systemd-linger.js", () => ({
ensureSystemdUserLingerInteractive,
}));
const { maybeInstallDaemon } = await import("./configure.daemon.js");
describe("maybeInstallDaemon", () => {
beforeEach(() => {
vi.clearAllMocks();
loadConfig.mockReturnValue({});
resolveGatewayInstallToken.mockResolvedValue({
token: undefined,
tokenRefConfigured: true,
warnings: [],
});
buildGatewayInstallPlan.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
workingDirectory: "/tmp",
environment: {},
});
});
it("does not serialize SecretRef token into service environment", async () => {
await maybeInstallDaemon({
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
port: 18789,
});
expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1);
expect(buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
}),
);
expect(serviceInstall).toHaveBeenCalledTimes(1);
});
it("blocks install when token SecretRef is unresolved", async () => {
resolveGatewayInstallToken.mockResolvedValue({
token: undefined,
tokenRefConfigured: true,
unavailableReason: "gateway.auth.token SecretRef is configured but unresolved (boom).",
warnings: [],
});
await maybeInstallDaemon({
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
port: 18789,
});
expect(note).toHaveBeenCalledWith(
expect.stringContaining("Gateway install blocked"),
"Gateway",
);
expect(buildGatewayInstallPlan).not.toHaveBeenCalled();
expect(serviceInstall).not.toHaveBeenCalled();
});
});

View File

@@ -10,13 +10,13 @@ import {
GATEWAY_DAEMON_RUNTIME_OPTIONS,
type GatewayDaemonRuntime,
} from "./daemon-runtime.js";
import { resolveGatewayInstallToken } from "./gateway-install-token.js";
import { guardCancel } from "./onboard-helpers.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
export async function maybeInstallDaemon(params: {
runtime: RuntimeEnv;
port: number;
gatewayToken?: string;
daemonRuntime?: GatewayDaemonRuntime;
}) {
const service = resolveGatewayService();
@@ -88,10 +88,26 @@ export async function maybeInstallDaemon(params: {
progress.setLabel("Preparing Gateway service…");
const cfg = loadConfig();
const tokenResolution = await resolveGatewayInstallToken({
config: cfg,
env: process.env,
});
for (const warning of tokenResolution.warnings) {
note(warning, "Gateway");
}
if (tokenResolution.unavailableReason) {
installError = [
"Gateway install blocked:",
tokenResolution.unavailableReason,
"Fix gateway auth config/token input and rerun configure.",
].join(" ");
progress.setLabel("Gateway service install blocked.");
return;
}
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port: params.port,
token: params.gatewayToken,
token: tokenResolution.token,
runtime: daemonRuntime,
warn: (message, title) => note(message, title),
config: cfg,

View File

@@ -10,7 +10,10 @@ function expectGeneratedTokenFromInput(token: string | undefined, literalToAvoid
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe(literalToAvoid);
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
if (typeof result?.token !== "string") {
throw new Error("Expected generated token to be a string.");
}
expect(result.token.length).toBeGreaterThan(0);
}
describe("buildGatewayAuthConfig", () => {
@@ -73,6 +76,23 @@ describe("buildGatewayAuthConfig", () => {
expectGeneratedTokenFromInput("null", "null");
});
it("preserves SecretRef tokens when token mode is selected", () => {
const tokenRef = {
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
} as const;
const result = buildGatewayAuthConfig({
mode: "token",
token: tokenRef,
});
expect(result).toEqual({
mode: "token",
token: tokenRef,
});
});
it("builds trusted-proxy config with all options", () => {
const result = buildGatewayAuthConfig({
mode: "trusted-proxy",

View File

@@ -1,5 +1,6 @@
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import type { OpenClawConfig, GatewayAuthConfig } from "../config/config.js";
import { isSecretRef, type SecretInput } from "../config/types.secrets.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { promptAuthChoiceGrouped } from "./auth-choice-prompt.js";
@@ -17,7 +18,7 @@ import { randomToken } from "./onboard-helpers.js";
type GatewayAuthChoice = "token" | "password" | "trusted-proxy";
/** Reject undefined, empty, and common JS string-coercion artifacts for token auth. */
function sanitizeTokenValue(value: string | undefined): string | undefined {
function sanitizeTokenValue(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
@@ -39,7 +40,7 @@ const ANTHROPIC_OAUTH_MODEL_KEYS = [
export function buildGatewayAuthConfig(params: {
existing?: GatewayAuthConfig;
mode: GatewayAuthChoice;
token?: string;
token?: SecretInput;
password?: string;
trustedProxy?: {
userHeader: string;
@@ -54,6 +55,9 @@ export function buildGatewayAuthConfig(params: {
}
if (params.mode === "token") {
if (isSecretRef(params.token)) {
return { ...base, mode: "token", token: params.token };
}
// Keep token mode always valid: treat empty/undefined/"undefined"/"null" as missing and generate a token.
const token = sanitizeTokenValue(params.token) ?? randomToken();
return { ...base, mode: "token", token };

View File

@@ -68,7 +68,13 @@ async function runGatewayPrompt(params: {
}) {
vi.clearAllMocks();
mocks.resolveGatewayPort.mockReturnValue(18789);
mocks.select.mockImplementation(async () => params.selectQueue.shift());
mocks.select.mockImplementation(async (input) => {
const next = params.selectQueue.shift();
if (next !== undefined) {
return next;
}
return input.initialValue ?? input.options[0]?.value;
});
mocks.text.mockImplementation(async () => params.textQueue.shift());
mocks.randomToken.mockReturnValue(params.randomToken ?? "generated-token");
mocks.confirm.mockResolvedValue(params.confirmResult ?? true);
@@ -95,7 +101,7 @@ async function runTrustedProxyPrompt(params: {
describe("promptGatewayConfig", () => {
it("generates a token when the prompt returns undefined", async () => {
const { result } = await runGatewayPrompt({
selectQueue: ["loopback", "token", "off"],
selectQueue: ["loopback", "token", "off", "plaintext"],
textQueue: ["18789", undefined],
randomToken: "generated-token",
authConfigFactory: ({ mode, token, password }) => ({ mode, token, password }),
@@ -163,7 +169,7 @@ describe("promptGatewayConfig", () => {
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
const { result } = await runGatewayPrompt({
// bind=loopback, auth=token, tailscale=serve
selectQueue: ["loopback", "token", "serve"],
selectQueue: ["loopback", "token", "serve", "plaintext"],
textQueue: ["18789", "my-token"],
confirmResult: true,
authConfigFactory: ({ mode, token }) => ({ mode, token }),
@@ -190,7 +196,7 @@ describe("promptGatewayConfig", () => {
it("does not add Tailscale origin when getTailnetHostname fails", async () => {
mocks.getTailnetHostname.mockRejectedValue(new Error("not found"));
const { result } = await runGatewayPrompt({
selectQueue: ["loopback", "token", "serve"],
selectQueue: ["loopback", "token", "serve", "plaintext"],
textQueue: ["18789", "my-token"],
confirmResult: true,
authConfigFactory: ({ mode, token }) => ({ mode, token }),
@@ -208,7 +214,7 @@ describe("promptGatewayConfig", () => {
},
},
},
selectQueue: ["loopback", "token", "serve"],
selectQueue: ["loopback", "token", "serve", "plaintext"],
textQueue: ["18789", "my-token"],
confirmResult: true,
authConfigFactory: ({ mode, token }) => ({ mode, token }),
@@ -223,7 +229,7 @@ describe("promptGatewayConfig", () => {
it("formats IPv6 Tailscale fallback addresses as valid HTTPS origins", async () => {
mocks.getTailnetHostname.mockResolvedValue("fd7a:115c:a1e0::12");
const { result } = await runGatewayPrompt({
selectQueue: ["loopback", "token", "serve"],
selectQueue: ["loopback", "token", "serve", "plaintext"],
textQueue: ["18789", "my-token"],
confirmResult: true,
authConfigFactory: ({ mode, token }) => ({ mode, token }),
@@ -232,4 +238,29 @@ describe("promptGatewayConfig", () => {
"https://[fd7a:115c:a1e0::12]",
);
});
it("stores gateway token as SecretRef when token source is ref", async () => {
const previous = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = "env-gateway-token";
try {
const { call, result } = await runGatewayPrompt({
selectQueue: ["loopback", "token", "off", "ref"],
textQueue: ["18789", "OPENCLAW_GATEWAY_TOKEN"],
authConfigFactory: ({ mode, token }) => ({ mode, token }),
});
expect(call?.token).toEqual({
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
});
expect(result.token).toBeUndefined();
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = previous;
}
}
});
});

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveGatewayPort } from "../config/config.js";
import { isValidEnvSecretRefId, type SecretInput } from "../config/types.secrets.js";
import {
maybeAddTailnetOriginToControlUiAllowedOrigins,
TAILSCALE_DOCS_LINES,
@@ -8,6 +9,7 @@ import {
} from "../gateway/gateway-config-prompts.shared.js";
import { findTailscaleBinary } from "../infra/tailscale.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
import { validateIPv4AddressInput } from "../shared/net/ipv4.js";
import { note } from "../terminal/note.js";
import { buildGatewayAuthConfig } from "./configure.gateway-auth.js";
@@ -20,6 +22,7 @@ import {
} from "./onboard-helpers.js";
type GatewayAuthChoice = "token" | "password" | "trusted-proxy";
type GatewayTokenInputMode = "plaintext" | "ref";
export async function promptGatewayConfig(
cfg: OpenClawConfig,
@@ -156,7 +159,8 @@ export async function promptGatewayConfig(
tailscaleResetOnExit = false;
}
let gatewayToken: string | undefined;
let gatewayToken: SecretInput | undefined;
let gatewayTokenForCalls: string | undefined;
let gatewayPassword: string | undefined;
let trustedProxyConfig:
| { userHeader: string; requiredHeaders?: string[]; allowUsers?: string[] }
@@ -165,14 +169,65 @@ export async function promptGatewayConfig(
let next = cfg;
if (authMode === "token") {
const tokenInput = guardCancel(
await text({
message: "Gateway token (blank to generate)",
initialValue: randomToken(),
const tokenInputMode = guardCancel(
await select<GatewayTokenInputMode>({
message: "Gateway token source",
options: [
{
value: "plaintext",
label: "Generate/store plaintext token",
hint: "Default",
},
{
value: "ref",
label: "Use SecretRef",
hint: "Store an env-backed reference instead of plaintext",
},
],
initialValue: "plaintext",
}),
runtime,
);
gatewayToken = normalizeGatewayTokenInput(tokenInput) || randomToken();
if (tokenInputMode === "ref") {
const envVar = guardCancel(
await text({
message: "Gateway token env var",
initialValue: "OPENCLAW_GATEWAY_TOKEN",
placeholder: "OPENCLAW_GATEWAY_TOKEN",
validate: (value) => {
const candidate = String(value ?? "").trim();
if (!isValidEnvSecretRefId(candidate)) {
return "Use an env var name like OPENCLAW_GATEWAY_TOKEN.";
}
const resolved = process.env[candidate]?.trim();
if (!resolved) {
return `Environment variable "${candidate}" is missing or empty in this session.`;
}
return undefined;
},
}),
runtime,
);
const envVarName = String(envVar ?? "").trim();
gatewayToken = {
source: "env",
provider: resolveDefaultSecretProviderAlias(cfg, "env", {
preferFirstProviderForSource: true,
}),
id: envVarName,
};
note(`Validated ${envVarName}. OpenClaw will store a token SecretRef.`, "Gateway token");
} else {
const tokenInput = guardCancel(
await text({
message: "Gateway token (blank to generate)",
initialValue: randomToken(),
}),
runtime,
);
gatewayTokenForCalls = normalizeGatewayTokenInput(tokenInput) || randomToken();
gatewayToken = gatewayTokenForCalls;
}
}
if (authMode === "password") {
@@ -294,5 +349,5 @@ export async function promptGatewayConfig(
tailscaleBin,
});
return { config: next, port, token: gatewayToken };
return { config: next, port, token: gatewayTokenForCalls };
}

View File

@@ -4,13 +4,13 @@ import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import { normalizeSecretInputString } from "../config/types.secrets.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { note } from "../terminal/note.js";
import { resolveUserPath } from "../utils.js";
import { createClackPrompter } from "../wizard/clack-prompter.js";
import { resolveOnboardingSecretInputString } from "../wizard/onboarding.secret-input.js";
import { WizardCancelledError } from "../wizard/prompts.js";
import { removeChannelConfigWizard } from "./configure.channels.js";
import { maybeInstallDaemon } from "./configure.daemon.js";
@@ -48,6 +48,23 @@ import { setupSkills } from "./onboard-skills.js";
type ConfigureSectionChoice = WizardSection | "__continue";
async function resolveGatewaySecretInputForWizard(params: {
cfg: OpenClawConfig;
value: unknown;
path: string;
}): Promise<string | undefined> {
try {
return await resolveOnboardingSecretInputString({
config: params.cfg,
value: params.value,
path: params.path,
env: process.env,
});
} catch {
return undefined;
}
}
async function runGatewayHealthCheck(params: {
cfg: OpenClawConfig;
runtime: RuntimeEnv;
@@ -61,10 +78,22 @@ async function runGatewayHealthCheck(params: {
});
const remoteUrl = params.cfg.gateway?.remote?.url?.trim();
const wsUrl = params.cfg.gateway?.mode === "remote" && remoteUrl ? remoteUrl : localLinks.wsUrl;
const token = params.cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN;
const configuredToken = await resolveGatewaySecretInputForWizard({
cfg: params.cfg,
value: params.cfg.gateway?.auth?.token,
path: "gateway.auth.token",
});
const configuredPassword = await resolveGatewaySecretInputForWizard({
cfg: params.cfg,
value: params.cfg.gateway?.auth?.password,
path: "gateway.auth.password",
});
const token =
process.env.OPENCLAW_GATEWAY_TOKEN ?? process.env.CLAWDBOT_GATEWAY_TOKEN ?? configuredToken;
const password =
normalizeSecretInputString(params.cfg.gateway?.auth?.password) ??
process.env.OPENCLAW_GATEWAY_PASSWORD;
process.env.OPENCLAW_GATEWAY_PASSWORD ??
process.env.CLAWDBOT_GATEWAY_PASSWORD ??
configuredPassword;
await waitForGatewayReachable({
url: wsUrl,
@@ -305,18 +334,37 @@ export async function runConfigureWizard(
}
const localUrl = "ws://127.0.0.1:18789";
const baseLocalProbeToken = await resolveGatewaySecretInputForWizard({
cfg: baseConfig,
value: baseConfig.gateway?.auth?.token,
path: "gateway.auth.token",
});
const baseLocalProbePassword = await resolveGatewaySecretInputForWizard({
cfg: baseConfig,
value: baseConfig.gateway?.auth?.password,
path: "gateway.auth.password",
});
const localProbe = await probeGatewayReachable({
url: localUrl,
token: baseConfig.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN,
token:
process.env.OPENCLAW_GATEWAY_TOKEN ??
process.env.CLAWDBOT_GATEWAY_TOKEN ??
baseLocalProbeToken,
password:
normalizeSecretInputString(baseConfig.gateway?.auth?.password) ??
process.env.OPENCLAW_GATEWAY_PASSWORD,
process.env.OPENCLAW_GATEWAY_PASSWORD ??
process.env.CLAWDBOT_GATEWAY_PASSWORD ??
baseLocalProbePassword,
});
const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? "";
const baseRemoteProbeToken = await resolveGatewaySecretInputForWizard({
cfg: baseConfig,
value: baseConfig.gateway?.remote?.token,
path: "gateway.remote.token",
});
const remoteProbe = remoteUrl
? await probeGatewayReachable({
url: remoteUrl,
token: normalizeSecretInputString(baseConfig.gateway?.remote?.token),
token: baseRemoteProbeToken,
})
: null;
@@ -374,10 +422,6 @@ export async function runConfigureWizard(
baseConfig.agents?.defaults?.workspace ??
DEFAULT_WORKSPACE;
let gatewayPort = resolveGatewayPort(baseConfig);
let gatewayToken: string | undefined =
normalizeSecretInputString(nextConfig.gateway?.auth?.token) ??
normalizeSecretInputString(baseConfig.gateway?.auth?.token) ??
process.env.OPENCLAW_GATEWAY_TOKEN;
const persistConfig = async () => {
nextConfig = applyWizardMetadata(nextConfig, {
@@ -486,7 +530,6 @@ export async function runConfigureWizard(
const gateway = await promptGatewayConfig(nextConfig, runtime);
nextConfig = gateway.config;
gatewayPort = gateway.port;
gatewayToken = gateway.token;
}
if (selected.includes("channels")) {
@@ -505,7 +548,7 @@ export async function runConfigureWizard(
await promptDaemonPort();
}
await maybeInstallDaemon({ runtime, port: gatewayPort, gatewayToken });
await maybeInstallDaemon({ runtime, port: gatewayPort });
}
if (selected.includes("health")) {
@@ -541,7 +584,6 @@ export async function runConfigureWizard(
const gateway = await promptGatewayConfig(nextConfig, runtime);
nextConfig = gateway.config;
gatewayPort = gateway.port;
gatewayToken = gateway.token;
didConfigureGateway = true;
await persistConfig();
}
@@ -564,7 +606,6 @@ export async function runConfigureWizard(
await maybeInstallDaemon({
runtime,
port: gatewayPort,
gatewayToken,
});
}
@@ -598,12 +639,29 @@ export async function runConfigureWizard(
});
// Try both new and old passwords since gateway may still have old config.
const newPassword =
normalizeSecretInputString(nextConfig.gateway?.auth?.password) ??
process.env.OPENCLAW_GATEWAY_PASSWORD;
process.env.OPENCLAW_GATEWAY_PASSWORD ??
process.env.CLAWDBOT_GATEWAY_PASSWORD ??
(await resolveGatewaySecretInputForWizard({
cfg: nextConfig,
value: nextConfig.gateway?.auth?.password,
path: "gateway.auth.password",
}));
const oldPassword =
normalizeSecretInputString(baseConfig.gateway?.auth?.password) ??
process.env.OPENCLAW_GATEWAY_PASSWORD;
const token = nextConfig.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_PASSWORD ??
process.env.CLAWDBOT_GATEWAY_PASSWORD ??
(await resolveGatewaySecretInputForWizard({
cfg: baseConfig,
value: baseConfig.gateway?.auth?.password,
path: "gateway.auth.password",
}));
const token =
process.env.OPENCLAW_GATEWAY_TOKEN ??
process.env.CLAWDBOT_GATEWAY_TOKEN ??
(await resolveGatewaySecretInputForWizard({
cfg: nextConfig,
value: nextConfig.gateway?.auth?.token,
path: "gateway.auth.token",
}));
let gatewayProbe = await probeGatewayReachable({
url: links.wsUrl,

View File

@@ -8,6 +8,7 @@ const detectBrowserOpenSupportMock = vi.hoisted(() => vi.fn());
const openUrlMock = vi.hoisted(() => vi.fn());
const formatControlUiSshHintMock = vi.hoisted(() => vi.fn());
const copyToClipboardMock = vi.hoisted(() => vi.fn());
const resolveSecretRefValuesMock = vi.hoisted(() => vi.fn());
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: readConfigFileSnapshotMock,
@@ -25,6 +26,10 @@ vi.mock("../infra/clipboard.js", () => ({
copyToClipboard: copyToClipboardMock,
}));
vi.mock("../secrets/resolve.js", () => ({
resolveSecretRefValues: resolveSecretRefValuesMock,
}));
const runtime = {
log: vi.fn(),
error: vi.fn(),
@@ -37,7 +42,7 @@ function resetRuntime() {
runtime.exit.mockClear();
}
function mockSnapshot(token = "abc") {
function mockSnapshot(token: unknown = "abc") {
readConfigFileSnapshotMock.mockResolvedValue({
path: "/tmp/openclaw.json",
exists: true,
@@ -53,6 +58,7 @@ function mockSnapshot(token = "abc") {
httpUrl: "http://127.0.0.1:18789/",
wsUrl: "ws://127.0.0.1:18789",
});
resolveSecretRefValuesMock.mockReset();
}
describe("dashboardCommand", () => {
@@ -65,6 +71,8 @@ describe("dashboardCommand", () => {
openUrlMock.mockClear();
formatControlUiSshHintMock.mockClear();
copyToClipboardMock.mockClear();
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
});
it("opens and copies the dashboard link by default", async () => {
@@ -115,4 +123,71 @@ describe("dashboardCommand", () => {
"Browser launch disabled (--no-open). Use the URL above.",
);
});
it("prints non-tokenized URL with guidance when token SecretRef is unresolved", async () => {
mockSnapshot({
source: "env",
provider: "default",
id: "MISSING_GATEWAY_TOKEN",
});
copyToClipboardMock.mockResolvedValue(true);
detectBrowserOpenSupportMock.mockResolvedValue({ ok: true });
openUrlMock.mockResolvedValue(true);
resolveSecretRefValuesMock.mockRejectedValue(new Error("missing env var"));
await dashboardCommand(runtime);
expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("Token auto-auth unavailable"),
);
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining(
"gateway.auth.token SecretRef is unresolved (env:default:MISSING_GATEWAY_TOKEN).",
),
);
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("missing env var"));
});
it("keeps URL non-tokenized when token SecretRef is unresolved but env fallback exists", async () => {
mockSnapshot({
source: "env",
provider: "default",
id: "MISSING_GATEWAY_TOKEN",
});
process.env.OPENCLAW_GATEWAY_TOKEN = "fallback-token";
copyToClipboardMock.mockResolvedValue(true);
detectBrowserOpenSupportMock.mockResolvedValue({ ok: true });
openUrlMock.mockResolvedValue(true);
resolveSecretRefValuesMock.mockRejectedValue(new Error("missing env var"));
await dashboardCommand(runtime);
expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(openUrlMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("Token auto-auth is disabled for SecretRef-managed"),
);
expect(runtime.log).not.toHaveBeenCalledWith(
expect.stringContaining("Token auto-auth unavailable"),
);
});
it("resolves env-template gateway.auth.token before building dashboard URL", async () => {
mockSnapshot("${CUSTOM_GATEWAY_TOKEN}");
copyToClipboardMock.mockResolvedValue(true);
detectBrowserOpenSupportMock.mockResolvedValue({ ok: true });
openUrlMock.mockResolvedValue(true);
resolveSecretRefValuesMock.mockResolvedValue(
new Map([["env:default:CUSTOM_GATEWAY_TOKEN", "resolved-secret-token"]]),
);
await dashboardCommand(runtime);
expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(openUrlMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("Token auto-auth is disabled for SecretRef-managed"),
);
});
});

View File

@@ -1,7 +1,11 @@
import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { copyToClipboard } from "../infra/clipboard.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
import {
detectBrowserOpenSupport,
formatControlUiSshHint,
@@ -13,6 +17,69 @@ type DashboardOptions = {
noOpen?: boolean;
};
function readGatewayTokenEnv(env: NodeJS.ProcessEnv): string | undefined {
const primary = env.OPENCLAW_GATEWAY_TOKEN?.trim();
if (primary) {
return primary;
}
const legacy = env.CLAWDBOT_GATEWAY_TOKEN?.trim();
return legacy || undefined;
}
async function resolveDashboardToken(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): Promise<{
token?: string;
source?: "config" | "env" | "secretRef";
unresolvedRefReason?: string;
tokenSecretRefConfigured: boolean;
}> {
const { ref } = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
});
const configToken =
ref || typeof cfg.gateway?.auth?.token !== "string"
? undefined
: cfg.gateway.auth.token.trim() || undefined;
if (configToken) {
return { token: configToken, source: "config", tokenSecretRefConfigured: false };
}
if (!ref) {
const envToken = readGatewayTokenEnv(env);
return envToken
? { token: envToken, source: "env", tokenSecretRefConfigured: false }
: { tokenSecretRefConfigured: false };
}
const refLabel = `${ref.source}:${ref.provider}:${ref.id}`;
try {
const resolved = await resolveSecretRefValues([ref], {
config: cfg,
env,
});
const value = resolved.get(secretRefKey(ref));
if (typeof value === "string" && value.trim().length > 0) {
return { token: value.trim(), source: "secretRef", tokenSecretRefConfigured: true };
}
const envToken = readGatewayTokenEnv(env);
return envToken
? { token: envToken, source: "env", tokenSecretRefConfigured: true }
: {
unresolvedRefReason: `gateway.auth.token SecretRef is unresolved (${refLabel}).`,
tokenSecretRefConfigured: true,
};
} catch {
const envToken = readGatewayTokenEnv(env);
return envToken
? { token: envToken, source: "env", tokenSecretRefConfigured: true }
: {
unresolvedRefReason: `gateway.auth.token SecretRef is unresolved (${refLabel}).`,
tokenSecretRefConfigured: true,
};
}
}
export async function dashboardCommand(
runtime: RuntimeEnv = defaultRuntime,
options: DashboardOptions = {},
@@ -23,7 +90,8 @@ export async function dashboardCommand(
const bind = cfg.gateway?.bind ?? "loopback";
const basePath = cfg.gateway?.controlUi?.basePath;
const customBindHost = cfg.gateway?.customBindHost;
const token = cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? "";
const resolvedToken = await resolveDashboardToken(cfg, process.env);
const token = resolvedToken.token ?? "";
// LAN URLs fail secure-context checks in browsers.
// Coerce only lan->loopback and preserve other bind modes.
@@ -33,12 +101,25 @@ export async function dashboardCommand(
customBindHost,
basePath,
});
// Avoid embedding externally managed SecretRef tokens in terminal/clipboard/browser args.
const includeTokenInUrl = token.length > 0 && !resolvedToken.tokenSecretRefConfigured;
// Prefer URL fragment to avoid leaking auth tokens via query params.
const dashboardUrl = token
const dashboardUrl = includeTokenInUrl
? `${links.httpUrl}#token=${encodeURIComponent(token)}`
: links.httpUrl;
runtime.log(`Dashboard URL: ${dashboardUrl}`);
if (resolvedToken.tokenSecretRefConfigured && token) {
runtime.log(
"Token auto-auth is disabled for SecretRef-managed gateway.auth.token; use your external token source if prompted.",
);
}
if (resolvedToken.unresolvedRefReason) {
runtime.log(`Token auto-auth unavailable: ${resolvedToken.unresolvedRefReason}`);
runtime.log(
"Set OPENCLAW_GATEWAY_TOKEN in this shell or resolve your secret provider, then rerun `openclaw dashboard`.",
);
}
const copied = await copyToClipboard(dashboardUrl).catch(() => false);
runtime.log(copied ? "Copied to clipboard." : "Copy to clipboard unavailable.");
@@ -54,7 +135,7 @@ export async function dashboardCommand(
hint = formatControlUiSshHint({
port,
basePath,
token: token || undefined,
token: includeTokenInUrl ? token || undefined : undefined,
});
}
} else {

View File

@@ -0,0 +1,226 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withEnvAsync } from "../test-utils/env.js";
import {
resolveGatewayAuthTokenForService,
shouldRequireGatewayTokenForInstall,
} from "./doctor-gateway-auth-token.js";
describe("resolveGatewayAuthTokenForService", () => {
it("returns plaintext gateway.auth.token when configured", async () => {
const resolved = await resolveGatewayAuthTokenForService(
{
gateway: {
auth: {
token: "config-token",
},
},
} as OpenClawConfig,
{} as NodeJS.ProcessEnv,
);
expect(resolved).toEqual({ token: "config-token" });
});
it("resolves SecretRef-backed gateway.auth.token", async () => {
const resolved = await resolveGatewayAuthTokenForService(
{
gateway: {
auth: {
token: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
{
CUSTOM_GATEWAY_TOKEN: "resolved-token",
} as NodeJS.ProcessEnv,
);
expect(resolved).toEqual({ token: "resolved-token" });
});
it("resolves env-template gateway.auth.token via SecretRef resolution", async () => {
const resolved = await resolveGatewayAuthTokenForService(
{
gateway: {
auth: {
token: "${CUSTOM_GATEWAY_TOKEN}",
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
{
CUSTOM_GATEWAY_TOKEN: "resolved-token",
} as NodeJS.ProcessEnv,
);
expect(resolved).toEqual({ token: "resolved-token" });
});
it("falls back to OPENCLAW_GATEWAY_TOKEN when SecretRef is unresolved", async () => {
const resolved = await resolveGatewayAuthTokenForService(
{
gateway: {
auth: {
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
{
OPENCLAW_GATEWAY_TOKEN: "env-fallback-token",
} as NodeJS.ProcessEnv,
);
expect(resolved).toEqual({ token: "env-fallback-token" });
});
it("falls back to OPENCLAW_GATEWAY_TOKEN when SecretRef resolves to empty", async () => {
const resolved = await resolveGatewayAuthTokenForService(
{
gateway: {
auth: {
token: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
{
CUSTOM_GATEWAY_TOKEN: " ",
OPENCLAW_GATEWAY_TOKEN: "env-fallback-token",
} as NodeJS.ProcessEnv,
);
expect(resolved).toEqual({ token: "env-fallback-token" });
});
it("returns unavailableReason when SecretRef is unresolved without env fallback", async () => {
const resolved = await resolveGatewayAuthTokenForService(
{
gateway: {
auth: {
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
{} as NodeJS.ProcessEnv,
);
expect(resolved.token).toBeUndefined();
expect(resolved.unavailableReason).toContain("gateway.auth.token SecretRef is configured");
});
});
describe("shouldRequireGatewayTokenForInstall", () => {
it("requires token when auth mode is token", () => {
const required = shouldRequireGatewayTokenForInstall(
{
gateway: {
auth: {
mode: "token",
},
},
} as OpenClawConfig,
{} as NodeJS.ProcessEnv,
);
expect(required).toBe(true);
});
it("does not require token when auth mode is password", () => {
const required = shouldRequireGatewayTokenForInstall(
{
gateway: {
auth: {
mode: "password",
},
},
} as OpenClawConfig,
{} as NodeJS.ProcessEnv,
);
expect(required).toBe(false);
});
it("requires token in inferred mode when password env exists only in shell", async () => {
await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "password-from-env" }, async () => {
const required = shouldRequireGatewayTokenForInstall(
{
gateway: {
auth: {},
},
} as OpenClawConfig,
process.env,
);
expect(required).toBe(true);
});
});
it("does not require token in inferred mode when password is configured", () => {
const required = shouldRequireGatewayTokenForInstall(
{
gateway: {
auth: {
password: { source: "env", provider: "default", id: "CUSTOM_GATEWAY_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
{} as NodeJS.ProcessEnv,
);
expect(required).toBe(false);
});
it("does not require token in inferred mode when password env is configured in config", () => {
const required = shouldRequireGatewayTokenForInstall(
{
gateway: {
auth: {},
},
env: {
vars: {
OPENCLAW_GATEWAY_PASSWORD: "configured-password",
},
},
} as OpenClawConfig,
{} as NodeJS.ProcessEnv,
);
expect(required).toBe(false);
});
it("requires token in inferred mode when no password candidate exists", () => {
const required = shouldRequireGatewayTokenForInstall(
{
gateway: {
auth: {},
},
} as OpenClawConfig,
{} as NodeJS.ProcessEnv,
);
expect(required).toBe(true);
});
});

View File

@@ -0,0 +1,54 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
export { shouldRequireGatewayTokenForInstall } from "../gateway/auth-install-policy.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
function readGatewayTokenEnv(env: NodeJS.ProcessEnv): string | undefined {
const value = env.OPENCLAW_GATEWAY_TOKEN ?? env.CLAWDBOT_GATEWAY_TOKEN;
const trimmed = value?.trim();
return trimmed || undefined;
}
export async function resolveGatewayAuthTokenForService(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
): Promise<{ token?: string; unavailableReason?: string }> {
const { ref } = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
});
const configToken =
ref || typeof cfg.gateway?.auth?.token !== "string"
? undefined
: cfg.gateway.auth.token.trim() || undefined;
if (configToken) {
return { token: configToken };
}
if (ref) {
try {
const resolved = await resolveSecretRefValues([ref], {
config: cfg,
env,
});
const value = resolved.get(secretRefKey(ref));
if (typeof value === "string" && value.trim().length > 0) {
return { token: value.trim() };
}
const envToken = readGatewayTokenEnv(env);
if (envToken) {
return { token: envToken };
}
return { unavailableReason: "gateway.auth.token SecretRef resolved to an empty value." };
} catch (err) {
const envToken = readGatewayTokenEnv(env);
if (envToken) {
return { token: envToken };
}
return {
unavailableReason: `gateway.auth.token SecretRef is configured but unresolved (${String(err)}).`,
};
}
}
return { token: readGatewayTokenEnv(env) };
}

View File

@@ -28,6 +28,7 @@ import {
} from "./daemon-runtime.js";
import { buildGatewayRuntimeHints, formatGatewayRuntimeSummary } from "./doctor-format.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
import { resolveGatewayInstallToken } from "./gateway-install-token.js";
import { formatHealthCheckFailure } from "./health-format.js";
import { healthCommand } from "./health.js";
@@ -171,11 +172,29 @@ export async function maybeRepairGatewayDaemon(params: {
},
DEFAULT_GATEWAY_DAEMON_RUNTIME,
);
const tokenResolution = await resolveGatewayInstallToken({
config: params.cfg,
env: process.env,
});
for (const warning of tokenResolution.warnings) {
note(warning, "Gateway");
}
if (tokenResolution.unavailableReason) {
note(
[
"Gateway service install aborted.",
tokenResolution.unavailableReason,
"Fix gateway auth config/token input and rerun doctor.",
].join("\n"),
"Gateway",
);
return;
}
const port = resolveGatewayPort(params.cfg, process.env);
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port,
token: params.cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN,
token: tokenResolution.token,
runtime: daemonRuntime,
warn: (message, title) => note(message, title),
config: params.cfg,

View File

@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
install: vi.fn(),
auditGatewayServiceConfig: vi.fn(),
buildGatewayInstallPlan: vi.fn(),
resolveGatewayInstallToken: vi.fn(),
resolveGatewayPort: vi.fn(() => 18789),
resolveIsNixMode: vi.fn(() => false),
findExtraGatewayServices: vi.fn().mockResolvedValue([]),
@@ -57,6 +58,10 @@ vi.mock("./daemon-install-helpers.js", () => ({
buildGatewayInstallPlan: mocks.buildGatewayInstallPlan,
}));
vi.mock("./gateway-install-token.js", () => ({
resolveGatewayInstallToken: mocks.resolveGatewayInstallToken,
}));
import {
maybeRepairGatewayServiceConfig,
maybeScanExtraGatewayServices,
@@ -114,6 +119,11 @@ function setupGatewayTokenRepairScenario(expectedToken: string) {
OPENCLAW_GATEWAY_TOKEN: expectedToken,
},
});
mocks.resolveGatewayInstallToken.mockResolvedValue({
token: expectedToken,
tokenRefConfigured: false,
warnings: [],
});
mocks.install.mockResolvedValue(undefined);
}
@@ -172,6 +182,57 @@ describe("maybeRepairGatewayServiceConfig", () => {
expect(mocks.install).toHaveBeenCalledTimes(1);
});
});
it("treats SecretRef-managed gateway token as non-persisted service state", async () => {
mocks.readCommand.mockResolvedValue({
programArguments: gatewayProgramArguments,
environment: {
OPENCLAW_GATEWAY_TOKEN: "stale-token",
},
});
mocks.resolveGatewayInstallToken.mockResolvedValue({
token: undefined,
tokenRefConfigured: true,
warnings: [],
});
mocks.auditGatewayServiceConfig.mockResolvedValue({
ok: false,
issues: [],
});
mocks.buildGatewayInstallPlan.mockResolvedValue({
programArguments: gatewayProgramArguments,
workingDirectory: "/tmp",
environment: {},
});
mocks.install.mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: {
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
},
},
},
};
await runRepair(cfg);
expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith(
expect.objectContaining({
expectedGatewayToken: undefined,
}),
);
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
}),
);
expect(mocks.install).toHaveBeenCalledTimes(1);
});
});
describe("maybeScanExtraGatewayServices", () => {

View File

@@ -5,6 +5,7 @@ import path from "node:path";
import { promisify } from "node:util";
import type { OpenClawConfig } from "../config/config.js";
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import {
findExtraGatewayServices,
renderGatewayServiceCleanupHints,
@@ -22,7 +23,9 @@ import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import { buildGatewayInstallPlan } from "./daemon-install-helpers.js";
import { DEFAULT_GATEWAY_DAEMON_RUNTIME, type GatewayDaemonRuntime } from "./daemon-runtime.js";
import { resolveGatewayAuthTokenForService } from "./doctor-gateway-auth-token.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
import { resolveGatewayInstallToken } from "./gateway-install-token.js";
const execFileAsync = promisify(execFile);
@@ -55,16 +58,6 @@ function normalizeExecutablePath(value: string): string {
return path.resolve(value);
}
function resolveGatewayAuthToken(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string | undefined {
const configToken = cfg.gateway?.auth?.token?.trim();
if (configToken) {
return configToken;
}
const envToken = env.OPENCLAW_GATEWAY_TOKEN ?? env.CLAWDBOT_GATEWAY_TOKEN;
const trimmedEnvToken = envToken?.trim();
return trimmedEnvToken || undefined;
}
function extractDetailPath(detail: string, prefix: string): string | null {
if (!detail.startsWith(prefix)) {
return null;
@@ -219,12 +212,35 @@ export async function maybeRepairGatewayServiceConfig(
return;
}
const expectedGatewayToken = resolveGatewayAuthToken(cfg, process.env);
const tokenRefConfigured = Boolean(
resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
}).ref,
);
const gatewayTokenResolution = await resolveGatewayAuthTokenForService(cfg, process.env);
if (gatewayTokenResolution.unavailableReason) {
note(
`Unable to verify gateway service token drift: ${gatewayTokenResolution.unavailableReason}`,
"Gateway service config",
);
}
const expectedGatewayToken = tokenRefConfigured ? undefined : gatewayTokenResolution.token;
const audit = await auditGatewayServiceConfig({
env: process.env,
command,
expectedGatewayToken,
});
const serviceToken = command.environment?.OPENCLAW_GATEWAY_TOKEN?.trim();
if (tokenRefConfigured && serviceToken) {
audit.issues.push({
code: SERVICE_AUDIT_CODES.gatewayTokenMismatch,
message:
"Gateway service OPENCLAW_GATEWAY_TOKEN should be unset when gateway.auth.token is SecretRef-managed",
detail: "service token is stale",
level: "recommended",
});
}
const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues);
const systemNodeInfo = needsNodeRuntime
? await resolveSystemNodeInfo({ env: process.env })
@@ -243,10 +259,24 @@ export async function maybeRepairGatewayServiceConfig(
const port = resolveGatewayPort(cfg, process.env);
const runtimeChoice = detectGatewayRuntime(command.programArguments);
const installTokenResolution = await resolveGatewayInstallToken({
config: cfg,
env: process.env,
});
for (const warning of installTokenResolution.warnings) {
note(warning, "Gateway service config");
}
if (installTokenResolution.unavailableReason) {
note(
`Unable to verify gateway service token drift: ${installTokenResolution.unavailableReason}`,
"Gateway service config",
);
return;
}
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port,
token: expectedGatewayToken,
token: installTokenResolution.token,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
warn: (message, title) => note(message, title),

View File

@@ -45,13 +45,11 @@ async function launchctlGetenv(name: string): Promise<string | undefined> {
}
function hasConfigGatewayCreds(cfg: OpenClawConfig): boolean {
const localToken =
typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway.auth.token : undefined;
const localPassword = cfg.gateway?.auth?.password;
const remoteToken = cfg.gateway?.remote?.token;
const remotePassword = cfg.gateway?.remote?.password;
return Boolean(
hasConfiguredSecretInput(localToken) ||
hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults) ||
hasConfiguredSecretInput(localPassword, cfg.secrets?.defaults) ||
hasConfiguredSecretInput(remoteToken, cfg.secrets?.defaults) ||
hasConfiguredSecretInput(remotePassword, cfg.secrets?.defaults),

View File

@@ -61,6 +61,22 @@ describe("noteSecurityWarnings gateway exposure", () => {
expect(message).not.toContain("CRITICAL");
});
it("treats SecretRef token config as authenticated for exposure warning level", async () => {
const cfg = {
gateway: {
bind: "lan",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
},
},
} as OpenClawConfig;
await noteSecurityWarnings(cfg);
const message = lastMessage();
expect(message).toContain("WARNING");
expect(message).not.toContain("CRITICAL");
});
it("treats whitespace token as missing", async () => {
const cfg = {
gateway: { bind: "lan", auth: { mode: "token", token: " " } },

View File

@@ -2,6 +2,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig, GatewayBindMode } from "../config/config.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js";
import { resolveDmAllowState } from "../security/dm-policy-shared.js";
@@ -44,8 +45,12 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
});
const authToken = resolvedAuth.token?.trim() ?? "";
const authPassword = resolvedAuth.password?.trim() ?? "";
const hasToken = authToken.length > 0;
const hasPassword = authPassword.length > 0;
const hasToken =
authToken.length > 0 ||
hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults);
const hasPassword =
authPassword.length > 0 ||
hasConfiguredSecretInput(cfg.gateway?.auth?.password, cfg.secrets?.defaults);
const hasSharedSecret =
(resolvedAuth.mode === "token" && hasToken) ||
(resolvedAuth.mode === "password" && hasPassword);

View File

@@ -12,7 +12,9 @@ import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { CONFIG_PATH, readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { resolveGatewayService } from "../daemon/service.js";
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
@@ -117,6 +119,17 @@ export async function doctorCommand(
}
note(lines.join("\n"), "Gateway");
}
if (resolveMode(cfg) === "local" && hasAmbiguousGatewayAuthModeConfig(cfg)) {
note(
[
"gateway.auth.token and gateway.auth.password are both configured while gateway.auth.mode is unset.",
"Set an explicit mode to avoid ambiguous auth selection and startup/runtime failures.",
`Set token mode: ${formatCliCommand("openclaw config set gateway.auth.mode token")}`,
`Set password mode: ${formatCliCommand("openclaw config set gateway.auth.mode password")}`,
].join("\n"),
"Gateway auth",
);
}
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
cfg = await maybeRemoveDeprecatedCliAuthProfiles(cfg, prompter);
@@ -130,39 +143,54 @@ export async function doctorCommand(
note(gatewayDetails.remoteFallbackNote, "Gateway");
}
if (resolveMode(cfg) === "local" && sourceConfigValid) {
const gatewayTokenRef = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
}).ref;
const auth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
});
const needsToken = auth.mode !== "password" && (auth.mode !== "token" || !auth.token);
if (needsToken) {
note(
"Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).",
"Gateway auth",
);
const shouldSetToken =
options.generateGatewayToken === true
? true
: options.nonInteractive === true
? false
: await prompter.confirmRepair({
message: "Generate and configure a gateway token now?",
initialValue: true,
});
if (shouldSetToken) {
const nextToken = randomToken();
cfg = {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
mode: "token",
token: nextToken,
if (gatewayTokenRef) {
note(
[
"Gateway token is managed via SecretRef and is currently unavailable.",
"Doctor will not overwrite gateway.auth.token with a plaintext value.",
"Resolve/rotate the external secret source, then rerun doctor.",
].join("\n"),
"Gateway auth",
);
} else {
note(
"Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).",
"Gateway auth",
);
const shouldSetToken =
options.generateGatewayToken === true
? true
: options.nonInteractive === true
? false
: await prompter.confirmRepair({
message: "Generate and configure a gateway token now?",
initialValue: true,
});
if (shouldSetToken) {
const nextToken = randomToken();
cfg = {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
mode: "token",
token: nextToken,
},
},
},
};
note("Gateway token configured.", "Gateway auth");
};
note("Gateway token configured.", "Gateway auth");
}
}
}
}

View File

@@ -87,4 +87,33 @@ describe("doctor command", () => {
);
expect(warned).toBe(false);
});
it("warns when token and password are both configured and gateway.auth.mode is unset", async () => {
mockDoctorConfigSnapshot({
config: {
gateway: {
mode: "local",
auth: {
token: "token-value",
password: "password-value",
},
},
},
});
note.mockClear();
await doctorCommand(createDoctorRuntime(), {
nonInteractive: true,
workspaceSuggestions: false,
});
const gatewayAuthNote = note.mock.calls.find((call) => call[1] === "Gateway auth");
expect(gatewayAuthNote).toBeTruthy();
expect(String(gatewayAuthNote?.[0])).toContain("gateway.auth.mode is unset");
expect(String(gatewayAuthNote?.[0])).toContain("openclaw config set gateway.auth.mode token");
expect(String(gatewayAuthNote?.[0])).toContain(
"openclaw config set gateway.auth.mode password",
);
});
});

View File

@@ -0,0 +1,283 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn());
const resolveSecretInputRefMock = vi.hoisted(() =>
vi.fn((): { ref: unknown } => ({ ref: undefined })),
);
const hasConfiguredSecretInputMock = vi.hoisted(() =>
vi.fn((value: unknown) => {
if (typeof value === "string") {
return value.trim().length > 0;
}
return value != null;
}),
);
const resolveGatewayAuthMock = vi.hoisted(() =>
vi.fn(() => ({
mode: "token",
token: undefined,
password: undefined,
allowTailscale: false,
})),
);
const shouldRequireGatewayTokenForInstallMock = vi.hoisted(() => vi.fn(() => true));
const resolveSecretRefValuesMock = vi.hoisted(() => vi.fn());
const secretRefKeyMock = vi.hoisted(() => vi.fn(() => "env:default:OPENCLAW_GATEWAY_TOKEN"));
const randomTokenMock = vi.hoisted(() => vi.fn(() => "generated-token"));
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: readConfigFileSnapshotMock,
writeConfigFile: writeConfigFileMock,
}));
vi.mock("../config/types.secrets.js", () => ({
resolveSecretInputRef: resolveSecretInputRefMock,
hasConfiguredSecretInput: hasConfiguredSecretInputMock,
}));
vi.mock("../gateway/auth.js", () => ({
resolveGatewayAuth: resolveGatewayAuthMock,
}));
vi.mock("../gateway/auth-install-policy.js", () => ({
shouldRequireGatewayTokenForInstall: shouldRequireGatewayTokenForInstallMock,
}));
vi.mock("../secrets/ref-contract.js", () => ({
secretRefKey: secretRefKeyMock,
}));
vi.mock("../secrets/resolve.js", () => ({
resolveSecretRefValues: resolveSecretRefValuesMock,
}));
vi.mock("./onboard-helpers.js", () => ({
randomToken: randomTokenMock,
}));
const { resolveGatewayInstallToken } = await import("./gateway-install-token.js");
describe("resolveGatewayInstallToken", () => {
beforeEach(() => {
vi.clearAllMocks();
readConfigFileSnapshotMock.mockResolvedValue({ exists: false, valid: true, config: {} });
resolveSecretInputRefMock.mockReturnValue({ ref: undefined });
hasConfiguredSecretInputMock.mockImplementation((value: unknown) => {
if (typeof value === "string") {
return value.trim().length > 0;
}
return value != null;
});
resolveSecretRefValuesMock.mockResolvedValue(new Map());
shouldRequireGatewayTokenForInstallMock.mockReturnValue(true);
resolveGatewayAuthMock.mockReturnValue({
mode: "token",
token: undefined,
password: undefined,
allowTailscale: false,
});
randomTokenMock.mockReturnValue("generated-token");
});
it("uses plaintext gateway.auth.token when configured", async () => {
const result = await resolveGatewayInstallToken({
config: {
gateway: { auth: { token: "config-token" } },
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
});
expect(result).toEqual({
token: "config-token",
tokenRefConfigured: false,
unavailableReason: undefined,
warnings: [],
});
});
it("validates SecretRef token but does not persist resolved plaintext", async () => {
const tokenRef = { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" };
resolveSecretInputRefMock.mockReturnValue({ ref: tokenRef });
resolveSecretRefValuesMock.mockResolvedValue(
new Map([["env:default:OPENCLAW_GATEWAY_TOKEN", "resolved-token"]]),
);
const result = await resolveGatewayInstallToken({
config: {
gateway: { auth: { mode: "token", token: tokenRef } },
} as OpenClawConfig,
env: { OPENCLAW_GATEWAY_TOKEN: "resolved-token" } as NodeJS.ProcessEnv,
});
expect(result.token).toBeUndefined();
expect(result.tokenRefConfigured).toBe(true);
expect(result.unavailableReason).toBeUndefined();
expect(result.warnings.some((message) => message.includes("SecretRef-managed"))).toBeTruthy();
});
it("returns unavailable reason when token SecretRef is unresolved in token mode", async () => {
resolveSecretInputRefMock.mockReturnValue({
ref: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
});
resolveSecretRefValuesMock.mockRejectedValue(new Error("missing env var"));
const result = await resolveGatewayInstallToken({
config: {
gateway: { auth: { mode: "token", token: "${MISSING_GATEWAY_TOKEN}" } },
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
});
expect(result.token).toBeUndefined();
expect(result.unavailableReason).toContain("gateway.auth.token SecretRef is configured");
});
it("returns unavailable reason when token and password are both configured and mode is unset", async () => {
const result = await resolveGatewayInstallToken({
config: {
gateway: {
auth: {
token: "token-value",
password: "password-value",
},
},
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
autoGenerateWhenMissing: true,
persistGeneratedToken: true,
});
expect(result.token).toBeUndefined();
expect(result.unavailableReason).toContain("gateway.auth.mode is unset");
expect(result.unavailableReason).toContain("openclaw config set gateway.auth.mode token");
expect(result.unavailableReason).toContain("openclaw config set gateway.auth.mode password");
expect(writeConfigFileMock).not.toHaveBeenCalled();
expect(resolveSecretRefValuesMock).not.toHaveBeenCalled();
});
it("auto-generates token when no source exists and auto-generation is enabled", async () => {
const result = await resolveGatewayInstallToken({
config: {
gateway: { auth: { mode: "token" } },
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
autoGenerateWhenMissing: true,
});
expect(result.token).toBe("generated-token");
expect(result.unavailableReason).toBeUndefined();
expect(
result.warnings.some((message) => message.includes("without saving to config")),
).toBeTruthy();
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("persists auto-generated token when requested", async () => {
const result = await resolveGatewayInstallToken({
config: {
gateway: { auth: { mode: "token" } },
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
autoGenerateWhenMissing: true,
persistGeneratedToken: true,
});
expect(result.warnings.some((message) => message.includes("saving to config"))).toBeTruthy();
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
gateway: {
auth: {
mode: "token",
token: "generated-token",
},
},
}),
);
});
it("drops generated plaintext when config changes to SecretRef before persist", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
config: {
gateway: {
auth: {
token: "${OPENCLAW_GATEWAY_TOKEN}",
},
},
},
issues: [],
});
resolveSecretInputRefMock.mockReturnValueOnce({ ref: undefined }).mockReturnValueOnce({
ref: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
});
const result = await resolveGatewayInstallToken({
config: {
gateway: { auth: { mode: "token" } },
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
autoGenerateWhenMissing: true,
persistGeneratedToken: true,
});
expect(result.token).toBeUndefined();
expect(
result.warnings.some((message) => message.includes("skipping plaintext token persistence")),
).toBeTruthy();
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("does not auto-generate when inferred mode has password SecretRef configured", async () => {
shouldRequireGatewayTokenForInstallMock.mockReturnValue(false);
const result = await resolveGatewayInstallToken({
config: {
gateway: {
auth: {
password: { source: "env", provider: "default", id: "GATEWAY_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
autoGenerateWhenMissing: true,
persistGeneratedToken: true,
});
expect(result.token).toBeUndefined();
expect(result.unavailableReason).toBeUndefined();
expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false);
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("skips token SecretRef resolution when token auth is not required", async () => {
const tokenRef = { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" };
resolveSecretInputRefMock.mockReturnValue({ ref: tokenRef });
shouldRequireGatewayTokenForInstallMock.mockReturnValue(false);
const result = await resolveGatewayInstallToken({
config: {
gateway: {
auth: {
mode: "password",
token: tokenRef,
},
},
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
});
expect(resolveSecretRefValuesMock).not.toHaveBeenCalled();
expect(result.unavailableReason).toBeUndefined();
expect(result.warnings).toEqual([]);
expect(result.token).toBeUndefined();
expect(result.tokenRefConfigured).toBe(true);
});
});

View File

@@ -0,0 +1,147 @@
import { formatCliCommand } from "../cli/command-format.js";
import { readConfigFileSnapshot, writeConfigFile, type OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { shouldRequireGatewayTokenForInstall } from "../gateway/auth-install-policy.js";
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
import { randomToken } from "./onboard-helpers.js";
type GatewayInstallTokenOptions = {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
explicitToken?: string;
autoGenerateWhenMissing?: boolean;
persistGeneratedToken?: boolean;
};
export type GatewayInstallTokenResolution = {
token?: string;
tokenRefConfigured: boolean;
unavailableReason?: string;
warnings: string[];
};
function formatAmbiguousGatewayAuthModeReason(): string {
return [
"gateway.auth.token and gateway.auth.password are both configured while gateway.auth.mode is unset.",
`Set ${formatCliCommand("openclaw config set gateway.auth.mode token")} or ${formatCliCommand("openclaw config set gateway.auth.mode password")}.`,
].join(" ");
}
export async function resolveGatewayInstallToken(
options: GatewayInstallTokenOptions,
): Promise<GatewayInstallTokenResolution> {
const cfg = options.config;
const warnings: string[] = [];
const tokenRef = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults: cfg.secrets?.defaults,
}).ref;
const tokenRefConfigured = Boolean(tokenRef);
const configToken =
tokenRef || typeof cfg.gateway?.auth?.token !== "string"
? undefined
: cfg.gateway.auth.token.trim() || undefined;
const explicitToken = options.explicitToken?.trim() || undefined;
const envToken =
options.env.OPENCLAW_GATEWAY_TOKEN?.trim() || options.env.CLAWDBOT_GATEWAY_TOKEN?.trim();
if (hasAmbiguousGatewayAuthModeConfig(cfg)) {
return {
token: undefined,
tokenRefConfigured,
unavailableReason: formatAmbiguousGatewayAuthModeReason(),
warnings,
};
}
const resolvedAuth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
});
const needsToken =
shouldRequireGatewayTokenForInstall(cfg, options.env) && !resolvedAuth.allowTailscale;
let token: string | undefined = explicitToken || configToken || (tokenRef ? undefined : envToken);
let unavailableReason: string | undefined;
if (tokenRef && !token && needsToken) {
try {
const resolved = await resolveSecretRefValues([tokenRef], {
config: cfg,
env: options.env,
});
const value = resolved.get(secretRefKey(tokenRef));
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error("gateway.auth.token resolved to an empty or non-string value.");
}
warnings.push(
"gateway.auth.token is SecretRef-managed; install will not persist a resolved token in service environment. Ensure the SecretRef is resolvable in the daemon runtime context.",
);
} catch (err) {
unavailableReason = `gateway.auth.token SecretRef is configured but unresolved (${String(err)}).`;
}
}
const allowAutoGenerate = options.autoGenerateWhenMissing ?? false;
const persistGeneratedToken = options.persistGeneratedToken ?? false;
if (!token && needsToken && !tokenRef && allowAutoGenerate) {
token = randomToken();
warnings.push(
persistGeneratedToken
? "No gateway token found. Auto-generated one and saving to config."
: "No gateway token found. Auto-generated one for this run without saving to config.",
);
if (persistGeneratedToken) {
// Persist token in config so daemon and CLI share a stable credential source.
try {
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
warnings.push("Warning: config file exists but is invalid; skipping token persistence.");
} else {
const baseConfig = snapshot.exists ? snapshot.config : {};
const existingTokenRef = resolveSecretInputRef({
value: baseConfig.gateway?.auth?.token,
defaults: baseConfig.secrets?.defaults,
}).ref;
const baseConfigToken =
existingTokenRef || typeof baseConfig.gateway?.auth?.token !== "string"
? undefined
: baseConfig.gateway.auth.token.trim() || undefined;
if (!existingTokenRef && !baseConfigToken) {
await writeConfigFile({
...baseConfig,
gateway: {
...baseConfig.gateway,
auth: {
...baseConfig.gateway?.auth,
mode: baseConfig.gateway?.auth?.mode ?? "token",
token,
},
},
});
} else if (baseConfigToken) {
token = baseConfigToken;
} else {
token = undefined;
warnings.push(
"Warning: gateway.auth.token is SecretRef-managed; skipping plaintext token persistence.",
);
}
}
} catch (err) {
warnings.push(`Warning: could not persist token to config: ${String(err)}`);
}
}
}
return {
token,
tokenRefConfigured,
unavailableReason,
warnings,
};
}

View File

@@ -184,6 +184,268 @@ describe("gateway-status command", () => {
expect(targets[0]?.summary).toBeTruthy();
});
it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => {
loadConfig.mockReturnValueOnce({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "local",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
} as unknown as ReturnType<typeof loadConfig>);
await runGatewayStatus(runtime, { timeout: "1000", json: true });
});
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>;
};
const unresolvedWarning = parsed.warnings?.find(
(warning) =>
warning.code === "auth_secretref_unresolved" &&
warning.message?.includes("gateway.auth.token SecretRef is unresolved"),
);
expect(unresolvedWarning).toBeTruthy();
expect(unresolvedWarning?.targetIds).toContain("localLoopback");
expect(unresolvedWarning?.message).toContain("env:default:MISSING_GATEWAY_TOKEN");
expect(unresolvedWarning?.message).not.toContain("missing or empty");
});
it("does not resolve local token SecretRef when OPENCLAW_GATEWAY_TOKEN is set", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: "env-token",
MISSING_GATEWAY_TOKEN: undefined,
},
async () => {
loadConfig.mockReturnValueOnce({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "local",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
} as unknown as ReturnType<typeof loadConfig>);
await runGatewayStatus(runtime, { timeout: "1000", json: true });
},
);
expect(runtimeErrors).toHaveLength(0);
expect(probeGateway).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({
token: "env-token",
}),
}),
);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
warnings?: Array<{ code?: string; message?: string }>;
};
const unresolvedWarning = parsed.warnings?.find(
(warning) =>
warning.code === "auth_secretref_unresolved" &&
warning.message?.includes("gateway.auth.token SecretRef is unresolved"),
);
expect(unresolvedWarning).toBeUndefined();
});
it("does not resolve local password SecretRef in token mode", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: "env-token",
MISSING_GATEWAY_PASSWORD: undefined,
},
async () => {
loadConfig.mockReturnValueOnce({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "local",
auth: {
mode: "token",
token: "config-token",
password: { source: "env", provider: "default", id: "MISSING_GATEWAY_PASSWORD" },
},
},
} as unknown as ReturnType<typeof loadConfig>);
await runGatewayStatus(runtime, { timeout: "1000", json: true });
},
);
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
warnings?: Array<{ code?: string; message?: string }>;
};
const unresolvedPasswordWarning = parsed.warnings?.find(
(warning) =>
warning.code === "auth_secretref_unresolved" &&
warning.message?.includes("gateway.auth.password SecretRef is unresolved"),
);
expect(unresolvedPasswordWarning).toBeUndefined();
});
it("resolves env-template gateway.auth.token before probing targets", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
await withEnvAsync(
{
CUSTOM_GATEWAY_TOKEN: "resolved-gateway-token",
OPENCLAW_GATEWAY_TOKEN: undefined,
CLAWDBOT_GATEWAY_TOKEN: undefined,
},
async () => {
loadConfig.mockReturnValueOnce({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "local",
auth: {
mode: "token",
token: "${CUSTOM_GATEWAY_TOKEN}",
},
},
} as unknown as ReturnType<typeof loadConfig>);
await runGatewayStatus(runtime, { timeout: "1000", json: true });
},
);
expect(runtimeErrors).toHaveLength(0);
expect(probeGateway).toHaveBeenCalledWith(
expect.objectContaining({
auth: expect.objectContaining({
token: "resolved-gateway-token",
}),
}),
);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
warnings?: Array<{ code?: string }>;
};
const unresolvedWarning = parsed.warnings?.find(
(warning) => warning.code === "auth_secretref_unresolved",
);
expect(unresolvedWarning).toBeUndefined();
});
it("emits stable SecretRef auth configuration booleans in --json output", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
const previousProbeImpl = probeGateway.getMockImplementation();
probeGateway.mockImplementation(async (opts: { url: string }) => ({
ok: true,
url: opts.url,
connectLatencyMs: 20,
error: null,
close: null,
health: { ok: true },
status: {
linkChannel: {
id: "whatsapp",
label: "WhatsApp",
linked: true,
authAgeMs: 1_000,
},
sessions: { count: 1 },
},
presence: [{ mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" }],
configSnapshot: {
path: "/tmp/secretref-config.json",
exists: true,
valid: true,
config: {
secrets: {
defaults: {
env: "default",
},
},
gateway: {
mode: "remote",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
password: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_PASSWORD" },
},
remote: {
url: "wss://remote.example:18789",
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
password: { source: "env", provider: "default", id: "REMOTE_GATEWAY_PASSWORD" },
},
},
discovery: {
wideArea: { enabled: true },
},
},
issues: [],
legacyIssues: [],
},
}));
try {
await runGatewayStatus(runtime, { timeout: "1000", json: true });
} finally {
if (previousProbeImpl) {
probeGateway.mockImplementation(previousProbeImpl);
} else {
probeGateway.mockReset();
}
}
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
targets?: Array<Record<string, unknown>>;
};
const configRemoteTarget = parsed.targets?.find((target) => target.kind === "configRemote");
expect(configRemoteTarget?.config).toMatchInlineSnapshot(`
{
"discovery": {
"wideAreaEnabled": true,
},
"exists": true,
"gateway": {
"authMode": "token",
"authPasswordConfigured": true,
"authTokenConfigured": true,
"bind": null,
"controlUiBasePath": null,
"controlUiEnabled": null,
"mode": "remote",
"port": null,
"remotePasswordConfigured": true,
"remoteTokenConfigured": true,
"remoteUrl": "wss://remote.example:18789",
"tailscaleMode": null,
},
"issues": [],
"legacyIssues": [],
"path": "/tmp/secretref-config.json",
"valid": true,
}
`);
});
it("supports SSH tunnel targets", async () => {
const { runtime, runtimeLogs } = createRuntimeCapture();

View File

@@ -152,10 +152,14 @@ export async function gatewayStatusCommand(
try {
const probed = await Promise.all(
targets.map(async (target) => {
const auth = resolveAuthForTarget(cfg, target, {
const authResolution = await resolveAuthForTarget(cfg, target, {
token: typeof opts.token === "string" ? opts.token : undefined,
password: typeof opts.password === "string" ? opts.password : undefined,
});
const auth = {
token: authResolution.token,
password: authResolution.password,
};
const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind);
const probe = await probeGateway({
url: target.url,
@@ -166,7 +170,13 @@ export async function gatewayStatusCommand(
? extractConfigSummary(probe.configSnapshot)
: null;
const self = pickGatewaySelfPresence(probe.presence);
return { target, probe, configSummary, self };
return {
target,
probe,
configSummary,
self,
authDiagnostics: authResolution.diagnostics ?? [],
};
}),
);
@@ -214,6 +224,18 @@ export async function gatewayStatusCommand(
targetIds: reachable.map((p) => p.target.id),
});
}
for (const result of probed) {
if (result.authDiagnostics.length === 0) {
continue;
}
for (const diagnostic of result.authDiagnostics) {
warnings.push({
code: "auth_secretref_unresolved",
message: diagnostic,
targetIds: [result.target.id],
});
}
}
if (opts.json) {
runtime.log(

View File

@@ -0,0 +1,235 @@
import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../../test-utils/env.js";
import { extractConfigSummary, resolveAuthForTarget } from "./helpers.js";
describe("extractConfigSummary", () => {
it("marks SecretRef-backed gateway auth credentials as configured", () => {
const summary = extractConfigSummary({
path: "/tmp/openclaw.json",
exists: true,
valid: true,
issues: [],
legacyIssues: [],
config: {
secrets: {
defaults: {
env: "default",
},
},
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
password: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_PASSWORD" },
},
remote: {
url: "wss://remote.example:18789",
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
password: { source: "env", provider: "default", id: "REMOTE_GATEWAY_PASSWORD" },
},
},
},
});
expect(summary.gateway.authTokenConfigured).toBe(true);
expect(summary.gateway.authPasswordConfigured).toBe(true);
expect(summary.gateway.remoteTokenConfigured).toBe(true);
expect(summary.gateway.remotePasswordConfigured).toBe(true);
});
it("still treats empty plaintext auth values as not configured", () => {
const summary = extractConfigSummary({
path: "/tmp/openclaw.json",
exists: true,
valid: true,
issues: [],
legacyIssues: [],
config: {
gateway: {
auth: {
mode: "token",
token: " ",
password: "",
},
remote: {
token: " ",
password: "",
},
},
},
});
expect(summary.gateway.authTokenConfigured).toBe(false);
expect(summary.gateway.authPasswordConfigured).toBe(false);
expect(summary.gateway.remoteTokenConfigured).toBe(false);
expect(summary.gateway.remotePasswordConfigured).toBe(false);
});
});
describe("resolveAuthForTarget", () => {
it("resolves local auth token SecretRef before probing local targets", async () => {
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
OPENCLAW_GATEWAY_PASSWORD: undefined,
LOCAL_GATEWAY_TOKEN: "resolved-local-token",
},
async () => {
const auth = await resolveAuthForTarget(
{
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
auth: {
token: { source: "env", provider: "default", id: "LOCAL_GATEWAY_TOKEN" },
},
},
},
{
id: "localLoopback",
kind: "localLoopback",
url: "ws://127.0.0.1:18789",
active: true,
},
{},
);
expect(auth).toEqual({ token: "resolved-local-token", password: undefined });
},
);
});
it("resolves remote auth token SecretRef before probing remote targets", async () => {
await withEnvAsync(
{
REMOTE_GATEWAY_TOKEN: "resolved-remote-token",
},
async () => {
const auth = await resolveAuthForTarget(
{
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
remote: {
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
},
},
},
{
id: "configRemote",
kind: "configRemote",
url: "wss://remote.example:18789",
active: true,
},
{},
);
expect(auth).toEqual({ token: "resolved-remote-token", password: undefined });
},
);
});
it("resolves remote auth even when local auth mode is none", async () => {
await withEnvAsync(
{
REMOTE_GATEWAY_TOKEN: "resolved-remote-token",
},
async () => {
const auth = await resolveAuthForTarget(
{
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
auth: {
mode: "none",
},
remote: {
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
},
},
},
{
id: "configRemote",
kind: "configRemote",
url: "wss://remote.example:18789",
active: true,
},
{},
);
expect(auth).toEqual({ token: "resolved-remote-token", password: undefined });
},
);
});
it("does not force remote auth type from local auth mode", async () => {
const auth = await resolveAuthForTarget(
{
gateway: {
auth: {
mode: "password",
},
remote: {
token: "remote-token",
password: "remote-password",
},
},
},
{
id: "configRemote",
kind: "configRemote",
url: "wss://remote.example:18789",
active: true,
},
{},
);
expect(auth).toEqual({ token: "remote-token", password: undefined });
});
it("redacts resolver internals from unresolved SecretRef diagnostics", async () => {
await withEnvAsync(
{
MISSING_GATEWAY_TOKEN: undefined,
},
async () => {
const auth = await resolveAuthForTarget(
{
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
},
{
id: "localLoopback",
kind: "localLoopback",
url: "ws://127.0.0.1:18789",
active: true,
},
{},
);
expect(auth.diagnostics).toContain(
"gateway.auth.token SecretRef is unresolved (env:default:MISSING_GATEWAY_TOKEN).",
);
expect(auth.diagnostics?.join("\n")).not.toContain("missing or empty");
},
);
});
});

View File

@@ -1,6 +1,8 @@
import { resolveGatewayPort } from "../../config/config.js";
import type { OpenClawConfig, ConfigFileSnapshot } from "../../config/types.js";
import { hasConfiguredSecretInput } from "../../config/types.secrets.js";
import type { GatewayProbeResult } from "../../gateway/probe.js";
import { resolveConfiguredSecretInputString } from "../../gateway/resolve-configured-secret-input-string.js";
import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js";
import { colorize, theme } from "../../terminal/theme.js";
import { pickGatewaySelfPresence } from "../gateway-presence.js";
@@ -144,38 +146,124 @@ export function sanitizeSshTarget(value: unknown): string | null {
return trimmed.replace(/^ssh\\s+/, "");
}
export function resolveAuthForTarget(
function readGatewayTokenEnv(env: NodeJS.ProcessEnv = process.env): string | undefined {
const token = env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim();
return token || undefined;
}
function readGatewayPasswordEnv(env: NodeJS.ProcessEnv = process.env): string | undefined {
const password = env.OPENCLAW_GATEWAY_PASSWORD?.trim() || env.CLAWDBOT_GATEWAY_PASSWORD?.trim();
return password || undefined;
}
export async function resolveAuthForTarget(
cfg: OpenClawConfig,
target: GatewayStatusTarget,
overrides: { token?: string; password?: string },
): { token?: string; password?: string } {
): Promise<{ token?: string; password?: string; diagnostics?: string[] }> {
const tokenOverride = overrides.token?.trim() ? overrides.token.trim() : undefined;
const passwordOverride = overrides.password?.trim() ? overrides.password.trim() : undefined;
if (tokenOverride || passwordOverride) {
return { token: tokenOverride, password: passwordOverride };
}
const diagnostics: string[] = [];
const authMode = cfg.gateway?.auth?.mode;
const tokenOnly = authMode === "token";
const passwordOnly = authMode === "password";
const resolveToken = async (value: unknown, path: string): Promise<string | undefined> => {
const tokenResolution = await resolveConfiguredSecretInputString({
config: cfg,
env: process.env,
value,
path,
unresolvedReasonStyle: "detailed",
});
if (tokenResolution.unresolvedRefReason) {
diagnostics.push(tokenResolution.unresolvedRefReason);
}
return tokenResolution.value;
};
const resolvePassword = async (value: unknown, path: string): Promise<string | undefined> => {
const passwordResolution = await resolveConfiguredSecretInputString({
config: cfg,
env: process.env,
value,
path,
unresolvedReasonStyle: "detailed",
});
if (passwordResolution.unresolvedRefReason) {
diagnostics.push(passwordResolution.unresolvedRefReason);
}
return passwordResolution.value;
};
if (target.kind === "configRemote" || target.kind === "sshTunnel") {
const token =
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway.remote.token.trim() : "";
const remotePassword = (cfg.gateway?.remote as { password?: unknown } | undefined)?.password;
const password = typeof remotePassword === "string" ? remotePassword.trim() : "";
const remoteTokenValue = cfg.gateway?.remote?.token;
const remotePasswordValue = (cfg.gateway?.remote as { password?: unknown } | undefined)
?.password;
const token = await resolveToken(remoteTokenValue, "gateway.remote.token");
const password = token
? undefined
: await resolvePassword(remotePasswordValue, "gateway.remote.password");
return {
token: token.length > 0 ? token : undefined,
password: password.length > 0 ? password : undefined,
token,
password,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
}
const envToken = process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || "";
const envPassword = process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || "";
const cfgToken =
typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway.auth.token.trim() : "";
const cfgPassword =
typeof cfg.gateway?.auth?.password === "string" ? cfg.gateway.auth.password.trim() : "";
const authDisabled = authMode === "none" || authMode === "trusted-proxy";
if (authDisabled) {
return {};
}
const envToken = readGatewayTokenEnv();
const envPassword = readGatewayPasswordEnv();
if (tokenOnly) {
if (envToken) {
return { token: envToken };
}
const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token");
return {
token,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
}
if (passwordOnly) {
if (envPassword) {
return { password: envPassword };
}
const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password");
return {
password,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
}
if (envToken) {
return { token: envToken };
}
const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token");
if (token) {
return {
token,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
}
if (envPassword) {
return {
password: envPassword,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
}
const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password");
return {
token: envToken || cfgToken || undefined,
password: envPassword || cfgPassword || undefined,
token,
password,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
}
@@ -191,6 +279,10 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
const cfg = (snap?.config ?? {}) as Record<string, unknown>;
const gateway = (cfg.gateway ?? {}) as Record<string, unknown>;
const secrets = (cfg.secrets ?? {}) as Record<string, unknown>;
const secretDefaults = (secrets.defaults ?? undefined) as
| { env?: string; file?: string; exec?: string }
| undefined;
const discovery = (cfg.discovery ?? {}) as Record<string, unknown>;
const wideArea = (discovery.wideArea ?? {}) as Record<string, unknown>;
@@ -200,15 +292,12 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
const tailscale = (gateway.tailscale ?? {}) as Record<string, unknown>;
const authMode = typeof auth.mode === "string" ? auth.mode : null;
const authTokenConfigured = typeof auth.token === "string" ? auth.token.trim().length > 0 : false;
const authPasswordConfigured =
typeof auth.password === "string" ? auth.password.trim().length > 0 : false;
const authTokenConfigured = hasConfiguredSecretInput(auth.token, secretDefaults);
const authPasswordConfigured = hasConfiguredSecretInput(auth.password, secretDefaults);
const remoteUrl = typeof remote.url === "string" ? normalizeWsUrl(remote.url) : null;
const remoteTokenConfigured =
typeof remote.token === "string" ? remote.token.trim().length > 0 : false;
const remotePasswordConfigured =
typeof remote.password === "string" ? String(remote.password).trim().length > 0 : false;
const remoteTokenConfigured = hasConfiguredSecretInput(remote.token, secretDefaults);
const remotePasswordConfigured = hasConfiguredSecretInput(remote.password, secretDefaults);
const wideAreaEnabled = typeof wideArea.enabled === "boolean" ? wideArea.enabled : null;

View File

@@ -9,7 +9,7 @@ const gatewayClientCalls: Array<{
url?: string;
token?: string;
password?: string;
onHelloOk?: () => void;
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void;
onClose?: (code: number, reason: string) => void;
}> = [];
const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {});
@@ -20,13 +20,13 @@ vi.mock("../gateway/client.js", () => ({
url?: string;
token?: string;
password?: string;
onHelloOk?: () => void;
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void;
};
constructor(params: {
url?: string;
token?: string;
password?: string;
onHelloOk?: () => void;
onHelloOk?: (hello: { features?: { methods?: string[] } }) => void;
}) {
this.params = params;
gatewayClientCalls.push(params);
@@ -35,7 +35,7 @@ vi.mock("../gateway/client.js", () => ({
return { ok: true };
}
start() {
queueMicrotask(() => this.params.onHelloOk?.());
queueMicrotask(() => this.params.onHelloOk?.({ features: { methods: ["health"] } }));
}
stop() {}
},
@@ -191,6 +191,84 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
});
}, 60_000);
it("writes gateway token SecretRef from --gateway-token-ref-env", async () => {
await withStateDir("state-env-token-ref-", async (stateDir) => {
const envToken = "tok_env_ref_123";
const workspace = path.join(stateDir, "openclaw");
const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_GATEWAY_TOKEN = envToken;
try {
await runNonInteractiveOnboarding(
{
nonInteractive: true,
mode: "local",
workspace,
authChoice: "skip",
skipSkills: true,
skipHealth: true,
installDaemon: false,
gatewayBind: "loopback",
gatewayAuth: "token",
gatewayTokenRefEnv: "OPENCLAW_GATEWAY_TOKEN",
},
runtime,
);
const configPath = resolveStateConfigPath(process.env, stateDir);
const cfg = await readJsonFile<{
gateway?: { auth?: { mode?: string; token?: unknown } };
}>(configPath);
expect(cfg?.gateway?.auth?.mode).toBe("token");
expect(cfg?.gateway?.auth?.token).toEqual({
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
});
} finally {
if (prevToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
}
}
});
}, 60_000);
it("fails when --gateway-token-ref-env points to a missing env var", async () => {
await withStateDir("state-env-token-ref-missing-", async (stateDir) => {
const workspace = path.join(stateDir, "openclaw");
const previous = process.env.MISSING_GATEWAY_TOKEN_ENV;
delete process.env.MISSING_GATEWAY_TOKEN_ENV;
try {
await expect(
runNonInteractiveOnboarding(
{
nonInteractive: true,
mode: "local",
workspace,
authChoice: "skip",
skipSkills: true,
skipHealth: true,
installDaemon: false,
gatewayBind: "loopback",
gatewayAuth: "token",
gatewayTokenRefEnv: "MISSING_GATEWAY_TOKEN_ENV",
},
runtime,
),
).rejects.toThrow(/MISSING_GATEWAY_TOKEN_ENV/);
} finally {
if (previous === undefined) {
delete process.env.MISSING_GATEWAY_TOKEN_ENV;
} else {
process.env.MISSING_GATEWAY_TOKEN_ENV = previous;
}
}
});
}, 60_000);
it("writes gateway.remote url/token and callGateway uses them", async () => {
await withStateDir("state-remote-", async () => {
const port = getPseudoPort(30_000);

View File

@@ -92,7 +92,6 @@ export async function runNonInteractiveOnboardingLocal(params: {
opts,
runtime,
port: gatewayResult.port,
gatewayToken: gatewayResult.gatewayToken,
});
}

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
const buildGatewayInstallPlan = vi.hoisted(() => vi.fn());
const gatewayInstallErrorHint = vi.hoisted(() => vi.fn(() => "hint"));
const resolveGatewayInstallToken = vi.hoisted(() => vi.fn());
const serviceInstall = vi.hoisted(() => vi.fn(async () => {}));
const ensureSystemdUserLingerNonInteractive = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("../../daemon-install-helpers.js", () => ({
buildGatewayInstallPlan,
gatewayInstallErrorHint,
}));
vi.mock("../../gateway-install-token.js", () => ({
resolveGatewayInstallToken,
}));
vi.mock("../../../daemon/service.js", () => ({
resolveGatewayService: vi.fn(() => ({
install: serviceInstall,
})),
}));
vi.mock("../../../daemon/systemd.js", () => ({
isSystemdUserServiceAvailable: vi.fn(async () => true),
}));
vi.mock("../../daemon-runtime.js", () => ({
DEFAULT_GATEWAY_DAEMON_RUNTIME: "node",
isGatewayDaemonRuntime: vi.fn(() => true),
}));
vi.mock("../../systemd-linger.js", () => ({
ensureSystemdUserLingerNonInteractive,
}));
const { installGatewayDaemonNonInteractive } = await import("./daemon-install.js");
describe("installGatewayDaemonNonInteractive", () => {
beforeEach(() => {
vi.clearAllMocks();
resolveGatewayInstallToken.mockResolvedValue({
token: undefined,
tokenRefConfigured: true,
warnings: [],
});
buildGatewayInstallPlan.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
workingDirectory: "/tmp",
environment: {},
});
});
it("does not pass plaintext token for SecretRef-managed install", async () => {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await installGatewayDaemonNonInteractive({
nextConfig: {
gateway: {
auth: {
mode: "token",
token: {
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
},
},
},
} as OpenClawConfig,
opts: { installDaemon: true },
runtime,
port: 18789,
});
expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1);
expect(buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
}),
);
expect(serviceInstall).toHaveBeenCalledTimes(1);
});
it("aborts with actionable error when SecretRef is unresolved", async () => {
resolveGatewayInstallToken.mockResolvedValue({
token: undefined,
tokenRefConfigured: true,
unavailableReason: "gateway.auth.token SecretRef is configured but unresolved (boom).",
warnings: [],
});
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
await installGatewayDaemonNonInteractive({
nextConfig: {} as OpenClawConfig,
opts: { installDaemon: true },
runtime,
port: 18789,
});
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Gateway install blocked"));
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(buildGatewayInstallPlan).not.toHaveBeenCalled();
expect(serviceInstall).not.toHaveBeenCalled();
});
});

View File

@@ -4,6 +4,7 @@ import { isSystemdUserServiceAvailable } from "../../../daemon/systemd.js";
import type { RuntimeEnv } from "../../../runtime.js";
import { buildGatewayInstallPlan, gatewayInstallErrorHint } from "../../daemon-install-helpers.js";
import { DEFAULT_GATEWAY_DAEMON_RUNTIME, isGatewayDaemonRuntime } from "../../daemon-runtime.js";
import { resolveGatewayInstallToken } from "../../gateway-install-token.js";
import type { OnboardOptions } from "../../onboard-types.js";
import { ensureSystemdUserLingerNonInteractive } from "../../systemd-linger.js";
@@ -12,9 +13,8 @@ export async function installGatewayDaemonNonInteractive(params: {
opts: OnboardOptions;
runtime: RuntimeEnv;
port: number;
gatewayToken?: string;
}) {
const { opts, runtime, port, gatewayToken } = params;
const { opts, runtime, port } = params;
if (!opts.installDaemon) {
return;
}
@@ -34,10 +34,28 @@ export async function installGatewayDaemonNonInteractive(params: {
}
const service = resolveGatewayService();
const tokenResolution = await resolveGatewayInstallToken({
config: params.nextConfig,
env: process.env,
});
for (const warning of tokenResolution.warnings) {
runtime.log(warning);
}
if (tokenResolution.unavailableReason) {
runtime.error(
[
"Gateway install blocked:",
tokenResolution.unavailableReason,
"Fix gateway auth config/token input and rerun onboarding.",
].join(" "),
);
runtime.exit(1);
return;
}
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port,
token: gatewayToken,
token: tokenResolution.token,
runtime: daemonRuntimeRaw,
warn: (message) => runtime.log(message),
config: params.nextConfig,

View File

@@ -1,5 +1,7 @@
import type { OpenClawConfig } from "../../../config/config.js";
import { isValidEnvSecretRefId } from "../../../config/types.secrets.js";
import type { RuntimeEnv } from "../../../runtime.js";
import { resolveDefaultSecretProviderAlias } from "../../../secrets/ref-contract.js";
import { normalizeGatewayTokenInput, randomToken } from "../../onboard-helpers.js";
import type { OnboardOptions } from "../../onboard-types.js";
@@ -49,26 +51,65 @@ export function applyNonInteractiveGatewayConfig(params: {
}
let nextConfig = params.nextConfig;
let gatewayToken =
normalizeGatewayTokenInput(opts.gatewayToken) ||
normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN) ||
undefined;
const explicitGatewayToken = normalizeGatewayTokenInput(opts.gatewayToken);
const envGatewayToken = normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN);
let gatewayToken = explicitGatewayToken || envGatewayToken || undefined;
const gatewayTokenRefEnv = String(opts.gatewayTokenRefEnv ?? "").trim();
if (authMode === "token") {
if (!gatewayToken) {
gatewayToken = randomToken();
}
nextConfig = {
...nextConfig,
gateway: {
...nextConfig.gateway,
auth: {
...nextConfig.gateway?.auth,
mode: "token",
token: gatewayToken,
if (gatewayTokenRefEnv) {
if (!isValidEnvSecretRefId(gatewayTokenRefEnv)) {
runtime.error(
"Invalid --gateway-token-ref-env (use env var name like OPENCLAW_GATEWAY_TOKEN).",
);
runtime.exit(1);
return null;
}
if (explicitGatewayToken) {
runtime.error("Use either --gateway-token or --gateway-token-ref-env, not both.");
runtime.exit(1);
return null;
}
const resolvedFromEnv = process.env[gatewayTokenRefEnv]?.trim();
if (!resolvedFromEnv) {
runtime.error(`Environment variable "${gatewayTokenRefEnv}" is missing or empty.`);
runtime.exit(1);
return null;
}
gatewayToken = resolvedFromEnv;
nextConfig = {
...nextConfig,
gateway: {
...nextConfig.gateway,
auth: {
...nextConfig.gateway?.auth,
mode: "token",
token: {
source: "env",
provider: resolveDefaultSecretProviderAlias(nextConfig, "env", {
preferFirstProviderForSource: true,
}),
id: gatewayTokenRefEnv,
},
},
},
},
};
};
} else {
if (!gatewayToken) {
gatewayToken = randomToken();
}
nextConfig = {
...nextConfig,
gateway: {
...nextConfig.gateway,
auth: {
...nextConfig.gateway?.auth,
mode: "token",
token: gatewayToken,
},
},
};
}
}
if (authMode === "password") {

View File

@@ -144,6 +144,7 @@ export type OnboardOptions = {
gatewayBind?: GatewayBind;
gatewayAuth?: GatewayAuthChoice;
gatewayToken?: string;
gatewayTokenRefEnv?: string;
gatewayPassword?: string;
tailscale?: TailscaleMode;
tailscaleResetOnExit?: boolean;

View File

@@ -10,7 +10,7 @@ import type { GatewayService } from "../daemon/service.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js";
import { resolveGatewayProbeAuth } from "../gateway/probe-auth.js";
import { resolveGatewayProbeAuthSafe } from "../gateway/probe-auth.js";
import { probeGateway } from "../gateway/probe.js";
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
@@ -116,9 +116,11 @@ export async function statusAllCommand(
const remoteUrlMissing = isRemoteMode && !remoteUrlRaw;
const gatewayMode = isRemoteMode ? "remote" : "local";
const localFallbackAuth = resolveGatewayProbeAuth({ cfg, mode: "local" });
const remoteAuth = resolveGatewayProbeAuth({ cfg, mode: "remote" });
const probeAuth = isRemoteMode && !remoteUrlMissing ? remoteAuth : localFallbackAuth;
const localProbeAuthResolution = resolveGatewayProbeAuthSafe({ cfg, mode: "local" });
const remoteProbeAuthResolution = resolveGatewayProbeAuthSafe({ cfg, mode: "remote" });
const probeAuthResolution =
isRemoteMode && !remoteUrlMissing ? remoteProbeAuthResolution : localProbeAuthResolution;
const probeAuth = probeAuthResolution.auth;
const gatewayProbe = await probeGateway({
url: connection.url,
@@ -179,8 +181,8 @@ export async function statusAllCommand(
const callOverrides = remoteUrlMissing
? {
url: connection.url,
token: localFallbackAuth.token,
password: localFallbackAuth.password,
token: localProbeAuthResolution.auth.token,
password: localProbeAuthResolution.auth.password,
}
: {};
@@ -292,6 +294,9 @@ export async function statusAllCommand(
Item: "Gateway",
Value: `${gatewayMode}${remoteUrlMissing ? " (remote.url missing)" : ""} · ${gatewayTarget} (${connection.urlSource}) · ${gatewayStatus}${gatewayAuth}`,
},
...(probeAuthResolution.warning
? [{ Item: "Gateway auth warning", Value: probeAuthResolution.warning }]
: []),
{ Item: "Security", Value: `Run: ${formatCliCommand("openclaw security audit --deep")}` },
gatewaySelfLine
? { Item: "Gateway self", Value: gatewaySelfLine }

View File

@@ -30,7 +30,6 @@ import {
formatTokensCompact,
shortenText,
} from "./status.format.js";
import { resolveGatewayProbeAuth } from "./status.gateway-probe.js";
import { scanStatus } from "./status.scan.js";
import {
formatUpdateAvailableHint,
@@ -118,6 +117,8 @@ export async function statusCommand(
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbeAuth,
gatewayProbeAuthWarning,
gatewayProbe,
gatewayReachable,
gatewaySelf,
@@ -195,6 +196,7 @@ export async function statusCommand(
connectLatencyMs: gatewayProbe?.connectLatencyMs ?? null,
self: gatewaySelf,
error: gatewayProbe?.error ?? null,
authWarning: gatewayProbeAuthWarning ?? null,
},
gatewayService: daemon,
nodeService: nodeDaemon,
@@ -250,7 +252,7 @@ export async function statusCommand(
: warn(gatewayProbe?.error ? `unreachable (${gatewayProbe.error})` : "unreachable");
const auth =
gatewayReachable && !remoteUrlMissing
? ` · auth ${formatGatewayAuthUsed(resolveGatewayProbeAuth(cfg))}`
? ` · auth ${formatGatewayAuthUsed(gatewayProbeAuth)}`
: "";
const self =
gatewaySelf?.host || gatewaySelf?.version || gatewaySelf?.platform
@@ -411,6 +413,9 @@ export async function statusCommand(
Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine,
},
{ Item: "Gateway", Value: gatewayValue },
...(gatewayProbeAuthWarning
? [{ Item: "Gateway auth warning", Value: warn(gatewayProbeAuthWarning) }]
: []),
{ Item: "Gateway service", Value: daemonValue },
{ Item: "Node service", Value: nodeDaemonValue },
{ Item: "Agents", Value: agentsValue },

View File

@@ -1,14 +1,24 @@
import type { loadConfig } from "../config/config.js";
import { resolveGatewayProbeAuth as resolveGatewayProbeAuthByMode } from "../gateway/probe-auth.js";
import { resolveGatewayProbeAuthSafe } from "../gateway/probe-auth.js";
export { pickGatewaySelfPresence } from "./gateway-presence.js";
export function resolveGatewayProbeAuth(cfg: ReturnType<typeof loadConfig>): {
token?: string;
password?: string;
export function resolveGatewayProbeAuthResolution(cfg: ReturnType<typeof loadConfig>): {
auth: {
token?: string;
password?: string;
};
warning?: string;
} {
return resolveGatewayProbeAuthByMode({
return resolveGatewayProbeAuthSafe({
cfg,
mode: cfg.gateway?.mode === "remote" ? "remote" : "local",
env: process.env,
});
}
export function resolveGatewayProbeAuth(cfg: ReturnType<typeof loadConfig>): {
token?: string;
password?: string;
} {
return resolveGatewayProbeAuthResolution(cfg).auth;
}

View File

@@ -14,7 +14,10 @@ import { runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { buildChannelsTable } from "./status-all/channels.js";
import { getAgentLocalStatuses } from "./status.agent-local.js";
import { pickGatewaySelfPresence, resolveGatewayProbeAuth } from "./status.gateway-probe.js";
import {
pickGatewaySelfPresence,
resolveGatewayProbeAuthResolution,
} from "./status.gateway-probe.js";
import { getStatusSummary } from "./status.summary.js";
import { getUpdateCheckResult } from "./status.update.js";
@@ -34,6 +37,11 @@ type GatewayProbeSnapshot = {
gatewayConnection: ReturnType<typeof buildGatewayConnectionDetails>;
remoteUrlMissing: boolean;
gatewayMode: "local" | "remote";
gatewayProbeAuth: {
token?: string;
password?: string;
};
gatewayProbeAuthWarning?: string;
gatewayProbe: Awaited<ReturnType<typeof probeGateway>> | null;
};
@@ -73,14 +81,29 @@ async function resolveGatewayProbeSnapshot(params: {
typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url : "";
const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim();
const gatewayMode = isRemoteMode ? "remote" : "local";
const gatewayProbeAuthResolution = resolveGatewayProbeAuthResolution(params.cfg);
let gatewayProbeAuthWarning = gatewayProbeAuthResolution.warning;
const gatewayProbe = remoteUrlMissing
? null
: await probeGateway({
url: gatewayConnection.url,
auth: resolveGatewayProbeAuth(params.cfg),
auth: gatewayProbeAuthResolution.auth,
timeoutMs: Math.min(params.opts.all ? 5000 : 2500, params.opts.timeoutMs ?? 10_000),
}).catch(() => null);
return { gatewayConnection, remoteUrlMissing, gatewayMode, gatewayProbe };
if (gatewayProbeAuthWarning && gatewayProbe?.ok === false) {
gatewayProbe.error = gatewayProbe.error
? `${gatewayProbe.error}; ${gatewayProbeAuthWarning}`
: gatewayProbeAuthWarning;
gatewayProbeAuthWarning = undefined;
}
return {
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbeAuth: gatewayProbeAuthResolution.auth,
gatewayProbeAuthWarning,
gatewayProbe,
};
}
async function resolveChannelsStatus(params: {
@@ -110,6 +133,11 @@ export type StatusScanResult = {
gatewayConnection: ReturnType<typeof buildGatewayConnectionDetails>;
remoteUrlMissing: boolean;
gatewayMode: "local" | "remote";
gatewayProbeAuth: {
token?: string;
password?: string;
};
gatewayProbeAuthWarning?: string;
gatewayProbe: Awaited<ReturnType<typeof probeGateway>> | null;
gatewayReachable: boolean;
gatewaySelf: ReturnType<typeof pickGatewaySelfPresence>;
@@ -188,7 +216,14 @@ async function scanStatusJsonFast(opts: {
? `https://${tailscaleDns}${normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath)}`
: null;
const { gatewayConnection, remoteUrlMissing, gatewayMode, gatewayProbe } = gatewaySnapshot;
const {
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbeAuth,
gatewayProbeAuthWarning,
gatewayProbe,
} = gatewaySnapshot;
const gatewayReachable = gatewayProbe?.ok === true;
const gatewaySelf = gatewayProbe?.presence
? pickGatewaySelfPresence(gatewayProbe.presence)
@@ -209,6 +244,8 @@ async function scanStatusJsonFast(opts: {
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbeAuth,
gatewayProbeAuthWarning,
gatewayProbe,
gatewayReachable,
gatewaySelf,
@@ -283,8 +320,14 @@ export async function scanStatus(
progress.tick();
progress.setLabel("Probing gateway…");
const { gatewayConnection, remoteUrlMissing, gatewayMode, gatewayProbe } =
await resolveGatewayProbeSnapshot({ cfg, opts });
const {
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbeAuth,
gatewayProbeAuthWarning,
gatewayProbe,
} = await resolveGatewayProbeSnapshot({ cfg, opts });
const gatewayReachable = gatewayProbe?.ok === true;
const gatewaySelf = gatewayProbe?.presence
? pickGatewaySelfPresence(gatewayProbe.presence)
@@ -326,6 +369,8 @@ export async function scanStatus(
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbeAuth,
gatewayProbeAuthWarning,
gatewayProbe,
gatewayReachable,
gatewaySelf,

View File

@@ -1,5 +1,5 @@
import type { Mock } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../test-utils/env.js";
let envSnapshot: ReturnType<typeof captureEnv>;
@@ -146,6 +146,7 @@ async function withEnvVar<T>(key: string, value: string, run: () => Promise<T>):
}
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn().mockReturnValue({ session: {} }),
loadSessionStore: vi.fn().mockReturnValue({
"+1000": createDefaultSessionStoreEntry(),
}),
@@ -345,7 +346,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({ session: {} }),
loadConfig: mocks.loadConfig,
};
});
vi.mock("../daemon/service.js", () => ({
@@ -389,6 +390,11 @@ const runtime = {
const runtimeLogMock = runtime.log as Mock<(...args: unknown[]) => void>;
describe("statusCommand", () => {
afterEach(() => {
mocks.loadConfig.mockReset();
mocks.loadConfig.mockReturnValue({ session: {} });
});
it("prints JSON when requested", async () => {
await statusCommand({ json: true }, runtime as never);
const payload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0]));
@@ -481,6 +487,28 @@ describe("statusCommand", () => {
});
});
it("warns instead of crashing when gateway auth SecretRef is unresolved for probe auth", async () => {
mocks.loadConfig.mockReturnValue({
session: {},
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
});
await statusCommand({ json: true }, runtime as never);
const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0]));
expect(payload.gateway.error).toContain("gateway.auth.token");
expect(payload.gateway.error).toContain("SecretRef");
});
it("surfaces channel runtime errors from the gateway", async () => {
mockProbeGatewayResult({
ok: true,

View File

@@ -136,8 +136,8 @@ export type GatewayTrustedProxyConfig = {
export type GatewayAuthConfig = {
/** Authentication mode for Gateway connections. Defaults to token when unset. */
mode?: GatewayAuthMode;
/** Shared token for token mode (stored locally for CLI auth). */
token?: string;
/** Shared token for token mode (plaintext or SecretRef). */
token?: SecretInput;
/** Shared password for password mode (consider env instead). */
password?: SecretInput;
/** Allow Tailscale identity headers when serve mode is enabled. */

View File

@@ -15,6 +15,7 @@ export type SecretRef = {
export type SecretInput = string | SecretRef;
export const DEFAULT_SECRET_PROVIDER_ALIAS = "default";
export const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/;
const ENV_SECRET_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/;
type SecretDefaults = {
env?: string;
@@ -22,6 +23,10 @@ type SecretDefaults = {
exec?: string;
};
export function isValidEnvSecretRefId(value: string): boolean {
return ENV_SECRET_REF_ID_RE.test(value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@@ -620,7 +620,7 @@ export const OpenClawSchema = z
z.literal("trusted-proxy"),
])
.optional(),
token: z.string().optional().register(sensitive),
token: SecretInputSchema.optional().register(sensitive),
password: SecretInputSchema.optional().register(sensitive),
allowTailscale: z.boolean().optional(),
rateLimit: z

View File

@@ -0,0 +1,37 @@
import type { OpenClawConfig } from "../config/config.js";
import { collectConfigServiceEnvVars } from "../config/env-vars.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
export function shouldRequireGatewayTokenForInstall(
cfg: OpenClawConfig,
_env: NodeJS.ProcessEnv,
): boolean {
const mode = cfg.gateway?.auth?.mode;
if (mode === "token") {
return true;
}
if (mode === "password" || mode === "none" || mode === "trusted-proxy") {
return false;
}
const hasConfiguredPassword = hasConfiguredSecretInput(
cfg.gateway?.auth?.password,
cfg.secrets?.defaults,
);
if (hasConfiguredPassword) {
return false;
}
// Service install should only infer password mode from durable sources that
// survive outside the invoking shell.
const configServiceEnv = collectConfigServiceEnvVars(cfg);
const hasConfiguredPasswordEnvCandidate = Boolean(
configServiceEnv.OPENCLAW_GATEWAY_PASSWORD?.trim() ||
configServiceEnv.CLAWDBOT_GATEWAY_PASSWORD?.trim(),
);
if (hasConfiguredPasswordEnvCandidate) {
return false;
}
return true;
}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
assertExplicitGatewayAuthModeWhenBothConfigured,
EXPLICIT_GATEWAY_AUTH_MODE_REQUIRED_ERROR,
hasAmbiguousGatewayAuthModeConfig,
} from "./auth-mode-policy.js";
describe("gateway auth mode policy", () => {
it("does not flag config when auth mode is explicit", () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: "token-value",
password: "password-value",
},
},
};
expect(hasAmbiguousGatewayAuthModeConfig(cfg)).toBe(false);
});
it("does not flag config when only one auth credential is configured", () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
token: "token-value",
},
},
};
expect(hasAmbiguousGatewayAuthModeConfig(cfg)).toBe(false);
});
it("flags config when both token and password are configured and mode is unset", () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
token: "token-value",
password: "password-value",
},
},
};
expect(hasAmbiguousGatewayAuthModeConfig(cfg)).toBe(true);
});
it("flags config when both token/password SecretRefs are configured and mode is unset", () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
token: { source: "env", provider: "default", id: "GW_TOKEN" },
password: { source: "env", provider: "default", id: "GW_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
};
expect(hasAmbiguousGatewayAuthModeConfig(cfg)).toBe(true);
});
it("throws the shared explicit-mode error for ambiguous dual auth config", () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
token: "token-value",
password: "password-value",
},
},
};
expect(() => assertExplicitGatewayAuthModeWhenBothConfigured(cfg)).toThrow(
EXPLICIT_GATEWAY_AUTH_MODE_REQUIRED_ERROR,
);
});
});

View File

@@ -0,0 +1,26 @@
import type { OpenClawConfig } from "../config/config.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
export const EXPLICIT_GATEWAY_AUTH_MODE_REQUIRED_ERROR =
"Invalid config: gateway.auth.token and gateway.auth.password are both configured, but gateway.auth.mode is unset. Set gateway.auth.mode to token or password.";
export function hasAmbiguousGatewayAuthModeConfig(cfg: OpenClawConfig): boolean {
const auth = cfg.gateway?.auth;
if (!auth) {
return false;
}
if (typeof auth.mode === "string" && auth.mode.trim().length > 0) {
return false;
}
const defaults = cfg.secrets?.defaults;
const tokenConfigured = hasConfiguredSecretInput(auth.token, defaults);
const passwordConfigured = hasConfiguredSecretInput(auth.password, defaults);
return tokenConfigured && passwordConfigured;
}
export function assertExplicitGatewayAuthModeWhenBothConfigured(cfg: OpenClawConfig): void {
if (!hasAmbiguousGatewayAuthModeConfig(cfg)) {
return;
}
throw new Error(EXPLICIT_GATEWAY_AUTH_MODE_REQUIRED_ERROR);
}

View File

@@ -138,6 +138,25 @@ describe("gateway auth", () => {
});
});
it("treats env-template auth secrets as SecretRefs instead of plaintext", () => {
expect(
resolveGatewayAuth({
authConfig: {
token: "${OPENCLAW_GATEWAY_TOKEN}",
password: "${OPENCLAW_GATEWAY_PASSWORD}",
},
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
}),
).toMatchObject({
token: "env-token",
password: "env-password",
mode: "password",
});
});
it("resolves explicit auth mode none from config", () => {
expect(
resolveGatewayAuth({

View File

@@ -4,6 +4,7 @@ import type {
GatewayTailscaleMode,
GatewayTrustedProxyConfig,
} from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import {
@@ -243,9 +244,11 @@ export function resolveGatewayAuth(params: {
}
}
const env = params.env ?? process.env;
const tokenRef = resolveSecretInputRef({ value: authConfig.token }).ref;
const passwordRef = resolveSecretInputRef({ value: authConfig.password }).ref;
const resolvedCredentials = resolveGatewayCredentialsFromValues({
configToken: authConfig.token,
configPassword: authConfig.password,
configToken: tokenRef ? undefined : authConfig.token,
configPassword: passwordRef ? undefined : authConfig.password,
env,
includeLegacyEnv: false,
tokenPrecedence: "config-first",

View File

@@ -140,6 +140,47 @@ describe("resolveGatewayCredentialsFromConfig", () => {
).toThrow("gateway.auth.password");
});
it("treats env-template local tokens as SecretRefs instead of plaintext", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
mode: "local",
auth: {
mode: "token",
token: "${OPENCLAW_GATEWAY_TOKEN}",
},
},
}),
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
} as NodeJS.ProcessEnv,
includeLegacyEnv: false,
});
expect(resolved).toEqual({
token: "env-token",
password: undefined,
});
});
it("throws when env-template local token SecretRef is unresolved in token mode", () => {
expect(() =>
resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
mode: "local",
auth: {
mode: "token",
token: "${OPENCLAW_GATEWAY_TOKEN}",
},
},
}),
env: {} as NodeJS.ProcessEnv,
includeLegacyEnv: false,
}),
).toThrow("gateway.auth.token");
});
it("ignores unresolved local password ref when local auth mode is none", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: {
@@ -305,6 +346,64 @@ describe("resolveGatewayCredentialsFromConfig", () => {
).toThrow("gateway.remote.token");
});
it("ignores unresolved local token ref in remote-only mode when local auth mode is token", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: {
gateway: {
mode: "remote",
remote: {
url: "wss://gateway.example",
},
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as unknown as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
includeLegacyEnv: false,
remoteTokenFallback: "remote-only",
remotePasswordFallback: "remote-only",
});
expect(resolved).toEqual({
token: undefined,
password: undefined,
});
});
it("throws for unresolved local token ref in remote mode when local fallback is enabled", () => {
expect(() =>
resolveGatewayCredentialsFromConfig({
cfg: {
gateway: {
mode: "remote",
remote: {
url: "wss://gateway.example",
},
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as unknown as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
includeLegacyEnv: false,
remoteTokenFallback: "remote-env-local",
remotePasswordFallback: "remote-only",
}),
).toThrow("gateway.auth.token");
});
it("does not throw for unresolved remote token ref when password is available", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: {

View File

@@ -16,6 +16,38 @@ export type GatewayCredentialPrecedence = "env-first" | "config-first";
export type GatewayRemoteCredentialPrecedence = "remote-first" | "env-first";
export type GatewayRemoteCredentialFallback = "remote-env-local" | "remote-only";
const GATEWAY_SECRET_REF_UNAVAILABLE_ERROR_CODE = "GATEWAY_SECRET_REF_UNAVAILABLE";
export class GatewaySecretRefUnavailableError extends Error {
readonly code = GATEWAY_SECRET_REF_UNAVAILABLE_ERROR_CODE;
readonly path: string;
constructor(path: string) {
super(
[
`${path} is configured as a secret reference but is unavailable in this command path.`,
"Fix: set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD, pass explicit --token/--password,",
"or run a gateway command path that resolves secret references before credential selection.",
].join("\n"),
);
this.name = "GatewaySecretRefUnavailableError";
this.path = path;
}
}
export function isGatewaySecretRefUnavailableError(
error: unknown,
expectedPath?: string,
): error is GatewaySecretRefUnavailableError {
if (!(error instanceof GatewaySecretRefUnavailableError)) {
return false;
}
if (!expectedPath) {
return true;
}
return error.path === expectedPath;
}
export function trimToUndefined(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
@@ -34,13 +66,7 @@ function firstDefined(values: Array<string | undefined>): string | undefined {
}
function throwUnresolvedGatewaySecretInput(path: string): never {
throw new Error(
[
`${path} is configured as a secret reference but is unavailable in this command path.`,
"Fix: set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD, pass explicit --token/--password,",
"or run a gateway command path that resolves secret references before credential selection.",
].join("\n"),
);
throw new GatewaySecretRefUnavailableError(path);
}
function readGatewayTokenEnv(
@@ -144,10 +170,28 @@ export function resolveGatewayCredentialsFromConfig(params: {
const envToken = readGatewayTokenEnv(env, includeLegacyEnv);
const envPassword = readGatewayPasswordEnv(env, includeLegacyEnv);
const remoteToken = trimToUndefined(remote?.token);
const remotePassword = trimToUndefined(remote?.password);
const localToken = trimToUndefined(params.cfg.gateway?.auth?.token);
const localPassword = trimToUndefined(params.cfg.gateway?.auth?.password);
const localTokenRef = resolveSecretInputRef({
value: params.cfg.gateway?.auth?.token,
defaults,
}).ref;
const localPasswordRef = resolveSecretInputRef({
value: params.cfg.gateway?.auth?.password,
defaults,
}).ref;
const remoteTokenRef = resolveSecretInputRef({
value: remote?.token,
defaults,
}).ref;
const remotePasswordRef = resolveSecretInputRef({
value: remote?.password,
defaults,
}).ref;
const remoteToken = remoteTokenRef ? undefined : trimToUndefined(remote?.token);
const remotePassword = remotePasswordRef ? undefined : trimToUndefined(remote?.password);
const localToken = localTokenRef ? undefined : trimToUndefined(params.cfg.gateway?.auth?.token);
const localPassword = localPasswordRef
? undefined
: trimToUndefined(params.cfg.gateway?.auth?.password);
const localTokenPrecedence = params.localTokenPrecedence ?? "env-first";
const localPasswordPrecedence = params.localPasswordPrecedence ?? "env-first";
@@ -172,10 +216,15 @@ export function resolveGatewayCredentialsFromConfig(params: {
authMode !== "none" &&
authMode !== "trusted-proxy" &&
!localResolved.token);
const localPasswordRef = resolveSecretInputRef({
value: params.cfg.gateway?.auth?.password,
defaults,
}).ref;
const localTokenCanWin =
authMode === "token" ||
(authMode !== "password" &&
authMode !== "none" &&
authMode !== "trusted-proxy" &&
!localResolved.password);
if (localTokenRef && !localResolved.token && !envToken && localTokenCanWin) {
throwUnresolvedGatewaySecretInput("gateway.auth.token");
}
if (localPasswordRef && !localResolved.password && !envPassword && localPasswordCanWin) {
throwUnresolvedGatewaySecretInput("gateway.auth.password");
}
@@ -200,14 +249,10 @@ export function resolveGatewayCredentialsFromConfig(params: {
? firstDefined([envPassword, remotePassword, localPassword])
: firstDefined([remotePassword, envPassword, localPassword]);
const remoteTokenRef = resolveSecretInputRef({
value: remote?.token,
defaults,
}).ref;
const remotePasswordRef = resolveSecretInputRef({
value: remote?.password,
defaults,
}).ref;
const localTokenCanWin =
authMode === "token" ||
(authMode !== "password" && authMode !== "none" && authMode !== "trusted-proxy");
const localTokenFallbackEnabled = remoteTokenFallback !== "remote-only";
const localTokenFallback = remoteTokenFallback === "remote-only" ? undefined : localToken;
const localPasswordFallback =
remotePasswordFallback === "remote-only" ? undefined : localPassword;
@@ -217,6 +262,17 @@ export function resolveGatewayCredentialsFromConfig(params: {
if (remotePasswordRef && !password && !envPassword && !localPasswordFallback && !token) {
throwUnresolvedGatewaySecretInput("gateway.remote.password");
}
if (
localTokenRef &&
localTokenFallbackEnabled &&
!token &&
!password &&
!envToken &&
!remoteToken &&
localTokenCanWin
) {
throwUnresolvedGatewaySecretInput("gateway.auth.token");
}
return { token, password };
}

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveGatewayProbeAuthSafe } from "./probe-auth.js";
describe("resolveGatewayProbeAuthSafe", () => {
it("returns probe auth credentials when available", () => {
const result = resolveGatewayProbeAuthSafe({
cfg: {
gateway: {
auth: {
token: "token-value",
},
},
} as OpenClawConfig,
mode: "local",
env: {} as NodeJS.ProcessEnv,
});
expect(result).toEqual({
auth: {
token: "token-value",
password: undefined,
},
});
});
it("returns warning and empty auth when token SecretRef is unresolved", () => {
const result = resolveGatewayProbeAuthSafe({
cfg: {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
mode: "local",
env: {} as NodeJS.ProcessEnv,
});
expect(result.auth).toEqual({});
expect(result.warning).toContain("gateway.auth.token");
expect(result.warning).toContain("unresolved");
});
it("ignores unresolved local token SecretRef in remote mode when remote-only auth is requested", () => {
const result = resolveGatewayProbeAuthSafe({
cfg: {
gateway: {
mode: "remote",
remote: {
url: "wss://gateway.example",
},
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
mode: "remote",
env: {} as NodeJS.ProcessEnv,
});
expect(result).toEqual({
auth: {
token: undefined,
password: undefined,
},
});
});
});

View File

@@ -1,5 +1,8 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveGatewayCredentialsFromConfig } from "./credentials.js";
import {
isGatewaySecretRefUnavailableError,
resolveGatewayCredentialsFromConfig,
} from "./credentials.js";
export function resolveGatewayProbeAuth(params: {
cfg: OpenClawConfig;
@@ -14,3 +17,24 @@ export function resolveGatewayProbeAuth(params: {
remoteTokenFallback: "remote-only",
});
}
export function resolveGatewayProbeAuthSafe(params: {
cfg: OpenClawConfig;
mode: "local" | "remote";
env?: NodeJS.ProcessEnv;
}): {
auth: { token?: string; password?: string };
warning?: string;
} {
try {
return { auth: resolveGatewayProbeAuth(params) };
} catch (error) {
if (!isGatewaySecretRefUnavailableError(error)) {
throw error;
}
return {
auth: {},
warning: `${error.path} SecretRef is unresolved in this command path; probing without configured auth credentials.`,
};
}
}

View File

@@ -0,0 +1,89 @@
import type { OpenClawConfig } from "../config/types.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
export type SecretInputUnresolvedReasonStyle = "generic" | "detailed";
function trimToUndefined(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function buildUnresolvedReason(params: {
path: string;
style: SecretInputUnresolvedReasonStyle;
kind: "unresolved" | "non-string" | "empty";
refLabel: string;
}): string {
if (params.style === "generic") {
return `${params.path} SecretRef is unresolved (${params.refLabel}).`;
}
if (params.kind === "non-string") {
return `${params.path} SecretRef resolved to a non-string value.`;
}
if (params.kind === "empty") {
return `${params.path} SecretRef resolved to an empty value.`;
}
return `${params.path} SecretRef is unresolved (${params.refLabel}).`;
}
export async function resolveConfiguredSecretInputString(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
value: unknown;
path: string;
unresolvedReasonStyle?: SecretInputUnresolvedReasonStyle;
}): Promise<{ value?: string; unresolvedRefReason?: string }> {
const style = params.unresolvedReasonStyle ?? "generic";
const { ref } = resolveSecretInputRef({
value: params.value,
defaults: params.config.secrets?.defaults,
});
if (!ref) {
return { value: trimToUndefined(params.value) };
}
const refLabel = `${ref.source}:${ref.provider}:${ref.id}`;
try {
const resolved = await resolveSecretRefValues([ref], {
config: params.config,
env: params.env,
});
const resolvedValue = resolved.get(secretRefKey(ref));
if (typeof resolvedValue !== "string") {
return {
unresolvedRefReason: buildUnresolvedReason({
path: params.path,
style,
kind: "non-string",
refLabel,
}),
};
}
const trimmed = resolvedValue.trim();
if (trimmed.length === 0) {
return {
unresolvedRefReason: buildUnresolvedReason({
path: params.path,
style,
kind: "empty",
refLabel,
}),
};
}
return { value: trimmed };
} catch {
return {
unresolvedRefReason: buildUnresolvedReason({
path: params.path,
style,
kind: "unresolved",
refLabel,
}),
};
}
}

View File

@@ -107,7 +107,11 @@ import {
refreshGatewayHealthSnapshot,
} from "./server/health-state.js";
import { loadGatewayTlsRuntime } from "./server/tls.js";
import { ensureGatewayStartupAuth } from "./startup-auth.js";
import {
ensureGatewayStartupAuth,
mergeGatewayAuthConfig,
mergeGatewayTailscaleConfig,
} from "./startup-auth.js";
import { maybeSeedControlUiAllowedOriginsAtStartup } from "./startup-control-ui-origins.js";
export { __resetModelCatalogCacheForTest } from "./server-model-catalog.js";
@@ -174,6 +178,23 @@ function logGatewayAuthSurfaceDiagnostics(prepared: {
}
}
function applyGatewayAuthOverridesForStartupPreflight(
config: OpenClawConfig,
overrides: Pick<GatewayServerOptions, "auth" | "tailscale">,
): OpenClawConfig {
if (!overrides.auth && !overrides.tailscale) {
return config;
}
return {
...config,
gateway: {
...config.gateway,
auth: mergeGatewayAuthConfig(config.gateway?.auth, overrides.auth),
tailscale: mergeGatewayTailscaleConfig(config.gateway?.tailscale, overrides.tailscale),
},
};
}
export type GatewayServer = {
close: (opts?: { reason?: string; restartExpectedMs?: number | null }) => Promise<void>;
};
@@ -373,7 +394,14 @@ export async function startGatewayServer(
: "Unknown validation issue.";
throw new Error(`Invalid config at ${freshSnapshot.path}.\n${issues}`);
}
await activateRuntimeSecrets(freshSnapshot.config, {
const startupPreflightConfig = applyGatewayAuthOverridesForStartupPreflight(
freshSnapshot.config,
{
auth: opts.auth,
tailscale: opts.tailscale,
},
);
await activateRuntimeSecrets(startupPreflightConfig, {
reason: "startup",
activate: false,
});

View File

@@ -270,6 +270,34 @@ describe("gateway hot reload", () => {
);
}
async function writeGatewayTokenRefConfig() {
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
throw new Error("OPENCLAW_CONFIG_PATH is not set");
}
await fs.writeFile(
configPath,
`${JSON.stringify(
{
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_STARTUP_GW_TOKEN" },
},
},
},
null,
2,
)}\n`,
"utf8",
);
}
async function writeAuthProfileEnvRefStore() {
const stateDir = process.env.OPENCLAW_STATE_DIR;
if (!stateDir) {
@@ -429,6 +457,21 @@ describe("gateway hot reload", () => {
await expect(withGatewayServer(async () => {})).resolves.toBeUndefined();
});
it("honors startup auth overrides before secret preflight gating", async () => {
await writeGatewayTokenRefConfig();
delete process.env.MISSING_STARTUP_GW_TOKEN;
await expect(
withGatewayServer(async () => {}, {
serverOptions: {
auth: {
mode: "password",
password: "override-password",
},
},
}),
).resolves.toBeUndefined();
});
it("fails startup when auth-profile secret refs are unresolved", async () => {
await writeAuthProfileEnvRefStore();
delete process.env.MISSING_OPENCLAW_AUTH_REF;

View File

@@ -130,6 +130,137 @@ describe("ensureGatewayStartupAuth", () => {
expect(result.generatedToken).toBeUndefined();
expect(result.auth.mode).toBe("password");
expect(result.auth.password).toBe("resolved-password");
expect(result.cfg.gateway?.auth?.password).toEqual({
source: "env",
provider: "default",
id: "GW_PASSWORD",
});
});
it("resolves gateway.auth.token SecretRef before startup auth checks", async () => {
const result = await ensureGatewayStartupAuth({
cfg: {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "GW_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
env: {
GW_TOKEN: "resolved-token",
} as NodeJS.ProcessEnv,
persist: true,
});
expect(result.generatedToken).toBeUndefined();
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe("resolved-token");
expect(result.cfg.gateway?.auth?.token).toEqual({
source: "env",
provider: "default",
id: "GW_TOKEN",
});
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("resolves env-template gateway.auth.token before env-token short-circuiting", async () => {
const result = await ensureGatewayStartupAuth({
cfg: {
gateway: {
auth: {
mode: "token",
token: "${OPENCLAW_GATEWAY_TOKEN}",
},
},
},
env: {
OPENCLAW_GATEWAY_TOKEN: "resolved-token",
} as NodeJS.ProcessEnv,
persist: true,
});
expect(result.generatedToken).toBeUndefined();
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe("resolved-token");
expect(result.cfg.gateway?.auth?.token).toBe("${OPENCLAW_GATEWAY_TOKEN}");
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("uses OPENCLAW_GATEWAY_TOKEN without resolving configured token SecretRef", async () => {
const result = await ensureGatewayStartupAuth({
cfg: {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
env: {
OPENCLAW_GATEWAY_TOKEN: "token-from-env",
} as NodeJS.ProcessEnv,
persist: true,
});
expect(result.generatedToken).toBeUndefined();
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe("token-from-env");
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("fails when gateway.auth.token SecretRef is active and unresolved", async () => {
await expect(
ensureGatewayStartupAuth({
cfg: {
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
env: {} as NodeJS.ProcessEnv,
persist: true,
}),
).rejects.toThrow(/MISSING_GW_TOKEN/i);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("requires explicit gateway.auth.mode when token and password are both configured", async () => {
await expect(
ensureGatewayStartupAuth({
cfg: {
gateway: {
auth: {
token: "configured-token",
password: "configured-password",
},
},
},
env: {} as NodeJS.ProcessEnv,
persist: true,
}),
).rejects.toThrow(/gateway\.auth\.mode is unset/i);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("uses OPENCLAW_GATEWAY_PASSWORD without resolving configured password SecretRef", async () => {

View File

@@ -5,9 +5,10 @@ import type {
OpenClawConfig,
} from "../config/config.js";
import { writeConfigFile } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "./auth-mode-policy.js";
import { resolveGatewayAuth, type ResolvedGatewayAuth } from "./auth.js";
export function mergeGatewayAuthConfig(
@@ -107,12 +108,19 @@ function hasGatewayTokenCandidate(params: {
) {
return true;
}
return (
typeof params.cfg.gateway?.auth?.token === "string" &&
params.cfg.gateway.auth.token.trim().length > 0
return hasConfiguredSecretInput(params.cfg.gateway?.auth?.token, params.cfg.secrets?.defaults);
}
function hasGatewayTokenOverrideCandidate(params: { authOverride?: GatewayAuthConfig }): boolean {
return Boolean(
typeof params.authOverride?.token === "string" && params.authOverride.token.trim().length > 0,
);
}
function hasGatewayTokenEnvCandidate(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim());
}
function hasGatewayPasswordEnvCandidate(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.OPENCLAW_GATEWAY_PASSWORD?.trim() || env.CLAWDBOT_GATEWAY_PASSWORD?.trim());
}
@@ -130,6 +138,61 @@ function hasGatewayPasswordOverrideCandidate(params: {
);
}
function shouldResolveGatewayTokenSecretRef(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
authOverride?: GatewayAuthConfig;
}): boolean {
if (hasGatewayTokenOverrideCandidate({ authOverride: params.authOverride })) {
return false;
}
if (hasGatewayTokenEnvCandidate(params.env)) {
return false;
}
const explicitMode = params.authOverride?.mode ?? params.cfg.gateway?.auth?.mode;
if (explicitMode === "token") {
return true;
}
if (explicitMode === "password" || explicitMode === "none" || explicitMode === "trusted-proxy") {
return false;
}
if (hasGatewayPasswordOverrideCandidate(params)) {
return false;
}
return !hasConfiguredSecretInput(
params.cfg.gateway?.auth?.password,
params.cfg.secrets?.defaults,
);
}
async function resolveGatewayTokenSecretRef(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
authOverride?: GatewayAuthConfig,
): Promise<string | undefined> {
const authToken = cfg.gateway?.auth?.token;
const { ref } = resolveSecretInputRef({
value: authToken,
defaults: cfg.secrets?.defaults,
});
if (!ref) {
return undefined;
}
if (!shouldResolveGatewayTokenSecretRef({ cfg, env, authOverride })) {
return undefined;
}
const resolved = await resolveSecretRefValues([ref], {
config: cfg,
env,
});
const value = resolved.get(secretRefKey(ref));
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error("gateway.auth.token resolved to an empty or non-string value.");
}
return value.trim();
}
function shouldResolveGatewayPasswordSecretRef(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
@@ -156,17 +219,17 @@ async function resolveGatewayPasswordSecretRef(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
authOverride?: GatewayAuthConfig,
): Promise<OpenClawConfig> {
): Promise<string | undefined> {
const authPassword = cfg.gateway?.auth?.password;
const { ref } = resolveSecretInputRef({
value: authPassword,
defaults: cfg.secrets?.defaults,
});
if (!ref) {
return cfg;
return undefined;
}
if (!shouldResolveGatewayPasswordSecretRef({ cfg, env, authOverride })) {
return cfg;
return undefined;
}
const resolved = await resolveSecretRefValues([ref], {
config: cfg,
@@ -176,16 +239,7 @@ async function resolveGatewayPasswordSecretRef(
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error("gateway.auth.password resolved to an empty or non-string value.");
}
return {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
password: value.trim(),
},
},
};
return value.trim();
}
export async function ensureGatewayStartupAuth(params: {
@@ -200,27 +254,39 @@ export async function ensureGatewayStartupAuth(params: {
generatedToken?: string;
persistedGeneratedToken: boolean;
}> {
assertExplicitGatewayAuthModeWhenBothConfigured(params.cfg);
const env = params.env ?? process.env;
const persistRequested = params.persist === true;
const cfgForAuth = await resolveGatewayPasswordSecretRef(params.cfg, env, params.authOverride);
const [resolvedTokenRefValue, resolvedPasswordRefValue] = await Promise.all([
resolveGatewayTokenSecretRef(params.cfg, env, params.authOverride),
resolveGatewayPasswordSecretRef(params.cfg, env, params.authOverride),
]);
const authOverride: GatewayAuthConfig | undefined =
params.authOverride || resolvedTokenRefValue || resolvedPasswordRefValue
? {
...params.authOverride,
...(resolvedTokenRefValue ? { token: resolvedTokenRefValue } : {}),
...(resolvedPasswordRefValue ? { password: resolvedPasswordRefValue } : {}),
}
: undefined;
const resolved = resolveGatewayAuthFromConfig({
cfg: cfgForAuth,
cfg: params.cfg,
env,
authOverride: params.authOverride,
authOverride,
tailscaleOverride: params.tailscaleOverride,
});
if (resolved.mode !== "token" || (resolved.token?.trim().length ?? 0) > 0) {
assertHooksTokenSeparateFromGatewayAuth({ cfg: cfgForAuth, auth: resolved });
return { cfg: cfgForAuth, auth: resolved, persistedGeneratedToken: false };
assertHooksTokenSeparateFromGatewayAuth({ cfg: params.cfg, auth: resolved });
return { cfg: params.cfg, auth: resolved, persistedGeneratedToken: false };
}
const generatedToken = crypto.randomBytes(24).toString("hex");
const nextCfg: OpenClawConfig = {
...cfgForAuth,
...params.cfg,
gateway: {
...cfgForAuth.gateway,
...params.cfg.gateway,
auth: {
...cfgForAuth.gateway?.auth,
...params.cfg.gateway?.auth,
mode: "token",
token: generatedToken,
},

View File

@@ -147,6 +147,181 @@ describe("pairing setup code", () => {
expect(resolved.payload.token).toBe("tok_123");
});
it("resolves gateway.auth.token SecretRef for pairing payload", async () => {
const resolved = await resolvePairingSetupFromConfig(
{
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "GW_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
{
env: {
GW_TOKEN: "resolved-token",
},
},
);
expect(resolved.ok).toBe(true);
if (!resolved.ok) {
throw new Error("expected setup resolution to succeed");
}
expect(resolved.authLabel).toBe("token");
expect(resolved.payload.token).toBe("resolved-token");
});
it("errors when gateway.auth.token SecretRef is unresolved in token mode", async () => {
await expect(
resolvePairingSetupFromConfig(
{
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
{
env: {},
},
),
).rejects.toThrow(/MISSING_GW_TOKEN/i);
});
it("uses password env in inferred mode without resolving token SecretRef", async () => {
const resolved = await resolvePairingSetupFromConfig(
{
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
{
env: {
OPENCLAW_GATEWAY_PASSWORD: "password-from-env",
},
},
);
expect(resolved.ok).toBe(true);
if (!resolved.ok) {
throw new Error("expected setup resolution to succeed");
}
expect(resolved.authLabel).toBe("password");
expect(resolved.payload.password).toBe("password-from-env");
});
it("does not treat env-template token as plaintext in inferred mode", async () => {
const resolved = await resolvePairingSetupFromConfig(
{
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
token: "${MISSING_GW_TOKEN}",
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
{
env: {
OPENCLAW_GATEWAY_PASSWORD: "password-from-env",
},
},
);
expect(resolved.ok).toBe(true);
if (!resolved.ok) {
throw new Error("expected setup resolution to succeed");
}
expect(resolved.authLabel).toBe("password");
expect(resolved.payload.token).toBeUndefined();
expect(resolved.payload.password).toBe("password-from-env");
});
it("requires explicit auth mode when token and password are both configured", async () => {
await expect(
resolvePairingSetupFromConfig(
{
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
token: { source: "env", provider: "default", id: "GW_TOKEN" },
password: { source: "env", provider: "default", id: "GW_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
{
env: {
GW_TOKEN: "resolved-token",
GW_PASSWORD: "resolved-password",
},
},
),
).rejects.toThrow(/gateway\.auth\.mode is unset/i);
});
it("errors when token and password SecretRefs are both configured with inferred mode", async () => {
await expect(
resolvePairingSetupFromConfig(
{
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
token: { source: "env", provider: "default", id: "MISSING_GW_TOKEN" },
password: { source: "env", provider: "default", id: "GW_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
},
{
env: {
GW_PASSWORD: "resolved-password",
},
},
),
).rejects.toThrow(/gateway\.auth\.mode is unset/i);
});
it("honors env token override", async () => {
const resolved = await resolvePairingSetupFromConfig(
{

View File

@@ -1,7 +1,12 @@
import os from "node:os";
import { resolveGatewayPort } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.js";
import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
resolveSecretInputRef,
} from "../config/types.secrets.js";
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
@@ -152,14 +157,23 @@ function pickTailnetIPv4(
function resolveAuth(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): ResolveAuthResult {
const mode = cfg.gateway?.auth?.mode;
const defaults = cfg.secrets?.defaults;
const tokenRef = resolveSecretInputRef({
value: cfg.gateway?.auth?.token,
defaults,
}).ref;
const passwordRef = resolveSecretInputRef({
value: cfg.gateway?.auth?.password,
defaults,
}).ref;
const token =
env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
cfg.gateway?.auth?.token?.trim();
(tokenRef ? undefined : normalizeSecretInputString(cfg.gateway?.auth?.token));
const password =
env.OPENCLAW_GATEWAY_PASSWORD?.trim() ||
env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
normalizeSecretInputString(cfg.gateway?.auth?.password);
(passwordRef ? undefined : normalizeSecretInputString(cfg.gateway?.auth?.password));
if (mode === "password") {
if (!password) {
@@ -182,6 +196,56 @@ function resolveAuth(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): ResolveAuthRe
return { error: "Gateway auth is not configured (no token or password)." };
}
async function resolveGatewayTokenSecretRef(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
): Promise<OpenClawConfig> {
const authToken = cfg.gateway?.auth?.token;
const { ref } = resolveSecretInputRef({
value: authToken,
defaults: cfg.secrets?.defaults,
});
if (!ref) {
return cfg;
}
const hasTokenEnvCandidate = Boolean(
env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim(),
);
if (hasTokenEnvCandidate) {
return cfg;
}
const mode = cfg.gateway?.auth?.mode;
if (mode === "password" || mode === "none" || mode === "trusted-proxy") {
return cfg;
}
if (mode !== "token") {
const hasPasswordEnvCandidate = Boolean(
env.OPENCLAW_GATEWAY_PASSWORD?.trim() || env.CLAWDBOT_GATEWAY_PASSWORD?.trim(),
);
if (hasPasswordEnvCandidate) {
return cfg;
}
}
const resolved = await resolveSecretRefValues([ref], {
config: cfg,
env,
});
const value = resolved.get(secretRefKey(ref));
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error("gateway.auth.token resolved to an empty or non-string value.");
}
return {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
token: value.trim(),
},
},
};
}
async function resolveGatewayPasswordSecretRef(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
@@ -207,7 +271,7 @@ async function resolveGatewayPasswordSecretRef(
if (mode !== "password") {
const hasTokenCandidate =
Boolean(env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim()) ||
Boolean(cfg.gateway?.auth?.token?.trim());
hasConfiguredSecretInput(cfg.gateway?.auth?.token, cfg.secrets?.defaults);
if (hasTokenCandidate) {
return cfg;
}
@@ -304,8 +368,10 @@ export async function resolvePairingSetupFromConfig(
cfg: OpenClawConfig,
options: ResolvePairingSetupOptions = {},
): Promise<PairingSetupResolution> {
assertExplicitGatewayAuthModeWhenBothConfigured(cfg);
const env = options.env ?? process.env;
const cfgForAuth = await resolveGatewayPasswordSecretRef(cfg, env);
const cfgWithToken = await resolveGatewayTokenSecretRef(cfg, env);
const cfgForAuth = await resolveGatewayPasswordSecretRef(cfgWithToken, env);
const auth = resolveAuth(cfgForAuth, env);
if (auth.error) {
return { ok: false, error: auth.error };

View File

@@ -24,7 +24,6 @@ const EXCLUDED_MUTABLE_OR_RUNTIME_MANAGED = [
"commands.ownerDisplaySecret",
"channels.matrix.accessToken",
"channels.matrix.accounts.*.accessToken",
"gateway.auth.token",
"hooks.token",
"hooks.gmail.pushToken",
"hooks.mappings[].sessionKey",

View File

@@ -202,6 +202,18 @@ function collectGatewayAssignments(params: {
defaults: params.defaults,
});
if (auth) {
collectSecretInputAssignment({
value: auth.token,
path: "gateway.auth.token",
expected: "string",
defaults: params.defaults,
context: params.context,
active: gatewaySurfaceStates["gateway.auth.token"].active,
inactiveReason: gatewaySurfaceStates["gateway.auth.token"].reason,
apply: (value) => {
auth.token = value;
},
});
collectSecretInputAssignment({
value: auth.password,
path: "gateway.auth.password",

View File

@@ -16,6 +16,60 @@ function evaluate(config: OpenClawConfig, env: NodeJS.ProcessEnv = EMPTY_ENV) {
}
describe("evaluateGatewayAuthSurfaceStates", () => {
it("marks gateway.auth.token active when token mode is explicit", () => {
const states = evaluate({
gateway: {
auth: {
mode: "token",
token: envRef("GW_AUTH_TOKEN"),
},
},
} as OpenClawConfig);
expect(states["gateway.auth.token"]).toMatchObject({
hasSecretRef: true,
active: true,
reason: 'gateway.auth.mode is "token".',
});
});
it("marks gateway.auth.token inactive when env token is configured", () => {
const states = evaluate(
{
gateway: {
auth: {
mode: "token",
token: envRef("GW_AUTH_TOKEN"),
},
},
} as OpenClawConfig,
{ OPENCLAW_GATEWAY_TOKEN: "env-token" } as NodeJS.ProcessEnv,
);
expect(states["gateway.auth.token"]).toMatchObject({
hasSecretRef: true,
active: false,
reason: "gateway token env var is configured.",
});
});
it("marks gateway.auth.token inactive when password mode is explicit", () => {
const states = evaluate({
gateway: {
auth: {
mode: "password",
token: envRef("GW_AUTH_TOKEN"),
},
},
} as OpenClawConfig);
expect(states["gateway.auth.token"]).toMatchObject({
hasSecretRef: true,
active: false,
reason: 'gateway.auth.mode is "password".',
});
});
it("marks gateway.auth.password active when password mode is explicit", () => {
const states = evaluate({
gateway: {

View File

@@ -10,6 +10,7 @@ const GATEWAY_PASSWORD_ENV_KEYS = [
] as const;
export const GATEWAY_AUTH_SURFACE_PATHS = [
"gateway.auth.token",
"gateway.auth.password",
"gateway.remote.token",
"gateway.remote.password",
@@ -85,6 +86,12 @@ export function evaluateGatewayAuthSurfaceStates(params: {
const gateway = params.config.gateway as Record<string, unknown> | undefined;
if (!isRecord(gateway)) {
return {
"gateway.auth.token": createState({
path: "gateway.auth.token",
active: false,
reason: "gateway configuration is not set.",
hasSecretRef: false,
}),
"gateway.auth.password": createState({
path: "gateway.auth.password",
active: false,
@@ -109,6 +116,7 @@ export function evaluateGatewayAuthSurfaceStates(params: {
const remote = isRecord(gateway?.remote) ? gateway.remote : undefined;
const authMode = auth && typeof auth.mode === "string" ? auth.mode : undefined;
const hasAuthTokenRef = coerceSecretRef(auth?.token, defaults) !== null;
const hasAuthPasswordRef = coerceSecretRef(auth?.password, defaults) !== null;
const hasRemoteTokenRef = coerceSecretRef(remote?.token, defaults) !== null;
const hasRemotePasswordRef = coerceSecretRef(remote?.password, defaults) !== null;
@@ -118,9 +126,14 @@ export function evaluateGatewayAuthSurfaceStates(params: {
const localTokenConfigured = hasConfiguredSecretInput(auth?.token, defaults);
const localPasswordConfigured = hasConfiguredSecretInput(auth?.password, defaults);
const remoteTokenConfigured = hasConfiguredSecretInput(remote?.token, defaults);
const passwordSourceConfigured = Boolean(envPassword || localPasswordConfigured);
const localTokenCanWin =
authMode !== "password" && authMode !== "none" && authMode !== "trusted-proxy";
const localTokenSurfaceActive =
localTokenCanWin &&
!envToken &&
(authMode === "token" || (authMode === undefined && !passwordSourceConfigured));
const tokenCanWin = Boolean(envToken || localTokenConfigured || remoteTokenConfigured);
const passwordCanWin =
authMode === "password" ||
@@ -165,6 +178,28 @@ export function evaluateGatewayAuthSurfaceStates(params: {
return "token auth can win.";
})();
const authTokenReason = (() => {
if (!auth) {
return "gateway.auth is not configured.";
}
if (authMode === "token") {
return envToken ? "gateway token env var is configured." : 'gateway.auth.mode is "token".';
}
if (authMode === "password" || authMode === "none" || authMode === "trusted-proxy") {
return `gateway.auth.mode is "${authMode}".`;
}
if (envToken) {
return "gateway token env var is configured.";
}
if (envPassword) {
return "gateway password env var is configured.";
}
if (localPasswordConfigured) {
return "gateway.auth.password is configured.";
}
return "token auth can win (mode is unset and no password source is configured).";
})();
const remoteSurfaceReason = describeRemoteConfiguredSurface({
remoteMode,
remoteUrlConfigured,
@@ -225,6 +260,12 @@ export function evaluateGatewayAuthSurfaceStates(params: {
})();
return {
"gateway.auth.token": createState({
path: "gateway.auth.token",
active: localTokenSurfaceActive,
reason: authTokenReason,
hasSecretRef: hasAuthTokenRef,
}),
"gateway.auth.password": createState({
path: "gateway.auth.password",
active: passwordCanWin,

View File

@@ -652,6 +652,71 @@ describe("secrets runtime snapshot", () => {
expect(snapshot.warnings.map((warning) => warning.path)).not.toContain("gateway.auth.password");
});
it("treats gateway.auth.token ref as active when token mode is explicit", async () => {
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "GATEWAY_TOKEN_REF" },
},
},
}),
env: {
GATEWAY_TOKEN_REF: "resolved-gateway-token",
},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
expect(snapshot.config.gateway?.auth?.token).toBe("resolved-gateway-token");
expect(snapshot.warnings.map((warning) => warning.path)).not.toContain("gateway.auth.token");
});
it("treats gateway.auth.token ref as inactive when password mode is explicit", async () => {
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
gateway: {
auth: {
mode: "password",
token: { source: "env", provider: "default", id: "GATEWAY_TOKEN_REF" },
password: "password-123",
},
},
}),
env: {
GATEWAY_TOKEN_REF: "resolved-gateway-token",
},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
expect(snapshot.config.gateway?.auth?.token).toEqual({
source: "env",
provider: "default",
id: "GATEWAY_TOKEN_REF",
});
expect(snapshot.warnings.map((warning) => warning.path)).toContain("gateway.auth.token");
});
it("fails when gateway.auth.token ref is active and unresolved", async () => {
await expect(
prepareSecretsRuntimeSnapshot({
config: asConfig({
gateway: {
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN_REF" },
},
},
}),
env: {},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
}),
).rejects.toThrow(/MISSING_GATEWAY_TOKEN_REF/i);
});
it("treats gateway.auth.password ref as inactive when auth mode is trusted-proxy", async () => {
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({

Some files were not shown because too many files have changed in this diff Show More