fix(onboard): honor secret-input-mode ref for the generated gateway token (#126877)

* fix(onboard): honor secret-input-mode ref for the generated gateway token

`openclaw onboard --secret-input-mode ref` was silently ignored for
`gateway.auth.token`: onboarding generated the token and wrote it into
`openclaw.json` as a plaintext string, so `openclaw doctor` warned about
`gateway.auth.token` on the install it had just created. The flag was
honored for provider credentials, so an operator who explicitly opted into
references still ended up with a plaintext secret and a remediation
(`openclaw secrets configure`) that cannot migrate a self-generated value,
because it validates a ref by resolving one that already exists.

Setup mints this token itself, so reference mode now provisions it:

- an ambient OPENCLAW_GATEWAY_TOKEN keeps an `env` ref to that variable, so a
  later rotation stays authoritative instead of being pinned by a stale copy
- anything else (freshly generated, or an existing plaintext token being
  migrated) goes into the shared SQLite secret store as a write-only `secret`
  entry, with config holding only `{source:"store",...}`

An existing store entry wins over a freshly generated one, so reruns never
rotate a token already paired with clients. The store write precedes the
config write: a ref persisted without its value would leave the gateway
unauthenticatable, while an orphaned entry is reused by the next run.

The interactive wizard had the same dead end and is fixed the same way.
Default (plaintext) onboarding is unchanged.

User impact: `--secret-input-mode ref` now keeps the gateway token out of
openclaw.json, and a fresh install no longer self-reports a plaintext-secret
warning.

* test(onboard): split gateway onboarding suite under the max-lines gate

The added gateway auth-token tests pushed
onboard-non-interactive.gateway.test.ts to 1014 lines, over the max-lines
limit (check-lint-core-3). Repo policy is to split, never suppress.

Extract the shared vi.mock/harness preamble into
onboard-non-interactive.gateway.test-mocks.ts, following the existing
agent-command.test-mocks.ts pattern, and move the four gateway auth-token
storage tests into their own suite. The reachability mock becomes a holder
object so both suites can swap it across the module boundary, and hoisted
mocks are re-exported in a separate export clause because Vitest rejects
exporting a vi.hoisted binding at its declaration.

Test set is unchanged: the it-declaration multiset matches the pre-split
file exactly, with no duplication across the two suites.

* test(onboard): give the shared gateway onboarding mocks unique export names

check-export-name-collisions flagged `runtime` and `readConfigFileSnapshotMock`
as colliding with program.test-mocks.ts and plugins-cli-test-helpers.ts once the
gateway onboarding preamble became a shared module. Rename the exports to
gatewayOnboardRuntime / gatewayOnboardConfigSnapshotMock per the repo's
unique-export-name rule; suites alias them locally so the assertions read the
same as before.

* test(tooling): route the new gateway auth-token suite from its test helper

test-projects asserts which suites a change to
onboard-non-interactive.test-helpers.ts should run. The new
onboard-non-interactive.gateway-auth-token.test.ts imports that helper, so it
belongs in the expected routing plan.
This commit is contained in:
Peter Steinberger
2026-08-20 17:46:59 -07:00
committed by GitHub
parent 133d5fff6c
commit df2cc8f259
14 changed files with 740 additions and 324 deletions
+1
View File
@@ -102,6 +102,7 @@ In interactive onboarding, choosing SecretRef storage runs preflight validation
- Env refs: validates the env var name and confirms a non-empty value is visible during setup.
- Provider refs (`file`, `exec`, or `store`): validates provider selection, resolves `id`, and checks the resolved value type.
- Quickstart flow: when `gateway.auth.token` is already a SecretRef, onboarding resolves it before probe/dashboard bootstrap (for `env`, `file`, `exec`, and `store` refs) using the same fail-fast gate.
- Generated gateway token: setup mints `gateway.auth.token` itself, so reference mode has nothing to prompt for. With `OPENCLAW_GATEWAY_TOKEN` exported it writes an `env` ref to that variable, keeping a later rotation authoritative; otherwise it writes the token to the secret store under `OPENCLAW_GATEWAY_TOKEN` and stores a `store` ref. An existing store entry is reused rather than rotated, so re-running setup never invalidates already-paired clients.
Validation failure shows the error and lets you retry.
+1
View File
@@ -35,6 +35,7 @@ Add `--json` for a machine-readable summary.
- `--gateway-port` defaults to `18789`; only pass it to override.
- `--skip-bootstrap` skips creating default workspace files, for automation that pre-seeds its own workspace.
- `--secret-input-mode ref` stores new credentials as env-backed references (`{ source: "env", provider: "default", id: "<ENV_VAR>" }`); set the provider env var when adding a credential or passing an inline key flag. Existing resolvable named profiles and their `env`, `file`, `exec`, or `store` references are reused unchanged, without a new credential write or additional provider env var. Existing plaintext is not migrated; run `openclaw secrets configure --apply`, then `openclaw secrets audit --check`. See [Secrets management](/gateway/secrets).
- The gateway token follows the same mode. Setup generates that value itself, so reference mode has no env var to point at unless you supply one: with `OPENCLAW_GATEWAY_TOKEN` exported, `gateway.auth.token` becomes an `env` ref to it; otherwise the token goes into the SQLite secret store as `OPENCLAW_GATEWAY_TOKEN` and config keeps a `store` ref. Either way `openclaw.json` holds no plaintext gateway token. Inspect the entry with `openclaw secrets store list`.
```bash
openclaw onboard --non-interactive --accept-risk --skip-health \
@@ -0,0 +1,233 @@
// Gateway auth-token storage tests cover what onboarding persists at gateway.auth.token:
// plaintext by default, and env/store SecretRefs under --secret-input-mode ref.
import fs from "node:fs/promises";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { makeTempWorkspace } from "../test-helpers/workspace.js";
import { setTestEnvValue } from "../test-utils/env.js";
import {
capturedReplaceConfigFileCalls,
configWritePluginLeaseDepths,
gatewayReachableState,
getPseudoPort,
loadGatewayOnboardModules,
readTestConfig,
resolveTestConfigPath,
runNonInteractiveSetup,
gatewayOnboardRuntime as runtime,
testConfigStore,
} from "./onboard-non-interactive.gateway.test-mocks.js";
import {
createOnboardStateDirHarness,
prepareOnboardGatewayTestEnv,
} from "./onboard-non-interactive.test-helpers.js";
describe("onboard (non-interactive): gateway auth token storage", () => {
let envSnapshot: ReturnType<typeof prepareOnboardGatewayTestEnv>;
let tempHome: string | undefined;
const { withStateDir } = createOnboardStateDirHarness(() => tempHome);
beforeAll(async () => {
envSnapshot = prepareOnboardGatewayTestEnv();
tempHome = await makeTempWorkspace("openclaw-onboard-auth-token-");
setTestEnvValue("HOME", tempHome);
await loadGatewayOnboardModules();
});
afterAll(async () => {
if (tempHome) {
await fs.rm(tempHome, { recursive: true, force: true });
}
envSnapshot.restore();
});
afterEach(() => {
gatewayReachableState.mock = undefined;
testConfigStore.clear();
capturedReplaceConfigFileCalls.length = 0;
configWritePluginLeaseDepths.length = 0;
vi.clearAllMocks();
});
it("writes gateway token auth into config", async () => {
await withStateDir("state-noninteractive-", async (stateDir) => {
const token = "tok_test_123";
const workspace = path.join(stateDir, "openclaw");
testConfigStore.set(resolveTestConfigPath(), {
gateway: {
bind: "lan",
auth: { mode: "password", password: "test-password" },
tailscale: { mode: "serve" },
},
} as OpenClawConfig);
await runNonInteractiveSetup(
{
nonInteractive: true,
mode: "local",
workspace,
authChoice: "skip",
skipSkills: true,
skipHealth: true,
installDaemon: false,
gatewayBind: "loopback",
gatewayAuth: "token",
gatewayToken: token,
tailscale: "off",
},
runtime,
);
const cfg = readTestConfig() as {
gateway?: {
mode?: string;
bind?: string;
auth?: { mode?: string; token?: string };
tailscale?: { mode?: string };
};
agents?: { defaults?: { workspace?: string } };
tools?: { profile?: string };
hooks?: { internal?: { entries?: Record<string, { enabled?: boolean }> } };
};
expect(cfg?.agents?.defaults?.workspace).toBe(workspace);
expect(cfg?.gateway?.mode).toBe("local");
expect(cfg?.gateway?.bind).toBe("loopback");
expect(cfg?.tools?.profile).toBe("coding");
expect(cfg?.gateway?.auth?.mode).toBe("token");
expect(cfg?.gateway?.auth?.token).toBe(token);
expect(cfg?.gateway?.tailscale).toEqual({ mode: "off" });
expect(cfg?.hooks?.internal?.entries?.["session-memory"]).toEqual({ enabled: true });
});
}, 60_000);
it("auto-generates token auth when binding LAN and persists the token", async () => {
if (process.platform === "win32") {
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
return;
}
await withStateDir("state-lan-", async (stateDir) => {
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
setTestEnvValue("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json"));
const port = getPseudoPort(40_000);
const workspace = path.join(stateDir, "openclaw");
await runNonInteractiveSetup(
{
nonInteractive: true,
mode: "local",
workspace,
authChoice: "skip",
skipSkills: true,
skipHealth: true,
installDaemon: false,
gatewayPort: port,
gatewayBind: "lan",
},
runtime,
);
const cfg = readTestConfig() as {
gateway?: {
bind?: string;
port?: number;
auth?: { mode?: string; token?: string };
};
};
expect(cfg.gateway?.bind).toBe("lan");
expect(cfg.gateway?.port).toBe(port);
expect(cfg.gateway?.auth?.mode).toBe("token");
expect((cfg.gateway?.auth?.token ?? "").length).toBeGreaterThan(8);
});
}, 60_000);
it("keeps the generated gateway token out of config under --secret-input-mode ref", async () => {
if (process.platform === "win32") {
// Matches the LAN case above: the Windows runner drops this flow's temp config write.
return;
}
await withStateDir("state-token-ref-", async (stateDir) => {
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
setTestEnvValue("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json"));
const port = getPseudoPort(41_000);
await runNonInteractiveSetup(
{
nonInteractive: true,
mode: "local",
workspace: path.join(stateDir, "openclaw"),
authChoice: "skip",
skipSkills: true,
skipHealth: true,
installDaemon: false,
gatewayPort: port,
secretInputMode: "ref",
},
runtime,
);
const cfg = readTestConfig() as {
gateway?: { auth?: { mode?: string; token?: unknown } };
};
expect(cfg.gateway?.auth?.mode).toBe("token");
expect(cfg.gateway?.auth?.token).toEqual({
source: "store",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
});
// A ref persisted without its value would leave the gateway unauthenticatable.
const { readSecretStoreValue } = await import("../secrets/store/secret-store.js");
const stored = readSecretStoreValue({
scope: { kind: "team" },
name: "OPENCLAW_GATEWAY_TOKEN",
});
expect(stored.ok).toBe(true);
expect(stored.ok && stored.value.length).toBeGreaterThan(8);
});
}, 60_000);
it("references an ambient gateway token by env instead of copying it into the store", async () => {
if (process.platform === "win32") {
// Matches the LAN case above: the Windows runner drops this flow's temp config write.
return;
}
await withStateDir("state-token-ref-env-", async (stateDir) => {
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
setTestEnvValue("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json"));
setTestEnvValue("OPENCLAW_GATEWAY_TOKEN", "ambient-gateway-token");
await runNonInteractiveSetup(
{
nonInteractive: true,
mode: "local",
workspace: path.join(stateDir, "openclaw"),
authChoice: "skip",
skipSkills: true,
skipHealth: true,
installDaemon: false,
gatewayPort: getPseudoPort(42_000),
secretInputMode: "ref",
},
runtime,
);
const cfg = readTestConfig() as { gateway?: { auth?: { token?: unknown } } };
expect(cfg.gateway?.auth?.token).toEqual({
source: "env",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
});
// A store copy would silently outlive a later rotation of the env var.
const { readSecretStoreValue } = await import("../secrets/store/secret-store.js");
expect(
readSecretStoreValue({ scope: { kind: "team" }, name: "OPENCLAW_GATEWAY_TOKEN" }).ok,
).toBe(false);
});
}, 60_000);
});
@@ -0,0 +1,219 @@
// Shared mocks and harness for the non-interactive gateway onboarding suites.
// vi.mock calls live here so sibling suites share one config-write/daemon/health surface.
import path from "node:path";
import { vi } from "vitest";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
import {
createOnboardTestConfigStore,
createThrowingRuntime,
mockOnboardingAgent,
} from "./onboard-non-interactive.test-helpers.js";
import type { WaitForGatewayReachableMock } from "./onboard-non-interactive.test-helpers.js";
import type { installGatewayDaemonNonInteractive } from "./onboard-non-interactive/local/daemon-install.js";
export const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {});
const onboardTestConfigStore = createOnboardTestConfigStore();
export const {
configStore: testConfigStore,
resolveConfigPath: resolveTestConfigPath,
readConfig: readTestConfig,
} = onboardTestConfigStore;
const gatewayOnboardConfigSnapshotMock = vi.hoisted(() =>
vi.fn<() => Promise<ConfigFileSnapshot>>(),
);
const pluginLifecycleLeaseState = vi.hoisted(() => ({ depth: 0 }));
export const configWritePluginLeaseDepths: number[] = [];
type InstallGatewayDaemonResult = Awaited<ReturnType<typeof installGatewayDaemonNonInteractive>>;
const installGatewayDaemonNonInteractiveMock = vi.hoisted(() =>
vi.fn(async (): Promise<InstallGatewayDaemonResult> => ({ installed: true })),
);
const healthCommandMock = vi.hoisted(() => vi.fn(async () => {}));
const gatewayServiceMock = vi.hoisted(() => ({
label: "LaunchAgent",
loadedText: "loaded",
isLoaded: vi.fn(async () => true),
readRuntime: vi.fn(async () => ({
status: "running",
state: "active",
pid: 4242,
})),
}));
const readLastGatewayErrorLineMock = vi.hoisted(() =>
vi.fn(async () => "Gateway failed to start: required secrets are unavailable."),
);
/** Suites swap reachability behavior per test; the hoisted mock factory reads the current value. */
export const gatewayReachableState: { mock: WaitForGatewayReachableMock } = { mock: undefined };
gatewayOnboardConfigSnapshotMock.mockImplementation(async () =>
onboardTestConfigStore.readSnapshot(),
);
vi.mock("../config/io.js", () => ({
createConfigIO: () => ({
configPath: resolveTestConfigPath(),
}),
loadConfig: () => readTestConfig(),
readConfigFileSnapshot: gatewayOnboardConfigSnapshotMock,
}));
vi.mock("../plugins/plugin-lifecycle-lease.js", () => ({
withPluginLifecycleLease: async (
_options: unknown,
run: (lease: {
databasePath: string;
signal: AbortSignal;
assertOwned: () => void;
assertOwnedInTransaction: () => void;
}) => Promise<unknown>,
) => {
pluginLifecycleLeaseState.depth += 1;
try {
return await run({
databasePath: path.join(path.dirname(resolveTestConfigPath()), "openclaw.sqlite"),
signal: new AbortController().signal,
assertOwned: () => {},
assertOwnedInTransaction: () => {},
});
} finally {
pluginLifecycleLeaseState.depth -= 1;
}
},
}));
export const capturedReplaceConfigFileCalls: Array<{
nextConfig: OpenClawConfig;
writeOptions?: { allowConfigSizeDrop?: boolean; unsetPaths?: string[][] };
}> = [];
vi.mock("../config/config.js", async (importActual) => {
const actual = await importActual<typeof import("../config/config.js")>();
return {
replaceConfigFile: async ({
nextConfig,
writeOptions,
}: {
nextConfig: OpenClawConfig;
writeOptions?: { allowConfigSizeDrop?: boolean; unsetPaths?: string[][] };
}) => {
configWritePluginLeaseDepths.push(pluginLifecycleLeaseState.depth);
capturedReplaceConfigFileCalls.push({
nextConfig,
...(writeOptions ? { writeOptions } : {}),
});
testConfigStore.set(resolveTestConfigPath(), nextConfig);
},
resolveConfigWriteAfterWrite: actual.resolveConfigWriteAfterWrite,
resolveGatewayPort: (cfg: OpenClawConfig) => cfg.gateway?.port ?? 18789,
transformConfigFileWithRetry: async (
params: Parameters<typeof import("../config/config.js").transformConfigFileWithRetry>[0],
) => {
const snapshot = await gatewayOnboardConfigSnapshotMock();
const previousHash = snapshot.hash ?? null;
const transformed = await params.transform(snapshot.sourceConfig, {
snapshot,
previousHash,
attempt: 0,
});
const committed = await params.commit!({
nextConfig: transformed.nextConfig,
snapshot,
...(previousHash ? { baseHash: previousHash } : {}),
writeOptions: params.writeOptions,
afterWrite: { mode: "auto" },
});
return { nextConfig: committed.config };
},
};
});
vi.mock("./onboard-agent.js", () => ({ ensureOnboardingAgent: mockOnboardingAgent }));
vi.mock("./onboard-helpers.js", () => {
const normalizeGatewayTokenInput = (value: unknown): string => {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
return trimmed === "undefined" || trimmed === "null" ? "" : trimmed;
};
return {
DEFAULT_WORKSPACE: "/tmp/openclaw-workspace",
applyWizardMetadata: (cfg: unknown) => cfg,
ensureWorkspaceAndSessions: ensureWorkspaceAndSessionsMock,
normalizeGatewayTokenInput,
randomToken: () => "tok_generated_gateway_test_token",
resolveControlUiLinks: ({ port }: { port: number }) => ({
httpUrl: `http://127.0.0.1:${port}`,
wsUrl: `ws://127.0.0.1:${port}`,
}),
resolveLocalControlUiProbeLinks: ({ port }: { port: number }) => ({
httpUrl: `http://127.0.0.1:${port}`,
wsUrl: `ws://127.0.0.1:${port}`,
}),
waitForGatewayReachable: (params: {
url: string;
token?: string;
password?: string;
deadlineMs?: number;
probeTimeoutMs?: number;
}) => gatewayReachableState.mock?.(params) ?? Promise.resolve({ ok: true }),
};
});
vi.mock("./onboard-non-interactive/local/daemon-install.js", () => ({
installGatewayDaemonNonInteractive: installGatewayDaemonNonInteractiveMock,
}));
vi.mock("./health.js", () => ({
healthCommandNonExiting: healthCommandMock,
}));
vi.mock("../daemon/service.js", () => ({
readGatewayServiceState: async () => {
const [loadState, runtime] = await Promise.all([
gatewayServiceMock
.isLoaded()
.then((loaded) =>
loaded ? ({ status: "loaded" } as const) : ({ status: "not-loaded" } as const),
)
.catch((error: unknown) => ({ status: "unknown" as const, detail: String(error) })),
gatewayServiceMock.readRuntime(),
]);
return {
installed: true,
loadState,
running: runtime.status === "running",
env: {},
command: null,
runtime,
};
},
resolveGatewayService: () => gatewayServiceMock,
}));
vi.mock("../daemon/diagnostics.js", () => ({
readLastGatewayErrorLine: readLastGatewayErrorLineMock,
}));
export let runNonInteractiveSetup: typeof import("./onboard-non-interactive.js").runNonInteractiveSetup;
export let resolveInstallDaemonGatewayHealthTiming: typeof import("./onboard-non-interactive/local.test-support.js").resolveInstallDaemonGatewayHealthTiming;
export async function loadGatewayOnboardModules(): Promise<void> {
vi.resetModules();
({ runNonInteractiveSetup } = await import("./onboard-non-interactive.js"));
({ resolveInstallDaemonGatewayHealthTiming } =
await import("./onboard-non-interactive/local.test-support.js"));
}
export const getPseudoPort = (base: number): number => base + (process.pid % 1000);
export const gatewayOnboardRuntime = createThrowingRuntime();
// vi.hoisted values cannot be exported at their declaration; re-export them here.
export {
gatewayServiceMock,
healthCommandMock,
installGatewayDaemonNonInteractiveMock,
gatewayOnboardConfigSnapshotMock,
readLastGatewayErrorLineMock,
};
@@ -1,20 +1,37 @@
// Non-interactive gateway onboarding tests cover local/remote setup, auth, daemon install, and config writes.
// Non-interactive gateway onboarding tests cover local/remote setup, daemon install, and config writes.
// Gateway auth-token storage has its own suite in onboard-non-interactive.gateway-auth-token.test.ts.
import fs from "node:fs/promises";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import { makeTempWorkspace } from "../test-helpers/workspace.js";
import { setTestEnvValue } from "../test-utils/env.js";
import {
capturedReplaceConfigFileCalls,
configWritePluginLeaseDepths,
ensureWorkspaceAndSessionsMock,
gatewayReachableState,
gatewayServiceMock,
getPseudoPort,
healthCommandMock,
installGatewayDaemonNonInteractiveMock,
loadGatewayOnboardModules,
gatewayOnboardConfigSnapshotMock as readConfigFileSnapshotMock,
readLastGatewayErrorLineMock,
readTestConfig,
resolveInstallDaemonGatewayHealthTiming,
resolveTestConfigPath,
runNonInteractiveSetup,
gatewayOnboardRuntime as runtime,
testConfigStore,
} from "./onboard-non-interactive.gateway.test-mocks.js";
import {
createOnboardGatewayTimeoutCapture,
createOnboardJsonCaptureRuntime,
createOnboardLocalDaemonOptions,
createOnboardStateDirHarness,
createOnboardTestConfigStore,
createThrowingRuntime,
expectOnboardLocalJsonSetupFailure,
mockOnboardingAgent,
prepareOnboardGatewayTestEnv,
readOnboardFirstMockCall,
runOnboardLocalDaemonSetup,
@@ -23,202 +40,7 @@ import type {
OnboardEnsureWorkspaceOptions,
OnboardGatewayHealthCall,
OnboardHealthCommandCall,
WaitForGatewayReachableMock,
} from "./onboard-non-interactive.test-helpers.js";
import type { installGatewayDaemonNonInteractive } from "./onboard-non-interactive/local/daemon-install.js";
const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {});
const {
configStore: testConfigStore,
resolveConfigPath: resolveTestConfigPath,
readConfig: readTestConfig,
readSnapshot: readTestConfigSnapshot,
} = createOnboardTestConfigStore();
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn<() => Promise<ConfigFileSnapshot>>());
const pluginLifecycleLeaseState = vi.hoisted(() => ({ depth: 0 }));
const configWritePluginLeaseDepths: number[] = [];
type InstallGatewayDaemonResult = Awaited<ReturnType<typeof installGatewayDaemonNonInteractive>>;
const installGatewayDaemonNonInteractiveMock = vi.hoisted(() =>
vi.fn(async (): Promise<InstallGatewayDaemonResult> => ({ installed: true })),
);
const healthCommandMock = vi.hoisted(() => vi.fn(async () => {}));
const gatewayServiceMock = vi.hoisted(() => ({
label: "LaunchAgent",
loadedText: "loaded",
isLoaded: vi.fn(async () => true),
readRuntime: vi.fn(async () => ({
status: "running",
state: "active",
pid: 4242,
})),
}));
const readLastGatewayErrorLineMock = vi.hoisted(() =>
vi.fn(async () => "Gateway failed to start: required secrets are unavailable."),
);
let waitForGatewayReachableMock: WaitForGatewayReachableMock;
readConfigFileSnapshotMock.mockImplementation(async () => readTestConfigSnapshot());
vi.mock("../config/io.js", () => ({
createConfigIO: () => ({
configPath: resolveTestConfigPath(),
}),
loadConfig: () => readTestConfig(),
readConfigFileSnapshot: readConfigFileSnapshotMock,
}));
vi.mock("../plugins/plugin-lifecycle-lease.js", () => ({
withPluginLifecycleLease: async (
_options: unknown,
run: (lease: {
databasePath: string;
signal: AbortSignal;
assertOwned: () => void;
assertOwnedInTransaction: () => void;
}) => Promise<unknown>,
) => {
pluginLifecycleLeaseState.depth += 1;
try {
return await run({
databasePath: path.join(path.dirname(resolveTestConfigPath()), "openclaw.sqlite"),
signal: new AbortController().signal,
assertOwned: () => {},
assertOwnedInTransaction: () => {},
});
} finally {
pluginLifecycleLeaseState.depth -= 1;
}
},
}));
const capturedReplaceConfigFileCalls: Array<{
nextConfig: OpenClawConfig;
writeOptions?: { allowConfigSizeDrop?: boolean; unsetPaths?: string[][] };
}> = [];
vi.mock("../config/config.js", async (importActual) => {
const actual = await importActual<typeof import("../config/config.js")>();
return {
replaceConfigFile: async ({
nextConfig,
writeOptions,
}: {
nextConfig: OpenClawConfig;
writeOptions?: { allowConfigSizeDrop?: boolean; unsetPaths?: string[][] };
}) => {
configWritePluginLeaseDepths.push(pluginLifecycleLeaseState.depth);
capturedReplaceConfigFileCalls.push({
nextConfig,
...(writeOptions ? { writeOptions } : {}),
});
testConfigStore.set(resolveTestConfigPath(), nextConfig);
},
resolveConfigWriteAfterWrite: actual.resolveConfigWriteAfterWrite,
resolveGatewayPort: (cfg: OpenClawConfig) => cfg.gateway?.port ?? 18789,
transformConfigFileWithRetry: async (
params: Parameters<typeof import("../config/config.js").transformConfigFileWithRetry>[0],
) => {
const snapshot = await readConfigFileSnapshotMock();
const previousHash = snapshot.hash ?? null;
const transformed = await params.transform(snapshot.sourceConfig, {
snapshot,
previousHash,
attempt: 0,
});
const committed = await params.commit!({
nextConfig: transformed.nextConfig,
snapshot,
...(previousHash ? { baseHash: previousHash } : {}),
writeOptions: params.writeOptions,
afterWrite: { mode: "auto" },
});
return { nextConfig: committed.config };
},
};
});
vi.mock("./onboard-agent.js", () => ({ ensureOnboardingAgent: mockOnboardingAgent }));
vi.mock("./onboard-helpers.js", () => {
const normalizeGatewayTokenInput = (value: unknown): string => {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
return trimmed === "undefined" || trimmed === "null" ? "" : trimmed;
};
return {
DEFAULT_WORKSPACE: "/tmp/openclaw-workspace",
applyWizardMetadata: (cfg: unknown) => cfg,
ensureWorkspaceAndSessions: ensureWorkspaceAndSessionsMock,
normalizeGatewayTokenInput,
randomToken: () => "tok_generated_gateway_test_token",
resolveControlUiLinks: ({ port }: { port: number }) => ({
httpUrl: `http://127.0.0.1:${port}`,
wsUrl: `ws://127.0.0.1:${port}`,
}),
resolveLocalControlUiProbeLinks: ({ port }: { port: number }) => ({
httpUrl: `http://127.0.0.1:${port}`,
wsUrl: `ws://127.0.0.1:${port}`,
}),
waitForGatewayReachable: (params: {
url: string;
token?: string;
password?: string;
deadlineMs?: number;
probeTimeoutMs?: number;
}) => waitForGatewayReachableMock?.(params) ?? Promise.resolve({ ok: true }),
};
});
vi.mock("./onboard-non-interactive/local/daemon-install.js", () => ({
installGatewayDaemonNonInteractive: installGatewayDaemonNonInteractiveMock,
}));
vi.mock("./health.js", () => ({
healthCommandNonExiting: healthCommandMock,
}));
vi.mock("../daemon/service.js", () => ({
readGatewayServiceState: async () => {
const [loadState, runtime] = await Promise.all([
gatewayServiceMock
.isLoaded()
.then((loaded) =>
loaded ? ({ status: "loaded" } as const) : ({ status: "not-loaded" } as const),
)
.catch((error: unknown) => ({ status: "unknown" as const, detail: String(error) })),
gatewayServiceMock.readRuntime(),
]);
return {
installed: true,
loadState,
running: runtime.status === "running",
env: {},
command: null,
runtime,
};
},
resolveGatewayService: () => gatewayServiceMock,
}));
vi.mock("../daemon/diagnostics.js", () => ({
readLastGatewayErrorLine: readLastGatewayErrorLineMock,
}));
let runNonInteractiveSetup: typeof import("./onboard-non-interactive.js").runNonInteractiveSetup;
let resolveInstallDaemonGatewayHealthTiming: typeof import("./onboard-non-interactive/local.test-support.js").resolveInstallDaemonGatewayHealthTiming;
async function loadGatewayOnboardModules(): Promise<void> {
vi.resetModules();
({ runNonInteractiveSetup } = await import("./onboard-non-interactive.js"));
({ resolveInstallDaemonGatewayHealthTiming } =
await import("./onboard-non-interactive/local.test-support.js"));
}
const getPseudoPort = (base: number): number => base + (process.pid % 1000);
const runtime = createThrowingRuntime();
describe("onboard (non-interactive): gateway and remote auth", () => {
let envSnapshot: ReturnType<typeof prepareOnboardGatewayTestEnv>;
@@ -241,7 +63,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
});
afterEach(() => {
waitForGatewayReachableMock = undefined;
gatewayReachableState.mock = undefined;
testConfigStore.clear();
capturedReplaceConfigFileCalls.length = 0;
configWritePluginLeaseDepths.length = 0;
@@ -428,58 +250,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
});
}, 60_000);
it("writes gateway token auth into config", async () => {
await withStateDir("state-noninteractive-", async (stateDir) => {
const token = "tok_test_123";
const workspace = path.join(stateDir, "openclaw");
testConfigStore.set(resolveTestConfigPath(), {
gateway: {
bind: "lan",
auth: { mode: "password", password: "test-password" },
tailscale: { mode: "serve" },
},
} as OpenClawConfig);
await runNonInteractiveSetup(
{
nonInteractive: true,
mode: "local",
workspace,
authChoice: "skip",
skipSkills: true,
skipHealth: true,
installDaemon: false,
gatewayBind: "loopback",
gatewayAuth: "token",
gatewayToken: token,
tailscale: "off",
},
runtime,
);
const cfg = readTestConfig() as {
gateway?: {
mode?: string;
bind?: string;
auth?: { mode?: string; token?: string };
tailscale?: { mode?: string };
};
agents?: { defaults?: { workspace?: string } };
tools?: { profile?: string };
hooks?: { internal?: { entries?: Record<string, { enabled?: boolean }> } };
};
expect(cfg?.agents?.defaults?.workspace).toBe(workspace);
expect(cfg?.gateway?.mode).toBe("local");
expect(cfg?.gateway?.bind).toBe("loopback");
expect(cfg?.tools?.profile).toBe("coding");
expect(cfg?.gateway?.auth?.mode).toBe("token");
expect(cfg?.gateway?.auth?.token).toBe(token);
expect(cfg?.gateway?.tailscale).toEqual({ mode: "off" });
expect(cfg?.hooks?.internal?.entries?.["session-memory"]).toEqual({ enabled: true });
});
}, 60_000);
it("does not auto-enable default hooks when skipHooks is set", async () => {
await withStateDir("state-skip-hooks-", async (stateDir) => {
const workspace = path.join(stateDir, "openclaw");
@@ -687,7 +457,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
it("completes explicit no-daemon setup when no gateway is listening", async () => {
await withStateDir("state-local-health-hint-", async (stateDir) => {
waitForGatewayReachableMock = vi.fn(async () => ({
gatewayReachableState.mock = vi.fn(async () => ({
ok: false,
detail: "connect ECONNREFUSED 127.0.0.1:18789",
}));
@@ -706,7 +476,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
it("still fails when an existing gateway is expected but unreachable", async () => {
await withStateDir("state-local-health-required-", async (stateDir) => {
waitForGatewayReachableMock = vi.fn(async () => ({
gatewayReachableState.mock = vi.fn(async () => ({
ok: false,
detail: "connect ECONNREFUSED 127.0.0.1:18789",
}));
@@ -725,7 +495,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
it("uses a longer health deadline when daemon install was requested", async () => {
await withStateDir("state-local-daemon-health-", async (stateDir) => {
const captured = createOnboardGatewayTimeoutCapture();
waitForGatewayReachableMock = captured.mock;
gatewayReachableState.mock = captured.mock;
await runOnboardLocalDaemonSetup({ runSetup: runNonInteractiveSetup, stateDir, runtime });
@@ -744,7 +514,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
it("passes pinned gateway auth through non-interactive health checks", async () => {
await withStateDir("state-local-daemon-health-auth-", async (stateDir) => {
const token = "tok_noninteractive_health";
waitForGatewayReachableMock = vi.fn(async () => ({ ok: true }));
gatewayReachableState.mock = vi.fn(async () => ({ ok: true }));
await runNonInteractiveSetup(
{
@@ -756,7 +526,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
);
const [gatewayHealthCall] = readOnboardFirstMockCall(
waitForGatewayReachableMock,
gatewayReachableState.mock,
"waitForGatewayReachable",
) as [OnboardGatewayHealthCall];
expect(gatewayHealthCall.token).toBe(token);
@@ -839,7 +609,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
it("emits structured JSON diagnostics when daemon health fails", async () => {
await withStateDir("state-local-daemon-health-json-fail-", async (stateDir) => {
waitForGatewayReachableMock = vi.fn(async () => ({
gatewayReachableState.mock = vi.fn(async () => ({
ok: false,
detail: "gateway closed (1006 abnormal closure (no close frame)): no close reason",
}));
@@ -886,7 +656,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
it("emits structured JSON failure when a reachable gateway fails its health check", async () => {
await withStateDir("state-local-daemon-health-exit-json-", async (stateDir) => {
waitForGatewayReachableMock = vi.fn(async () => ({ ok: true }));
gatewayReachableState.mock = vi.fn(async () => ({ ok: true }));
healthCommandMock.mockImplementationOnce(async (...args: unknown[]) => {
// healthCommand prints its reachable-gateway diagnostic before its
// CLI-style exit; the capture runtime must keep it off JSON stdout.
@@ -924,7 +694,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
it("routes thrown health-check errors through the onboarding failure owner", async () => {
await withStateDir("state-local-health-failure-text-", async (stateDir) => {
waitForGatewayReachableMock = vi.fn(async () => ({ ok: true }));
gatewayReachableState.mock = vi.fn(async () => ({ ok: true }));
healthCommandMock.mockRejectedValueOnce(new Error("health request timed out"));
await expect(
@@ -935,7 +705,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
it("preserves unknown service inspection in JSON diagnostics", async () => {
await withStateDir("state-local-daemon-health-unknown-", async (stateDir) => {
waitForGatewayReachableMock = vi.fn(async () => ({
gatewayReachableState.mock = vi.fn(async () => ({
ok: false,
detail: "connect ECONNREFUSED 127.0.0.1:18789",
}));
@@ -963,7 +733,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
it("classifies daemon health ECONNREFUSED failures with a recovery command", async () => {
await withStateDir("state-local-daemon-health-refused-", async (stateDir) => {
waitForGatewayReachableMock = vi.fn(async () => ({
gatewayReachableState.mock = vi.fn(async () => ({
ok: false,
detail: "connect ECONNREFUSED 127.0.0.1:18789",
}));
@@ -993,46 +763,4 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
expect(parsed.hints).toContain("Fix: run `openclaw gateway restart`.");
});
}, 60_000);
it("auto-generates token auth when binding LAN and persists the token", async () => {
if (process.platform === "win32") {
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
return;
}
await withStateDir("state-lan-", async (stateDir) => {
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
setTestEnvValue("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json"));
const port = getPseudoPort(40_000);
const workspace = path.join(stateDir, "openclaw");
await runNonInteractiveSetup(
{
nonInteractive: true,
mode: "local",
workspace,
authChoice: "skip",
skipSkills: true,
skipHealth: true,
installDaemon: false,
gatewayPort: port,
gatewayBind: "lan",
},
runtime,
);
const cfg = readTestConfig() as {
gateway?: {
bind?: string;
port?: number;
auth?: { mode?: string; token?: string };
};
};
expect(cfg.gateway?.bind).toBe("lan");
expect(cfg.gateway?.port).toBe(port);
expect(cfg.gateway?.auth?.mode).toBe("token");
expect((cfg.gateway?.auth?.token ?? "").length).toBeGreaterThan(8);
});
}, 60_000);
});
@@ -8,12 +8,46 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
import { formatCliCommand } from "../../../cli/command-format.js";
import { formatInvalidPortOption } from "../../../cli/error-format.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { isValidEnvSecretRefId, resolveSecretInputRef } from "../../../config/types.secrets.js";
import {
isValidEnvSecretRefId,
resolveSecretInputRef,
type SecretRef,
} from "../../../config/types.secrets.js";
import { provisionGatewayTokenStoreRef } from "../../../gateway/auth-token-store-ref.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";
function gatewayEnvTokenRef(config: OpenClawConfig, envVarName: string): SecretRef {
return {
source: "env",
provider: resolveDefaultSecretProviderAlias(config, "env", {
preferFirstProviderForSource: true,
}),
id: envVarName,
};
}
/** Resolves what `gateway.auth.token` should hold once setup owns the token value. */
function resolveGeneratedTokenInput(params: {
config: OpenClawConfig;
secretInputMode: OnboardOptions["secretInputMode"];
token: string | undefined;
ambientEnvOnly: boolean;
}): SecretRef | string {
if (params.secretInputMode !== "ref") {
return params.token ?? randomToken();
}
if (params.ambientEnvOnly) {
return gatewayEnvTokenRef(params.config, "OPENCLAW_GATEWAY_TOKEN");
}
return provisionGatewayTokenStoreRef({
config: params.config,
...(params.token ? { token: params.token } : {}),
}).ref;
}
/** Applies gateway CLI options to the pending config and returns normalized runtime settings. */
export function applyNonInteractiveGatewayConfig(params: {
nextConfig: OpenClawConfig;
@@ -95,7 +129,8 @@ export function applyNonInteractiveGatewayConfig(params: {
// plaintext > ambient OPENCLAW_GATEWAY_TOKEN > randomToken(). Ambient env
// must not rotate a token already written to disk — a stale shell or
// launchd env var otherwise breaks already-paired clients.
let gatewayToken = explicitGatewayToken || existingPlaintextToken || envGatewayToken || undefined;
const gatewayToken =
explicitGatewayToken || existingPlaintextToken || envGatewayToken || undefined;
const gatewayTokenRefEnv = normalizeOptionalString(opts.gatewayTokenRefEnv ?? "") ?? "";
if (authMode === "token") {
@@ -133,13 +168,7 @@ export function applyNonInteractiveGatewayConfig(params: {
auth: {
...nextConfig.gateway?.auth,
mode: "token",
token: {
source: "env",
provider: resolveDefaultSecretProviderAlias(nextConfig, "env", {
preferFirstProviderForSource: true,
}),
id: gatewayTokenRefEnv,
},
token: gatewayEnvTokenRef(nextConfig, gatewayTokenRefEnv),
},
},
};
@@ -160,9 +189,18 @@ export function applyNonInteractiveGatewayConfig(params: {
},
};
} else {
if (!gatewayToken) {
gatewayToken = randomToken();
}
// `--secret-input-mode ref` covers the gateway token too. An ambient
// OPENCLAW_GATEWAY_TOKEN keeps its env ref so a later rotation still wins;
// copying it into the store would silently pin the stale value. Anything else
// is a value setup itself holds, with nothing for an env/file/exec ref to point
// at, so the shared secret store keeps it and config keeps only the reference.
const tokenInput = resolveGeneratedTokenInput({
config: nextConfig,
secretInputMode: opts.secretInputMode,
token: gatewayToken,
ambientEnvOnly:
!explicitGatewayToken && !existingPlaintextToken && Boolean(envGatewayToken),
});
nextConfig = {
...nextConfig,
gateway: {
@@ -170,7 +208,7 @@ export function applyNonInteractiveGatewayConfig(params: {
auth: {
...nextConfig.gateway?.auth,
mode: "token",
token: gatewayToken,
token: tokenInput,
},
},
};
+78
View File
@@ -0,0 +1,78 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { readSecretStoreValue, writeSecretStoreEntry } from "../secrets/store/secret-store.js";
import { setTestEnvValue } from "../test-utils/env.js";
import { provisionGatewayTokenStoreRef } from "./auth-token-store-ref.js";
const STORE_SCOPE = { kind: "team" } as const;
const STORE_NAME = "OPENCLAW_GATEWAY_TOKEN";
function readStored(): string | undefined {
const result = readSecretStoreValue({ scope: STORE_SCOPE, name: STORE_NAME });
return result.ok ? result.value : undefined;
}
describe("provisionGatewayTokenStoreRef", () => {
let stateDir: string;
beforeEach(() => {
stateDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "gateway-token-store-")));
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
});
afterEach(() => {
fs.rmSync(stateDir, { recursive: true, force: true });
});
it("mints a token into the store and returns a default-provider store ref", () => {
const result = provisionGatewayTokenStoreRef({ config: {} });
expect(result.ref).toEqual({
source: "store",
provider: "default",
id: STORE_NAME,
});
expect(result.token.length).toBeGreaterThan(8);
expect(readStored()).toBe(result.token);
});
it("reuses an existing entry so reruns never rotate a paired token", () => {
writeSecretStoreEntry({
scope: STORE_SCOPE,
name: STORE_NAME,
value: "already-paired-token",
kind: "secret",
updatedBy: "test",
});
const result = provisionGatewayTokenStoreRef({ config: {} });
expect(result.token).toBe("already-paired-token");
expect(readStored()).toBe("already-paired-token");
});
it("lets an explicit token win so a persisted plaintext token migrates unchanged", () => {
writeSecretStoreEntry({
scope: STORE_SCOPE,
name: STORE_NAME,
value: "stale-token",
kind: "secret",
updatedBy: "test",
});
const result = provisionGatewayTokenStoreRef({ config: {}, token: "operator-token" });
expect(result.token).toBe("operator-token");
expect(readStored()).toBe("operator-token");
});
it("honors a configured store provider alias", () => {
const result = provisionGatewayTokenStoreRef({
config: { secrets: { defaults: { store: "vault" } } },
});
expect(result.ref.provider).toBe("vault");
});
});
+60
View File
@@ -0,0 +1,60 @@
/** Store-backed SecretRef provisioning for gateway auth tokens setup generates itself. */
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { randomToken } from "../commands/random-token.js";
import type { SecretRef } from "../config/types.secrets.js";
import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
import { readSecretStoreValue, writeSecretStoreEntry } from "../secrets/store/secret-store.js";
/** Store entry name for the gateway token; mirrors the documented env-var contract. */
const GATEWAY_AUTH_TOKEN_STORE_NAME = "OPENCLAW_GATEWAY_TOKEN";
const GATEWAY_AUTH_TOKEN_STORE_SCOPE = { kind: "team" } as const;
/** Minimal config shape needed to pick the store provider alias. */
type GatewayTokenStoreRefConfig = Parameters<typeof resolveDefaultSecretProviderAlias>[0];
function readStoredGatewayToken(): string | undefined {
const existing = readSecretStoreValue({
scope: GATEWAY_AUTH_TOKEN_STORE_SCOPE,
name: GATEWAY_AUTH_TOKEN_STORE_NAME,
});
return existing.ok ? normalizeOptionalString(existing.value) : undefined;
}
/**
* Provisions the gateway token in the secret store and returns the ref config points at.
*
* Omit `token` when setup has no value of its own: an existing store entry then wins so
* reruns never rotate a token already paired with clients or a running service, and a
* fresh one is minted otherwise. A supplied token always wins, which also migrates a
* previously persisted plaintext token without invalidating it. The store write stays
* ahead of the config write on purpose — a ref persisted without its value would leave
* the gateway unauthenticatable, while an entry whose config write later fails is simply
* picked up by the next run.
*/
export function provisionGatewayTokenStoreRef(params: {
config: GatewayTokenStoreRefConfig;
token?: string;
}): { ref: SecretRef; token: string } {
const stored = params.token ? undefined : readStoredGatewayToken();
const token = params.token ?? stored ?? randomToken();
if (token !== stored) {
writeSecretStoreEntry({
scope: GATEWAY_AUTH_TOKEN_STORE_SCOPE,
name: GATEWAY_AUTH_TOKEN_STORE_NAME,
value: token,
kind: "secret",
updatedBy: "setup",
});
}
return {
ref: {
source: "store",
provider: resolveDefaultSecretProviderAlias(params.config, "store", {
preferFirstProviderForSource: true,
}),
id: GATEWAY_AUTH_TOKEN_STORE_NAME,
},
token,
};
}
+2
View File
@@ -95,6 +95,8 @@ export const en = {
tokenPlaceholder: "Needed for multi-machine or non-loopback access",
tokenPrompt: "Gateway token",
tokenPromptGenerate: "Gateway token (blank to generate)",
tokenStoreProvisioned:
"Generated a gateway token and stored it in the OpenClaw secret store as {name}. Config keeps only a reference; inspect it with `openclaw secrets store list`.",
websocketUrl: "Gateway WebSocket URL",
},
gatewayTailscale: {
+2
View File
@@ -94,6 +94,8 @@ export const zh_CN = {
tokenPlaceholder: "多机器或非 loopback 访问需要使用",
tokenPrompt: "Gateway 令牌",
tokenPromptGenerate: "Gateway 令牌(留空则生成)",
tokenStoreProvisioned:
"已生成 Gateway 令牌并以 {name} 存入 OpenClaw 密钥存储。配置中只保留引用;可用 `openclaw secrets store list` 查看。",
websocketUrl: "Gateway WebSocket URL",
},
gatewayTailscale: {
+2
View File
@@ -94,6 +94,8 @@ export const zh_TW = {
tokenPlaceholder: "多機器或非 loopback 存取需要使用",
tokenPrompt: "Gateway 權杖",
tokenPromptGenerate: "Gateway 權杖(留空則產生)",
tokenStoreProvisioned:
"已產生 Gateway 權杖並以 {name} 存入 OpenClaw 祕密儲存。設定中只保留參照;可用 `openclaw secrets store list` 檢視。",
websocketUrl: "Gateway WebSocket URL",
},
gatewayTailscale: {
+39
View File
@@ -1,4 +1,7 @@
// Setup gateway config tests cover gateway prompt choices and config output.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -80,6 +83,7 @@ describe("configureGatewayForSetup", () => {
tailscaleChoice?: "off" | "serve";
textQueue?: Array<string | undefined>;
nextConfig?: Record<string, unknown>;
secretInputMode?: "plaintext" | "ref";
}) {
const authChoice = params?.authChoice ?? "token";
const prompter = createPrompter({
@@ -93,11 +97,46 @@ describe("configureGatewayForSetup", () => {
nextConfig: params?.nextConfig ?? {},
localPort: 18789,
quickstartGateway: createQuickstartGateway(authChoice),
...(params?.secretInputMode ? { secretInputMode: params.secretInputMode } : {}),
prompter,
runtime,
});
}
it("provisions a store ref when reference mode has no token to point at", async () => {
const stateDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "wizard-gateway-ref-")));
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const previousToken = process.env.OPENCLAW_GATEWAY_TOKEN;
process.env.OPENCLAW_STATE_DIR = stateDir;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
try {
const result = await runGatewayConfig({ flow: "quickstart", secretInputMode: "ref" });
expect(result.nextConfig.gateway?.auth?.token).toEqual({
source: "store",
provider: "default",
id: "OPENCLAW_GATEWAY_TOKEN",
});
const { readSecretStoreValue } = await import("../secrets/store/secret-store.js");
const stored = readSecretStoreValue({
scope: { kind: "team" },
name: "OPENCLAW_GATEWAY_TOKEN",
});
expect(stored.ok && stored.value).toBe(result.settings.gatewayToken);
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
if (previousToken !== undefined) {
process.env.OPENCLAW_GATEWAY_TOKEN = previousToken;
}
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("generates a token when the prompt returns undefined", async () => {
mocks.randomToken.mockReturnValue("generated-token");
const result = await runGatewayConfig();
+15 -5
View File
@@ -15,6 +15,7 @@ import {
resolveSecretInputRef,
type SecretInput,
} from "../config/types.secrets.js";
import { provisionGatewayTokenStoreRef } from "../gateway/auth-token-store-ref.js";
import {
maybeAddTailnetOriginToControlUiAllowedOrigins,
TAILSCALE_EXPOSURE_OPTIONS,
@@ -219,6 +220,7 @@ export async function configureGatewayForSetup(
refHint: t("wizard.gateway.refHint"),
},
});
const ambientToken = normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN);
if (tokenMode === "ref") {
if (quickstartTokenRef) {
gatewayTokenInput = quickstartTokenRef;
@@ -228,6 +230,17 @@ export async function configureGatewayForSetup(
path: "gateway.auth.token",
env: process.env,
});
} else if (!quickstartTokenString && !ambientToken) {
// Nothing exists for an env/file/exec ref to point at, so asking where the
// token lives has no answerable option. Setup mints it into the shared
// secret store instead and config keeps only the reference.
const provisioned = provisionGatewayTokenStoreRef({ config: nextConfig });
gatewayTokenInput = provisioned.ref;
gatewayToken = provisioned.token;
await prompter.note(
t("wizard.gateway.tokenStoreProvisioned", { name: provisioned.ref.id }),
t("wizard.gateway.auth"),
);
} else {
const resolved = await promptSecretRefForSetup({
provider: "gateway-auth-token",
@@ -243,13 +256,10 @@ export async function configureGatewayForSetup(
gatewayToken = resolved.resolvedValue;
}
} else if (flow === "quickstart") {
gatewayToken =
(quickstartTokenString ?? normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN)) ||
randomToken();
gatewayToken = (quickstartTokenString ?? ambientToken) || randomToken();
gatewayTokenInput = gatewayToken;
} else {
const existingToken =
quickstartTokenString ?? normalizeGatewayTokenInput(process.env.OPENCLAW_GATEWAY_TOKEN);
const existingToken = quickstartTokenString ?? ambientToken;
let tokenInput: string | undefined;
if (existingToken) {
const keep = await prompter.confirm({
+4 -1
View File
@@ -1678,7 +1678,10 @@ describe("scripts/test-projects changed-target routing", () => {
buildVitestRunPlans(["src/commands/onboard-non-interactive.test-helpers.ts"]),
{
config: "test/vitest/vitest.commands.config.ts",
includePatterns: ["src/commands/onboard-non-interactive.gateway.test.ts"],
includePatterns: [
"src/commands/onboard-non-interactive.gateway-auth-token.test.ts",
"src/commands/onboard-non-interactive.gateway.test.ts",
],
},
);
});