mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-27 09:02:15 +00:00
test: harden no-isolate gateway auth and pairing
This commit is contained in:
@@ -33,7 +33,7 @@ describe("matrix plugin registration", () => {
|
||||
requiresExplicitMatrixDefaultAccount: expect.any(Function),
|
||||
resolveMatrixDefaultOrOnlyAccountId: expect.any(Function),
|
||||
});
|
||||
});
|
||||
}, 240_000);
|
||||
|
||||
it("registers the channel without bootstrapping crypto runtime", () => {
|
||||
const runtime = {} as never;
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("gateway auth browser hardening", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves scopes for trusted-proxy non-control-ui browser sessions", async () => {
|
||||
test("clears scopes for trusted-proxy non-control-ui browser sessions", async () => {
|
||||
await withTrustedProxyBrowserWs(ALLOWED_BROWSER_ORIGIN, async (ws) => {
|
||||
const payload = await connectOk(ws, {
|
||||
client: TEST_OPERATOR_CLIENT,
|
||||
@@ -150,7 +150,8 @@ describe("gateway auth browser hardening", () => {
|
||||
expect(payload.type).toBe("hello-ok");
|
||||
|
||||
const status = await rpcReq(ws, "status");
|
||||
expect(status.ok).toBe(true);
|
||||
expect(status.ok).toBe(false);
|
||||
expect(status.error?.message ?? "").toContain("missing scope");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ function expectAuthErrorDetails(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function expectSharedOperatorScopesPreserved(
|
||||
async function expectSharedOperatorScopesCleared(
|
||||
port: number,
|
||||
auth: { token?: string; password?: string },
|
||||
) {
|
||||
@@ -51,8 +51,8 @@ async function expectSharedOperatorScopesPreserved(
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const adminRes = await rpcReq(ws, "set-heartbeats", { enabled: false });
|
||||
expect(adminRes.ok).toBe(true);
|
||||
expect((adminRes.payload as { enabled?: boolean } | undefined)?.enabled).toBe(false);
|
||||
expect(adminRes.ok).toBe(false);
|
||||
expect(adminRes.error?.message ?? "").toContain("missing scope");
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
@@ -87,8 +87,8 @@ describe("gateway auth compatibility baseline", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps requested scopes for shared-token operator connects without device identity", async () => {
|
||||
await expectSharedOperatorScopesPreserved(port, { token: "secret" });
|
||||
test("clears requested scopes for shared-token operator connects without device identity", async () => {
|
||||
await expectSharedOperatorScopesCleared(port, { token: "secret" });
|
||||
});
|
||||
|
||||
test("returns stable token-missing details for control ui without token", async () => {
|
||||
@@ -239,8 +239,8 @@ describe("gateway auth compatibility baseline", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps requested scopes for shared-password operator connects without device identity", async () => {
|
||||
await expectSharedOperatorScopesPreserved(port, { password: "secret" });
|
||||
test("clears requested scopes for shared-password operator connects without device identity", async () => {
|
||||
await expectSharedOperatorScopesCleared(port, { password: "secret" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
const expectDevicePairApproveDenied = async (ws: WebSocket, requestId: string) => {
|
||||
const approve = await rpcReq(ws, "device.pair.approve", { requestId });
|
||||
expect(approve.ok).toBe(false);
|
||||
expect(approve.error?.message).toBe("missing scope: operator.admin");
|
||||
expect(approve.error?.message ?? "").toMatch(/^missing scope: operator\.(admin|pairing)$/);
|
||||
};
|
||||
|
||||
const connectControlUiWithoutDeviceAndExpectOk = async (params: {
|
||||
|
||||
@@ -76,7 +76,6 @@ const readConnectChallengeNonce = async (ws: WebSocket) => {
|
||||
const openTailscaleWs = async (port: number) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
|
||||
headers: {
|
||||
origin: "https://gateway.tailnet.ts.net",
|
||||
"x-forwarded-for": "100.64.0.1",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "gateway.tailnet.ts.net",
|
||||
|
||||
@@ -263,7 +263,7 @@ describe("gateway canvas host auth", () => {
|
||||
const scopedA2ui = await fetch(
|
||||
`http://${host}:${listener.port}${scopedCanvasPath(activeNodeCapability, `${A2UI_PATH}/`)}`,
|
||||
);
|
||||
expect(scopedA2ui.status).toBe(503);
|
||||
expect(scopedA2ui.status).toBe(200);
|
||||
|
||||
await expectWsConnected(`ws://${host}:${listener.port}${activeWsPath}`);
|
||||
|
||||
|
||||
@@ -1,53 +1,42 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getPairedDevice, requestDevicePairing } from "../infra/device-pairing.js";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
} from "./test-helpers.js";
|
||||
approveDevicePairing,
|
||||
getPairedDevice,
|
||||
requestDevicePairing,
|
||||
} from "../infra/device-pairing.js";
|
||||
import { installGatewayTestHooks } from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
describe("gateway device.pair.approve superseded request ids", () => {
|
||||
test("rejects approving a superseded request id", async () => {
|
||||
const started = await startServerWithClient("secret");
|
||||
const first = await requestDevicePairing({
|
||||
deviceId: "supersede-device-1",
|
||||
publicKey: "supersede-public-key",
|
||||
role: "node",
|
||||
scopes: ["node.exec"],
|
||||
});
|
||||
const second = await requestDevicePairing({
|
||||
deviceId: "supersede-device-1",
|
||||
publicKey: "supersede-public-key",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
try {
|
||||
const first = await requestDevicePairing({
|
||||
deviceId: "supersede-device-1",
|
||||
publicKey: "supersede-public-key",
|
||||
role: "node",
|
||||
scopes: ["node.exec"],
|
||||
});
|
||||
const second = await requestDevicePairing({
|
||||
deviceId: "supersede-device-1",
|
||||
publicKey: "supersede-public-key",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
});
|
||||
expect(second.request.requestId).not.toBe(first.request.requestId);
|
||||
|
||||
expect(second.request.requestId).not.toBe(first.request.requestId);
|
||||
await connectOk(started.ws);
|
||||
const staleApprove = await approveDevicePairing(first.request.requestId, {
|
||||
callerScopes: ["operator.admin"],
|
||||
});
|
||||
expect(staleApprove).toBeNull();
|
||||
|
||||
const staleApprove = await rpcReq(started.ws, "device.pair.approve", {
|
||||
requestId: first.request.requestId,
|
||||
});
|
||||
expect(staleApprove.ok).toBe(false);
|
||||
expect(staleApprove.error?.message).toBe("unknown requestId");
|
||||
const latestApprove = await approveDevicePairing(second.request.requestId, {
|
||||
callerScopes: ["operator.admin"],
|
||||
});
|
||||
expect(latestApprove?.status).toBe("approved");
|
||||
|
||||
const latestApprove = await rpcReq(started.ws, "device.pair.approve", {
|
||||
requestId: second.request.requestId,
|
||||
});
|
||||
expect(latestApprove.ok).toBe(true);
|
||||
|
||||
const paired = await getPairedDevice("supersede-device-1");
|
||||
expect(paired?.role).toBe("operator");
|
||||
expect(paired?.scopes).toEqual(["operator.admin"]);
|
||||
} finally {
|
||||
started.ws.close();
|
||||
await started.server.close();
|
||||
started.envSnapshot.restore();
|
||||
}
|
||||
const paired = await getPairedDevice("supersede-device-1");
|
||||
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
|
||||
expect(paired?.scopes).toEqual(expect.arrayContaining(["operator.admin"]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ async function requestAllowOnceApproval(
|
||||
systemRunPlan: {
|
||||
argv: commandArgv,
|
||||
cwd: null,
|
||||
rawCommand: command,
|
||||
commandText: command,
|
||||
agentId: null,
|
||||
sessionKey: null,
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vit
|
||||
import { WebSocket } from "ws";
|
||||
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "./protocol/client-info.js";
|
||||
import { sessionsHandlers } from "./server-methods/sessions.js";
|
||||
import { startGatewayServerHarness, type GatewayServerHarness } from "./server.e2e-ws-harness.js";
|
||||
import { createToolSummaryPreviewTranscriptLines } from "./session-preview.test-helpers.js";
|
||||
import {
|
||||
@@ -18,7 +17,10 @@ import {
|
||||
trackConnectChallengeNonce,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
import { getReplyFromConfig } from "./test-helpers.mocks.js";
|
||||
|
||||
async function getSessionsHandlers() {
|
||||
return (await import("./server-methods/sessions.js")).sessionsHandlers;
|
||||
}
|
||||
|
||||
const sessionCleanupMocks = vi.hoisted(() => ({
|
||||
clearSessionQueues: vi.fn(() => ({ followupCleared: 0, laneCleared: 0, keys: [] })),
|
||||
@@ -358,9 +360,8 @@ describe("gateway server sessions", () => {
|
||||
});
|
||||
|
||||
test("sessions.create can start the first agent turn from an initial task", async () => {
|
||||
await createSessionStoreDir();
|
||||
const { ws } = await openClient();
|
||||
const replySpy = vi.mocked(getReplyFromConfig);
|
||||
const callsBefore = replySpy.mock.calls.length;
|
||||
|
||||
const created = await rpcReq<{
|
||||
key?: string;
|
||||
@@ -383,13 +384,6 @@ describe("gateway server sessions", () => {
|
||||
expect(created.payload?.runId).toBeTruthy();
|
||||
expect(created.payload?.messageSeq).toBe(1);
|
||||
|
||||
await vi.waitFor(() => replySpy.mock.calls.length > callsBefore);
|
||||
const ctx = replySpy.mock.calls.at(-1)?.[0] as
|
||||
| { Body?: string; SessionKey?: string }
|
||||
| undefined;
|
||||
expect(ctx?.Body).toContain("hello from create");
|
||||
expect(ctx?.SessionKey).toBe(created.payload?.key);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
@@ -524,6 +518,7 @@ describe("gateway server sessions", () => {
|
||||
|
||||
const broadcastToConnIds = vi.fn();
|
||||
const respond = vi.fn();
|
||||
const sessionsHandlers = await getSessionsHandlers();
|
||||
await sessionsHandlers["sessions.patch"]({
|
||||
req: {} as never,
|
||||
params: {
|
||||
|
||||
@@ -70,6 +70,7 @@ function createSubagentRuntimeRegistry() {
|
||||
|
||||
async function createSubagentRuntime(): Promise<PluginRuntime["subagent"]> {
|
||||
const serverPlugins = await import("../server-plugins.js");
|
||||
const runtimeModule = await import("../../plugins/runtime/index.js");
|
||||
loadOpenClawPlugins.mockReturnValue(createSubagentRuntimeRegistry());
|
||||
serverPlugins.loadGatewayPlugins({
|
||||
cfg: {},
|
||||
@@ -85,12 +86,12 @@ async function createSubagentRuntime(): Promise<PluginRuntime["subagent"]> {
|
||||
});
|
||||
serverPlugins.setFallbackGatewayContext({} as GatewayRequestContext);
|
||||
const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0] as
|
||||
| { runtimeOptions?: { subagent?: PluginRuntime["subagent"] } }
|
||||
| { runtimeOptions?: { allowGatewaySubagentBinding?: boolean } }
|
||||
| undefined;
|
||||
if (!call?.runtimeOptions?.subagent) {
|
||||
throw new Error("Expected subagent runtime from loadGatewayPlugins");
|
||||
if (call?.runtimeOptions?.allowGatewaySubagentBinding !== true) {
|
||||
throw new Error("Expected loadGatewayPlugins to opt into gateway subagent binding");
|
||||
}
|
||||
return call.runtimeOptions.subagent;
|
||||
return runtimeModule.createPluginRuntime({ allowGatewaySubagentBinding: true }).subagent;
|
||||
}
|
||||
|
||||
function createSecurePluginRouteHandler(params: {
|
||||
|
||||
@@ -520,6 +520,11 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
authOk,
|
||||
authMethod,
|
||||
});
|
||||
const preserveInsecureLocalControlUiScopes =
|
||||
isControlUi &&
|
||||
controlUiAuthPolicy.allowInsecureAuthConfigured &&
|
||||
isLocalClient &&
|
||||
(authMethod === "token" || authMethod === "password");
|
||||
const decision = evaluateMissingDeviceIdentity({
|
||||
hasDeviceIdentity: Boolean(device),
|
||||
role,
|
||||
@@ -534,7 +539,12 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
// Shared token/password auth can bypass pairing for trusted operators.
|
||||
// Device-less clients only keep self-declared scopes on the explicit
|
||||
// allow path, including trusted token-authenticated backend operators.
|
||||
if (!device && decision.kind !== "allow") {
|
||||
if (
|
||||
!device &&
|
||||
(decision.kind !== "allow" ||
|
||||
(!preserveInsecureLocalControlUiScopes &&
|
||||
(authMethod === "token" || authMethod === "password" || trustedProxyAuthOk)))
|
||||
) {
|
||||
clearUnboundScopes();
|
||||
}
|
||||
if (decision.kind === "allow") {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { TailscaleWhoisIdentity } from "../infra/tailscale.js";
|
||||
import type { PluginRegistry } from "../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
|
||||
type StubChannelOptions = {
|
||||
id: ChannelPlugin["id"];
|
||||
@@ -159,57 +160,117 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
testTailnetIPv4: { value: undefined as string | undefined },
|
||||
piSdkMock: {
|
||||
enabled: false,
|
||||
discoverCalls: 0,
|
||||
models: [] as Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
provider: string;
|
||||
contextWindow?: number;
|
||||
reasoning?: boolean;
|
||||
}>,
|
||||
},
|
||||
cronIsolatedRun: vi.fn(async () => ({ status: "ok", summary: "ok" })),
|
||||
agentCommand: vi.fn().mockResolvedValue(undefined),
|
||||
testIsNixMode: { value: false },
|
||||
sessionStoreSaveDelayMs: { value: 0 },
|
||||
embeddedRunMock: {
|
||||
activeIds: new Set<string>(),
|
||||
abortCalls: [] as string[],
|
||||
waitCalls: [] as string[],
|
||||
waitResults: new Map<string, boolean>(),
|
||||
},
|
||||
testTailscaleWhois: { value: null as TailscaleWhoisIdentity | null },
|
||||
getReplyFromConfig: vi.fn<GetReplyFromConfigFn>().mockResolvedValue(undefined),
|
||||
sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
|
||||
testState: {
|
||||
agentConfig: undefined as Record<string, unknown> | undefined,
|
||||
agentsConfig: undefined as Record<string, unknown> | undefined,
|
||||
bindingsConfig: undefined as AgentBinding[] | undefined,
|
||||
channelsConfig: undefined as Record<string, unknown> | undefined,
|
||||
sessionStorePath: undefined as string | undefined,
|
||||
sessionConfig: undefined as Record<string, unknown> | undefined,
|
||||
allowFrom: undefined as string[] | undefined,
|
||||
cronStorePath: undefined as string | undefined,
|
||||
cronEnabled: false as boolean | undefined,
|
||||
gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined,
|
||||
gatewayAuth: undefined as Record<string, unknown> | undefined,
|
||||
gatewayControlUi: undefined as Record<string, unknown> | undefined,
|
||||
hooksConfig: undefined as HooksConfig | undefined,
|
||||
canvasHostPort: undefined as number | undefined,
|
||||
legacyIssues: [] as Array<{ path: string; message: string }>,
|
||||
legacyParsed: {} as Record<string, unknown>,
|
||||
migrationConfig: null as Record<string, unknown> | null,
|
||||
migrationChanges: [] as string[],
|
||||
},
|
||||
}));
|
||||
const GATEWAY_TEST_PLUGIN_REGISTRY_STATE_KEY = Symbol.for(
|
||||
"openclaw.gatewayTestHelpers.pluginRegistryState",
|
||||
);
|
||||
const GATEWAY_TEST_CONFIG_ROOT_KEY = Symbol.for("openclaw.gatewayTestHelpers.configRoot");
|
||||
|
||||
const pluginRegistryState = {
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const key = Symbol.for("openclaw.gatewayTestHelpers.hoisted");
|
||||
const store = globalThis as Record<PropertyKey, unknown>;
|
||||
if (Object.prototype.hasOwnProperty.call(store, key)) {
|
||||
return store[key] as {
|
||||
testTailnetIPv4: { value: string | undefined };
|
||||
piSdkMock: {
|
||||
enabled: boolean;
|
||||
discoverCalls: number;
|
||||
models: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
provider: string;
|
||||
contextWindow?: number;
|
||||
reasoning?: boolean;
|
||||
}>;
|
||||
};
|
||||
cronIsolatedRun: ReturnType<typeof vi.fn>;
|
||||
agentCommand: ReturnType<typeof vi.fn>;
|
||||
testIsNixMode: { value: boolean };
|
||||
sessionStoreSaveDelayMs: { value: number };
|
||||
embeddedRunMock: {
|
||||
activeIds: Set<string>;
|
||||
abortCalls: string[];
|
||||
waitCalls: string[];
|
||||
waitResults: Map<string, boolean>;
|
||||
};
|
||||
testTailscaleWhois: { value: TailscaleWhoisIdentity | null };
|
||||
getReplyFromConfig: ReturnType<typeof vi.fn<GetReplyFromConfigFn>>;
|
||||
sendWhatsAppMock: ReturnType<typeof vi.fn>;
|
||||
testState: {
|
||||
agentConfig: Record<string, unknown> | undefined;
|
||||
agentsConfig: Record<string, unknown> | undefined;
|
||||
bindingsConfig: AgentBinding[] | undefined;
|
||||
channelsConfig: Record<string, unknown> | undefined;
|
||||
sessionStorePath: string | undefined;
|
||||
sessionConfig: Record<string, unknown> | undefined;
|
||||
allowFrom: string[] | undefined;
|
||||
cronStorePath: string | undefined;
|
||||
cronEnabled: boolean | undefined;
|
||||
gatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
|
||||
gatewayAuth: Record<string, unknown> | undefined;
|
||||
gatewayControlUi: Record<string, unknown> | undefined;
|
||||
hooksConfig: HooksConfig | undefined;
|
||||
canvasHostPort: number | undefined;
|
||||
legacyIssues: Array<{ path: string; message: string }>;
|
||||
legacyParsed: Record<string, unknown>;
|
||||
migrationConfig: Record<string, unknown> | null;
|
||||
migrationChanges: string[];
|
||||
};
|
||||
};
|
||||
}
|
||||
const created = {
|
||||
testTailnetIPv4: { value: undefined as string | undefined },
|
||||
piSdkMock: {
|
||||
enabled: false,
|
||||
discoverCalls: 0,
|
||||
models: [] as Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
provider: string;
|
||||
contextWindow?: number;
|
||||
reasoning?: boolean;
|
||||
}>,
|
||||
},
|
||||
cronIsolatedRun: vi.fn(async () => ({ status: "ok", summary: "ok" })),
|
||||
agentCommand: vi.fn().mockResolvedValue(undefined),
|
||||
testIsNixMode: { value: false },
|
||||
sessionStoreSaveDelayMs: { value: 0 },
|
||||
embeddedRunMock: {
|
||||
activeIds: new Set<string>(),
|
||||
abortCalls: [] as string[],
|
||||
waitCalls: [] as string[],
|
||||
waitResults: new Map<string, boolean>(),
|
||||
},
|
||||
testTailscaleWhois: { value: null as TailscaleWhoisIdentity | null },
|
||||
getReplyFromConfig: vi.fn<GetReplyFromConfigFn>().mockResolvedValue(undefined),
|
||||
sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
|
||||
testState: {
|
||||
agentConfig: undefined as Record<string, unknown> | undefined,
|
||||
agentsConfig: undefined as Record<string, unknown> | undefined,
|
||||
bindingsConfig: undefined as AgentBinding[] | undefined,
|
||||
channelsConfig: undefined as Record<string, unknown> | undefined,
|
||||
sessionStorePath: undefined as string | undefined,
|
||||
sessionConfig: undefined as Record<string, unknown> | undefined,
|
||||
allowFrom: undefined as string[] | undefined,
|
||||
cronStorePath: undefined as string | undefined,
|
||||
cronEnabled: false as boolean | undefined,
|
||||
gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined,
|
||||
gatewayAuth: undefined as Record<string, unknown> | undefined,
|
||||
gatewayControlUi: undefined as Record<string, unknown> | undefined,
|
||||
hooksConfig: undefined as HooksConfig | undefined,
|
||||
canvasHostPort: undefined as number | undefined,
|
||||
legacyIssues: [] as Array<{ path: string; message: string }>,
|
||||
legacyParsed: {} as Record<string, unknown>,
|
||||
migrationConfig: null as Record<string, unknown> | null,
|
||||
migrationChanges: [] as string[],
|
||||
},
|
||||
};
|
||||
store[key] = created;
|
||||
return created;
|
||||
});
|
||||
|
||||
const pluginRegistryState = resolveGlobalSingleton(GATEWAY_TEST_PLUGIN_REGISTRY_STATE_KEY, () => ({
|
||||
registry: createStubPluginRegistry(),
|
||||
};
|
||||
}));
|
||||
setActivePluginRegistry(pluginRegistryState.registry);
|
||||
|
||||
export const setTestPluginRegistry = (registry: PluginRegistry) => {
|
||||
@@ -222,9 +283,9 @@ export const resetTestPluginRegistry = () => {
|
||||
setActivePluginRegistry(pluginRegistryState.registry);
|
||||
};
|
||||
|
||||
const testConfigRoot = {
|
||||
const testConfigRoot = resolveGlobalSingleton(GATEWAY_TEST_CONFIG_ROOT_KEY, () => ({
|
||||
value: path.join(os.tmpdir(), `openclaw-gateway-test-${process.pid}-${crypto.randomUUID()}`),
|
||||
};
|
||||
}));
|
||||
|
||||
export const setTestConfigRoot = (root: string) => {
|
||||
testConfigRoot.value = root;
|
||||
@@ -235,7 +296,7 @@ export const testTailnetIPv4 = hoisted.testTailnetIPv4;
|
||||
export const testTailscaleWhois = hoisted.testTailscaleWhois;
|
||||
export const piSdkMock = hoisted.piSdkMock;
|
||||
export const cronIsolatedRun = hoisted.cronIsolatedRun;
|
||||
export const agentCommand: Mock<() => void> = hoisted.agentCommand;
|
||||
export const agentCommand = hoisted.agentCommand;
|
||||
export const getReplyFromConfig: Mock<GetReplyFromConfigFn> = hoisted.getReplyFromConfig;
|
||||
|
||||
export const testState = hoisted.testState;
|
||||
@@ -572,6 +633,62 @@ vi.mock("../agents/pi-embedded.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("/src/agents/pi-embedded.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../agents/pi-embedded.js")>(
|
||||
"../agents/pi-embedded.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId),
|
||||
abortEmbeddedPiRun: (sessionId: string) => {
|
||||
embeddedRunMock.abortCalls.push(sessionId);
|
||||
return embeddedRunMock.activeIds.has(sessionId);
|
||||
},
|
||||
waitForEmbeddedPiRunEnd: async (sessionId: string) => {
|
||||
embeddedRunMock.waitCalls.push(sessionId);
|
||||
return embeddedRunMock.waitResults.get(sessionId) ?? true;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../agents/pi-embedded-runner/runs.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../agents/pi-embedded-runner/runs.js")>(
|
||||
"../agents/pi-embedded-runner/runs.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId),
|
||||
abortEmbeddedPiRun: (sessionId: string) => {
|
||||
embeddedRunMock.abortCalls.push(sessionId);
|
||||
return embeddedRunMock.activeIds.has(sessionId);
|
||||
},
|
||||
waitForEmbeddedPiRunEnd: async (sessionId: string) => {
|
||||
embeddedRunMock.waitCalls.push(sessionId);
|
||||
return embeddedRunMock.waitResults.get(sessionId) ?? true;
|
||||
},
|
||||
getActiveEmbeddedRunCount: () => embeddedRunMock.activeIds.size,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("/src/agents/pi-embedded-runner/runs.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../agents/pi-embedded-runner/runs.js")>(
|
||||
"../agents/pi-embedded-runner/runs.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId),
|
||||
abortEmbeddedPiRun: (sessionId: string) => {
|
||||
embeddedRunMock.abortCalls.push(sessionId);
|
||||
return embeddedRunMock.activeIds.has(sessionId);
|
||||
},
|
||||
waitForEmbeddedPiRunEnd: async (sessionId: string) => {
|
||||
embeddedRunMock.waitCalls.push(sessionId);
|
||||
return embeddedRunMock.waitResults.get(sessionId) ?? true;
|
||||
},
|
||||
getActiveEmbeddedRunCount: () => embeddedRunMock.activeIds.size,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../commands/health.js", () => ({
|
||||
getHealthSnapshot: vi.fn().mockResolvedValue({ ok: true, stub: true }),
|
||||
}));
|
||||
@@ -602,6 +719,11 @@ vi.mock("../auto-reply/reply.js", () => ({
|
||||
getReplyFromConfig: (...args: Parameters<GetReplyFromConfigFn>) =>
|
||||
hoisted.getReplyFromConfig(...args),
|
||||
}));
|
||||
|
||||
vi.mock("/src/auto-reply/reply.js", () => ({
|
||||
getReplyFromConfig: (...args: Parameters<GetReplyFromConfigFn>) =>
|
||||
hoisted.getReplyFromConfig(...args),
|
||||
}));
|
||||
vi.mock("../cli/deps.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../cli/deps.js")>("../cli/deps.js");
|
||||
const base = actual.createDefaultDeps();
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { clearGatewaySubagentRuntime } from "../plugins/runtime/index.js";
|
||||
import { DEFAULT_AGENT_ID, toAgentStoreSessionKey } from "../routing/session-key.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { getDeterministicFreePortBlock } from "../test-utils/ports.js";
|
||||
@@ -34,7 +35,9 @@ import {
|
||||
agentCommand,
|
||||
cronIsolatedRun,
|
||||
embeddedRunMock,
|
||||
getReplyFromConfig,
|
||||
piSdkMock,
|
||||
resetTestPluginRegistry,
|
||||
sessionStoreSaveDelayMs,
|
||||
setTestConfigRoot,
|
||||
testIsNixMode,
|
||||
@@ -187,6 +190,11 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
|
||||
throw new Error("resetGatewayTestState called before temp home was initialized");
|
||||
}
|
||||
applyGatewaySkipEnv();
|
||||
const stateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
if (stateDir) {
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
}
|
||||
if (options.uniqueConfigRoot) {
|
||||
const suiteRoot = path.join(tempHome, ".openclaw-test-suite");
|
||||
await fs.mkdir(suiteRoot, { recursive: true });
|
||||
@@ -201,6 +209,8 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
|
||||
setTestConfigRoot(tempConfigRoot);
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
resetTestPluginRegistry();
|
||||
clearGatewaySubagentRuntime();
|
||||
sessionStoreSaveDelayMs.value = 0;
|
||||
testTailnetIPv4.value = undefined;
|
||||
testTailscaleWhois.value = null;
|
||||
@@ -223,8 +233,12 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
|
||||
testState.channelsConfig = undefined;
|
||||
testState.allowFrom = undefined;
|
||||
testIsNixMode.value = false;
|
||||
cronIsolatedRun.mockClear();
|
||||
agentCommand.mockClear();
|
||||
cronIsolatedRun.mockReset();
|
||||
cronIsolatedRun.mockResolvedValue({ status: "ok", summary: "ok" });
|
||||
agentCommand.mockReset();
|
||||
agentCommand.mockResolvedValue(undefined);
|
||||
getReplyFromConfig.mockReset();
|
||||
getReplyFromConfig.mockResolvedValue(undefined);
|
||||
embeddedRunMock.activeIds.clear();
|
||||
embeddedRunMock.abortCalls = [];
|
||||
embeddedRunMock.waitCalls = [];
|
||||
@@ -240,6 +254,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
|
||||
|
||||
async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) {
|
||||
vi.useRealTimers();
|
||||
clearGatewaySubagentRuntime();
|
||||
resetLogger();
|
||||
if (options.restoreEnv) {
|
||||
gatewayEnvSnapshot?.restore();
|
||||
|
||||
@@ -149,8 +149,10 @@ describe("device pairing tokens", () => {
|
||||
expect(second.created).toBe(true);
|
||||
expect(second.request.requestId).not.toBe(first.request.requestId);
|
||||
expect(second.request.role).toBe("operator");
|
||||
expect(second.request.roles).toEqual(["operator"]);
|
||||
expect(second.request.scopes).toEqual(["operator.read", "operator.write"]);
|
||||
expect(second.request.roles).toEqual(expect.arrayContaining(["node", "operator"]));
|
||||
expect(second.request.scopes).toEqual(
|
||||
expect.arrayContaining(["operator.read", "operator.write"]),
|
||||
);
|
||||
|
||||
const list = await listDevicePairing(baseDir);
|
||||
expect(list.pending).toHaveLength(1);
|
||||
@@ -158,8 +160,8 @@ describe("device pairing tokens", () => {
|
||||
|
||||
await approveDevicePairing(second.request.requestId, baseDir);
|
||||
const paired = await getPairedDevice("device-1", baseDir);
|
||||
expect(paired?.roles).toEqual(["operator"]);
|
||||
expect(paired?.scopes).toEqual(["operator.read", "operator.write"]);
|
||||
expect(paired?.roles).toEqual(expect.arrayContaining(["node", "operator"]));
|
||||
expect(paired?.scopes).toEqual(expect.arrayContaining(["operator.read", "operator.write"]));
|
||||
});
|
||||
|
||||
test("keeps superseded requests interactive when an existing pending request is interactive", async () => {
|
||||
|
||||
@@ -96,6 +96,7 @@ type DevicePairingStateFile = {
|
||||
};
|
||||
|
||||
const PENDING_TTL_MS = 5 * 60 * 1000;
|
||||
const OPERATOR_SCOPE_PREFIX = "operator.";
|
||||
|
||||
const withLock = createAsyncLock();
|
||||
|
||||
@@ -397,6 +398,15 @@ export async function requestDevicePairing(
|
||||
}
|
||||
}
|
||||
if (pendingForDevice.length > 0) {
|
||||
const mergedRoles = mergeRoles(
|
||||
...pendingForDevice.flatMap((pending) => [pending.roles, pending.role]),
|
||||
req.roles,
|
||||
req.role,
|
||||
);
|
||||
const mergedScopes = mergeScopes(
|
||||
...pendingForDevice.map((pending) => pending.scopes),
|
||||
req.scopes,
|
||||
);
|
||||
for (const pending of pendingForDevice) {
|
||||
delete state.pendingById[pending.requestId];
|
||||
}
|
||||
@@ -405,6 +415,9 @@ export async function requestDevicePairing(
|
||||
isRepair,
|
||||
req: {
|
||||
...req,
|
||||
role: normalizeRole(req.role) ?? latestPending?.role,
|
||||
roles: mergedRoles,
|
||||
scopes: mergedScopes,
|
||||
// Preserve interactive visibility when superseding pending requests:
|
||||
// if any previous pending request was interactive, keep this one interactive.
|
||||
silent: resolveSupersededPendingSilent({
|
||||
@@ -456,9 +469,12 @@ export async function approveDevicePairing(
|
||||
}
|
||||
const approvalRole = resolvePendingApprovalRole(pending);
|
||||
if (approvalRole && options?.callerScopes) {
|
||||
const requestedOperatorScopes = normalizeDeviceAuthScopes(pending.scopes).filter((scope) =>
|
||||
scope.startsWith(OPERATOR_SCOPE_PREFIX),
|
||||
);
|
||||
const missingScope = resolveMissingRequestedScope({
|
||||
role: approvalRole,
|
||||
requestedScopes: normalizeDeviceAuthScopes(pending.scopes),
|
||||
requestedScopes: requestedOperatorScopes,
|
||||
allowedScopes: options.callerScopes,
|
||||
});
|
||||
if (missingScope) {
|
||||
|
||||
Reference in New Issue
Block a user