mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: provider dead exports no longer block changed checks (#108592)
* fix(ci): clean provider dead exports * test(extensions): satisfy provider contract types * refactor(openai): tighten provider runtime factory
This commit is contained in:
committed by
GitHub
parent
527711d27c
commit
e7cba0e4d5
@@ -2,8 +2,7 @@
|
||||
import { stream as streamModel, type AssistantMessage, type Model } from "openclaw/plugin-sdk/llm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFirstGithubToken } from "./auth.js";
|
||||
import { buildCopilotDynamicHeaders } from "./stream.js";
|
||||
import { wrapCopilotOpenAIResponsesStream } from "./stream.js";
|
||||
import { wrapCopilotProviderStream } from "./stream.js";
|
||||
import { resolveCopilotApiToken } from "./token.js";
|
||||
|
||||
const LIVE =
|
||||
@@ -196,7 +195,7 @@ describeLive("github-copilot connection-bound Responses IDs live", () => {
|
||||
};
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
|
||||
const wrappedStream = wrapCopilotOpenAIResponsesStream(streamModel as never);
|
||||
const wrappedStream = wrapCopilotProviderStream({ streamFn: streamModel } as never);
|
||||
if (!wrappedStream) {
|
||||
throw new Error("expected Copilot Responses stream wrapper");
|
||||
}
|
||||
@@ -205,10 +204,6 @@ describeLive("github-copilot connection-bound Responses IDs live", () => {
|
||||
context as never,
|
||||
{
|
||||
apiKey: token.token,
|
||||
headers: buildCopilotDynamicHeaders({
|
||||
messages: context.messages,
|
||||
hasImages: false,
|
||||
}),
|
||||
maxTokens: 32,
|
||||
onPayload: (payload: unknown) => {
|
||||
capturedPayload = payload as Record<string, unknown>;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Github Copilot tests cover connection bound ids plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
rewriteCopilotConnectionBoundResponseIds,
|
||||
rewriteCopilotResponsePayloadConnectionBoundIds,
|
||||
sanitizeCopilotReplayResponseIds,
|
||||
} from "./connection-bound-ids.js";
|
||||
import { rewriteCopilotResponsePayloadConnectionBoundIds } from "./connection-bound-ids.js";
|
||||
|
||||
function rewriteInputIds(input: unknown): boolean {
|
||||
return rewriteCopilotResponsePayloadConnectionBoundIds({ input });
|
||||
}
|
||||
|
||||
describe("github-copilot connection-bound response IDs", () => {
|
||||
it("rewrites opaque message response item IDs deterministically", () => {
|
||||
@@ -12,8 +12,8 @@ describe("github-copilot connection-bound response IDs", () => {
|
||||
const first = [{ id: originalId, type: "message" }];
|
||||
const second = [{ id: originalId, type: "message" }];
|
||||
|
||||
expect(rewriteCopilotConnectionBoundResponseIds(first)).toBe(true);
|
||||
expect(rewriteCopilotConnectionBoundResponseIds(second)).toBe(true);
|
||||
expect(rewriteInputIds(first)).toBe(true);
|
||||
expect(rewriteInputIds(second)).toBe(true);
|
||||
expect(first[0]?.id).toMatch(/^msg_[a-f0-9]{16}$/);
|
||||
expect(first[0]?.id).toBe(second[0]?.id);
|
||||
});
|
||||
@@ -29,7 +29,7 @@ describe("github-copilot connection-bound response IDs", () => {
|
||||
{ id: messageId, type: "message" },
|
||||
];
|
||||
|
||||
expect(rewriteCopilotConnectionBoundResponseIds(input)).toBe(true);
|
||||
expect(rewriteInputIds(input)).toBe(true);
|
||||
expect(input[0]?.id).toBe("rs_existing");
|
||||
expect(input[1]?.id).toBe("msg_existing");
|
||||
expect(input[2]?.id).toBe("fc_existing");
|
||||
@@ -47,7 +47,7 @@ describe("github-copilot connection-bound response IDs", () => {
|
||||
{ id: withoutField, type: "reasoning" },
|
||||
];
|
||||
|
||||
expect(rewriteCopilotConnectionBoundResponseIds(input)).toBe(false);
|
||||
expect(rewriteInputIds(input)).toBe(false);
|
||||
expect(input[0]?.id).toBe(withEncrypted);
|
||||
expect(input[1]?.id).toBe(withNull);
|
||||
expect(input[2]?.id).toBe(withoutField);
|
||||
@@ -61,7 +61,7 @@ describe("github-copilot connection-bound response IDs", () => {
|
||||
{ id: withoutEncrypted, type: "reasoning" },
|
||||
];
|
||||
|
||||
expect(sanitizeCopilotReplayResponseIds(input)).toBe(false);
|
||||
expect(rewriteInputIds(input)).toBe(false);
|
||||
expect(input.map((item) => item.id)).toEqual([withEncrypted, withoutEncrypted]);
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("github-copilot connection-bound response IDs", () => {
|
||||
{ id: "rs_valid", type: "reasoning", encrypted_content: "valid", summary: [] },
|
||||
];
|
||||
|
||||
expect(sanitizeCopilotReplayResponseIds(input)).toBe(true);
|
||||
expect(rewriteInputIds(input)).toBe(true);
|
||||
expect(input).toEqual([
|
||||
{ type: "reasoning", encrypted_content: "missing-id", summary: [] },
|
||||
{ id: "rs_valid", type: "reasoning", encrypted_content: "valid", summary: [] },
|
||||
|
||||
@@ -34,7 +34,7 @@ function isValidReasoningReplayId(id: unknown): id is string {
|
||||
return typeof id === "string" && id.length > 0 && id.length <= 64;
|
||||
}
|
||||
|
||||
export function sanitizeCopilotReplayResponseIds(input: unknown): boolean {
|
||||
function sanitizeCopilotReplayResponseIds(input: unknown): boolean {
|
||||
if (!Array.isArray(input)) {
|
||||
return false;
|
||||
}
|
||||
@@ -66,10 +66,6 @@ export function sanitizeCopilotReplayResponseIds(input: unknown): boolean {
|
||||
return rewrote;
|
||||
}
|
||||
|
||||
export function rewriteCopilotConnectionBoundResponseIds(input: unknown): boolean {
|
||||
return sanitizeCopilotReplayResponseIds(input);
|
||||
}
|
||||
|
||||
function sanitizeCopilotReplayResponsePayloadIds(payload: unknown): boolean {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return false;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js";
|
||||
import {
|
||||
PUBLIC_GITHUB_COPILOT_DOMAIN,
|
||||
resolveGithubCopilotDomain,
|
||||
withGithubCopilotDomainConfig,
|
||||
} from "./domain.js";
|
||||
|
||||
describe("github-copilot domain resolution", () => {
|
||||
const withDomain = (githubDomain: string) =>
|
||||
@@ -36,3 +40,32 @@ describe("github-copilot domain resolution", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withGithubCopilotDomainConfig", () => {
|
||||
const tenantConfig = {
|
||||
models: {
|
||||
providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } },
|
||||
},
|
||||
} as never;
|
||||
|
||||
it("persists the tenant domain when login minted a tenant token", () => {
|
||||
const next = withGithubCopilotDomainConfig({} as never, "acme.ghe.com");
|
||||
expect(
|
||||
(next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } })
|
||||
.models?.providers?.["github-copilot"]?.params?.githubDomain,
|
||||
).toBe("acme.ghe.com");
|
||||
});
|
||||
|
||||
it("clears a stale tenant domain after public login", () => {
|
||||
const next = withGithubCopilotDomainConfig(tenantConfig, "github.com");
|
||||
const params = (
|
||||
next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } }
|
||||
).models?.providers?.["github-copilot"]?.params;
|
||||
expect(params && "githubDomain" in params).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves config untouched for public login without persisted domain", () => {
|
||||
const cfg = {} as never;
|
||||
expect(withGithubCopilotDomainConfig(cfg, "github.com")).toBe(cfg);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,3 +38,37 @@ export function resolveGithubCopilotDomain(params?: {
|
||||
}
|
||||
return normalizeGithubCopilotDomain(readConfiguredGithubCopilotDomain(params?.config));
|
||||
}
|
||||
|
||||
// Shortcut login must persist its token's tenant. A missing domain would route
|
||||
// the tenant token back to github.com after the environment override is removed.
|
||||
export function withGithubCopilotDomainConfig(cfg: OpenClawConfig, domain: string): OpenClawConfig {
|
||||
const models: NonNullable<OpenClawConfig["models"]> = cfg.models ?? {};
|
||||
const providers: NonNullable<typeof models.providers> = models.providers ?? {};
|
||||
const provider = providers["github-copilot"];
|
||||
const params = provider?.params;
|
||||
const isDefault = domain === PUBLIC_GITHUB_COPILOT_DOMAIN;
|
||||
if (isDefault && !(params && "githubDomain" in params)) {
|
||||
return cfg;
|
||||
}
|
||||
const nextParams: Record<string, unknown> = { ...params };
|
||||
if (isDefault) {
|
||||
delete nextParams.githubDomain;
|
||||
} else {
|
||||
nextParams.githubDomain = domain;
|
||||
}
|
||||
const nextProviders = { ...providers };
|
||||
if (provider) {
|
||||
nextProviders["github-copilot"] = { ...provider, params: nextParams };
|
||||
} else {
|
||||
// Source config accepts partial provider inputs; catalog materialization
|
||||
// supplies baseUrl/models before runtime consumption.
|
||||
Object.assign(nextProviders, { "github-copilot": { params: nextParams } });
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
models: {
|
||||
...models,
|
||||
providers: nextProviders,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,16 +17,15 @@ import type {
|
||||
UnifiedModelCatalogEntry,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import type { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
runGitHubCopilotDeviceFlow,
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting,
|
||||
} from "./login.js";
|
||||
import { runGitHubCopilotDeviceFlow } from "./login.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
githubCopilotLoginCommand: vi.fn(),
|
||||
fetchWithSsrFGuard: vi.fn(async (params: { url: string; init?: RequestInit }) => ({
|
||||
fetchWithSsrFGuard: vi.fn<typeof fetchWithSsrFGuard>(async (params) => ({
|
||||
response: await fetch(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: vi.fn(async () => {}),
|
||||
})),
|
||||
resolveCopilotApiToken: vi.fn(),
|
||||
@@ -85,7 +84,11 @@ type GithubCopilotTestModelCatalogProvider = {
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(null);
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
||||
response: await fetch(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: vi.fn(async () => {}),
|
||||
}));
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
@@ -730,7 +733,7 @@ describe("github-copilot plugin", () => {
|
||||
throw new Error(`unexpected fetch in github-copilot refresh test: ${target}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
||||
response: await fetchMock(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: async () => {},
|
||||
@@ -838,7 +841,7 @@ describe("github-copilot plugin", () => {
|
||||
writeExistingCopilotTokenProfile(agentDir);
|
||||
const fetchMock = buildDeviceFlowFetchMock("github.com", "public-fresh-token");
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
||||
response: await fetchMock(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: async () => {},
|
||||
@@ -898,7 +901,7 @@ describe("github-copilot plugin", () => {
|
||||
writeExistingCopilotTokenProfile(agentDir);
|
||||
const fetchMock = buildDeviceFlowFetchMock("acme.ghe.com", "tenant-fresh-token");
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
||||
response: await fetchMock(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: async () => {},
|
||||
@@ -956,7 +959,7 @@ describe("github-copilot plugin", () => {
|
||||
// the domain change is detected and the public token is not reused.
|
||||
const fetchMock = buildDeviceFlowFetchMock("acme.ghe.com", "tenant-fresh-token");
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
||||
response: await fetchMock(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: async () => {},
|
||||
@@ -1062,7 +1065,7 @@ describe("github-copilot plugin", () => {
|
||||
// prompt value instead, the fetch mock would throw on an unexpected host.
|
||||
const fetchMock = buildDeviceFlowFetchMock("env-tenant.ghe.com", "env-tenant-token");
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
||||
response: await fetchMock(params.url, params.init),
|
||||
finalUrl: params.url,
|
||||
release: async () => {},
|
||||
@@ -1112,7 +1115,7 @@ describe("github-copilot plugin", () => {
|
||||
|
||||
it("rejects unsafe GitHub device code lifetimes before polling", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => ({
|
||||
response: new Response(
|
||||
'{"device_code":"device-code-stub","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":1e309,"interval":0}',
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
@@ -1132,7 +1135,7 @@ describe("github-copilot plugin", () => {
|
||||
it("rejects GitHub device code expiries outside the Date timestamp range before polling", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(MAX_DATE_TIMESTAMP_MS);
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => ({
|
||||
response: new Response(
|
||||
'{"device_code":"device-code-stub","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":1,"interval":0}',
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
@@ -1159,7 +1162,7 @@ describe("github-copilot plugin", () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
let accessTokenPolls = 0;
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => {
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
|
||||
if (params.url === "https://github.com/login/device/code") {
|
||||
return {
|
||||
response: new Response(
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
// Github Copilot tests cover device-flow login behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
runGitHubCopilotDeviceFlow,
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting,
|
||||
withGithubCopilotDomainConfig,
|
||||
} from "./login.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
|
||||
"openclaw/plugin-sdk/ssrf-runtime",
|
||||
);
|
||||
return { ...actual, fetchWithSsrFGuard: mocks.fetchWithSsrFGuard };
|
||||
});
|
||||
|
||||
import { runGitHubCopilotDeviceFlow } from "./login.js";
|
||||
|
||||
const DEVICE_CODE_URL = "https://github.com/login/device/code";
|
||||
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
||||
@@ -30,7 +38,7 @@ function guardResponse(body: unknown, status = 200, url = DEVICE_CODE_URL) {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(null);
|
||||
mocks.fetchWithSsrFGuard.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -39,7 +47,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
|
||||
let callIdx = 0;
|
||||
const requestTimeouts: Array<number | undefined> = [];
|
||||
const controller = new AbortController();
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => {
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
|
||||
callIdx += 1;
|
||||
requestTimeouts.push(params.timeoutMs);
|
||||
expect(params.signal).toBe(controller.signal);
|
||||
@@ -70,7 +78,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
|
||||
|
||||
it("returns access_denied when GitHub rejects the authorization", async () => {
|
||||
let callIdx = 0;
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
|
||||
callIdx += 1;
|
||||
if (callIdx === 1) {
|
||||
return guardResponse(VALID_DEVICE_CODE_BODY);
|
||||
@@ -86,7 +94,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
|
||||
|
||||
it("returns expired when GitHub reports expired_token", async () => {
|
||||
let callIdx = 0;
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
|
||||
callIdx += 1;
|
||||
if (callIdx === 1) {
|
||||
return guardResponse(VALID_DEVICE_CODE_BODY);
|
||||
@@ -103,7 +111,7 @@ describe("runGitHubCopilotDeviceFlow — normal flow", () => {
|
||||
|
||||
describe("runGitHubCopilotDeviceFlow — HTTP error propagation", () => {
|
||||
it("throws with failureLabel on non-OK device code response", async () => {
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => guardResponse({}, 401));
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => guardResponse({}, 401));
|
||||
|
||||
await expect(runGitHubCopilotDeviceFlow({ showCode: vi.fn() })).rejects.toThrow(
|
||||
"GitHub device code failed: HTTP 401",
|
||||
@@ -112,7 +120,7 @@ describe("runGitHubCopilotDeviceFlow — HTTP error propagation", () => {
|
||||
|
||||
it("throws with failureLabel on non-OK access token response", async () => {
|
||||
let callIdx = 0;
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
|
||||
callIdx += 1;
|
||||
if (callIdx === 1) {
|
||||
return guardResponse(VALID_DEVICE_CODE_BODY);
|
||||
@@ -146,7 +154,7 @@ describe("postGitHubDeviceFlowForm — response size bound", () => {
|
||||
},
|
||||
});
|
||||
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => ({
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => ({
|
||||
response: new Response(oversizedBody, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -170,7 +178,7 @@ describe("postGitHubDeviceFlowForm — response size bound", () => {
|
||||
let canceled = false;
|
||||
let callIdx = 0;
|
||||
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () => {
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () => {
|
||||
callIdx += 1;
|
||||
if (callIdx === 1) {
|
||||
return guardResponse(VALID_DEVICE_CODE_BODY);
|
||||
@@ -219,7 +227,7 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () =
|
||||
|
||||
const urls: string[] = [];
|
||||
let callIdx = 0;
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => {
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
|
||||
urls.push(params.url);
|
||||
callIdx += 1;
|
||||
if (callIdx === 1) {
|
||||
@@ -250,7 +258,7 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () =
|
||||
});
|
||||
|
||||
it("rejects a verification URL whose host does not match the configured domain", async () => {
|
||||
setGitHubCopilotDeviceFlowFetchGuardForTesting(async () =>
|
||||
mocks.fetchWithSsrFGuard.mockImplementation(async () =>
|
||||
guardResponse(
|
||||
{ ...VALID_DEVICE_CODE_BODY, verification_uri: "https://github.com/login/device" },
|
||||
200,
|
||||
@@ -263,32 +271,3 @@ describe("runGitHubCopilotDeviceFlow — data-residency GitHub Enterprise", () =
|
||||
).rejects.toThrow("unexpected verification URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withGithubCopilotDomainConfig — shortcut login domain persistence", () => {
|
||||
const tenantConfig = {
|
||||
models: {
|
||||
providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } },
|
||||
},
|
||||
} as never;
|
||||
|
||||
it("persists the tenant domain when the shortcut minted a tenant token", () => {
|
||||
const next = withGithubCopilotDomainConfig({} as never, "acme.ghe.com");
|
||||
expect(
|
||||
(next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } })
|
||||
.models?.providers?.["github-copilot"]?.params?.githubDomain,
|
||||
).toBe("acme.ghe.com");
|
||||
});
|
||||
|
||||
it("clears a stale tenant domain when the shortcut logged in against github.com", () => {
|
||||
const next = withGithubCopilotDomainConfig(tenantConfig, "github.com");
|
||||
const params = (
|
||||
next as { models?: { providers?: Record<string, { params?: Record<string, unknown> }> } }
|
||||
).models?.providers?.["github-copilot"]?.params;
|
||||
expect(params && "githubDomain" in params).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves config untouched for a public login with no persisted domain", () => {
|
||||
const cfg = {} as never;
|
||||
expect(withGithubCopilotDomainConfig(cfg, "github.com")).toBe(cfg);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Github Copilot plugin module implements login behavior.
|
||||
import { intro, note, outro, spinner } from "@clack/prompts";
|
||||
import { stylePromptTitle } from "openclaw/plugin-sdk/cli-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { logConfigUpdated, updateConfig } from "openclaw/plugin-sdk/config-mutation";
|
||||
import {
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
@@ -18,7 +17,11 @@ import {
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js";
|
||||
import {
|
||||
PUBLIC_GITHUB_COPILOT_DOMAIN,
|
||||
resolveGithubCopilotDomain,
|
||||
withGithubCopilotDomainConfig,
|
||||
} from "./domain.js";
|
||||
|
||||
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
||||
const GITHUB_DEVICE_FLOW_REQUEST_TIMEOUT_MS = 30_000;
|
||||
@@ -68,14 +71,6 @@ class GitHubDeviceFlowError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
let githubDeviceFlowFetchGuard = fetchWithSsrFGuard;
|
||||
|
||||
export function setGitHubCopilotDeviceFlowFetchGuardForTesting(
|
||||
impl: typeof fetchWithSsrFGuard | null,
|
||||
): void {
|
||||
githubDeviceFlowFetchGuard = impl ?? fetchWithSsrFGuard;
|
||||
}
|
||||
|
||||
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
|
||||
const updated = await upsertAuthProfileWithLock(params);
|
||||
if (!updated) {
|
||||
@@ -142,7 +137,7 @@ async function postGitHubDeviceFlowForm(params: {
|
||||
domain: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const { response, release } = await githubDeviceFlowFetchGuard({
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: params.url,
|
||||
init: {
|
||||
method: "POST",
|
||||
@@ -357,46 +352,6 @@ export async function runGitHubCopilotDeviceFlow(
|
||||
}
|
||||
}
|
||||
|
||||
// The shortcut login mints its token against the resolved domain, so the same
|
||||
// domain must land in persisted config: a tenant token with no stored
|
||||
// githubDomain would silently route to github.com (and 401) once
|
||||
// COPILOT_GITHUB_DOMAIN is unset. Mirrors the enterprise auth method's
|
||||
// persist-on-tenant / clear-on-public behavior.
|
||||
export function withGithubCopilotDomainConfig(cfg: OpenClawConfig, domain: string): OpenClawConfig {
|
||||
// Normalize the optional layers to concrete objects before spreading:
|
||||
// spreading a possibly-undefined object widens every optional property to
|
||||
// `T | undefined`, which exactOptionalPropertyTypes rejects.
|
||||
const models: NonNullable<OpenClawConfig["models"]> = cfg.models ?? {};
|
||||
const providers: NonNullable<typeof models.providers> = models.providers ?? {};
|
||||
const provider = providers["github-copilot"];
|
||||
const params = provider?.params;
|
||||
const isDefault = domain === PUBLIC_GITHUB_COPILOT_DOMAIN;
|
||||
if (isDefault && !(params && "githubDomain" in params)) {
|
||||
return cfg;
|
||||
}
|
||||
const nextParams: Record<string, unknown> = { ...params };
|
||||
if (isDefault) {
|
||||
delete nextParams.githubDomain;
|
||||
} else {
|
||||
nextParams.githubDomain = domain;
|
||||
}
|
||||
const nextProviders = { ...providers };
|
||||
if (provider) {
|
||||
nextProviders["github-copilot"] = { ...provider, params: nextParams };
|
||||
} else {
|
||||
// Source config accepts partial provider inputs; catalog materialization
|
||||
// supplies baseUrl/models before runtime consumption.
|
||||
Object.assign(nextProviders, { "github-copilot": { params: nextParams } });
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
models: {
|
||||
...models,
|
||||
providers: nextProviders,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function githubCopilotLoginCommand(
|
||||
opts: { profileId?: string; yes?: boolean; agentDir?: string },
|
||||
runtime: RuntimeEnv,
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { deriveCopilotApiBaseUrlFromToken } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { createProviderUsageFetch, makeResponse } from "openclaw/plugin-sdk/test-env";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CachedCopilotToken } from "./token.js";
|
||||
import { deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } from "./token.js";
|
||||
import type { CachedCopilotToken } from "./token-cache.js";
|
||||
import { resolveCopilotApiToken } from "./token.js";
|
||||
import { fetchCopilotUsage } from "./usage.js";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-model-shared", async (importOriginal) => ({
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
// Github Copilot tests cover stream plugin behavior.
|
||||
import type { Context } from "openclaw/plugin-sdk/llm";
|
||||
import { buildCopilotIdeHeaders, COPILOT_INTEGRATION_ID } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildCopilotDynamicHeaders } from "./stream.js";
|
||||
import {
|
||||
wrapCopilotAnthropicStream,
|
||||
wrapCopilotOpenAICompletionsStream,
|
||||
wrapCopilotOpenAIResponsesStream,
|
||||
wrapCopilotProviderStream,
|
||||
} from "./stream.js";
|
||||
import { wrapCopilotAnthropicStream, wrapCopilotProviderStream } from "./stream.js";
|
||||
|
||||
function requireStreamFn(streamFn: ReturnType<typeof wrapCopilotProviderStream>) {
|
||||
expect(streamFn).toBeTypeOf("function");
|
||||
@@ -28,6 +24,19 @@ function requireFirstStreamOptions(mock: ReturnType<typeof vi.fn>, label: string
|
||||
return options as { headers?: Record<string, unknown>; onPayload?: unknown };
|
||||
}
|
||||
|
||||
function buildExpectedCopilotHeaders(
|
||||
initiator: "agent" | "user",
|
||||
hasImages: boolean,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...buildCopilotIdeHeaders(),
|
||||
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
|
||||
"Openai-Organization": "github-copilot",
|
||||
"x-initiator": initiator,
|
||||
...(hasImages ? { "Copilot-Vision-Request": "true" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("wrapCopilotAnthropicStream", () => {
|
||||
it("adds Copilot headers, strips thinking replay, and marks cache for Claude payloads", () => {
|
||||
const payloads: Array<{
|
||||
@@ -63,12 +72,9 @@ describe("wrapCopilotAnthropicStream", () => {
|
||||
{ type: "image", image: "data:image/png;base64,abc" },
|
||||
],
|
||||
},
|
||||
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
|
||||
] as Context["messages"];
|
||||
const context = { messages };
|
||||
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
|
||||
messages,
|
||||
hasImages: true,
|
||||
});
|
||||
const expectedCopilotHeaders = buildExpectedCopilotHeaders("user", true);
|
||||
expect(expectedCopilotHeaders["Accept-Encoding"]).toBe("identity");
|
||||
|
||||
void wrapped(
|
||||
@@ -192,7 +198,7 @@ describe("wrapCopilotAnthropicStream", () => {
|
||||
} as never;
|
||||
});
|
||||
|
||||
const wrapped = requireStreamFn(wrapCopilotOpenAIResponsesStream(baseStreamFn));
|
||||
const wrapped = requireStreamFn(wrapCopilotProviderStream({ streamFn: baseStreamFn } as never));
|
||||
const messages = [
|
||||
{
|
||||
role: "toolResult",
|
||||
@@ -201,11 +207,8 @@ describe("wrapCopilotAnthropicStream", () => {
|
||||
{ type: "image", image: "data:image/png;base64,abc" },
|
||||
],
|
||||
},
|
||||
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
|
||||
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
|
||||
messages,
|
||||
hasImages: true,
|
||||
});
|
||||
] as Context["messages"];
|
||||
const expectedCopilotHeaders = buildExpectedCopilotHeaders("agent", true);
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
@@ -249,7 +252,7 @@ describe("wrapCopilotAnthropicStream", () => {
|
||||
} as never;
|
||||
});
|
||||
|
||||
const wrapped = requireStreamFn(wrapCopilotOpenAIResponsesStream(baseStreamFn));
|
||||
const wrapped = requireStreamFn(wrapCopilotProviderStream({ streamFn: baseStreamFn } as never));
|
||||
|
||||
await wrapped(
|
||||
{
|
||||
@@ -270,7 +273,7 @@ describe("wrapCopilotAnthropicStream", () => {
|
||||
|
||||
it("adds Copilot headers for Chat Completions models", () => {
|
||||
const baseStreamFn = vi.fn(() => ({ async *[Symbol.asyncIterator]() {} }) as never);
|
||||
const wrapped = requireStreamFn(wrapCopilotOpenAICompletionsStream(baseStreamFn));
|
||||
const wrapped = requireStreamFn(wrapCopilotProviderStream({ streamFn: baseStreamFn } as never));
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
@@ -279,11 +282,8 @@ describe("wrapCopilotAnthropicStream", () => {
|
||||
{ type: "image", data: "abc", mimeType: "image/png" },
|
||||
],
|
||||
},
|
||||
] as Parameters<typeof buildCopilotDynamicHeaders>[0]["messages"];
|
||||
const expectedCopilotHeaders = buildCopilotDynamicHeaders({
|
||||
messages,
|
||||
hasImages: true,
|
||||
});
|
||||
] as Context["messages"];
|
||||
const expectedCopilotHeaders = buildExpectedCopilotHeaders("user", true);
|
||||
|
||||
void wrapped(
|
||||
{
|
||||
|
||||
@@ -46,7 +46,7 @@ function hasCopilotVisionInput(messages: Context["messages"]): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCopilotDynamicHeaders(params: {
|
||||
function buildCopilotDynamicHeaders(params: {
|
||||
messages: Context["messages"];
|
||||
hasImages: boolean;
|
||||
}): Record<string, string> {
|
||||
@@ -115,7 +115,7 @@ export function wrapCopilotAnthropicStream(
|
||||
};
|
||||
}
|
||||
|
||||
export function wrapCopilotOpenAIResponsesStream(
|
||||
function wrapCopilotOpenAIResponsesStream(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
): StreamFn | undefined {
|
||||
if (!baseStreamFn) {
|
||||
@@ -140,7 +140,7 @@ export function wrapCopilotOpenAIResponsesStream(
|
||||
};
|
||||
}
|
||||
|
||||
export function wrapCopilotOpenAICompletionsStream(
|
||||
function wrapCopilotOpenAICompletionsStream(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
): StreamFn | undefined {
|
||||
if (!baseStreamFn) {
|
||||
|
||||
@@ -19,9 +19,6 @@ import {
|
||||
resolveCopilotTokenCache,
|
||||
type CachedCopilotToken,
|
||||
} from "./token-cache.js";
|
||||
export { deriveCopilotApiBaseUrlFromToken } from "openclaw/plugin-sdk/provider-auth";
|
||||
export type { CachedCopilotToken } from "./token-cache.js";
|
||||
|
||||
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
|
||||
const COPILOT_TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
|
||||
let openConfiguredCacheStore: (() => PluginStateSyncKeyedStore<CachedCopilotToken>) | undefined;
|
||||
@@ -87,8 +84,6 @@ async function cancelUnreadResponseBody(response: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolveCopilotApiToken = typeof resolveCopilotApiToken;
|
||||
|
||||
export async function resolveCopilotApiToken(params: {
|
||||
githubToken: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
|
||||
Reference in New Issue
Block a user