fix(gateway): share approval runtime socket token

This commit is contained in:
fuller-stack-dev
2026-05-26 19:21:13 -06:00
committed by Shakker
parent 9a82b60024
commit 2affecc720
3 changed files with 152 additions and 12 deletions
@@ -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);
});
});
+41 -11
View File
@@ -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);
}
@@ -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([