mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
1fe66e0fe8
commit
3a4f337802
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user