mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 12:01:35 +00:00
231 lines
8.2 KiB
TypeScript
231 lines
8.2 KiB
TypeScript
// Real-transport regression proof for GitHub Copilot embedding error redaction.
|
|
// Drives the production discovery + embeddings paths against a loopback HTTP
|
|
// server with NO ssrf-runtime or global fetch mocks, so redactSensitiveText is
|
|
// exercised end to end over real sockets rather than synthetic stubs.
|
|
import fs from "node:fs";
|
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const resolveFirstGithubTokenMock = vi.hoisted(() => vi.fn());
|
|
const resolveCopilotRuntimeAuthMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("./auth.js", () => ({
|
|
resolveFirstGithubToken: resolveFirstGithubTokenMock,
|
|
}));
|
|
|
|
vi.mock("./runtime-auth.js", () => ({
|
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.githubcopilot.test",
|
|
resolveCopilotRuntimeAuth: resolveCopilotRuntimeAuthMock,
|
|
}));
|
|
|
|
// Intentionally NOT mocked: openclaw/plugin-sdk/ssrf-runtime, global fetch, and
|
|
// openclaw/plugin-sdk/logging-core (redactSensitiveText is the unit under proof).
|
|
import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.js";
|
|
|
|
type CopilotServer = {
|
|
baseUrl: string;
|
|
requests: Array<{ method: string | undefined; url: string | undefined }>;
|
|
};
|
|
|
|
const servers: Array<{ close: () => Promise<void> }> = [];
|
|
|
|
const DISCOVERY_MODELS_BODY = JSON.stringify({
|
|
data: [{ id: "text-embedding-3-small", supported_endpoints: ["/v1/embeddings"] }],
|
|
});
|
|
|
|
async function startCopilotServer(handle: {
|
|
models: { status: number; body: string };
|
|
embeddings?: { status: number; body: string };
|
|
}): Promise<CopilotServer> {
|
|
const requests: CopilotServer["requests"] = [];
|
|
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
void (async () => {
|
|
// Drain the request body so keep-alive sockets close cleanly.
|
|
await new Promise<void>((resolve) => {
|
|
req.resume();
|
|
req.on("end", resolve);
|
|
});
|
|
requests.push({ method: req.method, url: req.url });
|
|
const isEmbeddings = req.method === "POST" && req.url === "/embeddings";
|
|
const route = isEmbeddings && handle.embeddings ? handle.embeddings : handle.models;
|
|
res.writeHead(route.status, { "content-type": "application/json" });
|
|
res.end(route.body);
|
|
})();
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
server.off("error", reject);
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
servers.push({
|
|
close: () =>
|
|
new Promise<void>((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
}),
|
|
});
|
|
|
|
const address = server.address() as AddressInfo;
|
|
return { baseUrl: `http://127.0.0.1:${address.port}`, requests };
|
|
}
|
|
|
|
function pointTokenAt(baseUrl: string): void {
|
|
resolveCopilotRuntimeAuthMock.mockResolvedValue({
|
|
apiKey: "copilot_test_token_abc",
|
|
source: "test",
|
|
baseUrl,
|
|
});
|
|
}
|
|
|
|
function defaultCreateOptions() {
|
|
return {
|
|
config: {} as Record<string, unknown>,
|
|
agentDir: "/tmp/test-agent",
|
|
model: "",
|
|
};
|
|
}
|
|
|
|
// Points the global logging-config reader at an on-disk config with sensitive
|
|
// redaction turned off, so the test proves the error paths force masking rather
|
|
// than inheriting the operator's `logging.redactSensitive` preference.
|
|
function withRedactionDisabledConfig(): () => void {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-redact-off-"));
|
|
const configPath = path.join(dir, "openclaw.json");
|
|
fs.writeFileSync(configPath, JSON.stringify({ logging: { redactSensitive: "off" } }));
|
|
const previous = process.env.OPENCLAW_CONFIG_PATH;
|
|
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
|
return () => {
|
|
if (previous === undefined) {
|
|
delete process.env.OPENCLAW_CONFIG_PATH;
|
|
} else {
|
|
process.env.OPENCLAW_CONFIG_PATH = previous;
|
|
}
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
};
|
|
}
|
|
|
|
describe("githubCopilotMemoryEmbeddingProviderAdapter real transport", () => {
|
|
beforeEach(() => {
|
|
resolveFirstGithubTokenMock.mockResolvedValue({
|
|
githubToken: "gh_test_token_123",
|
|
hasProfile: false,
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
const pending = servers.splice(0);
|
|
await Promise.all(pending.map((server) => server.close()));
|
|
resolveFirstGithubTokenMock.mockReset();
|
|
resolveCopilotRuntimeAuthMock.mockReset();
|
|
});
|
|
|
|
it("redacts credential-shaped text in model discovery errors over real transport", async () => {
|
|
const server = await startCopilotServer({
|
|
models: {
|
|
status: 401,
|
|
body: '{"error":{"message":"authentication failed"},"access_token":"ghu_AAAAUNIQUESECRETXXXX111122223333"}',
|
|
},
|
|
});
|
|
pointTokenAt(server.baseUrl);
|
|
|
|
let caught: Error | undefined;
|
|
try {
|
|
await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
|
|
} catch (error) {
|
|
caught = error as Error;
|
|
}
|
|
|
|
expect(server.requests).toEqual([{ method: "GET", url: "/models" }]);
|
|
expect(caught?.message).toContain("GitHub Copilot model discovery HTTP 401");
|
|
expect(caught?.message).toContain("authentication failed");
|
|
expect(caught?.message).not.toContain("ghu_AAAAUNIQUESECRETXXXX111122223333");
|
|
expect(caught?.message).not.toContain("UNIQUESECRET");
|
|
});
|
|
|
|
it("redacts credential-shaped text in embeddings errors over real transport", async () => {
|
|
const server = await startCopilotServer({
|
|
models: { status: 200, body: DISCOVERY_MODELS_BODY },
|
|
embeddings: {
|
|
status: 429,
|
|
body: '{"error":{"message":"rate limit exceeded"},"access_token":"gho_BBBBUNIQUEEMBSECRETYYYY444455556666"}',
|
|
},
|
|
});
|
|
pointTokenAt(server.baseUrl);
|
|
|
|
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
|
|
|
|
let caught: Error | undefined;
|
|
try {
|
|
await result.provider?.embedQuery("hello");
|
|
} catch (error) {
|
|
caught = error as Error;
|
|
}
|
|
|
|
expect(server.requests).toEqual([
|
|
{ method: "GET", url: "/models" },
|
|
{ method: "POST", url: "/embeddings" },
|
|
]);
|
|
expect(caught?.message).toContain("GitHub Copilot embeddings HTTP 429");
|
|
expect(caught?.message).toContain("rate limit exceeded");
|
|
expect(caught?.message).not.toContain("gho_BBBBUNIQUEEMBSECRETYYYY444455556666");
|
|
expect(caught?.message).not.toContain("UNIQUEEMBSECRET");
|
|
});
|
|
|
|
it("still redacts when logging.redactSensitive is off", async () => {
|
|
const restoreConfig = withRedactionDisabledConfig();
|
|
try {
|
|
const server = await startCopilotServer({
|
|
models: {
|
|
status: 403,
|
|
body: '{"error":{"message":"forbidden"},"access_token":"ghu_CCCCUNIQUEOFFSECRETZZZZ777788889999"}',
|
|
},
|
|
});
|
|
pointTokenAt(server.baseUrl);
|
|
|
|
let caught: Error | undefined;
|
|
try {
|
|
await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
|
|
} catch (error) {
|
|
caught = error as Error;
|
|
}
|
|
|
|
expect(caught?.message).toContain("GitHub Copilot model discovery HTTP 403");
|
|
expect(caught?.message).toContain("forbidden");
|
|
// Forced `tools` mode must mask the token even though on-disk config
|
|
// disables general log redaction; a config-honoring call would leak it.
|
|
expect(caught?.message).not.toContain("ghu_CCCCUNIQUEOFFSECRETZZZZ777788889999");
|
|
expect(caught?.message).not.toContain("UNIQUEOFFSECRET");
|
|
} finally {
|
|
restoreConfig();
|
|
}
|
|
});
|
|
|
|
it("returns embedding vectors on a successful response over real transport", async () => {
|
|
const server = await startCopilotServer({
|
|
models: { status: 200, body: DISCOVERY_MODELS_BODY },
|
|
embeddings: {
|
|
status: 200,
|
|
body: JSON.stringify({ data: [{ index: 0, embedding: [0.1, 0.2, 0.3] }] }),
|
|
},
|
|
});
|
|
pointTokenAt(server.baseUrl);
|
|
|
|
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create(defaultCreateOptions());
|
|
const vector = await result.provider?.embedQuery("hello");
|
|
|
|
expect(server.requests).toEqual([
|
|
{ method: "GET", url: "/models" },
|
|
{ method: "POST", url: "/embeddings" },
|
|
]);
|
|
expect(Array.isArray(vector)).toBe(true);
|
|
expect(vector).toHaveLength(3);
|
|
expect(vector?.every((value) => typeof value === "number")).toBe(true);
|
|
});
|
|
});
|