Files
openclaw/extensions/copilot/src/auth-bridge.test.ts
Ramrajprabu f3cfd752d3 feat(copilot): add GitHub Copilot agent runtime
Adds the opt-in bundled GitHub Copilot agent runtime, pinned SDK install path, docs/inventory, SDK/tool/sandbox/auth wiring, and replay/tool-safety fixes.

Verification:
- Local: git diff --check; fnm exec --using 24.15.0 pnpm tsgo:extensions; fnm exec --using 24.15.0 pnpm check:test-types; fnm exec --using 24.15.0 pnpm build.
- Autoreview local: clean for the replay-safety fix; branch autoreview engine returned empty output twice, so local autoreview plus local/Crabbox/CI proof was used.
- Crabbox focused Copilot: run_2c0db9f48a4a, 19 files / 485 tests passed.
- Crabbox additional boundary shard: run_26a246a1aa24, prompt snapshots and plugin SDK boundary/export checks passed.
- Crabbox live Copilot: run_d128e4048b4e, real gpt-4.1 turn with live_echo phase-1-green and clean session-file check.
- GitHub checks: green on head 7cc8657e0d, including Dependency Guard after exact-head approval.

Co-authored-by: Ramraj Balasubramanian <ramrajba@microsoft.com>
2026-05-29 05:15:22 +01:00

525 lines
18 KiB
TypeScript
Executable File

