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
This commit is contained in:
Peter Steinberger
2026-05-29 18:46:03 +02:00
committed by GitHub
parent 95e898bf05
commit ece92bcbde
6 changed files with 552 additions and 74 deletions

View File

@@ -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<string, CopilotSessionBinding>();
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<T>() {
let resolve!: (value: T | PromiseLike<T>) => 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", () => {

View File

@@ -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<CopilotSessionBinding>,
"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<string, TrackedSession>();
const resetBlockedStoredSessions = new Set<string>();
async function getPool(): Promise<CopilotClientPool> {
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) {

View File

@@ -21,6 +21,12 @@ function loadManifest(): Record<string, unknown> {
function registerWithPluginConfig(pluginConfig: Record<string, unknown> | 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<string, unknown> | undefi
source: "test",
config: {},
pluginConfig,
runtime: {} as never,
runtime: { state: { openSyncKeyedStore } } as never,
registerAgentHarness,
}),
);
@@ -41,7 +47,7 @@ function registerWithPluginConfig(pluginConfig: Record<string, unknown> | 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 }));
});
});

View File

@@ -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<string, unknown> {
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<CopilotSessionBinding>({
namespace: "sdk-sessions",
maxEntries: 5000,
defaultTtlMs: 90 * 24 * 60 * 60 * 1000,
});
api.registerAgentHarness(createCopilotAgentHarness(poolOptions ? { poolOptions } : undefined));
api.registerAgentHarness(
createCopilotAgentHarness({
...(poolOptions ? { poolOptions } : {}),
sessionStore,
}),
);
},
});

View File

@@ -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<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
};
});
function mockGuardedTokenResponse(body: BodyInit, init?: ResponseInit): ReturnType<typeof vi.fn> {
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");

View File

@@ -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<void>) | 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<void> {