mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 15:08:25 +00:00
test: dedupe gateway auth and sessions patch coverage
This commit is contained in:
@@ -78,6 +78,85 @@ const TEST_OPERATOR_CLIENT = {
|
|||||||
mode: GATEWAY_CLIENT_MODES.TEST,
|
mode: GATEWAY_CLIENT_MODES.TEST,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CONTROL_UI_CLIENT = {
|
||||||
|
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||||
|
version: "1.0.0",
|
||||||
|
platform: "web",
|
||||||
|
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function expectHelloOkServerVersion(port: number, expectedVersion: string) {
|
||||||
|
const ws = await openWs(port);
|
||||||
|
try {
|
||||||
|
const res = await connectReq(ws);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const payload = res.payload as
|
||||||
|
| {
|
||||||
|
type?: unknown;
|
||||||
|
server?: { version?: string };
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
expect(payload?.type).toBe("hello-ok");
|
||||||
|
expect(payload?.server?.version).toBe(expectedVersion);
|
||||||
|
} finally {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectMissingScopeAfterConnect(
|
||||||
|
port: number,
|
||||||
|
opts?: Parameters<typeof connectReq>[1],
|
||||||
|
) {
|
||||||
|
const ws = await openWs(port);
|
||||||
|
try {
|
||||||
|
const res = await connectReq(ws, opts);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const health = await rpcReq(ws, "health");
|
||||||
|
expect(health.ok).toBe(false);
|
||||||
|
expect(health.error?.message).toContain("missing scope");
|
||||||
|
} finally {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSignedDevice(params: {
|
||||||
|
token: string;
|
||||||
|
scopes: string[];
|
||||||
|
clientId: string;
|
||||||
|
clientMode: string;
|
||||||
|
identityPath?: string;
|
||||||
|
nonce?: string;
|
||||||
|
signedAtMs?: number;
|
||||||
|
}) {
|
||||||
|
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
|
||||||
|
await import("../infra/device-identity.js");
|
||||||
|
const identity = params.identityPath
|
||||||
|
? loadOrCreateDeviceIdentity(params.identityPath)
|
||||||
|
: loadOrCreateDeviceIdentity();
|
||||||
|
const signedAtMs = params.signedAtMs ?? Date.now();
|
||||||
|
const payload = buildDeviceAuthPayload({
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
clientId: params.clientId,
|
||||||
|
clientMode: params.clientMode,
|
||||||
|
role: "operator",
|
||||||
|
scopes: params.scopes,
|
||||||
|
signedAtMs,
|
||||||
|
token: params.token,
|
||||||
|
nonce: params.nonce,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
identity,
|
||||||
|
signedAtMs,
|
||||||
|
device: {
|
||||||
|
id: identity.deviceId,
|
||||||
|
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||||
|
signature: signDevicePayload(identity.privateKeyPem, payload),
|
||||||
|
signedAt: signedAtMs,
|
||||||
|
nonce: params.nonce,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveGatewayTokenOrEnv(): string {
|
function resolveGatewayTokenOrEnv(): string {
|
||||||
const token =
|
const token =
|
||||||
typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string"
|
typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string"
|
||||||
@@ -250,20 +329,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
OPENCLAW_SERVICE_VERSION: "2.4.6-service",
|
OPENCLAW_SERVICE_VERSION: "2.4.6-service",
|
||||||
npm_package_version: "1.0.0-package",
|
npm_package_version: "1.0.0-package",
|
||||||
},
|
},
|
||||||
async () => {
|
async () => expectHelloOkServerVersion(port, "2.4.6-service"),
|
||||||
const ws = await openWs(port);
|
|
||||||
const res = await connectReq(ws);
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
const payload = res.payload as
|
|
||||||
| {
|
|
||||||
type?: unknown;
|
|
||||||
server?: { version?: string };
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
expect(payload?.type).toBe("hello-ok");
|
|
||||||
expect(payload?.server?.version).toBe("2.4.6-service");
|
|
||||||
ws.close();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -274,20 +340,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
OPENCLAW_SERVICE_VERSION: "2.4.6-service",
|
OPENCLAW_SERVICE_VERSION: "2.4.6-service",
|
||||||
npm_package_version: "1.0.0-package",
|
npm_package_version: "1.0.0-package",
|
||||||
},
|
},
|
||||||
async () => {
|
async () => expectHelloOkServerVersion(port, "9.9.9-cli"),
|
||||||
const ws = await openWs(port);
|
|
||||||
const res = await connectReq(ws);
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
const payload = res.payload as
|
|
||||||
| {
|
|
||||||
type?: unknown;
|
|
||||||
server?: { version?: string };
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
expect(payload?.type).toBe("hello-ok");
|
|
||||||
expect(payload?.server?.version).toBe("9.9.9-cli");
|
|
||||||
ws.close();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -298,76 +351,33 @@ describe("gateway server auth/connect", () => {
|
|||||||
OPENCLAW_SERVICE_VERSION: "\t",
|
OPENCLAW_SERVICE_VERSION: "\t",
|
||||||
npm_package_version: "1.0.0-package",
|
npm_package_version: "1.0.0-package",
|
||||||
},
|
},
|
||||||
async () => {
|
async () => expectHelloOkServerVersion(port, "1.0.0-package"),
|
||||||
const ws = await openWs(port);
|
|
||||||
const res = await connectReq(ws);
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
const payload = res.payload as
|
|
||||||
| {
|
|
||||||
type?: unknown;
|
|
||||||
server?: { version?: string };
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
expect(payload?.type).toBe("hello-ok");
|
|
||||||
expect(payload?.server?.version).toBe("1.0.0-package");
|
|
||||||
ws.close();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not grant admin when scopes are empty", async () => {
|
test("does not grant admin when scopes are empty", async () => {
|
||||||
const ws = await openWs(port);
|
await expectMissingScopeAfterConnect(port, { scopes: [] });
|
||||||
const res = await connectReq(ws, { scopes: [] });
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
|
|
||||||
const health = await rpcReq(ws, "health");
|
|
||||||
expect(health.ok).toBe(false);
|
|
||||||
expect(health.error?.message).toContain("missing scope");
|
|
||||||
|
|
||||||
ws.close();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ignores requested scopes when device identity is omitted", async () => {
|
test("ignores requested scopes when device identity is omitted", async () => {
|
||||||
const ws = await openWs(port);
|
await expectMissingScopeAfterConnect(port, { device: null });
|
||||||
const res = await connectReq(ws, { device: null });
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
|
|
||||||
const health = await rpcReq(ws, "health");
|
|
||||||
expect(health.ok).toBe(false);
|
|
||||||
expect(health.error?.message).toContain("missing scope");
|
|
||||||
|
|
||||||
ws.close();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not grant admin when scopes are omitted", async () => {
|
test("does not grant admin when scopes are omitted", async () => {
|
||||||
const ws = await openWs(port);
|
const ws = await openWs(port);
|
||||||
const token = resolveGatewayTokenOrEnv();
|
const token = resolveGatewayTokenOrEnv();
|
||||||
|
|
||||||
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
|
|
||||||
await import("../infra/device-identity.js");
|
|
||||||
const { randomUUID } = await import("node:crypto");
|
const { randomUUID } = await import("node:crypto");
|
||||||
const os = await import("node:os");
|
const os = await import("node:os");
|
||||||
const path = await import("node:path");
|
const path = await import("node:path");
|
||||||
// Fresh identity: avoid leaking prior scopes (presence merges lists).
|
// Fresh identity: avoid leaking prior scopes (presence merges lists).
|
||||||
const identity = loadOrCreateDeviceIdentity(
|
const { identity, device } = await createSignedDevice({
|
||||||
path.join(os.tmpdir(), `openclaw-test-device-${randomUUID()}.json`),
|
token,
|
||||||
);
|
scopes: [],
|
||||||
const signedAtMs = Date.now();
|
|
||||||
const payload = buildDeviceAuthPayload({
|
|
||||||
deviceId: identity.deviceId,
|
|
||||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||||
role: "operator",
|
identityPath: path.join(os.tmpdir(), `openclaw-test-device-${randomUUID()}.json`),
|
||||||
scopes: [],
|
|
||||||
signedAtMs,
|
|
||||||
token,
|
|
||||||
});
|
});
|
||||||
const device = {
|
|
||||||
id: identity.deviceId,
|
|
||||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
|
||||||
signature: signDevicePayload(identity.privateKeyPem, payload),
|
|
||||||
signedAt: signedAtMs,
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectRes = await sendRawConnectReq(ws, {
|
const connectRes = await sendRawConnectReq(ws, {
|
||||||
id: "c-no-scopes",
|
id: "c-no-scopes",
|
||||||
@@ -401,25 +411,12 @@ describe("gateway server auth/connect", () => {
|
|||||||
const ws = await openWs(port);
|
const ws = await openWs(port);
|
||||||
const token = resolveGatewayTokenOrEnv();
|
const token = resolveGatewayTokenOrEnv();
|
||||||
|
|
||||||
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
|
const { device } = await createSignedDevice({
|
||||||
await import("../infra/device-identity.js");
|
token,
|
||||||
const identity = loadOrCreateDeviceIdentity();
|
scopes: ["operator.admin"],
|
||||||
const signedAtMs = Date.now();
|
|
||||||
const payload = buildDeviceAuthPayload({
|
|
||||||
deviceId: identity.deviceId,
|
|
||||||
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||||
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||||
role: "operator",
|
|
||||||
scopes: ["operator.admin"],
|
|
||||||
signedAtMs,
|
|
||||||
token,
|
|
||||||
});
|
});
|
||||||
const device = {
|
|
||||||
id: identity.deviceId,
|
|
||||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
|
||||||
signature: signDevicePayload(identity.privateKeyPem, payload),
|
|
||||||
signedAt: signedAtMs,
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectRes = await sendRawConnectReq(ws, {
|
const connectRes = await sendRawConnectReq(ws, {
|
||||||
id: "c-no-scopes-signed-admin",
|
id: "c-no-scopes-signed-admin",
|
||||||
@@ -596,10 +593,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
const res = await connectReq(ws, {
|
const res = await connectReq(ws, {
|
||||||
skipDefaultAuth: true,
|
skipDefaultAuth: true,
|
||||||
client: {
|
client: {
|
||||||
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
...CONTROL_UI_CLIENT,
|
||||||
version: "1.0.0",
|
|
||||||
platform: "web",
|
|
||||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(res.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
@@ -613,10 +607,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
token: "secret",
|
token: "secret",
|
||||||
device: null,
|
device: null,
|
||||||
client: {
|
client: {
|
||||||
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
...CONTROL_UI_CLIENT,
|
||||||
version: "1.0.0",
|
|
||||||
platform: "web",
|
|
||||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(res.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
@@ -684,11 +675,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
ws.close();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
if (prevToken === undefined) {
|
restoreGatewayToken(prevToken);
|
||||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
||||||
} else {
|
|
||||||
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allows control ui with device identity when insecure auth is enabled", async () => {
|
test("allows control ui with device identity when insecure auth is enabled", async () => {
|
||||||
@@ -720,48 +707,27 @@ describe("gateway server auth/connect", () => {
|
|||||||
const challenge = await challengePromise;
|
const challenge = await challengePromise;
|
||||||
const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce;
|
const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce;
|
||||||
expect(typeof nonce).toBe("string");
|
expect(typeof nonce).toBe("string");
|
||||||
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
|
|
||||||
await import("../infra/device-identity.js");
|
|
||||||
const identity = loadOrCreateDeviceIdentity();
|
|
||||||
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
||||||
const signedAtMs = Date.now();
|
const { device } = await createSignedDevice({
|
||||||
const payload = buildDeviceAuthPayload({
|
token: "secret",
|
||||||
deviceId: identity.deviceId,
|
scopes,
|
||||||
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||||
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||||
role: "operator",
|
|
||||||
scopes,
|
|
||||||
signedAtMs,
|
|
||||||
token: "secret",
|
|
||||||
nonce: String(nonce),
|
nonce: String(nonce),
|
||||||
});
|
});
|
||||||
const device = {
|
|
||||||
id: identity.deviceId,
|
|
||||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
|
||||||
signature: signDevicePayload(identity.privateKeyPem, payload),
|
|
||||||
signedAt: signedAtMs,
|
|
||||||
nonce: String(nonce),
|
|
||||||
};
|
|
||||||
const res = await connectReq(ws, {
|
const res = await connectReq(ws, {
|
||||||
token: "secret",
|
token: "secret",
|
||||||
scopes,
|
scopes,
|
||||||
device,
|
device,
|
||||||
client: {
|
client: {
|
||||||
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
...CONTROL_UI_CLIENT,
|
||||||
version: "1.0.0",
|
|
||||||
platform: "web",
|
|
||||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
ws.close();
|
ws.close();
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (prevToken === undefined) {
|
restoreGatewayToken(prevToken);
|
||||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
||||||
} else {
|
|
||||||
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -773,34 +739,19 @@ describe("gateway server auth/connect", () => {
|
|||||||
try {
|
try {
|
||||||
await withGatewayServer(async ({ port }) => {
|
await withGatewayServer(async ({ port }) => {
|
||||||
const ws = await openWs(port, { origin: originForPort(port) });
|
const ws = await openWs(port, { origin: originForPort(port) });
|
||||||
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
|
const { device } = await createSignedDevice({
|
||||||
await import("../infra/device-identity.js");
|
token: "secret",
|
||||||
const identity = loadOrCreateDeviceIdentity();
|
scopes: [],
|
||||||
const signedAtMs = Date.now() - 60 * 60 * 1000;
|
|
||||||
const payload = buildDeviceAuthPayload({
|
|
||||||
deviceId: identity.deviceId,
|
|
||||||
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||||
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||||
role: "operator",
|
signedAtMs: Date.now() - 60 * 60 * 1000,
|
||||||
scopes: [],
|
|
||||||
signedAtMs,
|
|
||||||
token: "secret",
|
|
||||||
});
|
});
|
||||||
const device = {
|
|
||||||
id: identity.deviceId,
|
|
||||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
|
||||||
signature: signDevicePayload(identity.privateKeyPem, payload),
|
|
||||||
signedAt: signedAtMs,
|
|
||||||
};
|
|
||||||
const res = await connectReq(ws, {
|
const res = await connectReq(ws, {
|
||||||
token: "secret",
|
token: "secret",
|
||||||
scopes: ["operator.read"],
|
scopes: ["operator.read"],
|
||||||
device,
|
device,
|
||||||
client: {
|
client: {
|
||||||
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
...CONTROL_UI_CLIENT,
|
||||||
version: "1.0.0",
|
|
||||||
platform: "web",
|
|
||||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
@@ -810,11 +761,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
ws.close();
|
ws.close();
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (prevToken === undefined) {
|
restoreGatewayToken(prevToken);
|
||||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
||||||
} else {
|
|
||||||
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -831,11 +778,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
|
|
||||||
ws2.close();
|
ws2.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
if (prevToken === undefined) {
|
restoreGatewayToken(prevToken);
|
||||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
||||||
} else {
|
|
||||||
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps shared-secret lockout separate from device-token auth", async () => {
|
test("keeps shared-secret lockout separate from device-token auth", async () => {
|
||||||
@@ -958,11 +901,7 @@ describe("gateway server auth/connect", () => {
|
|||||||
|
|
||||||
ws2.close();
|
ws2.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
if (prevToken === undefined) {
|
restoreGatewayToken(prevToken);
|
||||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
||||||
} else {
|
|
||||||
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects revoked device token", async () => {
|
test("rejects revoked device token", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,57 @@ import type { OpenClawConfig } from "../config/config.js";
|
|||||||
import type { SessionEntry } from "../config/sessions.js";
|
import type { SessionEntry } from "../config/sessions.js";
|
||||||
import { applySessionsPatchToStore } from "./sessions-patch.js";
|
import { applySessionsPatchToStore } from "./sessions-patch.js";
|
||||||
|
|
||||||
|
const SUBAGENT_MODEL = "synthetic/hf:moonshotai/Kimi-K2.5";
|
||||||
|
const KIMI_SUBAGENT_KEY = "agent:kimi:subagent:child";
|
||||||
|
|
||||||
|
async function applySubagentModelPatch(cfg: OpenClawConfig) {
|
||||||
|
const res = await applySessionsPatchToStore({
|
||||||
|
cfg,
|
||||||
|
store: {},
|
||||||
|
storeKey: KIMI_SUBAGENT_KEY,
|
||||||
|
patch: {
|
||||||
|
key: KIMI_SUBAGENT_KEY,
|
||||||
|
model: SUBAGENT_MODEL,
|
||||||
|
},
|
||||||
|
loadGatewayModelCatalog: async () => [
|
||||||
|
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" },
|
||||||
|
{ provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(res.error.message);
|
||||||
|
}
|
||||||
|
return res.entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeKimiSubagentCfg(params: {
|
||||||
|
agentPrimaryModel: string;
|
||||||
|
agentSubagentModel?: string;
|
||||||
|
defaultsSubagentModel?: string;
|
||||||
|
}): OpenClawConfig {
|
||||||
|
return {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: "anthropic/claude-sonnet-4-6" },
|
||||||
|
subagents: params.defaultsSubagentModel
|
||||||
|
? { model: params.defaultsSubagentModel }
|
||||||
|
: undefined,
|
||||||
|
models: {
|
||||||
|
"anthropic/claude-sonnet-4-6": { alias: "default" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: "kimi",
|
||||||
|
model: { primary: params.agentPrimaryModel },
|
||||||
|
subagents: params.agentSubagentModel ? { model: params.agentSubagentModel } : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
}
|
||||||
|
|
||||||
describe("gateway sessions patch", () => {
|
describe("gateway sessions patch", () => {
|
||||||
test("persists thinkingLevel=off (does not clear)", async () => {
|
test("persists thinkingLevel=off (does not clear)", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const store: Record<string, SessionEntry> = {};
|
||||||
@@ -158,124 +209,109 @@ describe("gateway sessions patch", () => {
|
|||||||
expect(res.error.message).toContain("spawnDepth is only supported");
|
expect(res.error.message).toContain("spawnDepth is only supported");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allows target agent own model for subagent session even when missing from global allowlist", async () => {
|
test("normalizes exec/send/group patches", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const store: Record<string, SessionEntry> = {};
|
||||||
const cfg: OpenClawConfig = {
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
model: { primary: "anthropic/claude-sonnet-4-6" },
|
|
||||||
models: {
|
|
||||||
"anthropic/claude-sonnet-4-6": { alias: "default" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
id: "kimi",
|
|
||||||
model: { primary: "synthetic/hf:moonshotai/Kimi-K2.5" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
const res = await applySessionsPatchToStore({
|
const res = await applySessionsPatchToStore({
|
||||||
cfg,
|
cfg: {} as OpenClawConfig,
|
||||||
store,
|
store,
|
||||||
storeKey: "agent:kimi:subagent:child",
|
storeKey: "agent:main:main",
|
||||||
patch: {
|
patch: {
|
||||||
key: "agent:kimi:subagent:child",
|
key: "agent:main:main",
|
||||||
model: "synthetic/hf:moonshotai/Kimi-K2.5",
|
execHost: " NODE ",
|
||||||
|
execSecurity: " ALLOWLIST ",
|
||||||
|
execAsk: " ON-MISS ",
|
||||||
|
execNode: " worker-1 ",
|
||||||
|
sendPolicy: "DENY" as unknown as "allow",
|
||||||
|
groupActivation: "Always" as unknown as "mention",
|
||||||
},
|
},
|
||||||
loadGatewayModelCatalog: async () => [
|
|
||||||
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" },
|
|
||||||
{ provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
expect(res.entry.execHost).toBe("node");
|
||||||
|
expect(res.entry.execSecurity).toBe("allowlist");
|
||||||
|
expect(res.entry.execAsk).toBe("on-miss");
|
||||||
|
expect(res.entry.execNode).toBe("worker-1");
|
||||||
|
expect(res.entry.sendPolicy).toBe("deny");
|
||||||
|
expect(res.entry.groupActivation).toBe("always");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid execHost values", async () => {
|
||||||
|
const store: Record<string, SessionEntry> = {};
|
||||||
|
const res = await applySessionsPatchToStore({
|
||||||
|
cfg: {} as OpenClawConfig,
|
||||||
|
store,
|
||||||
|
storeKey: "agent:main:main",
|
||||||
|
patch: { key: "agent:main:main", execHost: "edge" },
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (res.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(res.error.message).toContain("invalid execHost");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid sendPolicy values", async () => {
|
||||||
|
const store: Record<string, SessionEntry> = {};
|
||||||
|
const res = await applySessionsPatchToStore({
|
||||||
|
cfg: {} as OpenClawConfig,
|
||||||
|
store,
|
||||||
|
storeKey: "agent:main:main",
|
||||||
|
patch: { key: "agent:main:main", sendPolicy: "ask" as unknown as "allow" },
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (res.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(res.error.message).toContain("invalid sendPolicy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid groupActivation values", async () => {
|
||||||
|
const store: Record<string, SessionEntry> = {};
|
||||||
|
const res = await applySessionsPatchToStore({
|
||||||
|
cfg: {} as OpenClawConfig,
|
||||||
|
store,
|
||||||
|
storeKey: "agent:main:main",
|
||||||
|
patch: { key: "agent:main:main", groupActivation: "never" as unknown as "mention" },
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (res.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(res.error.message).toContain("invalid groupActivation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows target agent own model for subagent session even when missing from global allowlist", async () => {
|
||||||
|
const cfg = makeKimiSubagentCfg({
|
||||||
|
agentPrimaryModel: "synthetic/hf:moonshotai/Kimi-K2.5",
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry = await applySubagentModelPatch(cfg);
|
||||||
// Selected model matches the target agent default, so no override is stored.
|
// Selected model matches the target agent default, so no override is stored.
|
||||||
expect(res.entry.providerOverride).toBeUndefined();
|
expect(entry.providerOverride).toBeUndefined();
|
||||||
expect(res.entry.modelOverride).toBeUndefined();
|
expect(entry.modelOverride).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allows target agent subagents.model for subagent session even when missing from global allowlist", async () => {
|
test("allows target agent subagents.model for subagent session even when missing from global allowlist", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const cfg = makeKimiSubagentCfg({
|
||||||
const cfg: OpenClawConfig = {
|
agentPrimaryModel: "anthropic/claude-sonnet-4-6",
|
||||||
agents: {
|
agentSubagentModel: SUBAGENT_MODEL,
|
||||||
defaults: {
|
|
||||||
model: { primary: "anthropic/claude-sonnet-4-6" },
|
|
||||||
models: {
|
|
||||||
"anthropic/claude-sonnet-4-6": { alias: "default" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
id: "kimi",
|
|
||||||
model: { primary: "anthropic/claude-sonnet-4-6" },
|
|
||||||
subagents: { model: "synthetic/hf:moonshotai/Kimi-K2.5" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
const res = await applySessionsPatchToStore({
|
|
||||||
cfg,
|
|
||||||
store,
|
|
||||||
storeKey: "agent:kimi:subagent:child",
|
|
||||||
patch: {
|
|
||||||
key: "agent:kimi:subagent:child",
|
|
||||||
model: "synthetic/hf:moonshotai/Kimi-K2.5",
|
|
||||||
},
|
|
||||||
loadGatewayModelCatalog: async () => [
|
|
||||||
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" },
|
|
||||||
{ provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.ok).toBe(true);
|
const entry = await applySubagentModelPatch(cfg);
|
||||||
if (!res.ok) {
|
expect(entry.providerOverride).toBe("synthetic");
|
||||||
return;
|
expect(entry.modelOverride).toBe("hf:moonshotai/Kimi-K2.5");
|
||||||
}
|
|
||||||
expect(res.entry.providerOverride).toBe("synthetic");
|
|
||||||
expect(res.entry.modelOverride).toBe("hf:moonshotai/Kimi-K2.5");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("allows global defaults.subagents.model for subagent session even when missing from global allowlist", async () => {
|
test("allows global defaults.subagents.model for subagent session even when missing from global allowlist", async () => {
|
||||||
const store: Record<string, SessionEntry> = {};
|
const cfg = makeKimiSubagentCfg({
|
||||||
const cfg: OpenClawConfig = {
|
agentPrimaryModel: "anthropic/claude-sonnet-4-6",
|
||||||
agents: {
|
defaultsSubagentModel: SUBAGENT_MODEL,
|
||||||
defaults: {
|
|
||||||
model: { primary: "anthropic/claude-sonnet-4-6" },
|
|
||||||
subagents: { model: "synthetic/hf:moonshotai/Kimi-K2.5" },
|
|
||||||
models: {
|
|
||||||
"anthropic/claude-sonnet-4-6": { alias: "default" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
list: [{ id: "kimi", model: { primary: "anthropic/claude-sonnet-4-6" } }],
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
const res = await applySessionsPatchToStore({
|
|
||||||
cfg,
|
|
||||||
store,
|
|
||||||
storeKey: "agent:kimi:subagent:child",
|
|
||||||
patch: {
|
|
||||||
key: "agent:kimi:subagent:child",
|
|
||||||
model: "synthetic/hf:moonshotai/Kimi-K2.5",
|
|
||||||
},
|
|
||||||
loadGatewayModelCatalog: async () => [
|
|
||||||
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" },
|
|
||||||
{ provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.ok).toBe(true);
|
const entry = await applySubagentModelPatch(cfg);
|
||||||
if (!res.ok) {
|
expect(entry.providerOverride).toBe("synthetic");
|
||||||
return;
|
expect(entry.modelOverride).toBe("hf:moonshotai/Kimi-K2.5");
|
||||||
}
|
|
||||||
expect(res.entry.providerOverride).toBe("synthetic");
|
|
||||||
expect(res.entry.modelOverride).toBe("hf:moonshotai/Kimi-K2.5");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user