import { createHash } from "node:crypto";
import { resolve, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
COPILOT_DEFAULT_AGENT_ID,
COPILOT_TOKEN_PROFILE_ERROR,
normalizeCopilotHomePath,
resolveCopilotAuth,
sanitizeAgentId,
tokenFingerprint,
} from "./auth-bridge.js";
function cleanEnv(): NodeJS.ProcessEnv {
return {} as NodeJS.ProcessEnv;
}
const FAKE_HOME = "/fake-home";
const fakeHomeDir = () => FAKE_HOME;
describe("sanitizeAgentId", () => {
it("returns default for null/undefined/empty", () => {
expect(sanitizeAgentId(undefined)).toBe(COPILOT_DEFAULT_AGENT_ID);
expect(sanitizeAgentId(null)).toBe(COPILOT_DEFAULT_AGENT_ID);
expect(sanitizeAgentId("")).toBe(COPILOT_DEFAULT_AGENT_ID);
expect(sanitizeAgentId(" ")).toBe(COPILOT_DEFAULT_AGENT_ID);
});
it("lowercases and accepts alnum + dash + underscore", () => {
expect(sanitizeAgentId("Agent-1")).toBe("agent-1");
expect(sanitizeAgentId("my_agent_42")).toBe("my_agent_42");
expect(sanitizeAgentId("a")).toBe("a");
});
it("rejects path-traversal segments and falls back to default", () => {
expect(sanitizeAgentId("../etc/passwd")).toBe(COPILOT_DEFAULT_AGENT_ID);
expect(sanitizeAgentId("../..")).toBe(COPILOT_DEFAULT_AGENT_ID);
expect(sanitizeAgentId("a/b")).toBe(COPILOT_DEFAULT_AGENT_ID);
expect(sanitizeAgentId("a\\b")).toBe(COPILOT_DEFAULT_AGENT_ID);
expect(sanitizeAgentId("a\u0000b")).toBe(COPILOT_DEFAULT_AGENT_ID);
});
it("rejects ids that do not start with alnum", () => {
expect(sanitizeAgentId("-foo")).toBe(COPILOT_DEFAULT_AGENT_ID);
expect(sanitizeAgentId("_bar")).toBe(COPILOT_DEFAULT_AGENT_ID);
});
it("rejects ids longer than 64 chars", () => {
expect(sanitizeAgentId("a".repeat(64))).toBe("a".repeat(64));
expect(sanitizeAgentId("a".repeat(65))).toBe(COPILOT_DEFAULT_AGENT_ID);
});
});
describe("tokenFingerprint", () => {
it("returns a stable sha256-prefixed 12-hex fingerprint", () => {
const a = tokenFingerprint("hello");
const b = tokenFingerprint("hello");
expect(a).toBe(b);
expect(a.startsWith("sha256:")).toBe(true);
expect(a.length).toBe("sha256:".length + 12);
const expected = "sha256:" + createHash("sha256").update("hello").digest("hex").slice(0, 12);
expect(a).toBe(expected);
});
it("differs across distinct inputs (no collision for common values)", () => {
expect(tokenFingerprint("alpha")).not.toBe(tokenFingerprint("beta"));
expect(tokenFingerprint("token-v1")).not.toBe(tokenFingerprint("token-v2"));
});
it("never contains the raw token", () => {
const token = "ghp_abcdefghijklmnop";
expect(tokenFingerprint(token).includes(token)).toBe(false);
});
});
describe("resolveCopilotAuth - copilotHome resolution", () => {
it("uses explicit copilotHome when provided", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
copilotHome: "/explicit/home",
env: cleanEnv(),
homeDir: fakeHomeDir,
});
expect(result.copilotHome).toBe(resolve("/explicit/home"));
});
it("falls back to <agentDir>/copilot when copilotHome is absent", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
agentDir: "/agent/dir",
env: cleanEnv(),
homeDir: fakeHomeDir,
});
expect(result.copilotHome).toBe(resolve(join("/agent/dir", "copilot")));
});
it("synthesises per-agent default from homeDir when no path is given", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
env: cleanEnv(),
homeDir: fakeHomeDir,
});
expect(result.copilotHome).toBe(
resolve(join(FAKE_HOME, ".openclaw", "agents", "agent-1", "copilot")),
);
});
it("respects OPENCLAW_HOME env var as the home root", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
env: { OPENCLAW_HOME: "/custom/openclaw" } as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(result.copilotHome).toBe(
resolve(join("/custom/openclaw", ".openclaw", "agents", "agent-1", "copilot")),
);
});
it("uses the default agent id when agentId is invalid/missing", () => {
const result = resolveCopilotAuth({
agentId: undefined,
env: cleanEnv(),
homeDir: fakeHomeDir,
});
expect(result.agentId).toBe(COPILOT_DEFAULT_AGENT_ID);
expect(result.copilotHome).toBe(
resolve(join(FAKE_HOME, ".openclaw", "agents", COPILOT_DEFAULT_AGENT_ID, "copilot")),
);
});
it("isolates per-agent copilotHome between agents", () => {
const a = resolveCopilotAuth({
agentId: "agent-a",
env: cleanEnv(),
homeDir: fakeHomeDir,
});
const b = resolveCopilotAuth({
agentId: "agent-b",
env: cleanEnv(),
homeDir: fakeHomeDir,
});
expect(a.copilotHome).not.toBe(b.copilotHome);
expect(a.copilotHome.endsWith(join("agent-a", "copilot"))).toBe(true);
expect(b.copilotHome.endsWith(join("agent-b", "copilot"))).toBe(true);
});
});
describe("resolveCopilotAuth - auth mode resolution", () => {
it("returns useLoggedInUser when auth.useLoggedInUser=true (ignoring gitHubToken)", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
auth: { useLoggedInUser: true, gitHubToken: "should-be-ignored" },
env: { GITHUB_TOKEN: "env-token" } as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("useLoggedInUser");
expect(result.gitHubToken).toBeUndefined();
expect(result.authProfileId).toBeUndefined();
expect(result.authProfileVersion).toBeUndefined();
});
it("returns gitHubToken when explicit token + profile id/version provided", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
auth: { gitHubToken: "tok", profileId: "p", profileVersion: "v1" },
env: cleanEnv(),
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("gitHubToken");
expect(result.gitHubToken).toBe("tok");
expect(result.authProfileId).toBe("p");
expect(result.authProfileVersion).toBe("v1");
});
it("accepts legacy top-level profileVersion + authProfileId fallbacks", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
auth: { gitHubToken: "tok" },
authProfileId: "legacy-p",
profileVersion: "legacy-v1",
env: cleanEnv(),
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("gitHubToken");
expect(result.authProfileId).toBe("legacy-p");
expect(result.authProfileVersion).toBe("legacy-v1");
});
it("throws when explicit gitHubToken is given without both profileId + profileVersion", () => {
expect(() =>
resolveCopilotAuth({
agentId: "agent-1",
auth: { gitHubToken: "tok" },
env: cleanEnv(),
homeDir: fakeHomeDir,
}),
).toThrow(COPILOT_TOKEN_PROFILE_ERROR);
expect(() =>
resolveCopilotAuth({
agentId: "agent-1",
auth: { gitHubToken: "tok", profileId: "p" },
env: cleanEnv(),
homeDir: fakeHomeDir,
}),
).toThrow(COPILOT_TOKEN_PROFILE_ERROR);
expect(() =>
resolveCopilotAuth({
agentId: "agent-1",
auth: { gitHubToken: "tok", profileVersion: "v" },
env: cleanEnv(),
homeDir: fakeHomeDir,
}),
).toThrow(COPILOT_TOKEN_PROFILE_ERROR);
});
it("defaults to useLoggedInUser when no auth signal at all", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
env: cleanEnv(),
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("useLoggedInUser");
expect(result.gitHubToken).toBeUndefined();
});
});
describe("resolveCopilotAuth - contract-resolved auth (resolvedApiKey + authProfileId)", () => {
it("consumes resolvedApiKey + authProfileId from the EmbeddedRunAttemptParams contract", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
resolvedApiKey: "contract-token-xyz",
authProfileId: "github-copilot:main",
env: cleanEnv(),
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("gitHubToken");
expect(result.gitHubToken).toBe("contract-token-xyz");
expect(result.authProfileId).toBe("github-copilot:main");
expect(result.authProfileVersion).toBe(tokenFingerprint("contract-token-xyz"));
});
it("synthesises authProfileId when contract-resolved token has no profile id", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
resolvedApiKey: "contract-token-xyz",
env: cleanEnv(),
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("gitHubToken");
expect(result.gitHubToken).toBe("contract-token-xyz");
expect(result.authProfileId).toBe("pi:resolved");
expect(result.authProfileVersion).toBe(tokenFingerprint("contract-token-xyz"));
});
it("auth.useLoggedInUser=true takes precedence over contract resolvedApiKey", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
auth: { useLoggedInUser: true },
resolvedApiKey: "should-be-ignored",
authProfileId: "p",
env: cleanEnv(),
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("useLoggedInUser");
expect(result.gitHubToken).toBeUndefined();
});
it("explicit auth.gitHubToken takes precedence over contract resolvedApiKey", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
auth: { gitHubToken: "explicit", profileId: "p", profileVersion: "v1" },
resolvedApiKey: "contract-should-be-ignored",
authProfileId: "contract-profile",
env: cleanEnv(),
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("gitHubToken");
expect(result.gitHubToken).toBe("explicit");
expect(result.authProfileId).toBe("p");
expect(result.authProfileVersion).toBe("v1");
});
it("contract resolvedApiKey takes precedence over env fallback", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
resolvedApiKey: "contract-token",
authProfileId: "p",
env: {
OPENCLAW_GITHUB_TOKEN: "env-should-be-ignored",
COPILOT_GITHUB_TOKEN: "copilot-env-should-be-ignored",
GH_TOKEN: "gh-env-should-be-ignored",
GITHUB_TOKEN: "github-env-should-be-ignored",
} as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(result.gitHubToken).toBe("contract-token");
expect(result.authProfileId).toBe("p");
});
it("falls back to env when resolvedApiKey is absent", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
authProfileId: "p",
env: { GITHUB_TOKEN: "env-only" } as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(result.gitHubToken).toBe("env-only");
expect(result.authProfileId).toBe("env:GITHUB_TOKEN");
});
});
describe("resolveCopilotAuth - env var fallbacks", () => {
it("falls back to GITHUB_TOKEN with synthesised profile id + fingerprint", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
env: { GITHUB_TOKEN: "env-token-123" } as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("gitHubToken");
expect(result.gitHubToken).toBe("env-token-123");
expect(result.authProfileId).toBe("env:GITHUB_TOKEN");
expect(result.authProfileVersion).toBe(tokenFingerprint("env-token-123"));
});
it("OPENCLAW_GITHUB_TOKEN takes precedence over GITHUB_TOKEN", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
env: {
OPENCLAW_GITHUB_TOKEN: "openclaw-tok",
GITHUB_TOKEN: "github-tok",
} as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(result.gitHubToken).toBe("openclaw-tok");
expect(result.authProfileId).toBe("env:OPENCLAW_GITHUB_TOKEN");
expect(result.authProfileVersion).toBe(tokenFingerprint("openclaw-tok"));
});
it("falls back to COPILOT_GITHUB_TOKEN with synthesised profile id + fingerprint", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
env: { COPILOT_GITHUB_TOKEN: "copilot-tok-123" } as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("gitHubToken");
expect(result.gitHubToken).toBe("copilot-tok-123");
expect(result.authProfileId).toBe("env:COPILOT_GITHUB_TOKEN");
expect(result.authProfileVersion).toBe(tokenFingerprint("copilot-tok-123"));
});
it("falls back to GH_TOKEN with synthesised profile id + fingerprint", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
env: { GH_TOKEN: "gh-tok-456" } as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("gitHubToken");
expect(result.gitHubToken).toBe("gh-tok-456");
expect(result.authProfileId).toBe("env:GH_TOKEN");
expect(result.authProfileVersion).toBe(tokenFingerprint("gh-tok-456"));
});
it("OPENCLAW_GITHUB_TOKEN takes precedence over COPILOT_GITHUB_TOKEN, GH_TOKEN and GITHUB_TOKEN", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
env: {
OPENCLAW_GITHUB_TOKEN: "openclaw-tok",
COPILOT_GITHUB_TOKEN: "copilot-tok",
GH_TOKEN: "gh-tok",
GITHUB_TOKEN: "github-tok",
} as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(result.gitHubToken).toBe("openclaw-tok");
expect(result.authProfileId).toBe("env:OPENCLAW_GITHUB_TOKEN");
});
it("COPILOT_GITHUB_TOKEN takes precedence over GH_TOKEN and GITHUB_TOKEN", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
env: {
COPILOT_GITHUB_TOKEN: "copilot-tok",
GH_TOKEN: "gh-tok",
GITHUB_TOKEN: "github-tok",
} as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(result.gitHubToken).toBe("copilot-tok");
expect(result.authProfileId).toBe("env:COPILOT_GITHUB_TOKEN");
});
it("GH_TOKEN takes precedence over GITHUB_TOKEN", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
env: {
GH_TOKEN: "gh-tok",
GITHUB_TOKEN: "github-tok",
} as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(result.gitHubToken).toBe("gh-tok");
expect(result.authProfileId).toBe("env:GH_TOKEN");
});
it("token rotation in env changes the pool fingerprint (cache-busting)", () => {
const a = resolveCopilotAuth({
agentId: "agent-1",
env: { GITHUB_TOKEN: "v1" } as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
const b = resolveCopilotAuth({
agentId: "agent-1",
env: { GITHUB_TOKEN: "v2" } as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(a.authProfileVersion).not.toBe(b.authProfileVersion);
});
it("explicit auth.useLoggedInUser=true wins over env tokens", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
auth: { useLoggedInUser: true },
env: { OPENCLAW_GITHUB_TOKEN: "env-tok" } as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("useLoggedInUser");
});
it("explicit auth.gitHubToken wins over env tokens", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
auth: { gitHubToken: "explicit", profileId: "p", profileVersion: "v" },
env: { OPENCLAW_GITHUB_TOKEN: "env-tok" } as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("gitHubToken");
expect(result.gitHubToken).toBe("explicit");
expect(result.authProfileId).toBe("p");
expect(result.authProfileVersion).toBe("v");
});
it("ignores empty-string env tokens (treated as absent)", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
env: {
GITHUB_TOKEN: "",
OPENCLAW_GITHUB_TOKEN: "",
COPILOT_GITHUB_TOKEN: "",
GH_TOKEN: "",
} as NodeJS.ProcessEnv,
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("useLoggedInUser");
});
});
describe("resolveCopilotAuth - defaults wiring", () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = process.env;
process.env = { ...originalEnv };
delete process.env.GITHUB_TOKEN;
delete process.env.OPENCLAW_GITHUB_TOKEN;
delete process.env.COPILOT_GITHUB_TOKEN;
delete process.env.GH_TOKEN;
delete process.env.OPENCLAW_HOME;
});
afterEach(() => {
process.env = originalEnv;
});
it("uses process.env when env is not injected", () => {
process.env.GITHUB_TOKEN = "from-process-env";
const result = resolveCopilotAuth({
agentId: "agent-1",
homeDir: fakeHomeDir,
});
expect(result.authMode).toBe("gitHubToken");
expect(result.gitHubToken).toBe("from-process-env");
});
it("uses os.homedir() when homeDir is not injected", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
});
// We don't know the actual home, just that the resolver did not throw and
// produced an absolute path containing the per-agent suffix.
expect(result.copilotHome.endsWith(join(".openclaw", "agents", "agent-1", "copilot"))).toBe(
true,
);
});
it("falls back to process.cwd() if homeDir throws", () => {
const result = resolveCopilotAuth({
agentId: "agent-1",
env: cleanEnv(),
homeDir: () => {
throw new Error("no home");
},
});
// Should not throw; should produce a path under cwd.
expect(result.copilotHome.includes(join(".openclaw", "agents", "agent-1", "copilot"))).toBe(
true,
);
});
});
describe("normalizeCopilotHomePath", () => {
it("resolves to absolute and strips trailing separators", () => {
const normalized = normalizeCopilotHomePath("./foo/bar/");
expect(normalized).toBe(resolve("./foo/bar"));
expect(normalized.endsWith("/")).toBe(false);
expect(normalized.endsWith("\\")).toBe(false);
});
it("is idempotent", () => {
const once = normalizeCopilotHomePath("/some/path/");
const twice = normalizeCopilotHomePath(once);
expect(twice).toBe(once);
});
});