fix(gateway): connect first-boot loopback agents after readiness (#114380)

* fix(gateway): pair local CLI before runtime-token readiness

* docs(gateway): explain first-boot loopback CLI auth

* fix(gateway): keep startup pairing result internal

* chore(gateway): leave release note to release process
This commit is contained in:
Peter Steinberger
2026-07-27 03:15:12 -04:00
committed by GitHub
parent 1fe66e0fe8
commit 3a4f337802
6 changed files with 218 additions and 0 deletions
+2
View File
@@ -586,6 +586,8 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures -
<Accordion title="Why do I need a token on localhost now?">
OpenClaw enforces gateway auth by default, including loopback. If no explicit auth path is configured, startup resolves to token mode and generates a runtime-only token for that startup, so local WS clients must authenticate. This blocks other local processes from calling the Gateway.
On a fresh loopback start, the Gateway prepares the canonical same-user CLI device credential before `/readyz`, so normal `openclaw` CLI calls can authenticate without persisting the generated token. Other clients still need an explicit shared secret or an approved device pairing.
Configure `gateway.auth.token`, `gateway.auth.password`, `OPENCLAW_GATEWAY_TOKEN`, or `OPENCLAW_GATEWAY_PASSWORD` explicitly when clients need a stable secret across restarts. You can also choose password mode, or `trusted-proxy` for identity-aware reverse proxies. For open loopback, set `gateway.auth.mode: "none"` explicitly. `openclaw doctor --generate-gateway-token` generates a token any time.
</Accordion>
+53
View File
@@ -16,8 +16,12 @@ import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-s
import type { GatewayAuthConfig, GatewayTailscaleConfig } from "../config/types.gateway.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resetAgentEventsForTest } from "../infra/agent-events.js";
import { loadDeviceAuthToken } from "../infra/device-auth-store.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { getPairedDevice } from "../infra/device-pairing.js";
import { clearGatewaySubagentRuntime } from "../plugins/runtime/gateway-bindings.test-fixtures.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { callGateway } from "./call.js";
import { startGatewayServer } from "./server.js";
import {
connectDeviceAuthReq,
@@ -186,6 +190,55 @@ describe("gateway e2e", () => {
({ createConfigIO } = await import("../config/config.js"));
});
it("pairs the local CLI before a runtime-token loopback gateway becomes ready", async () => {
const { envSnapshot, tempHome } = await setupGatewayTempHome({
prefix: "openclaw-gw-runtime-token-cli-pairing-",
});
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
try {
deleteTestEnvValue("OPENCLAW_GATEWAY_TOKEN");
const configPath = await createGatewayConfigPath(tempHome);
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
const initialConfig: OpenClawConfig = {
gateway: { mode: "local", bind: "loopback" },
logging: { level: "info" },
};
await createConfigIO({ configPath }).writeConfigFile(initialConfig);
const port = await getFreeGatewayPort();
server = await startGatewayServer(port, {
bind: "loopback",
controlUiEnabled: false,
sidecarStartup: "defer",
});
await expect(
callGateway({
config: initialConfig,
localPortOverride: port,
method: "health",
timeoutMs: 5_000,
}),
).resolves.toEqual(expect.any(Object));
const persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig;
expect(persisted.gateway?.auth?.token).toBeUndefined();
const identity = loadOrCreateDeviceIdentity();
expect(loadDeviceAuthToken({ deviceId: identity.deviceId, role: "operator" })).toMatchObject({
scopes: expect.arrayContaining(["operator.admin"]),
});
await expect(getPairedDevice(identity.deviceId)).resolves.toMatchObject({
approvedVia: "silent",
approvedScopes: expect.arrayContaining(["operator.admin"]),
});
} finally {
if (server) {
await server.close({ reason: "runtime-token local CLI pairing test complete" });
}
await removeGatewayTempHome(tempHome);
envSnapshot.restore();
}
});
it.each(["generated", "explicit-override", "secret-ref-override", "runtime-overrides"] as const)(
"preserves %s auth across a safe direct gateway reload",
async (authSource) => {
@@ -13,6 +13,7 @@ import type { RuntimeEnv } from "../runtime.js";
import { getActiveSecretsRuntimeConfigSnapshot } from "../secrets/runtime-state.js";
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
import { resolveGatewayAuth } from "./auth.js";
import { isLoopbackHost } from "./net.js";
import { createNodeReapprovalCoordinator } from "./node-reapproval-coordinator.js";
import { resolveGatewayPluginConfig } from "./runtime-plugin-config.js";
import { resolveGatewayControlUiRootState } from "./server-control-ui-root.js";
@@ -201,6 +202,19 @@ export async function prepareGatewayRuntimeState(params: {
tailscaleConfig,
tailscaleMode,
} = runtimeConfig;
if (bootstrap.generatedStartupAuthToken && isLoopbackHost(bindHost)) {
const { ensureStartupLocalCliPairing } = await import("./startup-local-cli-pairing.js");
const pairingResult = await startupTrace.measure("runtime.local-cli-pairing", () =>
ensureStartupLocalCliPairing(),
);
if (pairingResult === "created") {
log.info("runtime-only gateway auth paired the local CLI device before readiness");
} else if (pairingResult === "unavailable") {
log.warn(
"runtime-only gateway auth could not prepare local CLI device credentials; configure gateway.auth.token or gateway.auth.password for CLI access",
);
}
}
const getResolvedAuth = () =>
resolveGatewayAuth({
authConfig:
+1
View File
@@ -530,6 +530,7 @@ export async function prepareGatewayServerBootstrap(input: {
startupActivationSourceConfig,
startupRuntimeConfig,
cfgAtStart,
generatedStartupAuthToken: authBootstrap.generatedToken !== undefined,
claimControlUiDeviceAuthMigration,
completeControlUiDeviceAuthMigration,
releaseControlUiDeviceAuthMigrationClaim,
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it } from "vitest";
import { loadDeviceAuthToken } from "../infra/device-auth-store.js";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../infra/device-identity.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { withStateDirEnv } from "../test-helpers/state-dir-env.js";
import { READ_SCOPE } from "./operator-scopes.js";
import { ensureStartupLocalCliPairing } from "./startup-local-cli-pairing.js";
afterEach(() => {
closeOpenClawStateDatabaseForTest();
});
describe("startup local CLI pairing", () => {
it("does not report a limited existing operator token as admin-ready", async () => {
await withStateDirEnv("openclaw-startup-local-cli-pairing-", async () => {
const identity = loadOrCreateDeviceIdentity();
const request = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
clientId: "openclaw-cli",
clientMode: "cli",
role: "operator",
scopes: [READ_SCOPE],
silent: true,
});
const approved = await approveDevicePairing(request.request.requestId, {
callerScopes: [READ_SCOPE],
approvedVia: "silent",
});
expect(approved?.status).toBe("approved");
await expect(ensureStartupLocalCliPairing()).resolves.toBe("unavailable");
expect(loadDeviceAuthToken({ deviceId: identity.deviceId, role: "operator" })).toBeNull();
await expect(getPairedDevice(identity.deviceId)).resolves.toMatchObject({
approvedScopes: [READ_SCOPE],
});
});
});
});
+101
View File
@@ -0,0 +1,101 @@
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../../packages/gateway-protocol/src/client-info.js";
import { storeDeviceAuthToken } from "../infra/device-auth-store.js";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
} from "../infra/device-identity.js";
import {
approveDevicePairing,
getPairedDevice,
requestDevicePairing,
} from "../infra/device-pairing.js";
import { roleScopesAllow } from "../shared/operator-scope-compat.js";
import { ADMIN_SCOPE } from "./operator-scopes.js";
type StartupLocalCliPairingResult = "created" | "reused" | "unavailable";
function cacheOperatorToken(params: {
deviceId: string;
paired: Awaited<ReturnType<typeof getPairedDevice>>;
}): boolean {
const token = params.paired?.tokens?.operator;
if (
!token?.token ||
!roleScopesAllow({
role: "operator",
requestedScopes: [ADMIN_SCOPE],
allowedScopes: token.scopes,
})
) {
return false;
}
storeDeviceAuthToken({
deviceId: params.deviceId,
role: "operator",
token: token.token,
scopes: token.scopes,
});
return true;
}
/**
* Runtime-only auth has no shared secret a sibling CLI process can read. Bind
* the canonical same-user device identity before readiness instead, preserving
* authenticated loopback access without writing generated auth into config.
*/
export async function ensureStartupLocalCliPairing(): Promise<StartupLocalCliPairingResult> {
const identity = loadOrCreateDeviceIdentity();
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
const existing = await getPairedDevice(identity.deviceId);
if (existing) {
if (existing.publicKey !== publicKey) {
throw new Error("local CLI pairing identity does not match the canonical device key");
}
return cacheOperatorToken({ deviceId: identity.deviceId, paired: existing })
? "reused"
: "unavailable";
}
const pairing = await requestDevicePairing({
deviceId: identity.deviceId,
publicKey,
displayName: "OpenClaw CLI",
platform: process.platform,
clientId: GATEWAY_CLIENT_NAMES.CLI,
clientMode: GATEWAY_CLIENT_MODES.CLI,
role: "operator",
scopes: [ADMIN_SCOPE],
remoteIp: "127.0.0.1",
silent: true,
});
const approved = await approveDevicePairing(pairing.request.requestId, {
callerScopes: [ADMIN_SCOPE],
approvedVia: "silent",
accessMetadata: {
displayName: "OpenClaw CLI",
remoteIp: "127.0.0.1",
lastSeenAtMs: Date.now(),
lastSeenReason: "runtime-token-startup",
},
});
if (approved?.status === "approved") {
if (!cacheOperatorToken({ deviceId: identity.deviceId, paired: approved.device })) {
throw new Error("local CLI pairing approval did not issue an operator token");
}
return "created";
}
// A concurrent startup can win the pairing transaction. Re-read the
// authoritative row instead of rotating or duplicating its token.
const pairedAfterApproval = await getPairedDevice(identity.deviceId);
if (
pairedAfterApproval?.publicKey === publicKey &&
cacheOperatorToken({ deviceId: identity.deviceId, paired: pairedAfterApproval })
) {
return "reused";
}
return "unavailable";
}