mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-04 03:34:04 +00:00
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:
committed by
GitHub
parent
95e898bf05
commit
ece92bcbde
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user