From ece92bcbdefc7db9f2e96d15e982c294fd628464 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 18:46:03 +0200 Subject: [PATCH] fix: persist Copilot SDK session bindings Persist GitHub Copilot SDK session ids in the plugin-state SQLite store so separate OpenClaw process turns can resume the same Copilot-side session when the compatibility fingerprint still matches. The fingerprint covers provider/model/cwd, resolved agent id, resolved Copilot home, and auth identity. Plugin-state lookup/register/delete failures are non-fatal, stale rows are invalidated, and reset delete failures use an in-process tombstone so reset does not accidentally reuse a durable binding. Also routes the QQBot token POST through the plugin SDK SSRF guard with capture disabled for the secret-bearing request, preserving the current token lifetime validation from main. Verification: focused Copilot and QQBot Vitest suites, raw channel fetch guard, autoreview clean, Blacksmith Testbox pnpm check:changed tbx_01kst9fwjmsfzwaxqatszcbf40, live local Copilot two-turn smoke with the same SDK session id persisted in SQLite. Refs #88064 --- extensions/copilot/harness.test.ts | 299 +++++++++++++++++- extensions/copilot/harness.ts | 115 ++++++- extensions/copilot/index.test.ts | 32 +- extensions/copilot/index.ts | 14 +- extensions/qqbot/src/engine/api/token.test.ts | 77 +++-- extensions/qqbot/src/engine/api/token.ts | 89 +++--- 6 files changed, 552 insertions(+), 74 deletions(-) diff --git a/extensions/copilot/harness.test.ts b/extensions/copilot/harness.test.ts index c41c222c156..6bbcb75a735 100644 --- a/extensions/copilot/harness.test.ts +++ b/extensions/copilot/harness.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CopilotClientPool } from "./harness.js"; -import { createCopilotAgentHarness } from "./harness.js"; +import { createCopilotAgentHarness, type CopilotSessionBinding } from "./harness.js"; const mocks = vi.hoisted(() => ({ runCopilotAttempt: vi.fn(), @@ -30,6 +30,20 @@ function makePoolMock(): CopilotClientPool { }; } +function makeSessionStoreMock() { + const entries = new Map(); + return { + entries, + store: { + register: vi.fn((key: string, value: CopilotSessionBinding) => { + entries.set(key, value); + }), + lookup: vi.fn((key: string) => entries.get(key)), + delete: vi.fn((key: string) => entries.delete(key)), + }, + }; +} + function createDeferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; @@ -736,6 +750,289 @@ describe("createCopilotAgentHarness", () => { // the stale first-turn id. expect(deleteSession).toHaveBeenCalledWith("sdk-sess-2"); }); + + it("persists sdkSessionId in plugin state and resumes it from a new harness instance", async () => { + const firstPool = makePoolMock(); + const secondPool = makePoolMock(); + const sessionStore = makeSessionStoreMock(); + mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => { + deps.onSessionEstablished?.({ + sdkSessionId: "sdk-sess-sqlite", + pooledClient: { key: {} as any, client: {} as any }, + }); + return ATTEMPT_RESULT; + }); + const firstHarness = createCopilotAgentHarness({ + pool: firstPool, + sessionStore: sessionStore.store, + }); + const secondHarness = createCopilotAgentHarness({ + pool: secondPool, + sessionStore: sessionStore.store, + }); + + await firstHarness.runAttempt(makeAttemptParams({ runId: "t1" })); + await secondHarness.runAttempt(makeAttemptParams({ runId: "t2" })); + + expect(sessionStore.store.register).toHaveBeenCalledWith( + "oc-sess-reuse", + expect.objectContaining({ + schemaVersion: 1, + sdkSessionId: "sdk-sess-sqlite", + }), + ); + const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as { + initialReplayState?: { sdkSessionId?: string }; + }; + expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-sqlite"); + }); + + it("starts a fresh SDK session when persisted binding lookup fails", async () => { + const sessionStore = makeSessionStoreMock(); + sessionStore.store.lookup.mockImplementation(() => { + throw new Error("sqlite read failed"); + }); + mocks.runCopilotAttempt.mockResolvedValue(ATTEMPT_RESULT); + const harness = createCopilotAgentHarness({ + pool: makePoolMock(), + sessionStore: sessionStore.store, + }); + + await expect(harness.runAttempt(makeAttemptParams({ runId: "t1" }))).resolves.toBe( + ATTEMPT_RESULT, + ); + + const callParams = mocks.runCopilotAttempt.mock.calls[0]?.[0] as { + initialReplayState?: { sdkSessionId?: string }; + }; + expect(callParams.initialReplayState?.sdkSessionId).toBeUndefined(); + expect(sessionStore.store.delete).toHaveBeenCalledWith("oc-sess-reuse"); + }); + + it("keeps the in-memory binding when durable register fails", async () => { + const sessionStore = makeSessionStoreMock(); + sessionStore.entries.set("oc-sess-reuse", { + schemaVersion: 1, + sdkSessionId: "sdk-sess-stale", + compatKey: "stale", + updatedAt: 1, + }); + sessionStore.store.register.mockImplementation(() => { + throw new Error("sqlite write failed"); + }); + mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => { + deps.onSessionEstablished?.({ + sdkSessionId: "sdk-sess-memory-only", + pooledClient: { key: {} as any, client: {} as any }, + }); + return ATTEMPT_RESULT; + }); + const harness = createCopilotAgentHarness({ + pool: makePoolMock(), + sessionStore: sessionStore.store, + }); + + await harness.runAttempt(makeAttemptParams({ runId: "t1" })); + await harness.runAttempt(makeAttemptParams({ runId: "t2" })); + + const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as { + initialReplayState?: { sdkSessionId?: string }; + }; + expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-memory-only"); + expect(sessionStore.store.delete).toHaveBeenCalledWith("oc-sess-reuse"); + expect(sessionStore.entries.has("oc-sess-reuse")).toBe(false); + }); + + it("ignores a persisted sdkSessionId when the compatibility fingerprint changes", async () => { + const sessionStore = makeSessionStoreMock(); + mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => { + deps.onSessionEstablished?.({ + sdkSessionId: "sdk-sess-old-model", + pooledClient: { key: {} as any, client: {} as any }, + }); + return ATTEMPT_RESULT; + }); + const firstHarness = createCopilotAgentHarness({ + pool: makePoolMock(), + sessionStore: sessionStore.store, + }); + const secondHarness = createCopilotAgentHarness({ + pool: makePoolMock(), + sessionStore: sessionStore.store, + }); + + await firstHarness.runAttempt( + makeAttemptParams({ runId: "t1", model: { provider: "github-copilot", id: "gpt-4.1" } }), + ); + await secondHarness.runAttempt( + makeAttemptParams({ + runId: "t2", + model: { provider: "github-copilot", id: "claude-sonnet-4.5" }, + }), + ); + + const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as { + initialReplayState?: { sdkSessionId?: string }; + }; + expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined(); + }); + + it("ignores a persisted sdkSessionId when the default Copilot home changes by agent id", async () => { + const sessionStore = makeSessionStoreMock(); + mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => { + deps.onSessionEstablished?.({ + sdkSessionId: "sdk-sess-main-home", + pooledClient: { key: {} as any, client: {} as any }, + }); + return ATTEMPT_RESULT; + }); + const firstHarness = createCopilotAgentHarness({ + pool: makePoolMock(), + sessionStore: sessionStore.store, + }); + const secondHarness = createCopilotAgentHarness({ + pool: makePoolMock(), + sessionStore: sessionStore.store, + }); + const defaultHomeParams = { + agentDir: undefined, + copilotHome: undefined, + }; + + await firstHarness.runAttempt( + makeAttemptParams({ + ...defaultHomeParams, + runId: "t1", + agentId: "main", + }), + ); + await secondHarness.runAttempt( + makeAttemptParams({ + ...defaultHomeParams, + runId: "t2", + agentId: "ops", + }), + ); + + const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as { + initialReplayState?: { sdkSessionId?: string }; + }; + expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined(); + }); + + it("does not let stale plugin state override a newer incompatible tracked session", async () => { + const sessionStore = makeSessionStoreMock(); + mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => { + deps.onSessionEstablished?.({ + sdkSessionId: "sdk-sess-tracked-model", + pooledClient: { key: {} as any, client: {} as any }, + }); + return ATTEMPT_RESULT; + }); + const harness = createCopilotAgentHarness({ + pool: makePoolMock(), + sessionStore: sessionStore.store, + }); + + await harness.runAttempt( + makeAttemptParams({ runId: "t1", model: { provider: "github-copilot", id: "gpt-4.1" } }), + ); + const persisted = sessionStore.entries.get("oc-sess-reuse"); + expect(persisted).toBeDefined(); + + await harness.runAttempt( + makeAttemptParams({ + runId: "t2", + model: { provider: "github-copilot", id: "claude-sonnet-4.5" }, + }), + ); + sessionStore.entries.set("oc-sess-reuse", persisted!); + await harness.runAttempt( + makeAttemptParams({ runId: "t3", model: { provider: "github-copilot", id: "gpt-4.1" } }), + ); + + const thirdCallParams = mocks.runCopilotAttempt.mock.calls[2]?.[0] as { + initialReplayState?: { sdkSessionId?: string }; + }; + expect(thirdCallParams.initialReplayState?.sdkSessionId).toBeUndefined(); + }); + + it("deletes persisted sdkSessionId on reset even when no in-memory client is tracked", async () => { + const sessionStore = makeSessionStoreMock(); + sessionStore.entries.set("oc-sess-reuse", { + schemaVersion: 1, + sdkSessionId: "sdk-sess-orphan", + compatKey: "compat", + updatedAt: 1, + }); + const harness = createCopilotAgentHarness({ + pool: makePoolMock(), + sessionStore: sessionStore.store, + }); + + await harness.reset?.({ sessionId: "oc-sess-reuse" }); + + expect(sessionStore.store.delete).toHaveBeenCalledWith("oc-sess-reuse"); + expect(sessionStore.entries.has("oc-sess-reuse")).toBe(false); + }); + + it("still clears tracked SDK sessions when durable reset delete fails", async () => { + const sessionStore = makeSessionStoreMock(); + sessionStore.store.delete.mockImplementation(() => { + throw new Error("sqlite delete failed"); + }); + const deleteSession = vi.fn(); + mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => { + deps.onSessionEstablished?.({ + sdkSessionId: "sdk-sess-reset", + pooledClient: { key: {} as any, client: { deleteSession } as any }, + }); + return ATTEMPT_RESULT; + }); + const harness = createCopilotAgentHarness({ + pool: makePoolMock(), + sessionStore: sessionStore.store, + }); + + await harness.runAttempt(makeAttemptParams({ runId: "t1" })); + await harness.reset?.({ sessionId: "oc-sess-reuse" }); + + expect(deleteSession).toHaveBeenCalledWith("sdk-sess-reset"); + }); + + it("blocks persisted reuse after reset cannot delete a durable binding", async () => { + const sessionStore = makeSessionStoreMock(); + mocks.runCopilotAttempt.mockImplementationOnce(async (_params, deps) => { + deps.onSessionEstablished?.({ + sdkSessionId: "sdk-sess-before-reset", + pooledClient: { key: {} as any, client: {} as any }, + }); + return ATTEMPT_RESULT; + }); + const firstHarness = createCopilotAgentHarness({ + pool: makePoolMock(), + sessionStore: sessionStore.store, + }); + + await firstHarness.runAttempt(makeAttemptParams({ runId: "t1" })); + expect(sessionStore.entries.get("oc-sess-reuse")?.sdkSessionId).toBe("sdk-sess-before-reset"); + sessionStore.store.delete.mockImplementation(() => { + throw new Error("sqlite delete failed"); + }); + mocks.runCopilotAttempt.mockResolvedValue(ATTEMPT_RESULT); + const harness = createCopilotAgentHarness({ + pool: makePoolMock(), + sessionStore: sessionStore.store, + }); + + await harness.reset?.({ sessionId: "oc-sess-reuse" }); + await harness.runAttempt(makeAttemptParams({ runId: "t2" })); + + const callParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as { + initialReplayState?: { sdkSessionId?: string }; + }; + expect(callParams.initialReplayState?.sdkSessionId).toBeUndefined(); + }); }); describe("compact", () => { diff --git a/extensions/copilot/harness.ts b/extensions/copilot/harness.ts index a7e43914f80..45562ecc5cb 100644 --- a/extensions/copilot/harness.ts +++ b/extensions/copilot/harness.ts @@ -7,6 +7,7 @@ import type { AgentHarnessCompactResult, AgentHarnessResetParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; import { resolveCopilotAuth } from "./src/auth-bridge.js"; import { writeOpenClawCompactionMarker } from "./src/compaction-bridge.js"; import type { CopilotClientPool, CopilotClientPoolOptions, PooledClient } from "./src/runtime.js"; @@ -21,6 +22,7 @@ export interface CreateCopilotAgentHarnessOptions { pluginConfig?: unknown; pool?: CopilotClientPool; poolOptions?: CopilotClientPoolOptions; + sessionStore?: CopilotSessionBindingStore; } interface TrackedSession { @@ -36,6 +38,86 @@ interface TrackedSession { compatKey: string; } +export type CopilotSessionBinding = { + schemaVersion: 1; + sdkSessionId: string; + compatKey: string; + updatedAt: number; +}; + +type CopilotSessionBindingStore = Pick< + PluginStateSyncKeyedStore, + "delete" | "lookup" | "register" +>; + +function normalizeBinding( + value: CopilotSessionBinding | undefined, +): CopilotSessionBinding | undefined { + if ( + !value || + value.schemaVersion !== 1 || + typeof value.sdkSessionId !== "string" || + value.sdkSessionId.trim() === "" || + typeof value.compatKey !== "string" || + value.compatKey.trim() === "" || + typeof value.updatedAt !== "number" || + !Number.isFinite(value.updatedAt) + ) { + return undefined; + } + return { + schemaVersion: 1, + sdkSessionId: value.sdkSessionId.trim(), + compatKey: value.compatKey, + updatedAt: value.updatedAt, + }; +} + +function lookupStoredBinding( + store: CopilotSessionBindingStore | undefined, + key: string, +): CopilotSessionBinding | undefined { + try { + return normalizeBinding(store?.lookup(key)); + } catch { + try { + store?.delete(key); + } catch { + // Durable binding cleanup is best-effort; the turn can create a fresh SDK session. + } + return undefined; + } +} + +function registerStoredBinding( + store: CopilotSessionBindingStore | undefined, + key: string, + binding: CopilotSessionBinding, +): boolean { + try { + store?.register(key, binding); + return true; + } catch { + try { + store?.delete(key); + } catch { + // A failed invalidation just degrades to in-memory reuse for this process. + } + // The in-memory binding still keeps this process warm; persistence is an optimization. + return false; + } +} + +function deleteStoredBinding(store: CopilotSessionBindingStore | undefined, key: string): boolean { + try { + store?.delete(key); + return true; + } catch { + // Reset must still clear tracked SDK sessions even if plugin state is unhealthy. + return false; + } +} + // Build a string fingerprint of the attempt params that must agree // across turns for SDK-session reuse to be safe. Keep this list // conservative: any field whose change would invalidate the SDK @@ -83,6 +165,8 @@ function computeSessionCompatKey(params: AgentHarnessAttemptParams): string { // (would break the deterministic equality check). Use a stable // sentinel that will never match any previously-tracked compat key. let authParts: string[]; + let resolvedAgentId = ""; + let resolvedCopilotHome = ""; try { const resolved = resolveCopilotAuth({ agentId: typeof p.agentId === "string" ? p.agentId : undefined, @@ -94,6 +178,8 @@ function computeSessionCompatKey(params: AgentHarnessAttemptParams): string { authProfileId: typeof p.authProfileId === "string" ? p.authProfileId : undefined, profileVersion: typeof p.profileVersion === "string" ? p.profileVersion : undefined, }); + resolvedAgentId = resolved.agentId; + resolvedCopilotHome = resolved.copilotHome; authParts = [ `auth.mode=${resolved.authMode}`, `auth.profileId=${resolved.authProfileId ?? ""}`, @@ -107,8 +193,10 @@ function computeSessionCompatKey(params: AgentHarnessAttemptParams): string { `model=${modelObj.id ?? ""}`, `api=${modelObj.api ?? ""}`, `cwd=${p.cwd ?? p.workspaceDir ?? ""}`, + `agentId=${resolvedAgentId}`, `agentDir=${p.agentDir ?? ""}`, `copilotHome=${p.copilotHome ?? ""}`, + `resolvedCopilotHome=${resolvedCopilotHome}`, ...authParts, ]; return parts.join("|"); @@ -127,6 +215,7 @@ export function createCopilotAgentHarness( // runCopilotAttempt via the onSessionEstablished callback so that // reset(params) can call client.deleteSession on the right client. const trackedSessions = new Map(); + const resetBlockedStoredSessions = new Set(); async function getPool(): Promise { if (options?.pool) { @@ -201,8 +290,17 @@ export function createCopilotAgentHarness( // surfaces as a prompt error. const currentCompatKey = computeSessionCompatKey(params); const tracked = openclawSessionId ? trackedSessions.get(openclawSessionId) : undefined; + const stored = openclawSessionId + ? resetBlockedStoredSessions.has(openclawSessionId) + ? undefined + : lookupStoredBinding(options?.sessionStore, openclawSessionId) + : undefined; const resumableSessionId = - tracked && tracked.compatKey === currentCompatKey ? tracked.sdkSessionId : undefined; + tracked && tracked.compatKey === currentCompatKey + ? tracked.sdkSessionId + : !tracked && stored && stored.compatKey === currentCompatKey + ? stored.sdkSessionId + : undefined; const effectiveParams: AgentHarnessAttemptParams = resumableSessionId ? ({ ...params, @@ -228,6 +326,15 @@ export function createCopilotAgentHarness( client: pooledClient.client, compatKey: currentCompatKey, }); + const persisted = registerStoredBinding(options?.sessionStore, openclawSessionId, { + schemaVersion: 1, + sdkSessionId, + compatKey: currentCompatKey, + updatedAt: Date.now(), + }); + if (persisted) { + resetBlockedStoredSessions.delete(openclawSessionId); + } } : undefined, }); @@ -246,6 +353,11 @@ export function createCopilotAgentHarness( return; } const tracked = trackedSessions.get(openclawSessionId); + if (deleteStoredBinding(options?.sessionStore, openclawSessionId)) { + resetBlockedStoredSessions.delete(openclawSessionId); + } else { + resetBlockedStoredSessions.add(openclawSessionId); + } if (!tracked) { // Session was created by a different harness, or already reset. return; @@ -326,6 +438,7 @@ export function createCopilotAgentHarness( await Promise.allSettled(inFlight); } trackedSessions.clear(); + resetBlockedStoredSessions.clear(); if (createdPool) { const errors = await createdPool.dispose(); if (errors.length > 0) { diff --git a/extensions/copilot/index.test.ts b/extensions/copilot/index.test.ts index 7bd694fe969..23aa4466d99 100644 --- a/extensions/copilot/index.test.ts +++ b/extensions/copilot/index.test.ts @@ -21,6 +21,12 @@ function loadManifest(): Record { function registerWithPluginConfig(pluginConfig: Record | undefined) { const registerAgentHarness = vi.fn(); + const sessionStore = { + register: vi.fn(), + lookup: vi.fn(), + delete: vi.fn(), + }; + const openSyncKeyedStore = vi.fn(() => sessionStore); plugin.register( createTestPluginApi({ id: "copilot", @@ -28,7 +34,7 @@ function registerWithPluginConfig(pluginConfig: Record | undefi source: "test", config: {}, pluginConfig, - runtime: {} as never, + runtime: { state: { openSyncKeyedStore } } as never, registerAgentHarness, }), ); @@ -41,7 +47,7 @@ function registerWithPluginConfig(pluginConfig: Record | undefi requestedRuntime?: string; }): { supported: true; priority?: number } | { supported: false; reason?: string }; }; - return { registerAgentHarness, harness }; + return { registerAgentHarness, harness, openSyncKeyedStore, sessionStore }; } describe("copilot plugin", () => { @@ -76,7 +82,7 @@ describe("copilot plugin", () => { source: "test", config: {}, pluginConfig: {}, - runtime: {} as never, + runtime: { state: { openSyncKeyedStore: vi.fn(() => ({})) } } as never, registerAgentHarness, registerProvider, registerModelCatalogProvider, @@ -134,7 +140,23 @@ describe("copilot plugin", () => { registerWithPluginConfig({ pool: { idleTtlMs: 2500 } }); registerWithPluginConfig({ pool: { idleTtlMs: 0 } }); - expect(createHarness).toHaveBeenNthCalledWith(1, { poolOptions: { idleTtlMs: 2500 } }); - expect(createHarness.mock.calls[1]?.[0]).toBeUndefined(); + expect(createHarness).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ poolOptions: { idleTtlMs: 2500 } }), + ); + expect(createHarness.mock.calls[1]?.[0]).not.toHaveProperty("poolOptions"); + }); + + it("opens the durable Copilot SDK session binding store", () => { + const createHarness = vi.mocked(createCopilotAgentHarness); + createHarness.mockClear(); + const { openSyncKeyedStore, sessionStore } = registerWithPluginConfig({}); + + expect(openSyncKeyedStore).toHaveBeenCalledWith({ + namespace: "sdk-sessions", + maxEntries: 5000, + defaultTtlMs: 90 * 24 * 60 * 60 * 1000, + }); + expect(createHarness).toHaveBeenCalledWith(expect.objectContaining({ sessionStore })); }); }); diff --git a/extensions/copilot/index.ts b/extensions/copilot/index.ts index 79fe7c2cbda..657e1325f58 100644 --- a/extensions/copilot/index.ts +++ b/extensions/copilot/index.ts @@ -1,5 +1,5 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { createCopilotAgentHarness } from "./harness.js"; +import { createCopilotAgentHarness, type CopilotSessionBinding } from "./harness.js"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -29,7 +29,17 @@ export default definePluginEntry({ description: "Registers the GitHub Copilot agent runtime.", register(api) { const poolOptions = readPoolOptions(api.pluginConfig); + const sessionStore = api.runtime.state.openSyncKeyedStore({ + namespace: "sdk-sessions", + maxEntries: 5000, + defaultTtlMs: 90 * 24 * 60 * 60 * 1000, + }); - api.registerAgentHarness(createCopilotAgentHarness(poolOptions ? { poolOptions } : undefined)); + api.registerAgentHarness( + createCopilotAgentHarness({ + ...(poolOptions ? { poolOptions } : {}), + sessionStore, + }), + ); }, }); diff --git a/extensions/qqbot/src/engine/api/token.test.ts b/extensions/qqbot/src/engine/api/token.test.ts index 8c48f796369..a0478a1391d 100644 --- a/extensions/qqbot/src/engine/api/token.test.ts +++ b/extensions/qqbot/src/engine/api/token.test.ts @@ -1,40 +1,66 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TokenManager } from "./token.js"; +const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: fetchWithSsrFGuardMock, + }; +}); + +function mockGuardedTokenResponse(body: BodyInit, init?: ResponseInit): ReturnType { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(body, init), + release, + }); + return release; +} + describe("QQBot token manager", () => { + beforeEach(() => { + fetchWithSsrFGuardMock.mockReset(); + }); + afterEach(() => { - vi.unstubAllGlobals(); vi.useRealTimers(); }); it("wraps malformed access token JSON", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue( - new Response("{not json", { - status: 200, - headers: { "content-type": "application/json" }, - }), - ), - ); + const release = mockGuardedTokenResponse("{not json", { + status: 200, + headers: { "content-type": "application/json" }, + }); await expect(new TokenManager().getAccessToken("app-id", "secret")).rejects.toThrow( "QQBot access_token response was malformed JSON", ); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({ + url: "https://bots.qq.com/app/getAppAccessToken", + auditContext: "qqbot-token", + capture: false, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "QQBotPlugin/unknown", + }, + body: JSON.stringify({ appId: "app-id", clientSecret: "secret" }), + }, + }); + expect(release).toHaveBeenCalledTimes(1); }); it("does not cache access tokens forever when expires_in is unsafe", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z")); - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue( - new Response('{"access_token":"token-1","expires_in":1e309}', { - status: 200, - headers: { "content-type": "application/json" }, - }), - ), - ); + mockGuardedTokenResponse('{"access_token":"token-1","expires_in":1e309}', { + status: 200, + headers: { "content-type": "application/json" }, + }); const manager = new TokenManager(); await expect(manager.getAccessToken("app-id", "secret")).resolves.toBe("token-1"); @@ -47,13 +73,10 @@ describe("QQBot token manager", () => { it("does not extend explicit non-positive token lifetimes", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z")); - const fetch = vi.fn().mockResolvedValue( - new Response('{"access_token":"token-1","expires_in":0}', { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - vi.stubGlobal("fetch", fetch); + mockGuardedTokenResponse('{"access_token":"token-1","expires_in":0}', { + status: 200, + headers: { "content-type": "application/json" }, + }); const manager = new TokenManager(); await expect(manager.getAccessToken("app-id", "secret")).resolves.toBe("token-1"); diff --git a/extensions/qqbot/src/engine/api/token.ts b/extensions/qqbot/src/engine/api/token.ts index e6742dcbab5..2444a189c32 100644 --- a/extensions/qqbot/src/engine/api/token.ts +++ b/extensions/qqbot/src/engine/api/token.ts @@ -7,6 +7,7 @@ */ import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import type { EngineLogger } from "../types.js"; import { formatErrorMessage } from "../utils/format.js"; @@ -220,15 +221,23 @@ export class TokenManager { this.logger?.debug?.(`[qqbot:token:${appId}] >>> POST ${TOKEN_URL}`); let response: Response; + let release: (() => Promise) | undefined; try { - response = await fetch(TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": this.resolveUserAgent(), + const guarded = await fetchWithSsrFGuard({ + url: TOKEN_URL, + auditContext: "qqbot-token", + capture: false, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": this.resolveUserAgent(), + }, + body: JSON.stringify({ appId, clientSecret }), }, - body: JSON.stringify({ appId, clientSecret }), }); + response = guarded.response; + release = guarded.release; } catch (err) { this.logger?.error?.(`[qqbot:token:${appId}] Network error: ${formatErrorMessage(err)}`); throw new Error(`Network error getting access_token: ${formatErrorMessage(err)}`, { @@ -236,40 +245,44 @@ export class TokenManager { }); } - const traceId = response.headers.get("x-tps-trace-id") ?? ""; - this.logger?.debug?.( - `[qqbot:token:${appId}] <<< ${response.status}${traceId ? ` | TraceId: ${traceId}` : ""}`, - ); - - let rawBody: string; try { - rawBody = await response.text(); - } catch (err) { - throw new Error(`Failed to read access_token response: ${formatErrorMessage(err)}`, { - cause: err, - }); + const traceId = response.headers.get("x-tps-trace-id") ?? ""; + this.logger?.debug?.( + `[qqbot:token:${appId}] <<< ${response.status}${traceId ? ` | TraceId: ${traceId}` : ""}`, + ); + + let rawBody: string; + try { + rawBody = await response.text(); + } catch (err) { + throw new Error(`Failed to read access_token response: ${formatErrorMessage(err)}`, { + cause: err, + }); + } + const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"'); + this.logger?.debug?.(`[qqbot:token:${appId}] <<< Body: ${logBody}`); + + let data: { access_token?: string; expires_in?: unknown }; + try { + data = JSON.parse(rawBody); + } catch { + throw new Error("QQBot access_token response was malformed JSON"); + } + + if (!data.access_token) { + throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`); + } + + const expiresAt = Date.now() + resolveTokenExpiresInSeconds(data.expires_in) * 1000; + this.cache.set(appId, { token: data.access_token, expiresAt, appId }); + this.logger?.debug?.( + `[qqbot:token:${appId}] Cached, expires at: ${new Date(expiresAt).toISOString()}`, + ); + + return data.access_token; + } finally { + await release?.(); } - const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"'); - this.logger?.debug?.(`[qqbot:token:${appId}] <<< Body: ${logBody}`); - - let data: { access_token?: string; expires_in?: unknown }; - try { - data = JSON.parse(rawBody); - } catch { - throw new Error("QQBot access_token response was malformed JSON"); - } - - if (!data.access_token) { - throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`); - } - - const expiresAt = Date.now() + resolveTokenExpiresInSeconds(data.expires_in) * 1000; - this.cache.set(appId, { token: data.access_token, expiresAt, appId }); - this.logger?.debug?.( - `[qqbot:token:${appId}] Cached, expires at: ${new Date(expiresAt).toISOString()}`, - ); - - return data.access_token; } private abortableSleep(ms: number, signal: AbortSignal): Promise {