mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 01:11:16 +07:00
refactor: dedupe gateway and binding helpers
This commit is contained in:
@@ -16,6 +16,36 @@ import {
|
|||||||
} from "./configured-binding-match.js";
|
} from "./configured-binding-match.js";
|
||||||
import { resolveConfiguredBindingRecordBySessionKeyFromRegistry } from "./configured-binding-session-lookup.js";
|
import { resolveConfiguredBindingRecordBySessionKeyFromRegistry } from "./configured-binding-session-lookup.js";
|
||||||
|
|
||||||
|
function resolveMaterializedConfiguredBinding(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
conversation: ConversationRef;
|
||||||
|
}) {
|
||||||
|
const conversation = toConfiguredBindingConversationRef(params.conversation);
|
||||||
|
if (!conversation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rules = resolveCompiledBindingRegistry(params.cfg).rulesByChannel.get(conversation.channel);
|
||||||
|
if (!rules || rules.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const resolved = resolveMatchingConfiguredBinding({
|
||||||
|
rules,
|
||||||
|
conversation,
|
||||||
|
});
|
||||||
|
if (!resolved) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
conversation,
|
||||||
|
resolved,
|
||||||
|
materializedTarget: materializeConfiguredBindingRecord({
|
||||||
|
rule: resolved.rule,
|
||||||
|
accountId: conversation.accountId,
|
||||||
|
conversation: resolved.match,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function primeConfiguredBindingRegistry(params: { cfg: OpenClawConfig }): {
|
export function primeConfiguredBindingRegistry(params: { cfg: OpenClawConfig }): {
|
||||||
bindingCount: number;
|
bindingCount: number;
|
||||||
channelCount: number;
|
channelCount: number;
|
||||||
@@ -49,59 +79,26 @@ export function resolveConfiguredBindingRecordForConversation(params: {
|
|||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
conversation: ConversationRef;
|
conversation: ConversationRef;
|
||||||
}): ConfiguredBindingRecordResolution | null {
|
}): ConfiguredBindingRecordResolution | null {
|
||||||
const conversation = toConfiguredBindingConversationRef(params.conversation);
|
const resolved = resolveMaterializedConfiguredBinding(params);
|
||||||
if (!conversation) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const registry = resolveCompiledBindingRegistry(params.cfg);
|
|
||||||
const rules = registry.rulesByChannel.get(conversation.channel);
|
|
||||||
if (!rules || rules.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const resolved = resolveMatchingConfiguredBinding({
|
|
||||||
rules,
|
|
||||||
conversation,
|
|
||||||
});
|
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return materializeConfiguredBindingRecord({
|
return resolved.materializedTarget;
|
||||||
rule: resolved.rule,
|
|
||||||
accountId: conversation.accountId,
|
|
||||||
conversation: resolved.match,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveConfiguredBinding(params: {
|
export function resolveConfiguredBinding(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
conversation: ConversationRef;
|
conversation: ConversationRef;
|
||||||
}): ConfiguredBindingResolution | null {
|
}): ConfiguredBindingResolution | null {
|
||||||
const conversation = toConfiguredBindingConversationRef(params.conversation);
|
const resolved = resolveMaterializedConfiguredBinding(params);
|
||||||
if (!conversation) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const registry = resolveCompiledBindingRegistry(params.cfg);
|
|
||||||
const rules = registry.rulesByChannel.get(conversation.channel);
|
|
||||||
if (!rules || rules.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const resolved = resolveMatchingConfiguredBinding({
|
|
||||||
rules,
|
|
||||||
conversation,
|
|
||||||
});
|
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const materializedTarget = materializeConfiguredBindingRecord({
|
|
||||||
rule: resolved.rule,
|
|
||||||
accountId: conversation.accountId,
|
|
||||||
conversation: resolved.match,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
conversation,
|
conversation: resolved.conversation,
|
||||||
compiledBinding: resolved.rule,
|
compiledBinding: resolved.resolved.rule,
|
||||||
match: resolved.match,
|
match: resolved.resolved.match,
|
||||||
...materializedTarget,
|
...resolved.materializedTarget,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -491,6 +491,38 @@ function cloneIfObject<T>(value: T): T {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveSingleAccountKeysIntoAccount(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
channelKey: string;
|
||||||
|
channel: ChannelSectionRecord;
|
||||||
|
accounts: Record<string, Record<string, unknown>>;
|
||||||
|
keysToMove: string[];
|
||||||
|
targetAccountId: string;
|
||||||
|
baseAccount?: Record<string, unknown>;
|
||||||
|
}): OpenClawConfig {
|
||||||
|
const nextAccount: Record<string, unknown> = { ...params.baseAccount };
|
||||||
|
for (const key of params.keysToMove) {
|
||||||
|
nextAccount[key] = cloneIfObject(params.channel[key]);
|
||||||
|
}
|
||||||
|
const nextChannel: ChannelSectionRecord = { ...params.channel };
|
||||||
|
for (const key of params.keysToMove) {
|
||||||
|
delete nextChannel[key];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...params.cfg,
|
||||||
|
channels: {
|
||||||
|
...params.cfg.channels,
|
||||||
|
[params.channelKey]: {
|
||||||
|
...nextChannel,
|
||||||
|
accounts: {
|
||||||
|
...params.accounts,
|
||||||
|
[params.targetAccountId]: nextAccount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenClawConfig;
|
||||||
|
}
|
||||||
|
|
||||||
// When promoting a single-account channel config to multi-account,
|
// When promoting a single-account channel config to multi-account,
|
||||||
// move top-level account settings into accounts.default so the original
|
// move top-level account settings into accounts.default so the original
|
||||||
// account keeps working without duplicate account values at channel root.
|
// account keeps working without duplicate account values at channel root.
|
||||||
@@ -523,56 +555,26 @@ export function moveSingleAccountChannelSectionToDefaultAccount(params: {
|
|||||||
channelKey: params.channelKey,
|
channelKey: params.channelKey,
|
||||||
channel: base,
|
channel: base,
|
||||||
});
|
});
|
||||||
const defaultAccount: Record<string, unknown> = {
|
return moveSingleAccountKeysIntoAccount({
|
||||||
...accounts[targetAccountId],
|
cfg: params.cfg,
|
||||||
};
|
channelKey: params.channelKey,
|
||||||
for (const key of keysToMove) {
|
channel: base,
|
||||||
const value = base[key];
|
accounts,
|
||||||
defaultAccount[key] = cloneIfObject(value);
|
keysToMove,
|
||||||
}
|
targetAccountId,
|
||||||
const nextChannel: ChannelSectionRecord = { ...base };
|
baseAccount: accounts[targetAccountId],
|
||||||
for (const key of keysToMove) {
|
});
|
||||||
delete nextChannel[key];
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...params.cfg,
|
|
||||||
channels: {
|
|
||||||
...params.cfg.channels,
|
|
||||||
[params.channelKey]: {
|
|
||||||
...nextChannel,
|
|
||||||
accounts: {
|
|
||||||
...accounts,
|
|
||||||
[targetAccountId]: defaultAccount,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
}
|
}
|
||||||
const keysToMove = resolveSingleAccountKeysToMove({
|
const keysToMove = resolveSingleAccountKeysToMove({
|
||||||
channelKey: params.channelKey,
|
channelKey: params.channelKey,
|
||||||
channel: base,
|
channel: base,
|
||||||
});
|
});
|
||||||
const defaultAccount: Record<string, unknown> = {};
|
return moveSingleAccountKeysIntoAccount({
|
||||||
for (const key of keysToMove) {
|
cfg: params.cfg,
|
||||||
const value = base[key];
|
channelKey: params.channelKey,
|
||||||
defaultAccount[key] = cloneIfObject(value);
|
channel: base,
|
||||||
}
|
accounts,
|
||||||
const nextChannel: ChannelSectionRecord = { ...base };
|
keysToMove,
|
||||||
for (const key of keysToMove) {
|
targetAccountId: DEFAULT_ACCOUNT_ID,
|
||||||
delete nextChannel[key];
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...params.cfg,
|
|
||||||
channels: {
|
|
||||||
...params.cfg.channels,
|
|
||||||
[params.channelKey]: {
|
|
||||||
...nextChannel,
|
|
||||||
accounts: {
|
|
||||||
...accounts,
|
|
||||||
[DEFAULT_ACCOUNT_ID]: defaultAccount,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,72 @@ async function invokeSecureGatewayRoute(params: { gatewayAuthSatisfied: boolean
|
|||||||
return { handled, exactPluginHandler, prefixGatewayHandler };
|
return { handled, exactPluginHandler, prefixGatewayHandler };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockOperatorAdminScopeFailure() {
|
||||||
|
loadOpenClawPlugins.mockReset();
|
||||||
|
handleGatewayRequest.mockReset();
|
||||||
|
handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => {
|
||||||
|
const scopes = opts.client?.connect.scopes ?? [];
|
||||||
|
if (opts.req.method === "sessions.delete" && !scopes.includes("operator.admin")) {
|
||||||
|
opts.respond(false, undefined, {
|
||||||
|
code: "invalid_request",
|
||||||
|
message: "missing scope: operator.admin",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
opts.respond(true, {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeLeastPrivilegeDeleteRoute(params: {
|
||||||
|
path: string;
|
||||||
|
auth: "gateway" | "plugin";
|
||||||
|
gatewayAuthSatisfied: boolean;
|
||||||
|
}) {
|
||||||
|
mockOperatorAdminScopeFailure();
|
||||||
|
|
||||||
|
const subagent = await createSubagentRuntime();
|
||||||
|
const log = createPluginLog();
|
||||||
|
const handler = createGatewayPluginRequestHandler({
|
||||||
|
registry: createTestRegistry({
|
||||||
|
httpRoutes: [
|
||||||
|
createRoute({
|
||||||
|
path: params.path,
|
||||||
|
auth: params.auth,
|
||||||
|
handler: async () => {
|
||||||
|
await subagent.deleteSession({ sessionKey: "agent:main:subagent:child" });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = makeMockHttpResponse();
|
||||||
|
const handled = await handler({ url: params.path } as IncomingMessage, response.res, undefined, {
|
||||||
|
gatewayAuthSatisfied: params.gatewayAuthSatisfied,
|
||||||
|
});
|
||||||
|
return { handled, log, ...response };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectLeastPrivilegeDeleteRouteFailure(params: {
|
||||||
|
handled: boolean;
|
||||||
|
setHeader: ReturnType<typeof makeMockHttpResponse>["setHeader"];
|
||||||
|
end: ReturnType<typeof makeMockHttpResponse>["end"];
|
||||||
|
log: ReturnType<typeof createPluginLog>;
|
||||||
|
}) {
|
||||||
|
expect(params.handled).toBe(true);
|
||||||
|
expect(handleGatewayRequest).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handleGatewayRequest.mock.calls[0]?.[0]?.client?.connect.scopes).toEqual([
|
||||||
|
"operator.write",
|
||||||
|
]);
|
||||||
|
expect(params.setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
expect(params.end).toHaveBeenCalledWith("Internal Server Error");
|
||||||
|
expect(params.log.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("missing scope: operator.admin"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("createGatewayPluginRequestHandler", () => {
|
describe("createGatewayPluginRequestHandler", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
releasePinnedPluginHttpRouteRegistry();
|
releasePinnedPluginHttpRouteRegistry();
|
||||||
@@ -144,101 +210,25 @@ describe("createGatewayPluginRequestHandler", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("caps unauthenticated plugin routes to non-admin subagent scopes", async () => {
|
it("caps unauthenticated plugin routes to non-admin subagent scopes", async () => {
|
||||||
loadOpenClawPlugins.mockReset();
|
const { handled, res, setHeader, end, log } = await invokeLeastPrivilegeDeleteRoute({
|
||||||
handleGatewayRequest.mockReset();
|
path: "/hook",
|
||||||
handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => {
|
auth: "plugin",
|
||||||
const scopes = opts.client?.connect.scopes ?? [];
|
|
||||||
if (opts.req.method === "sessions.delete" && !scopes.includes("operator.admin")) {
|
|
||||||
opts.respond(false, undefined, {
|
|
||||||
code: "invalid_request",
|
|
||||||
message: "missing scope: operator.admin",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
opts.respond(true, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
const subagent = await createSubagentRuntime();
|
|
||||||
const log = createPluginLog();
|
|
||||||
const handler = createGatewayPluginRequestHandler({
|
|
||||||
registry: createTestRegistry({
|
|
||||||
httpRoutes: [
|
|
||||||
createRoute({
|
|
||||||
path: "/hook",
|
|
||||||
auth: "plugin",
|
|
||||||
handler: async (_req, _res) => {
|
|
||||||
await subagent.deleteSession({ sessionKey: "agent:main:subagent:child" });
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
log,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { res, setHeader, end } = makeMockHttpResponse();
|
|
||||||
const handled = await handler({ url: "/hook" } as IncomingMessage, res, undefined, {
|
|
||||||
gatewayAuthSatisfied: false,
|
gatewayAuthSatisfied: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(handled).toBe(true);
|
|
||||||
expect(handleGatewayRequest).toHaveBeenCalledTimes(1);
|
|
||||||
expect(handleGatewayRequest.mock.calls[0]?.[0]?.client?.connect.scopes).toEqual([
|
|
||||||
"operator.write",
|
|
||||||
]);
|
|
||||||
expect(res.statusCode).toBe(500);
|
expect(res.statusCode).toBe(500);
|
||||||
expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8");
|
expectLeastPrivilegeDeleteRouteFailure({ handled, setHeader, end, log });
|
||||||
expect(end).toHaveBeenCalledWith("Internal Server Error");
|
|
||||||
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("missing scope: operator.admin"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps gateway-authenticated plugin routes on least-privilege runtime scopes", async () => {
|
it("keeps gateway-authenticated plugin routes on least-privilege runtime scopes", async () => {
|
||||||
loadOpenClawPlugins.mockReset();
|
const { handled, res, setHeader, end, log } = await invokeLeastPrivilegeDeleteRoute({
|
||||||
handleGatewayRequest.mockReset();
|
path: "/secure-hook",
|
||||||
handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => {
|
auth: "gateway",
|
||||||
const scopes = opts.client?.connect.scopes ?? [];
|
|
||||||
if (opts.req.method === "sessions.delete" && !scopes.includes("operator.admin")) {
|
|
||||||
opts.respond(false, undefined, {
|
|
||||||
code: "invalid_request",
|
|
||||||
message: "missing scope: operator.admin",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
opts.respond(true, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
const subagent = await createSubagentRuntime();
|
|
||||||
const log = createPluginLog();
|
|
||||||
const handler = createGatewayPluginRequestHandler({
|
|
||||||
registry: createTestRegistry({
|
|
||||||
httpRoutes: [
|
|
||||||
createRoute({
|
|
||||||
path: "/secure-hook",
|
|
||||||
auth: "gateway",
|
|
||||||
handler: async (_req, _res) => {
|
|
||||||
await subagent.deleteSession({ sessionKey: "agent:main:subagent:child" });
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
log,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { res, setHeader, end } = makeMockHttpResponse();
|
|
||||||
const handled = await handler({ url: "/secure-hook" } as IncomingMessage, res, undefined, {
|
|
||||||
gatewayAuthSatisfied: true,
|
gatewayAuthSatisfied: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(handled).toBe(true);
|
|
||||||
expect(handleGatewayRequest).toHaveBeenCalledTimes(1);
|
|
||||||
expect(handleGatewayRequest.mock.calls[0]?.[0]?.client?.connect.scopes).toEqual([
|
|
||||||
"operator.write",
|
|
||||||
]);
|
|
||||||
expect(res.statusCode).toBe(500);
|
expect(res.statusCode).toBe(500);
|
||||||
expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8");
|
expectLeastPrivilegeDeleteRouteFailure({ handled, setHeader, end, log });
|
||||||
expect(end).toHaveBeenCalledWith("Internal Server Error");
|
|
||||||
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("missing scope: operator.admin"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false when no routes are registered", async () => {
|
it("returns false when no routes are registered", async () => {
|
||||||
|
|||||||
@@ -32,6 +32,33 @@ async function createSessionStoreFile(): Promise<string> {
|
|||||||
return storePath;
|
return storePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withOperatorSessionSubscriber<T>(
|
||||||
|
harness: Awaited<ReturnType<typeof createGatewaySuiteHarness>>,
|
||||||
|
run: (ws: Awaited<ReturnType<typeof harness.openWs>>) => Promise<T>,
|
||||||
|
) {
|
||||||
|
const ws = await harness.openWs();
|
||||||
|
try {
|
||||||
|
await connectOk(ws, { scopes: ["operator.read"] });
|
||||||
|
await rpcReq(ws, "sessions.subscribe");
|
||||||
|
return await run(ws);
|
||||||
|
} finally {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForSessionMessageEvent(
|
||||||
|
ws: Awaited<ReturnType<Awaited<ReturnType<typeof createGatewaySuiteHarness>>["openWs"]>>,
|
||||||
|
sessionKey: string,
|
||||||
|
) {
|
||||||
|
return onceMessage(
|
||||||
|
ws,
|
||||||
|
(message) =>
|
||||||
|
message.type === "event" &&
|
||||||
|
message.event === "session.message" &&
|
||||||
|
(message.payload as { sessionKey?: string } | undefined)?.sessionKey === sessionKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function expectNoMessageWithin(params: {
|
async function expectNoMessageWithin(params: {
|
||||||
action?: () => Promise<void> | void;
|
action?: () => Promise<void> | void;
|
||||||
watch: () => Promise<unknown>;
|
watch: () => Promise<unknown>;
|
||||||
@@ -220,19 +247,8 @@ describe("session.message websocket events", () => {
|
|||||||
|
|
||||||
const harness = await createGatewaySuiteHarness();
|
const harness = await createGatewaySuiteHarness();
|
||||||
try {
|
try {
|
||||||
const ws = await harness.openWs();
|
await withOperatorSessionSubscriber(harness, async (ws) => {
|
||||||
try {
|
const messageEventPromise = waitForSessionMessageEvent(ws, "agent:main:main");
|
||||||
await connectOk(ws, { scopes: ["operator.read"] });
|
|
||||||
await rpcReq(ws, "sessions.subscribe");
|
|
||||||
|
|
||||||
const messageEventPromise = onceMessage(
|
|
||||||
ws,
|
|
||||||
(message) =>
|
|
||||||
message.type === "event" &&
|
|
||||||
message.event === "session.message" &&
|
|
||||||
(message.payload as { sessionKey?: string } | undefined)?.sessionKey ===
|
|
||||||
"agent:main:main",
|
|
||||||
);
|
|
||||||
const changedEventPromise = onceMessage(
|
const changedEventPromise = onceMessage(
|
||||||
ws,
|
ws,
|
||||||
(message) =>
|
(message) =>
|
||||||
@@ -278,9 +294,7 @@ describe("session.message websocket events", () => {
|
|||||||
modelProvider: "openai",
|
modelProvider: "openai",
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
});
|
});
|
||||||
} finally {
|
});
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
await harness.close();
|
await harness.close();
|
||||||
}
|
}
|
||||||
@@ -314,14 +328,7 @@ describe("session.message websocket events", () => {
|
|||||||
expect(subscribeRes.payload?.subscribed).toBe(true);
|
expect(subscribeRes.payload?.subscribed).toBe(true);
|
||||||
expect(subscribeRes.payload?.key).toBe("agent:main:main");
|
expect(subscribeRes.payload?.key).toBe("agent:main:main");
|
||||||
|
|
||||||
const mainEvent = onceMessage(
|
const mainEvent = waitForSessionMessageEvent(ws, "agent:main:main");
|
||||||
ws,
|
|
||||||
(message) =>
|
|
||||||
message.type === "event" &&
|
|
||||||
message.event === "session.message" &&
|
|
||||||
(message.payload as { sessionKey?: string } | undefined)?.sessionKey ===
|
|
||||||
"agent:main:main",
|
|
||||||
);
|
|
||||||
const [mainAppend] = await Promise.all([
|
const [mainAppend] = await Promise.all([
|
||||||
appendAssistantMessageToSessionTranscript({
|
appendAssistantMessageToSessionTranscript({
|
||||||
sessionKey: "agent:main:main",
|
sessionKey: "agent:main:main",
|
||||||
@@ -423,19 +430,8 @@ describe("session.message websocket events", () => {
|
|||||||
|
|
||||||
const harness = await createGatewaySuiteHarness();
|
const harness = await createGatewaySuiteHarness();
|
||||||
try {
|
try {
|
||||||
const ws = await harness.openWs();
|
await withOperatorSessionSubscriber(harness, async (ws) => {
|
||||||
try {
|
const messageEventPromise = waitForSessionMessageEvent(ws, "agent:main:newer");
|
||||||
await connectOk(ws, { scopes: ["operator.read"] });
|
|
||||||
await rpcReq(ws, "sessions.subscribe");
|
|
||||||
|
|
||||||
const messageEventPromise = onceMessage(
|
|
||||||
ws,
|
|
||||||
(message) =>
|
|
||||||
message.type === "event" &&
|
|
||||||
message.event === "session.message" &&
|
|
||||||
(message.payload as { sessionKey?: string } | undefined)?.sessionKey ===
|
|
||||||
"agent:main:newer",
|
|
||||||
);
|
|
||||||
|
|
||||||
emitSessionTranscriptUpdate({
|
emitSessionTranscriptUpdate({
|
||||||
sessionFile: transcriptPath,
|
sessionFile: transcriptPath,
|
||||||
@@ -453,9 +449,7 @@ describe("session.message websocket events", () => {
|
|||||||
messageId: "msg-shared",
|
messageId: "msg-shared",
|
||||||
messageSeq: 1,
|
messageSeq: 1,
|
||||||
});
|
});
|
||||||
} finally {
|
});
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
await harness.close();
|
await harness.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,53 @@ async function seedSession(params?: { text?: string }) {
|
|||||||
return { storePath };
|
return { storePath };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchSessionHistory(
|
||||||
|
port: number,
|
||||||
|
sessionKey: string,
|
||||||
|
params?: {
|
||||||
|
query?: string;
|
||||||
|
headers?: HeadersInit;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const headers = new Headers(AUTH_HEADER);
|
||||||
|
for (const [key, value] of new Headers(READ_SCOPE_HEADER).entries()) {
|
||||||
|
headers.set(key, value);
|
||||||
|
}
|
||||||
|
for (const [key, value] of new Headers(params?.headers).entries()) {
|
||||||
|
headers.set(key, value);
|
||||||
|
}
|
||||||
|
return fetch(
|
||||||
|
`http://127.0.0.1:${port}/sessions/${encodeURIComponent(sessionKey)}/history${params?.query ?? ""}`,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withGatewayHarness<T>(
|
||||||
|
run: (harness: Awaited<ReturnType<typeof createGatewaySuiteHarness>>) => Promise<T>,
|
||||||
|
) {
|
||||||
|
const harness = await createGatewaySuiteHarness();
|
||||||
|
try {
|
||||||
|
return await run(harness);
|
||||||
|
} finally {
|
||||||
|
await harness.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSessionHistoryText(params: { sessionKey: string; expectedText: string }) {
|
||||||
|
await withGatewayHarness(async (harness) => {
|
||||||
|
const res = await fetchSessionHistory(harness.port, params.sessionKey);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
sessionKey?: string;
|
||||||
|
messages?: Array<{ content?: Array<{ text?: string }> }>;
|
||||||
|
};
|
||||||
|
expect(body.sessionKey).toBe(params.sessionKey);
|
||||||
|
expect(body.messages?.[0]?.content?.[0]?.text).toBe(params.expectedText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function readSseEvent(
|
async function readSseEvent(
|
||||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||||
state: { buffer: string },
|
state: { buffer: string },
|
||||||
@@ -90,16 +137,8 @@ async function readSseEvent(
|
|||||||
describe("session history HTTP endpoints", () => {
|
describe("session history HTTP endpoints", () => {
|
||||||
test("returns session history over direct REST", async () => {
|
test("returns session history over direct REST", async () => {
|
||||||
await seedSession({ text: "hello from history" });
|
await seedSession({ text: "hello from history" });
|
||||||
|
await withGatewayHarness(async (harness) => {
|
||||||
const harness = await createGatewaySuiteHarness();
|
const res = await fetchSessionHistory(harness.port, "agent:main:main");
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history`,
|
|
||||||
{
|
|
||||||
headers: { ...AUTH_HEADER, ...READ_SCOPE_HEADER },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
@@ -117,23 +156,13 @@ describe("session history HTTP endpoints", () => {
|
|||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
seq: 1,
|
seq: 1,
|
||||||
});
|
});
|
||||||
} finally {
|
});
|
||||||
await harness.close();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns 404 for unknown sessions", async () => {
|
test("returns 404 for unknown sessions", async () => {
|
||||||
await createSessionStoreFile();
|
await createSessionStoreFile();
|
||||||
|
await withGatewayHarness(async (harness) => {
|
||||||
const harness = await createGatewaySuiteHarness();
|
const res = await fetchSessionHistory(harness.port, "agent:main:missing");
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:missing")}/history`,
|
|
||||||
{
|
|
||||||
headers: { ...AUTH_HEADER, ...READ_SCOPE_HEADER },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
await expect(res.json()).resolves.toMatchObject({
|
await expect(res.json()).resolves.toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -142,9 +171,7 @@ describe("session history HTTP endpoints", () => {
|
|||||||
message: "Session not found: agent:main:missing",
|
message: "Session not found: agent:main:missing",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
});
|
||||||
await harness.close();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("prefers the freshest duplicate row for direct history reads", async () => {
|
test("prefers the freshest duplicate row for direct history reads", async () => {
|
||||||
@@ -193,25 +220,10 @@ describe("session history HTTP endpoints", () => {
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
const harness = await createGatewaySuiteHarness();
|
await expectSessionHistoryText({
|
||||||
try {
|
sessionKey: "agent:main:main",
|
||||||
const res = await fetch(
|
expectedText: "fresh history",
|
||||||
`http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history`,
|
});
|
||||||
{
|
|
||||||
headers: { ...AUTH_HEADER, ...READ_SCOPE_HEADER },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
sessionKey?: string;
|
|
||||||
messages?: Array<{ content?: Array<{ text?: string }> }>;
|
|
||||||
};
|
|
||||||
expect(body.sessionKey).toBe("agent:main:main");
|
|
||||||
expect(body.messages?.[0]?.content?.[0]?.text).toBe("fresh history");
|
|
||||||
} finally {
|
|
||||||
await harness.close();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("supports cursor pagination over direct REST while preserving the messages field", async () => {
|
test("supports cursor pagination over direct REST while preserving the messages field", async () => {
|
||||||
@@ -229,14 +241,10 @@ describe("session history HTTP endpoints", () => {
|
|||||||
});
|
});
|
||||||
expect(third.ok).toBe(true);
|
expect(third.ok).toBe(true);
|
||||||
|
|
||||||
const harness = await createGatewaySuiteHarness();
|
await withGatewayHarness(async (harness) => {
|
||||||
try {
|
const firstPage = await fetchSessionHistory(harness.port, "agent:main:main", {
|
||||||
const firstPage = await fetch(
|
query: "?limit=2",
|
||||||
`http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history?limit=2`,
|
});
|
||||||
{
|
|
||||||
headers: { ...AUTH_HEADER, ...READ_SCOPE_HEADER },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(firstPage.status).toBe(200);
|
expect(firstPage.status).toBe(200);
|
||||||
const firstBody = (await firstPage.json()) as {
|
const firstBody = (await firstPage.json()) as {
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
@@ -254,12 +262,9 @@ describe("session history HTTP endpoints", () => {
|
|||||||
expect(firstBody.hasMore).toBe(true);
|
expect(firstBody.hasMore).toBe(true);
|
||||||
expect(firstBody.nextCursor).toBe("2");
|
expect(firstBody.nextCursor).toBe("2");
|
||||||
|
|
||||||
const secondPage = await fetch(
|
const secondPage = await fetchSessionHistory(harness.port, "agent:main:main", {
|
||||||
`http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history?limit=2&cursor=${encodeURIComponent(firstBody.nextCursor ?? "")}`,
|
query: `?limit=2&cursor=${encodeURIComponent(firstBody.nextCursor ?? "")}`,
|
||||||
{
|
});
|
||||||
headers: { ...AUTH_HEADER, ...READ_SCOPE_HEADER },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(secondPage.status).toBe(200);
|
expect(secondPage.status).toBe(200);
|
||||||
const secondBody = (await secondPage.json()) as {
|
const secondBody = (await secondPage.json()) as {
|
||||||
items?: Array<{ content?: Array<{ text?: string }>; __openclaw?: { seq?: number } }>;
|
items?: Array<{ content?: Array<{ text?: string }>; __openclaw?: { seq?: number } }>;
|
||||||
@@ -273,9 +278,7 @@ describe("session history HTTP endpoints", () => {
|
|||||||
expect(secondBody.messages?.map((message) => message.__openclaw?.seq)).toEqual([1]);
|
expect(secondBody.messages?.map((message) => message.__openclaw?.seq)).toEqual([1]);
|
||||||
expect(secondBody.hasMore).toBe(false);
|
expect(secondBody.hasMore).toBe(false);
|
||||||
expect(secondBody.nextCursor).toBeUndefined();
|
expect(secondBody.nextCursor).toBeUndefined();
|
||||||
} finally {
|
});
|
||||||
await harness.close();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("streams bounded history windows over SSE", async () => {
|
test("streams bounded history windows over SSE", async () => {
|
||||||
@@ -287,18 +290,11 @@ describe("session history HTTP endpoints", () => {
|
|||||||
});
|
});
|
||||||
expect(second.ok).toBe(true);
|
expect(second.ok).toBe(true);
|
||||||
|
|
||||||
const harness = await createGatewaySuiteHarness();
|
await withGatewayHarness(async (harness) => {
|
||||||
try {
|
const res = await fetchSessionHistory(harness.port, "agent:main:main", {
|
||||||
const res = await fetch(
|
query: "?limit=1",
|
||||||
`http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history?limit=1`,
|
headers: { Accept: "text/event-stream" },
|
||||||
{
|
});
|
||||||
headers: {
|
|
||||||
...AUTH_HEADER,
|
|
||||||
...READ_SCOPE_HEADER,
|
|
||||||
Accept: "text/event-stream",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const reader = res.body?.getReader();
|
const reader = res.body?.getReader();
|
||||||
@@ -326,26 +322,16 @@ describe("session history HTTP endpoints", () => {
|
|||||||
).toBe("third message");
|
).toBe("third message");
|
||||||
|
|
||||||
await reader?.cancel();
|
await reader?.cancel();
|
||||||
} finally {
|
});
|
||||||
await harness.close();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("streams session history updates over SSE", async () => {
|
test("streams session history updates over SSE", async () => {
|
||||||
const { storePath } = await seedSession({ text: "first message" });
|
const { storePath } = await seedSession({ text: "first message" });
|
||||||
|
|
||||||
const harness = await createGatewaySuiteHarness();
|
await withGatewayHarness(async (harness) => {
|
||||||
try {
|
const res = await fetchSessionHistory(harness.port, "agent:main:main", {
|
||||||
const res = await fetch(
|
headers: { Accept: "text/event-stream" },
|
||||||
`http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history`,
|
});
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
...AUTH_HEADER,
|
|
||||||
...READ_SCOPE_HEADER,
|
|
||||||
Accept: "text/event-stream",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
||||||
@@ -396,9 +382,7 @@ describe("session history HTTP endpoints", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await reader?.cancel();
|
await reader?.cancel();
|
||||||
} finally {
|
});
|
||||||
await harness.close();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects session history when operator.read is not requested", async () => {
|
test("rejects session history when operator.read is not requested", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user