diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 20dfce525da5..5034bac4091d 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -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. diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md index 7cf86da9be80..b2c581009f05 100644 --- a/docs/start/wizard-cli-automation.md +++ b/docs/start/wizard-cli-automation.md @@ -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: "" }`); 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 \ diff --git a/src/commands/onboard-non-interactive.gateway-auth-token.test.ts b/src/commands/onboard-non-interactive.gateway-auth-token.test.ts new file mode 100644 index 000000000000..ae611ea4ce1d --- /dev/null +++ b/src/commands/onboard-non-interactive.gateway-auth-token.test.ts @@ -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; + 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 } }; + }; + + 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); +}); diff --git a/src/commands/onboard-non-interactive.gateway.test-mocks.ts b/src/commands/onboard-non-interactive.gateway.test-mocks.ts new file mode 100644 index 000000000000..c3d2f9bf48a1 --- /dev/null +++ b/src/commands/onboard-non-interactive.gateway.test-mocks.ts @@ -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>(), +); +const pluginLifecycleLeaseState = vi.hoisted(() => ({ depth: 0 })); +export const configWritePluginLeaseDepths: number[] = []; +type InstallGatewayDaemonResult = Awaited>; +const installGatewayDaemonNonInteractiveMock = vi.hoisted(() => + vi.fn(async (): Promise => ({ 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, + ) => { + 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(); + 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[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 { + 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, +}; diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 78091a62638b..79755cbc0bdc 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -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>()); -const pluginLifecycleLeaseState = vi.hoisted(() => ({ depth: 0 })); -const configWritePluginLeaseDepths: number[] = []; -type InstallGatewayDaemonResult = Awaited>; -const installGatewayDaemonNonInteractiveMock = vi.hoisted(() => - vi.fn(async (): Promise => ({ 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, - ) => { - 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(); - 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[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 { - 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; @@ -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 } }; - }; - - 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); }); diff --git a/src/commands/onboard-non-interactive/local/gateway-config.ts b/src/commands/onboard-non-interactive/local/gateway-config.ts index e3d628668e19..d7c2be19a7c7 100644 --- a/src/commands/onboard-non-interactive/local/gateway-config.ts +++ b/src/commands/onboard-non-interactive/local/gateway-config.ts @@ -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, }, }, }; diff --git a/src/gateway/auth-token-store-ref.test.ts b/src/gateway/auth-token-store-ref.test.ts new file mode 100644 index 000000000000..bd18c2c29e17 --- /dev/null +++ b/src/gateway/auth-token-store-ref.test.ts @@ -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"); + }); +}); diff --git a/src/gateway/auth-token-store-ref.ts b/src/gateway/auth-token-store-ref.ts new file mode 100644 index 000000000000..438f157da085 --- /dev/null +++ b/src/gateway/auth-token-store-ref.ts @@ -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[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, + }; +} diff --git a/src/wizard/i18n/locales/en.ts b/src/wizard/i18n/locales/en.ts index a68641529d43..14c2fb5215da 100644 --- a/src/wizard/i18n/locales/en.ts +++ b/src/wizard/i18n/locales/en.ts @@ -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: { diff --git a/src/wizard/i18n/locales/zh-CN.ts b/src/wizard/i18n/locales/zh-CN.ts index f24536621237..fa32f55fe686 100644 --- a/src/wizard/i18n/locales/zh-CN.ts +++ b/src/wizard/i18n/locales/zh-CN.ts @@ -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: { diff --git a/src/wizard/i18n/locales/zh-TW.ts b/src/wizard/i18n/locales/zh-TW.ts index 456869a35f13..d7f7ca8df399 100644 --- a/src/wizard/i18n/locales/zh-TW.ts +++ b/src/wizard/i18n/locales/zh-TW.ts @@ -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: { diff --git a/src/wizard/setup.gateway-config.test.ts b/src/wizard/setup.gateway-config.test.ts index 3766bbcefa68..9ee49f6bab07 100644 --- a/src/wizard/setup.gateway-config.test.ts +++ b/src/wizard/setup.gateway-config.test.ts @@ -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; nextConfig?: Record; + 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(); diff --git a/src/wizard/setup.gateway-config.ts b/src/wizard/setup.gateway-config.ts index 7b732196d4da..092004f151d2 100644 --- a/src/wizard/setup.gateway-config.ts +++ b/src/wizard/setup.gateway-config.ts @@ -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({ diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 022517b677d6..66a3f8e9c0a6 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -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", + ], }, ); });