mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
fix(gateway): share approval runtime socket token
This commit is contained in:
committed by
Shakker
parent
9a82b60024
commit
2affecc720
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user