diff --git a/src/gateway/operator-approval-runtime-token.test.ts b/src/gateway/operator-approval-runtime-token.test.ts new file mode 100644 index 000000000000..7947867101a2 --- /dev/null +++ b/src/gateway/operator-approval-runtime-token.test.ts @@ -0,0 +1,109 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const originalEnv = { + HOME: process.env.HOME, + OPENCLAW_HOME: process.env.OPENCLAW_HOME, +}; + +const tempHomes: string[] = []; + +function useTempHome(): string { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-runtime-")); + tempHomes.push(home); + process.env.HOME = home; + process.env.OPENCLAW_HOME = home; + return home; +} + +function execApprovalsPath(home: string): string { + return path.join(home, ".openclaw", "exec-approvals.json"); +} + +function writeExecApprovalsToken(home: string, token: string): void { + fs.mkdirSync(path.join(home, ".openclaw"), { recursive: true }); + fs.writeFileSync( + execApprovalsPath(home), + `${JSON.stringify( + { + version: 1, + socket: { + path: "~/.openclaw/exec-approvals.sock", + token, + }, + agents: {}, + }, + null, + 2, + )}\n`, + ); +} + +async function importRuntimeTokenModule(): Promise< + typeof import("./operator-approval-runtime-token.js") +> { + vi.resetModules(); + return await import("./operator-approval-runtime-token.js"); +} + +afterEach(() => { + vi.resetModules(); + if (originalEnv.HOME === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalEnv.HOME; + } + if (originalEnv.OPENCLAW_HOME === undefined) { + delete process.env.OPENCLAW_HOME; + } else { + process.env.OPENCLAW_HOME = originalEnv.OPENCLAW_HOME; + } + for (const home of tempHomes.splice(0)) { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +describe("operator approval runtime token", () => { + it("derives the shared approval runtime token from the exec approvals socket token", async () => { + const home = useTempHome(); + writeExecApprovalsToken(home, "shared-runtime-token"); + + const runtimeToken = await importRuntimeTokenModule(); + const sharedToken = runtimeToken.getOperatorApprovalRuntimeToken(); + + expect(sharedToken).toEqual(expect.any(String)); + expect(sharedToken).not.toBe("shared-runtime-token"); + expect(runtimeToken.isOperatorApprovalRuntimeToken(` ${sharedToken} `)).toBe(true); + expect(runtimeToken.isOperatorApprovalRuntimeToken("shared-runtime-token")).toBe(false); + expect(runtimeToken.isOperatorApprovalRuntimeToken("different-token")).toBe(false); + }); + + it("does not pin the process fallback once a shared exec approvals token appears", async () => { + const home = useTempHome(); + const runtimeToken = await importRuntimeTokenModule(); + + const fallback = runtimeToken.getOperatorApprovalRuntimeToken(); + writeExecApprovalsToken(home, "late-shared-runtime-token"); + const sharedToken = runtimeToken.getOperatorApprovalRuntimeToken(); + + expect(sharedToken).not.toBe(fallback); + expect(sharedToken).not.toBe("late-shared-runtime-token"); + expect(runtimeToken.isOperatorApprovalRuntimeToken(fallback)).toBe(true); + expect(runtimeToken.isOperatorApprovalRuntimeToken(sharedToken)).toBe(true); + expect(runtimeToken.isOperatorApprovalRuntimeToken("late-shared-runtime-token")).toBe(false); + }); + + it("keeps a stable process fallback without creating exec-approvals.json", async () => { + const home = useTempHome(); + const runtimeToken = await importRuntimeTokenModule(); + + const first = runtimeToken.getOperatorApprovalRuntimeToken(); + const second = runtimeToken.getOperatorApprovalRuntimeToken(); + + expect(first).toEqual(expect.any(String)); + expect(second).toBe(first); + expect(fs.existsSync(execApprovalsPath(home))).toBe(false); + }); +}); diff --git a/src/gateway/operator-approval-runtime-token.ts b/src/gateway/operator-approval-runtime-token.ts index 0b8ea24e7d5f..e7a69fa0d64c 100644 --- a/src/gateway/operator-approval-runtime-token.ts +++ b/src/gateway/operator-approval-runtime-token.ts @@ -1,15 +1,43 @@ // Operator approval runtime token. -// Provides a process-local loopback token for approval helper clients. -import { randomBytes, timingSafeEqual } from "node:crypto"; +// Uses an existing shared socket token when available, with a process-local fallback. +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { loadExecApprovals } from "../infra/exec-approvals.js"; -let approvalRuntimeToken: string | null = null; +const APPROVAL_RUNTIME_TOKEN_CONTEXT = "openclaw:gateway-approval-runtime-token:v1"; + +let fallbackApprovalRuntimeToken: string | null = null; + +function deriveApprovalRuntimeToken(socketToken: string): string { + return createHmac("sha256", socketToken) + .update(APPROVAL_RUNTIME_TOKEN_CONTEXT) + .digest("base64url"); +} + +function readSharedApprovalRuntimeToken(): string | null { + const token = loadExecApprovals().socket?.token?.trim(); + return token ? deriveApprovalRuntimeToken(token) : null; +} + +function tokenMatches(token: string, expected: string | null | undefined): boolean { + if (!expected) { + return false; + } + const tokenBytes = Buffer.from(token); + const expectedBytes = Buffer.from(expected); + // timingSafeEqual requires equal lengths; keep length rejection explicit instead of catching. + return tokenBytes.length === expectedBytes.length && timingSafeEqual(tokenBytes, expectedBytes); +} /** - * Returns the process-local token used to authorize loopback operator-approval clients. + * Returns the token used to authorize local operator-approval clients. */ export function getOperatorApprovalRuntimeToken(): string { - approvalRuntimeToken ??= randomBytes(32).toString("base64url"); - return approvalRuntimeToken; + const sharedToken = readSharedApprovalRuntimeToken(); + if (sharedToken) { + return sharedToken; + } + fallbackApprovalRuntimeToken ??= randomBytes(32).toString("base64url"); + return fallbackApprovalRuntimeToken; } /** @@ -20,9 +48,11 @@ export function isOperatorApprovalRuntimeToken(value: string | null | undefined) if (!token) { return false; } - const expected = getOperatorApprovalRuntimeToken(); - const tokenBytes = Buffer.from(token); - const expectedBytes = Buffer.from(expected); - // timingSafeEqual requires equal lengths; keep length rejection explicit instead of catching. - return tokenBytes.length === expectedBytes.length && timingSafeEqual(tokenBytes, expectedBytes); + const sharedToken = readSharedApprovalRuntimeToken(); + if (tokenMatches(token, sharedToken)) { + return true; + } + const fallbackToken = + fallbackApprovalRuntimeToken ?? (sharedToken ? null : getOperatorApprovalRuntimeToken()); + return tokenMatches(token, fallbackToken); } diff --git a/src/gateway/operator-approvals-client.test.ts b/src/gateway/operator-approvals-client.test.ts index c50d31679c2b..32fd6dfc5473 100644 --- a/src/gateway/operator-approvals-client.test.ts +++ b/src/gateway/operator-approvals-client.test.ts @@ -122,7 +122,7 @@ describe("withOperatorApprovalsGatewayClient", () => { expect(clientState.stopAndWaitSpy).toHaveBeenCalledTimes(1); }); - it("keeps device identity for remote shared-auth approval clients", async () => { + it("keeps device identity and omits approval runtime token for remote shared-auth approval clients", async () => { bootstrapState.url = "wss://gateway.example/ws"; bootstrapState.urlSource = "config gateway.remote.url"; @@ -130,6 +130,7 @@ describe("withOperatorApprovalsGatewayClient", () => { expect(clientState.options).not.toHaveProperty("deviceIdentity", null); expect(clientState.options?.deviceIdentity).toBeUndefined(); + expect(clientState.options).not.toHaveProperty("approvalRuntimeToken"); }); it.each([