fix(gateway): land #28428 from @l0cka
Landed from contributor PR #28428 by @l0cka. Co-authored-by: Daniel Alkurdi <danielalkurdi@gmail.com>
This commit is contained in:
parent
e83094e63f
commit
265367d99b
@ -82,6 +82,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example `@Bot/model` and `@Bot /reset`) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai.
|
- Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example `@Bot/model` and `@Bot /reset`) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai.
|
||||||
- Control UI/auth token separation: keep the shared gateway token in browser auth validation while reserving cached device tokens for signed device payloads, preventing false `device token mismatch` disconnects after restart/rotation. Landed from contributor PR #37382 by @FradSer. Thanks @FradSer.
|
- Control UI/auth token separation: keep the shared gateway token in browser auth validation while reserving cached device tokens for signed device payloads, preventing false `device token mismatch` disconnects after restart/rotation. Landed from contributor PR #37382 by @FradSer. Thanks @FradSer.
|
||||||
- Gateway/browser auth reconnect hardening: stop counting missing token/password submissions as auth rate-limit failures, and stop auto-reconnecting Control UI clients on non-recoverable auth errors so misconfigured browser tabs no longer lock out healthy sessions. Landed from contributor PR #38725 by @ademczuk. Thanks @ademczuk.
|
- Gateway/browser auth reconnect hardening: stop counting missing token/password submissions as auth rate-limit failures, and stop auto-reconnecting Control UI clients on non-recoverable auth errors so misconfigured browser tabs no longer lock out healthy sessions. Landed from contributor PR #38725 by @ademczuk. Thanks @ademczuk.
|
||||||
|
- Gateway/service token drift repair: stop persisting shared auth tokens into installed gateway service units, flag stale embedded service tokens for reinstall, and treat tokenless service env as canonical so token rotation/reboot flows stay aligned with config/env resolution. Landed from contributor PR #28428 by @l0cka. Thanks @l0cka.
|
||||||
- Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing `thinking`/`text` strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin.
|
- Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing `thinking`/`text` strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin.
|
||||||
- Agents/transcript policy: set `preserveSignatures` to Anthropic-only handling in `resolveTranscriptPolicy` so Anthropic thinking signatures are preserved while non-Anthropic providers remain unchanged. (#32813) thanks @Sid-Qin.
|
- Agents/transcript policy: set `preserveSignatures` to Anthropic-only handling in `resolveTranscriptPolicy` so Anthropic thinking signatures are preserved while non-Anthropic providers remain unchanged. (#32813) thanks @Sid-Qin.
|
||||||
- Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok `Invalid arguments` failures. (openclaw#35355) thanks @Sid-Qin.
|
- Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok `Invalid arguments` failures. (openclaw#35355) thanks @Sid-Qin.
|
||||||
|
|||||||
@ -116,7 +116,7 @@ describe("runDaemonInstall integration", () => {
|
|||||||
expect(joined).toContain("MISSING_GATEWAY_TOKEN");
|
expect(joined).toContain("MISSING_GATEWAY_TOKEN");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auto-mints token when no source exists and persists the same token used for install env", async () => {
|
it("auto-mints token when no source exists without embedding it into service env", async () => {
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
configPath,
|
configPath,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
@ -143,6 +143,6 @@ describe("runDaemonInstall integration", () => {
|
|||||||
expect((persistedToken ?? "").length).toBeGreaterThan(0);
|
expect((persistedToken ?? "").length).toBeGreaterThan(0);
|
||||||
|
|
||||||
const installEnv = serviceMock.install.mock.calls[0]?.[0]?.environment;
|
const installEnv = serviceMock.install.mock.calls[0]?.[0]?.environment;
|
||||||
expect(installEnv?.OPENCLAW_GATEWAY_TOKEN).toBe(persistedToken);
|
expect(installEnv?.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -197,11 +197,8 @@ describe("runDaemonInstall", () => {
|
|||||||
await runDaemonInstall({ json: true });
|
await runDaemonInstall({ json: true });
|
||||||
|
|
||||||
expect(actionState.failed).toEqual([]);
|
expect(actionState.failed).toEqual([]);
|
||||||
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
|
expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1);
|
||||||
expect.objectContaining({
|
expect("token" in buildGatewayInstallPlanMock.mock.calls[0][0]).toBe(false);
|
||||||
token: undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(writeConfigFileMock).not.toHaveBeenCalled();
|
expect(writeConfigFileMock).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(
|
||||||
actionState.warnings.some((warning) =>
|
actionState.warnings.some((warning) =>
|
||||||
@ -225,11 +222,8 @@ describe("runDaemonInstall", () => {
|
|||||||
|
|
||||||
expect(actionState.failed).toEqual([]);
|
expect(actionState.failed).toEqual([]);
|
||||||
expect(resolveSecretRefValuesMock).toHaveBeenCalledTimes(1);
|
expect(resolveSecretRefValuesMock).toHaveBeenCalledTimes(1);
|
||||||
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
|
expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1);
|
||||||
expect.objectContaining({
|
expect("token" in buildGatewayInstallPlanMock.mock.calls[0][0]).toBe(false);
|
||||||
token: undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auto-mints and persists token when no source exists", async () => {
|
it("auto-mints and persists token when no source exists", async () => {
|
||||||
@ -249,8 +243,9 @@ describe("runDaemonInstall", () => {
|
|||||||
};
|
};
|
||||||
expect(writtenConfig.gateway?.auth?.token).toBe("minted-token");
|
expect(writtenConfig.gateway?.auth?.token).toBe("minted-token");
|
||||||
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
|
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ token: "minted-token", port: 18789 }),
|
expect.objectContaining({ port: 18789 }),
|
||||||
);
|
);
|
||||||
|
expect("token" in buildGatewayInstallPlanMock.mock.calls[0][0]).toBe(false);
|
||||||
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
|
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
|
||||||
expect(actionState.warnings.some((warning) => warning.includes("Auto-generated"))).toBe(true);
|
expect(actionState.warnings.some((warning) => warning.includes("Auto-generated"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -91,7 +91,6 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
|||||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
port,
|
port,
|
||||||
token: tokenResolution.token,
|
|
||||||
runtime: runtimeRaw,
|
runtime: runtimeRaw,
|
||||||
warn: (message) => {
|
warn: (message) => {
|
||||||
if (json) {
|
if (json) {
|
||||||
|
|||||||
@ -83,7 +83,7 @@ describe("runServiceRestart token drift", () => {
|
|||||||
expect(payload.warnings?.[0]).toContain("gateway install --force");
|
expect(payload.warnings?.[0]).toContain("gateway install --force");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses env-first token precedence when checking drift", async () => {
|
it("uses gateway.auth.token when checking drift", async () => {
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
gateway: {
|
gateway: {
|
||||||
auth: {
|
auth: {
|
||||||
@ -106,7 +106,7 @@ describe("runServiceRestart token drift", () => {
|
|||||||
|
|
||||||
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
|
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
|
||||||
const payload = JSON.parse(jsonLine ?? "{}") as { warnings?: string[] };
|
const payload = JSON.parse(jsonLine ?? "{}") as { warnings?: string[] };
|
||||||
expect(payload.warnings).toBeUndefined();
|
expect(payload.warnings?.[0]).toContain("gateway install --force");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips drift warning when disabled", async () => {
|
it("skips drift warning when disabled", async () => {
|
||||||
|
|||||||
@ -5,10 +5,7 @@ import { checkTokenDrift } from "../../daemon/service-audit.js";
|
|||||||
import type { GatewayService } from "../../daemon/service.js";
|
import type { GatewayService } from "../../daemon/service.js";
|
||||||
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
|
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
|
||||||
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
|
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
|
||||||
import {
|
import { isGatewaySecretRefUnavailableError } from "../../gateway/credentials.js";
|
||||||
isGatewaySecretRefUnavailableError,
|
|
||||||
resolveGatewayCredentialsFromConfig,
|
|
||||||
} from "../../gateway/credentials.js";
|
|
||||||
import { isWSL } from "../../infra/wsl.js";
|
import { isWSL } from "../../infra/wsl.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import {
|
import {
|
||||||
@ -284,11 +281,7 @@ export async function runServiceRestart(params: {
|
|||||||
const command = await params.service.readCommand(process.env);
|
const command = await params.service.readCommand(process.env);
|
||||||
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN;
|
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN;
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const configToken = resolveGatewayCredentialsFromConfig({
|
const configToken = cfg.gateway?.auth?.token?.trim() || undefined;
|
||||||
cfg,
|
|
||||||
env: process.env,
|
|
||||||
modeOverride: "local",
|
|
||||||
}).token;
|
|
||||||
const driftIssue = checkTokenDrift({ serviceToken, configToken });
|
const driftIssue = checkTokenDrift({ serviceToken, configToken });
|
||||||
if (driftIssue) {
|
if (driftIssue) {
|
||||||
const warning = driftIssue.detail
|
const warning = driftIssue.detail
|
||||||
|
|||||||
@ -82,11 +82,8 @@ describe("maybeInstallDaemon", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1);
|
expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1);
|
||||||
expect(buildGatewayInstallPlan).toHaveBeenCalledWith(
|
expect(buildGatewayInstallPlan).toHaveBeenCalledTimes(1);
|
||||||
expect.objectContaining({
|
expect("token" in buildGatewayInstallPlan.mock.calls[0][0]).toBe(false);
|
||||||
token: undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(serviceInstall).toHaveBeenCalledTimes(1);
|
expect(serviceInstall).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -112,7 +112,6 @@ export async function maybeInstallDaemon(params: {
|
|||||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
port: params.port,
|
port: params.port,
|
||||||
token: tokenResolution.token,
|
|
||||||
runtime: daemonRuntime,
|
runtime: daemonRuntime,
|
||||||
warn: (message, title) => note(message, title),
|
warn: (message, title) => note(message, title),
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
|||||||
@ -23,7 +23,6 @@ export async function buildGatewayInstallPlan(params: {
|
|||||||
env: Record<string, string | undefined>;
|
env: Record<string, string | undefined>;
|
||||||
port: number;
|
port: number;
|
||||||
runtime: GatewayDaemonRuntime;
|
runtime: GatewayDaemonRuntime;
|
||||||
token?: string;
|
|
||||||
devMode?: boolean;
|
devMode?: boolean;
|
||||||
nodePath?: string;
|
nodePath?: string;
|
||||||
warn?: DaemonInstallWarnFn;
|
warn?: DaemonInstallWarnFn;
|
||||||
@ -52,7 +51,6 @@ export async function buildGatewayInstallPlan(params: {
|
|||||||
const serviceEnvironment = buildServiceEnvironment({
|
const serviceEnvironment = buildServiceEnvironment({
|
||||||
env: params.env,
|
env: params.env,
|
||||||
port: params.port,
|
port: params.port,
|
||||||
token: params.token,
|
|
||||||
launchdLabel:
|
launchdLabel:
|
||||||
process.platform === "darwin"
|
process.platform === "darwin"
|
||||||
? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE)
|
? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE)
|
||||||
|
|||||||
@ -194,7 +194,6 @@ export async function maybeRepairGatewayDaemon(params: {
|
|||||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
port,
|
port,
|
||||||
token: tokenResolution.token,
|
|
||||||
runtime: daemonRuntime,
|
runtime: daemonRuntime,
|
||||||
warn: (message, title) => note(message, title),
|
warn: (message, title) => note(message, title),
|
||||||
config: params.cfg,
|
config: params.cfg,
|
||||||
|
|||||||
@ -5,9 +5,10 @@ import { withEnvAsync } from "../test-utils/env.js";
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
readCommand: vi.fn(),
|
readCommand: vi.fn(),
|
||||||
install: vi.fn(),
|
install: vi.fn(),
|
||||||
|
writeConfigFile: vi.fn().mockResolvedValue(undefined),
|
||||||
auditGatewayServiceConfig: vi.fn(),
|
auditGatewayServiceConfig: vi.fn(),
|
||||||
buildGatewayInstallPlan: vi.fn(),
|
buildGatewayInstallPlan: vi.fn(),
|
||||||
resolveGatewayInstallToken: vi.fn(),
|
resolveGatewayAuthTokenForService: vi.fn(),
|
||||||
resolveGatewayPort: vi.fn(() => 18789),
|
resolveGatewayPort: vi.fn(() => 18789),
|
||||||
resolveIsNixMode: vi.fn(() => false),
|
resolveIsNixMode: vi.fn(() => false),
|
||||||
findExtraGatewayServices: vi.fn().mockResolvedValue([]),
|
findExtraGatewayServices: vi.fn().mockResolvedValue([]),
|
||||||
@ -21,6 +22,10 @@ vi.mock("../config/paths.js", () => ({
|
|||||||
resolveIsNixMode: mocks.resolveIsNixMode,
|
resolveIsNixMode: mocks.resolveIsNixMode,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", () => ({
|
||||||
|
writeConfigFile: mocks.writeConfigFile,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../daemon/inspect.js", () => ({
|
vi.mock("../daemon/inspect.js", () => ({
|
||||||
findExtraGatewayServices: mocks.findExtraGatewayServices,
|
findExtraGatewayServices: mocks.findExtraGatewayServices,
|
||||||
renderGatewayServiceCleanupHints: mocks.renderGatewayServiceCleanupHints,
|
renderGatewayServiceCleanupHints: mocks.renderGatewayServiceCleanupHints,
|
||||||
@ -58,8 +63,8 @@ vi.mock("./daemon-install-helpers.js", () => ({
|
|||||||
buildGatewayInstallPlan: mocks.buildGatewayInstallPlan,
|
buildGatewayInstallPlan: mocks.buildGatewayInstallPlan,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./gateway-install-token.js", () => ({
|
vi.mock("./doctor-gateway-auth-token.js", () => ({
|
||||||
resolveGatewayInstallToken: mocks.resolveGatewayInstallToken,
|
resolveGatewayAuthTokenForService: mocks.resolveGatewayAuthTokenForService,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -95,7 +100,7 @@ const gatewayProgramArguments = [
|
|||||||
"18789",
|
"18789",
|
||||||
];
|
];
|
||||||
|
|
||||||
function setupGatewayTokenRepairScenario(expectedToken: string) {
|
function setupGatewayTokenRepairScenario() {
|
||||||
mocks.readCommand.mockResolvedValue({
|
mocks.readCommand.mockResolvedValue({
|
||||||
programArguments: gatewayProgramArguments,
|
programArguments: gatewayProgramArguments,
|
||||||
environment: {
|
environment: {
|
||||||
@ -115,14 +120,7 @@ function setupGatewayTokenRepairScenario(expectedToken: string) {
|
|||||||
mocks.buildGatewayInstallPlan.mockResolvedValue({
|
mocks.buildGatewayInstallPlan.mockResolvedValue({
|
||||||
programArguments: gatewayProgramArguments,
|
programArguments: gatewayProgramArguments,
|
||||||
workingDirectory: "/tmp",
|
workingDirectory: "/tmp",
|
||||||
environment: {
|
environment: {},
|
||||||
OPENCLAW_GATEWAY_TOKEN: expectedToken,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mocks.resolveGatewayInstallToken.mockResolvedValue({
|
|
||||||
token: expectedToken,
|
|
||||||
tokenRefConfigured: false,
|
|
||||||
warnings: [],
|
|
||||||
});
|
});
|
||||||
mocks.install.mockResolvedValue(undefined);
|
mocks.install.mockResolvedValue(undefined);
|
||||||
}
|
}
|
||||||
@ -130,10 +128,16 @@ function setupGatewayTokenRepairScenario(expectedToken: string) {
|
|||||||
describe("maybeRepairGatewayServiceConfig", () => {
|
describe("maybeRepairGatewayServiceConfig", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mocks.resolveGatewayAuthTokenForService.mockImplementation(async (cfg: OpenClawConfig, env) => {
|
||||||
|
const configToken =
|
||||||
|
typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway.auth.token.trim() : undefined;
|
||||||
|
const envToken = env.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined;
|
||||||
|
return { token: configToken || envToken };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats gateway.auth.token as source of truth for service token repairs", async () => {
|
it("treats gateway.auth.token as source of truth for service token repairs", async () => {
|
||||||
setupGatewayTokenRepairScenario("config-token");
|
setupGatewayTokenRepairScenario();
|
||||||
|
|
||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
gateway: {
|
gateway: {
|
||||||
@ -153,15 +157,22 @@ describe("maybeRepairGatewayServiceConfig", () => {
|
|||||||
);
|
);
|
||||||
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
|
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
token: "config-token",
|
config: expect.objectContaining({
|
||||||
|
gateway: expect.objectContaining({
|
||||||
|
auth: expect.objectContaining({
|
||||||
|
token: "config-token",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
||||||
expect(mocks.install).toHaveBeenCalledTimes(1);
|
expect(mocks.install).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses OPENCLAW_GATEWAY_TOKEN when config token is missing", async () => {
|
it("uses OPENCLAW_GATEWAY_TOKEN when config token is missing", async () => {
|
||||||
await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => {
|
await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => {
|
||||||
setupGatewayTokenRepairScenario("env-token");
|
setupGatewayTokenRepairScenario();
|
||||||
|
|
||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
gateway: {},
|
gateway: {},
|
||||||
@ -176,7 +187,22 @@ describe("maybeRepairGatewayServiceConfig", () => {
|
|||||||
);
|
);
|
||||||
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
|
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
token: "env-token",
|
config: expect.objectContaining({
|
||||||
|
gateway: expect.objectContaining({
|
||||||
|
auth: expect.objectContaining({
|
||||||
|
token: "env-token",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
gateway: expect.objectContaining({
|
||||||
|
auth: expect.objectContaining({
|
||||||
|
token: "env-token",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mocks.install).toHaveBeenCalledTimes(1);
|
expect(mocks.install).toHaveBeenCalledTimes(1);
|
||||||
@ -190,11 +216,6 @@ describe("maybeRepairGatewayServiceConfig", () => {
|
|||||||
OPENCLAW_GATEWAY_TOKEN: "stale-token",
|
OPENCLAW_GATEWAY_TOKEN: "stale-token",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
mocks.resolveGatewayInstallToken.mockResolvedValue({
|
|
||||||
token: undefined,
|
|
||||||
tokenRefConfigured: true,
|
|
||||||
warnings: [],
|
|
||||||
});
|
|
||||||
mocks.auditGatewayServiceConfig.mockResolvedValue({
|
mocks.auditGatewayServiceConfig.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
issues: [],
|
issues: [],
|
||||||
@ -228,11 +249,56 @@ describe("maybeRepairGatewayServiceConfig", () => {
|
|||||||
);
|
);
|
||||||
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
|
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
token: undefined,
|
config: cfg,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(mocks.install).toHaveBeenCalledTimes(1);
|
expect(mocks.install).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to embedded service token when config and env tokens are missing", async () => {
|
||||||
|
await withEnvAsync(
|
||||||
|
{
|
||||||
|
OPENCLAW_GATEWAY_TOKEN: undefined,
|
||||||
|
CLAWDBOT_GATEWAY_TOKEN: undefined,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
setupGatewayTokenRepairScenario();
|
||||||
|
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
gateway: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await runRepair(cfg);
|
||||||
|
|
||||||
|
expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
expectedGatewayToken: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
gateway: expect.objectContaining({
|
||||||
|
auth: expect.objectContaining({
|
||||||
|
token: "stale-token",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
config: expect.objectContaining({
|
||||||
|
gateway: expect.objectContaining({
|
||||||
|
auth: expect.objectContaining({
|
||||||
|
token: "stale-token",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.install).toHaveBeenCalledTimes(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("maybeScanExtraGatewayServices", () => {
|
describe("maybeScanExtraGatewayServices", () => {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import { writeConfigFile, type OpenClawConfig } from "../config/config.js";
|
||||||
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
|
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
|
||||||
import { resolveSecretInputRef } from "../config/types.secrets.js";
|
import { resolveSecretInputRef } from "../config/types.secrets.js";
|
||||||
import {
|
import {
|
||||||
@ -25,7 +25,6 @@ import { buildGatewayInstallPlan } from "./daemon-install-helpers.js";
|
|||||||
import { DEFAULT_GATEWAY_DAEMON_RUNTIME, type GatewayDaemonRuntime } from "./daemon-runtime.js";
|
import { DEFAULT_GATEWAY_DAEMON_RUNTIME, type GatewayDaemonRuntime } from "./daemon-runtime.js";
|
||||||
import { resolveGatewayAuthTokenForService } from "./doctor-gateway-auth-token.js";
|
import { resolveGatewayAuthTokenForService } from "./doctor-gateway-auth-token.js";
|
||||||
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
|
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
|
||||||
import { resolveGatewayInstallToken } from "./gateway-install-token.js";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@ -259,24 +258,9 @@ export async function maybeRepairGatewayServiceConfig(
|
|||||||
|
|
||||||
const port = resolveGatewayPort(cfg, process.env);
|
const port = resolveGatewayPort(cfg, process.env);
|
||||||
const runtimeChoice = detectGatewayRuntime(command.programArguments);
|
const runtimeChoice = detectGatewayRuntime(command.programArguments);
|
||||||
const installTokenResolution = await resolveGatewayInstallToken({
|
const { programArguments } = await buildGatewayInstallPlan({
|
||||||
config: cfg,
|
|
||||||
env: process.env,
|
|
||||||
});
|
|
||||||
for (const warning of installTokenResolution.warnings) {
|
|
||||||
note(warning, "Gateway service config");
|
|
||||||
}
|
|
||||||
if (installTokenResolution.unavailableReason) {
|
|
||||||
note(
|
|
||||||
`Unable to verify gateway service token drift: ${installTokenResolution.unavailableReason}`,
|
|
||||||
"Gateway service config",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
|
||||||
env: process.env,
|
env: process.env,
|
||||||
port,
|
port,
|
||||||
token: installTokenResolution.token,
|
|
||||||
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
|
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
|
||||||
nodePath: systemNodePath ?? undefined,
|
nodePath: systemNodePath ?? undefined,
|
||||||
warn: (message, title) => note(message, title),
|
warn: (message, title) => note(message, title),
|
||||||
@ -332,13 +316,56 @@ export async function maybeRepairGatewayServiceConfig(
|
|||||||
if (!repair) {
|
if (!repair) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const serviceEmbeddedToken = command.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined;
|
||||||
|
const gatewayTokenForRepair = expectedGatewayToken ?? serviceEmbeddedToken;
|
||||||
|
const configuredGatewayToken =
|
||||||
|
typeof cfg.gateway?.auth?.token === "string"
|
||||||
|
? cfg.gateway.auth.token.trim() || undefined
|
||||||
|
: undefined;
|
||||||
|
let cfgForServiceInstall = cfg;
|
||||||
|
if (!tokenRefConfigured && !configuredGatewayToken && gatewayTokenForRepair) {
|
||||||
|
const nextCfg: OpenClawConfig = {
|
||||||
|
...cfg,
|
||||||
|
gateway: {
|
||||||
|
...cfg.gateway,
|
||||||
|
auth: {
|
||||||
|
...cfg.gateway?.auth,
|
||||||
|
mode: cfg.gateway?.auth?.mode ?? "token",
|
||||||
|
token: gatewayTokenForRepair,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await writeConfigFile(nextCfg);
|
||||||
|
cfgForServiceInstall = nextCfg;
|
||||||
|
note(
|
||||||
|
expectedGatewayToken
|
||||||
|
? "Persisted gateway.auth.token from environment before reinstalling service."
|
||||||
|
: "Persisted gateway.auth.token from existing service definition before reinstalling service.",
|
||||||
|
"Gateway",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
runtime.error(`Failed to persist gateway.auth.token before service repair: ${String(err)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPort = resolveGatewayPort(cfgForServiceInstall, process.env);
|
||||||
|
const updatedPlan = await buildGatewayInstallPlan({
|
||||||
|
env: process.env,
|
||||||
|
port: updatedPort,
|
||||||
|
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
|
||||||
|
nodePath: systemNodePath ?? undefined,
|
||||||
|
warn: (message, title) => note(message, title),
|
||||||
|
config: cfgForServiceInstall,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await service.install({
|
await service.install({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
stdout: process.stdout,
|
stdout: process.stdout,
|
||||||
programArguments,
|
programArguments: updatedPlan.programArguments,
|
||||||
workingDirectory,
|
workingDirectory: updatedPlan.workingDirectory,
|
||||||
environment,
|
environment: updatedPlan.environment,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(`Gateway service update failed: ${String(err)}`);
|
runtime.error(`Gateway service update failed: ${String(err)}`);
|
||||||
|
|||||||
@ -189,6 +189,8 @@ export async function resolveAuthForTarget(
|
|||||||
}
|
}
|
||||||
return passwordResolution.value;
|
return passwordResolution.value;
|
||||||
};
|
};
|
||||||
|
const withDiagnostics = <T extends { token?: string; password?: string }>(result: T) =>
|
||||||
|
diagnostics.length > 0 ? { ...result, diagnostics } : result;
|
||||||
|
|
||||||
if (target.kind === "configRemote" || target.kind === "sshTunnel") {
|
if (target.kind === "configRemote" || target.kind === "sshTunnel") {
|
||||||
const remoteTokenValue = cfg.gateway?.remote?.token;
|
const remoteTokenValue = cfg.gateway?.remote?.token;
|
||||||
@ -198,11 +200,7 @@ export async function resolveAuthForTarget(
|
|||||||
const password = token
|
const password = token
|
||||||
? undefined
|
? undefined
|
||||||
: await resolvePassword(remotePasswordValue, "gateway.remote.password");
|
: await resolvePassword(remotePasswordValue, "gateway.remote.password");
|
||||||
return {
|
return withDiagnostics({ token, password });
|
||||||
token,
|
|
||||||
password,
|
|
||||||
...(diagnostics.length > 0 ? { diagnostics } : {}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authDisabled = authMode === "none" || authMode === "trusted-proxy";
|
const authDisabled = authMode === "none" || authMode === "trusted-proxy";
|
||||||
@ -213,49 +211,39 @@ export async function resolveAuthForTarget(
|
|||||||
const envToken = readGatewayTokenEnv();
|
const envToken = readGatewayTokenEnv();
|
||||||
const envPassword = readGatewayPasswordEnv();
|
const envPassword = readGatewayPasswordEnv();
|
||||||
if (tokenOnly) {
|
if (tokenOnly) {
|
||||||
|
const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token");
|
||||||
|
if (token) {
|
||||||
|
return withDiagnostics({ token });
|
||||||
|
}
|
||||||
if (envToken) {
|
if (envToken) {
|
||||||
return { token: envToken };
|
return { token: envToken };
|
||||||
}
|
}
|
||||||
const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token");
|
return withDiagnostics({});
|
||||||
return {
|
|
||||||
token,
|
|
||||||
...(diagnostics.length > 0 ? { diagnostics } : {}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (passwordOnly) {
|
if (passwordOnly) {
|
||||||
|
const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password");
|
||||||
|
if (password) {
|
||||||
|
return withDiagnostics({ password });
|
||||||
|
}
|
||||||
if (envPassword) {
|
if (envPassword) {
|
||||||
return { password: envPassword };
|
return { password: envPassword };
|
||||||
}
|
}
|
||||||
const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password");
|
return withDiagnostics({});
|
||||||
return {
|
|
||||||
password,
|
|
||||||
...(diagnostics.length > 0 ? { diagnostics } : {}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token");
|
||||||
|
if (token) {
|
||||||
|
return withDiagnostics({ token });
|
||||||
|
}
|
||||||
if (envToken) {
|
if (envToken) {
|
||||||
return { token: envToken };
|
return { token: envToken };
|
||||||
}
|
}
|
||||||
const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token");
|
|
||||||
if (token) {
|
|
||||||
return {
|
|
||||||
token,
|
|
||||||
...(diagnostics.length > 0 ? { diagnostics } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (envPassword) {
|
if (envPassword) {
|
||||||
return {
|
return withDiagnostics({ password: envPassword });
|
||||||
password: envPassword,
|
|
||||||
...(diagnostics.length > 0 ? { diagnostics } : {}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password");
|
const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password");
|
||||||
|
|
||||||
return {
|
return withDiagnostics({ token, password });
|
||||||
token,
|
|
||||||
password,
|
|
||||||
...(diagnostics.length > 0 ? { diagnostics } : {}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { pickGatewaySelfPresence };
|
export { pickGatewaySelfPresence };
|
||||||
|
|||||||
@ -74,11 +74,8 @@ describe("installGatewayDaemonNonInteractive", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1);
|
expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1);
|
||||||
expect(buildGatewayInstallPlan).toHaveBeenCalledWith(
|
expect(buildGatewayInstallPlan).toHaveBeenCalledTimes(1);
|
||||||
expect.objectContaining({
|
expect("token" in buildGatewayInstallPlan.mock.calls[0][0]).toBe(false);
|
||||||
token: undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(serviceInstall).toHaveBeenCalledTimes(1);
|
expect(serviceInstall).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -55,7 +55,6 @@ export async function installGatewayDaemonNonInteractive(params: {
|
|||||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
port,
|
port,
|
||||||
token: tokenResolution.token,
|
|
||||||
runtime: daemonRuntimeRaw,
|
runtime: daemonRuntimeRaw,
|
||||||
warn: (message) => runtime.log(message),
|
warn: (message) => runtime.log(message),
|
||||||
config: params.nextConfig,
|
config: params.nextConfig,
|
||||||
|
|||||||
@ -78,12 +78,15 @@ describe("auditGatewayServiceConfig", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(
|
||||||
|
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenEmbedded),
|
||||||
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch),
|
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not flag gateway token mismatch when service token matches config token", async () => {
|
it("flags embedded service token even when it matches config token", async () => {
|
||||||
const audit = await auditGatewayServiceConfig({
|
const audit = await auditGatewayServiceConfig({
|
||||||
env: { HOME: "/tmp" },
|
env: { HOME: "/tmp" },
|
||||||
platform: "linux",
|
platform: "linux",
|
||||||
@ -96,6 +99,29 @@ describe("auditGatewayServiceConfig", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(
|
||||||
|
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenEmbedded),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag token issues when service token is not embedded", async () => {
|
||||||
|
const audit = await auditGatewayServiceConfig({
|
||||||
|
env: { HOME: "/tmp" },
|
||||||
|
platform: "linux",
|
||||||
|
expectedGatewayToken: "new-token",
|
||||||
|
command: {
|
||||||
|
programArguments: ["/usr/bin/node", "gateway"],
|
||||||
|
environment: {
|
||||||
|
PATH: "/usr/local/bin:/usr/bin:/bin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenEmbedded),
|
||||||
|
).toBe(false);
|
||||||
expect(
|
expect(
|
||||||
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch),
|
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
@ -143,10 +169,9 @@ describe("checkTokenDrift", () => {
|
|||||||
expect(result?.message).toContain("differs from service token");
|
expect(result?.message).toContain("differs from service token");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("detects drift when config has token but service has no token", () => {
|
it("returns null when config has token but service has no token", () => {
|
||||||
const result = checkTokenDrift({ serviceToken: undefined, configToken: "new-token" });
|
const result = checkTokenDrift({ serviceToken: undefined, configToken: "new-token" });
|
||||||
expect(result).not.toBeNull();
|
expect(result).toBeNull();
|
||||||
expect(result?.code).toBe(SERVICE_AUDIT_CODES.gatewayTokenDrift);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when service has token but config does not", () => {
|
it("returns null when service has token but config does not", () => {
|
||||||
|
|||||||
@ -35,6 +35,7 @@ export const SERVICE_AUDIT_CODES = {
|
|||||||
gatewayPathMissing: "gateway-path-missing",
|
gatewayPathMissing: "gateway-path-missing",
|
||||||
gatewayPathMissingDirs: "gateway-path-missing-dirs",
|
gatewayPathMissingDirs: "gateway-path-missing-dirs",
|
||||||
gatewayPathNonMinimal: "gateway-path-nonminimal",
|
gatewayPathNonMinimal: "gateway-path-nonminimal",
|
||||||
|
gatewayTokenEmbedded: "gateway-token-embedded",
|
||||||
gatewayTokenMismatch: "gateway-token-mismatch",
|
gatewayTokenMismatch: "gateway-token-mismatch",
|
||||||
gatewayRuntimeBun: "gateway-runtime-bun",
|
gatewayRuntimeBun: "gateway-runtime-bun",
|
||||||
gatewayRuntimeNodeVersionManager: "gateway-runtime-node-version-manager",
|
gatewayRuntimeNodeVersionManager: "gateway-runtime-node-version-manager",
|
||||||
@ -208,19 +209,25 @@ function auditGatewayToken(
|
|||||||
issues: ServiceConfigIssue[],
|
issues: ServiceConfigIssue[],
|
||||||
expectedGatewayToken?: string,
|
expectedGatewayToken?: string,
|
||||||
) {
|
) {
|
||||||
const expectedToken = expectedGatewayToken?.trim();
|
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim();
|
||||||
if (!expectedToken) {
|
if (!serviceToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim();
|
issues.push({
|
||||||
if (serviceToken === expectedToken) {
|
code: SERVICE_AUDIT_CODES.gatewayTokenEmbedded,
|
||||||
|
message: "Gateway service embeds OPENCLAW_GATEWAY_TOKEN and should be reinstalled.",
|
||||||
|
detail: "Run `openclaw gateway install --force` to remove embedded service token.",
|
||||||
|
level: "recommended",
|
||||||
|
});
|
||||||
|
const expectedToken = expectedGatewayToken?.trim();
|
||||||
|
if (!expectedToken || serviceToken === expectedToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
issues.push({
|
issues.push({
|
||||||
code: SERVICE_AUDIT_CODES.gatewayTokenMismatch,
|
code: SERVICE_AUDIT_CODES.gatewayTokenMismatch,
|
||||||
message:
|
message:
|
||||||
"Gateway service OPENCLAW_GATEWAY_TOKEN does not match gateway.auth.token in openclaw.json",
|
"Gateway service OPENCLAW_GATEWAY_TOKEN does not match gateway.auth.token in openclaw.json",
|
||||||
detail: serviceToken ? "service token is stale" : "service token is missing",
|
detail: "service token is stale",
|
||||||
level: "recommended",
|
level: "recommended",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -360,21 +367,15 @@ export function checkTokenDrift(params: {
|
|||||||
serviceToken: string | undefined;
|
serviceToken: string | undefined;
|
||||||
configToken: string | undefined;
|
configToken: string | undefined;
|
||||||
}): ServiceConfigIssue | null {
|
}): ServiceConfigIssue | null {
|
||||||
const { serviceToken, configToken } = params;
|
const serviceToken = params.serviceToken?.trim() || undefined;
|
||||||
|
const configToken = params.configToken?.trim() || undefined;
|
||||||
|
|
||||||
// Normalise both tokens before comparing: service-file parsers (systemd,
|
// Tokenless service units are canonical; no drift to report.
|
||||||
// launchd) can return values with trailing newlines or whitespace that
|
if (!serviceToken) {
|
||||||
// cause a false-positive mismatch against the config value.
|
|
||||||
const normService = serviceToken?.trim() || undefined;
|
|
||||||
const normConfig = configToken?.trim() || undefined;
|
|
||||||
|
|
||||||
// No drift if both are undefined/empty
|
|
||||||
if (!normService && !normConfig) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drift: config has token, service has different or no token
|
if (configToken && serviceToken !== configToken) {
|
||||||
if (normConfig && normService !== normConfig) {
|
|
||||||
return {
|
return {
|
||||||
code: SERVICE_AUDIT_CODES.gatewayTokenDrift,
|
code: SERVICE_AUDIT_CODES.gatewayTokenDrift,
|
||||||
message:
|
message:
|
||||||
|
|||||||
@ -264,7 +264,6 @@ describe("buildServiceEnvironment", () => {
|
|||||||
const env = buildServiceEnvironment({
|
const env = buildServiceEnvironment({
|
||||||
env: { HOME: "/home/user" },
|
env: { HOME: "/home/user" },
|
||||||
port: 18789,
|
port: 18789,
|
||||||
token: "secret",
|
|
||||||
});
|
});
|
||||||
expect(env.HOME).toBe("/home/user");
|
expect(env.HOME).toBe("/home/user");
|
||||||
if (process.platform === "win32") {
|
if (process.platform === "win32") {
|
||||||
@ -273,7 +272,7 @@ describe("buildServiceEnvironment", () => {
|
|||||||
expect(env.PATH).toContain("/usr/bin");
|
expect(env.PATH).toContain("/usr/bin");
|
||||||
}
|
}
|
||||||
expect(env.OPENCLAW_GATEWAY_PORT).toBe("18789");
|
expect(env.OPENCLAW_GATEWAY_PORT).toBe("18789");
|
||||||
expect(env.OPENCLAW_GATEWAY_TOKEN).toBe("secret");
|
expect(env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
|
||||||
expect(env.OPENCLAW_SERVICE_MARKER).toBe("openclaw");
|
expect(env.OPENCLAW_SERVICE_MARKER).toBe("openclaw");
|
||||||
expect(env.OPENCLAW_SERVICE_KIND).toBe("gateway");
|
expect(env.OPENCLAW_SERVICE_KIND).toBe("gateway");
|
||||||
expect(typeof env.OPENCLAW_SERVICE_VERSION).toBe("string");
|
expect(typeof env.OPENCLAW_SERVICE_VERSION).toBe("string");
|
||||||
|
|||||||
@ -245,11 +245,10 @@ export function buildMinimalServicePath(options: BuildServicePathOptions = {}):
|
|||||||
export function buildServiceEnvironment(params: {
|
export function buildServiceEnvironment(params: {
|
||||||
env: Record<string, string | undefined>;
|
env: Record<string, string | undefined>;
|
||||||
port: number;
|
port: number;
|
||||||
token?: string;
|
|
||||||
launchdLabel?: string;
|
launchdLabel?: string;
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
}): Record<string, string | undefined> {
|
}): Record<string, string | undefined> {
|
||||||
const { env, port, token, launchdLabel } = params;
|
const { env, port, launchdLabel } = params;
|
||||||
const platform = params.platform ?? process.platform;
|
const platform = params.platform ?? process.platform;
|
||||||
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform);
|
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform);
|
||||||
const profile = env.OPENCLAW_PROFILE;
|
const profile = env.OPENCLAW_PROFILE;
|
||||||
@ -260,7 +259,6 @@ export function buildServiceEnvironment(params: {
|
|||||||
...buildCommonServiceEnvironment(env, sharedEnv),
|
...buildCommonServiceEnvironment(env, sharedEnv),
|
||||||
OPENCLAW_PROFILE: profile,
|
OPENCLAW_PROFILE: profile,
|
||||||
OPENCLAW_GATEWAY_PORT: String(port),
|
OPENCLAW_GATEWAY_PORT: String(port),
|
||||||
OPENCLAW_GATEWAY_TOKEN: token,
|
|
||||||
OPENCLAW_LAUNCHD_LABEL: resolvedLaunchdLabel,
|
OPENCLAW_LAUNCHD_LABEL: resolvedLaunchdLabel,
|
||||||
OPENCLAW_SYSTEMD_UNIT: systemdUnit,
|
OPENCLAW_SYSTEMD_UNIT: systemdUnit,
|
||||||
OPENCLAW_WINDOWS_TASK_NAME: resolveGatewayWindowsTaskName(profile),
|
OPENCLAW_WINDOWS_TASK_NAME: resolveGatewayWindowsTaskName(profile),
|
||||||
|
|||||||
@ -41,6 +41,7 @@ function withGatewayAuthEnv<T>(env: NodeJS.ProcessEnv, fn: () => T): T {
|
|||||||
const keys = [
|
const keys = [
|
||||||
"OPENCLAW_GATEWAY_TOKEN",
|
"OPENCLAW_GATEWAY_TOKEN",
|
||||||
"OPENCLAW_GATEWAY_PASSWORD",
|
"OPENCLAW_GATEWAY_PASSWORD",
|
||||||
|
"OPENCLAW_SERVICE_KIND",
|
||||||
"CLAWDBOT_GATEWAY_TOKEN",
|
"CLAWDBOT_GATEWAY_TOKEN",
|
||||||
"CLAWDBOT_GATEWAY_PASSWORD",
|
"CLAWDBOT_GATEWAY_PASSWORD",
|
||||||
] as const;
|
] as const;
|
||||||
@ -138,6 +139,29 @@ describe("gateway credential precedence parity", () => {
|
|||||||
auth: { token: undefined, password: undefined },
|
auth: { token: undefined, password: undefined },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "local mode in gateway service runtime uses config-first token precedence",
|
||||||
|
cfg: {
|
||||||
|
gateway: {
|
||||||
|
mode: "local",
|
||||||
|
auth: {
|
||||||
|
token: "config-token",
|
||||||
|
password: "config-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig,
|
||||||
|
env: {
|
||||||
|
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||||
|
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||||
|
OPENCLAW_SERVICE_KIND: "gateway",
|
||||||
|
} as NodeJS.ProcessEnv,
|
||||||
|
expected: {
|
||||||
|
call: { token: "config-token", password: "env-password" },
|
||||||
|
probe: { token: "config-token", password: "env-password" },
|
||||||
|
status: { token: "config-token", password: "env-password" },
|
||||||
|
auth: { token: "config-token", password: "config-password" },
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
it.each(cases)("$name", ({ cfg, env, expected }) => {
|
it.each(cases)("$name", ({ cfg, env, expected }) => {
|
||||||
|
|||||||
@ -120,6 +120,26 @@ describe("resolveGatewayCredentialsFromConfig", () => {
|
|||||||
expectEnvGatewayCredentials(resolved);
|
expectEnvGatewayCredentials(resolved);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses config-first local token precedence inside gateway service runtime", () => {
|
||||||
|
const resolved = resolveGatewayCredentialsFromConfig({
|
||||||
|
cfg: cfg({
|
||||||
|
gateway: {
|
||||||
|
mode: "local",
|
||||||
|
auth: { token: "config-token", password: "config-password" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
env: {
|
||||||
|
OPENCLAW_GATEWAY_TOKEN: "env-token",
|
||||||
|
OPENCLAW_GATEWAY_PASSWORD: "env-password",
|
||||||
|
OPENCLAW_SERVICE_KIND: "gateway",
|
||||||
|
} as NodeJS.ProcessEnv,
|
||||||
|
});
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
token: "config-token",
|
||||||
|
password: "env-password",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to remote credentials in local mode when local auth is missing", () => {
|
it("falls back to remote credentials in local mode when local auth is missing", () => {
|
||||||
const resolved = resolveGatewayCredentialsFromConfig({
|
const resolved = resolveGatewayCredentialsFromConfig({
|
||||||
cfg: cfg({
|
cfg: cfg({
|
||||||
|
|||||||
@ -223,7 +223,9 @@ export function resolveGatewayCredentialsFromConfig(params: {
|
|||||||
? undefined
|
? undefined
|
||||||
: trimToUndefined(params.cfg.gateway?.auth?.password);
|
: trimToUndefined(params.cfg.gateway?.auth?.password);
|
||||||
|
|
||||||
const localTokenPrecedence = params.localTokenPrecedence ?? "env-first";
|
const localTokenPrecedence =
|
||||||
|
params.localTokenPrecedence ??
|
||||||
|
(env.OPENCLAW_SERVICE_KIND === "gateway" ? "config-first" : "env-first");
|
||||||
const localPasswordPrecedence = params.localPasswordPrecedence ?? "env-first";
|
const localPasswordPrecedence = params.localPasswordPrecedence ?? "env-first";
|
||||||
|
|
||||||
if (mode === "local") {
|
if (mode === "local") {
|
||||||
|
|||||||
@ -158,7 +158,16 @@ describe("resolveGatewayConnection", () => {
|
|||||||
expect(result.url).toBe("ws://127.0.0.1:18800");
|
expect(result.url).toBe("ws://127.0.0.1:18800");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses OPENCLAW_GATEWAY_TOKEN for local mode", async () => {
|
it("uses config auth token for local mode when both config and env tokens are set", async () => {
|
||||||
|
loadConfig.mockReturnValue({ gateway: { mode: "local", auth: { token: "config-token" } } });
|
||||||
|
|
||||||
|
await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => {
|
||||||
|
const result = await resolveGatewayConnection({});
|
||||||
|
expect(result.token).toBe("config-token");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to OPENCLAW_GATEWAY_TOKEN when config token is missing", async () => {
|
||||||
loadConfig.mockReturnValue({ gateway: { mode: "local" } });
|
loadConfig.mockReturnValue({ gateway: { mode: "local" } });
|
||||||
|
|
||||||
await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => {
|
await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => {
|
||||||
@ -167,13 +176,6 @@ describe("resolveGatewayConnection", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to config auth token when env token is missing", async () => {
|
|
||||||
loadConfig.mockReturnValue({ gateway: { mode: "local", auth: { token: "config-token" } } });
|
|
||||||
|
|
||||||
const result = await resolveGatewayConnection({});
|
|
||||||
expect(result.token).toBe("config-token");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses local password auth when gateway.auth.mode is unset and password-only is configured", async () => {
|
it("uses local password auth when gateway.auth.mode is unset and password-only is configured", async () => {
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
gateway: {
|
gateway: {
|
||||||
|
|||||||
@ -370,16 +370,15 @@ export async function resolveGatewayConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolveToken = async () => {
|
const resolveToken = async () => {
|
||||||
const localToken =
|
const localToken = explicitAuth.token
|
||||||
explicitAuth.token || envToken
|
? { value: explicitAuth.token }
|
||||||
? { value: explicitAuth.token ?? envToken }
|
: await resolveConfiguredSecretInputString({
|
||||||
: await resolveConfiguredSecretInputString({
|
value: config.gateway?.auth?.token,
|
||||||
value: config.gateway?.auth?.token,
|
path: "gateway.auth.token",
|
||||||
path: "gateway.auth.token",
|
env,
|
||||||
env,
|
config,
|
||||||
config,
|
});
|
||||||
});
|
const token = explicitAuth.token ?? localToken.value ?? envToken;
|
||||||
const token = explicitAuth.token ?? envToken ?? localToken.value;
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throwGatewayAuthResolutionError(
|
throwGatewayAuthResolutionError(
|
||||||
localToken.unresolvedRefReason ?? "Missing gateway auth token.",
|
localToken.unresolvedRefReason ?? "Missing gateway auth token.",
|
||||||
@ -410,7 +409,7 @@ export async function resolveGatewayConnection(
|
|||||||
env,
|
env,
|
||||||
config,
|
config,
|
||||||
});
|
});
|
||||||
const password = passwordCandidate ?? localPassword.value;
|
const password = explicitAuth.password ?? localPassword.value ?? envPassword;
|
||||||
if (!password) {
|
if (!password) {
|
||||||
throwGatewayAuthResolutionError(
|
throwGatewayAuthResolutionError(
|
||||||
localPassword.unresolvedRefReason ?? "Missing gateway auth password.",
|
localPassword.unresolvedRefReason ?? "Missing gateway auth password.",
|
||||||
|
|||||||
@ -233,11 +233,8 @@ describe("finalizeOnboardingWizard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1);
|
expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1);
|
||||||
expect(buildGatewayInstallPlan).toHaveBeenCalledWith(
|
expect(buildGatewayInstallPlan).toHaveBeenCalledTimes(1);
|
||||||
expect.objectContaining({
|
expect("token" in buildGatewayInstallPlan.mock.calls[0][0]).toBe(false);
|
||||||
token: undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(gatewayServiceInstall).toHaveBeenCalledTimes(1);
|
expect(gatewayServiceInstall).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -184,7 +184,6 @@ export async function finalizeOnboardingWizard(
|
|||||||
{
|
{
|
||||||
env: process.env,
|
env: process.env,
|
||||||
port: settings.port,
|
port: settings.port,
|
||||||
token: tokenResolution.token,
|
|
||||||
runtime: daemonRuntime,
|
runtime: daemonRuntime,
|
||||||
warn: (message, title) => prompter.note(message, title),
|
warn: (message, title) => prompter.note(message, title),
|
||||||
config: nextConfig,
|
config: nextConfig,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user