mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
// Exercise discovery and embeddings over real sockets, including request
|
|
// cancellation and redaction without relying on fetch or SSRF mocks.
|
|
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,
|
|
}));
|
|
|
|
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 } | ((response: ServerResponse) => void);
|
|
embeddings?: { status: number; body: string } | ((response: ServerResponse) => void);
|
|
observeRequest?: (request: IncomingMessage, body: string) => void;
|
|
}): 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.
|
|
const body = await new Promise<string>((resolve) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
});
|
|
handle.observeRequest?.(req, body);
|
|
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;
|
|
if (typeof route === "function") {
|
|
route(res);
|
|
return;
|
|
}
|
|
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()));
|
|
server.closeAllConnections();
|
|
}),
|
|
});
|
|
|
|
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.each(["headers", "trickling body"])(
|
|
"aborts model discovery with stalled %s within its operation deadline",
|
|
async (phase) => {
|
|
let connectionClosed = false;
|
|
const server = await startCopilotServer({
|
|
models: (response) => {
|
|
const trickle =
|
|
phase === "trickling body" ? setInterval(() => response.write(" "), 100) : undefined;
|
|
// Finish successfully beyond the deadline so a missing timeout fails
|
|
// the assertion without leaving a hanging request in the test process.
|
|
const finish = setTimeout(() => response.end(DISCOVERY_MODELS_BODY), 15_000);
|
|
response.on("close", () => {
|
|
connectionClosed = true;
|
|
clearInterval(trickle);
|
|
clearTimeout(finish);
|
|
});
|
|
},
|
|
});
|
|
const startedAt = performance.now();
|
|
|
|
await expect(
|
|
githubCopilotMemoryEmbeddingProviderAdapter.create({
|
|
...defaultCreateOptions(),
|
|
remote: { baseUrl: server.baseUrl, apiKey: "copilot-test-only" },
|
|
}),
|
|
).rejects.toThrow("request timed out");
|
|
|
|
expect(performance.now() - startedAt).toBeLessThan(15_000);
|
|
await vi.waitFor(() => expect(connectionClosed).toBe(true));
|
|
expect(server.requests).toEqual([{ method: "GET", url: "/models" }]);
|
|
},
|
|
20_000,
|
|
);
|
|
|
|
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?.embed("hello", { inputType: "query" });
|
|
} 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.each(["profile", "custom"])(
|
|
"preserves %s authentication and embedding wire requests",
|
|
async (auth) => {
|
|
const observed: Array<{
|
|
authorization: string | undefined;
|
|
integration: unknown;
|
|
custom: unknown;
|
|
body: string;
|
|
}> = [];
|
|
const server = await startCopilotServer({
|
|
models: { status: 200, body: DISCOVERY_MODELS_BODY },
|
|
observeRequest: (request, body) => {
|
|
observed.push({
|
|
authorization: request.headers.authorization,
|
|
integration: request.headers["copilot-integration-id"],
|
|
custom: request.headers["x-proof"],
|
|
body,
|
|
});
|
|
},
|
|
embeddings: (response) => {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(
|
|
JSON.stringify({
|
|
data:
|
|
server.requests.length === 2
|
|
? [{ index: 0, embedding: [3, 4] }]
|
|
: [
|
|
{ index: 1, embedding: [0, 2] },
|
|
{ index: 0, embedding: [3, 0] },
|
|
],
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
pointTokenAt(server.baseUrl);
|
|
|
|
const result = await githubCopilotMemoryEmbeddingProviderAdapter.create({
|
|
...defaultCreateOptions(),
|
|
remote: {
|
|
...(auth === "custom" ? { baseUrl: server.baseUrl, apiKey: "explicit-test-token" } : {}),
|
|
headers: { Authorization: "replaced-test-token", "X-Proof": "forwarded" },
|
|
},
|
|
});
|
|
expect(await result.provider?.embed("hello", { inputType: "query" })).toEqual([0.6, 0.8]);
|
|
expect(await result.provider?.embedBatch(["first", { text: "second" }])).toEqual([
|
|
[1, 0],
|
|
[0, 1],
|
|
]);
|
|
|
|
expect(server.requests).toEqual([
|
|
{ method: "GET", url: "/models" },
|
|
{ method: "POST", url: "/embeddings" },
|
|
{ method: "POST", url: "/embeddings" },
|
|
]);
|
|
const authorization = `Bearer ${auth === "custom" ? "explicit-test-token" : "copilot_test_token_abc"}`;
|
|
expect(observed).toEqual(
|
|
[
|
|
"",
|
|
JSON.stringify({ model: "text-embedding-3-small", input: ["hello"] }),
|
|
JSON.stringify({ model: "text-embedding-3-small", input: ["first", "second"] }),
|
|
].map((body) => ({
|
|
authorization,
|
|
integration: "copilot-developer-cli",
|
|
custom: "forwarded",
|
|
body,
|
|
})),
|
|
);
|
|
expect(resolveFirstGithubTokenMock).toHaveBeenCalledTimes(auth === "custom" ? 0 : 1);
|
|
expect(resolveCopilotRuntimeAuthMock).toHaveBeenCalledTimes(auth === "custom" ? 0 : 1);
|
|
},
|
|
);
|
|
});
|