Files
openclaw/extensions/copilot/src/workspace-bootstrap.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

269 lines
11 KiB
TypeScript

import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { AgentHarnessAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
TESTING_EXPORTS,
remapCopilotBootstrapContextFiles,
renderCopilotWorkspaceBootstrapInstructions,
resolveCopilotWorkspaceBootstrapContext,
} from "./workspace-bootstrap.js";
const { COPILOT_NATIVE_PROJECT_DOC_BASENAMES, compareCopilotContextFiles } = TESTING_EXPORTS;
function makeAttempt(
overrides: Partial<AgentHarnessAttemptParams> = {},
): AgentHarnessAttemptParams {
return {
agentId: "agent-1",
prompt: "hello",
runId: "run-1",
sessionFile: "session.json",
sessionId: "session-1",
timeoutMs: 5000,
workspaceDir: "C:\\workspace",
...overrides,
} as unknown as AgentHarnessAttemptParams;
}
describe("renderCopilotWorkspaceBootstrapInstructions", () => {
it("returns undefined when there are no context files", () => {
expect(renderCopilotWorkspaceBootstrapInstructions([])).toBeUndefined();
});
it("returns undefined when every file is filtered as SDK-native", () => {
expect(
renderCopilotWorkspaceBootstrapInstructions([
{ path: "/ws/AGENTS.md", content: "Follow AGENTS guidance." },
]),
).toBeUndefined();
});
it("filters AGENTS.md (the SDK loads it natively from workingDirectory)", () => {
const rendered = renderCopilotWorkspaceBootstrapInstructions([
{ path: "/ws/AGENTS.md", content: "Follow AGENTS guidance." },
{ path: "/ws/SOUL.md", content: "Soul voice goes here." },
]);
expect(rendered).toBeDefined();
expect(rendered).toContain("Soul voice goes here.");
expect(rendered).not.toContain("Follow AGENTS guidance.");
});
it("renders persona files ahead of free-form context (SOUL before USER)", () => {
const rendered = renderCopilotWorkspaceBootstrapInstructions([
{ path: "/ws/USER.md", content: "USER body" },
{ path: "/ws/SOUL.md", content: "SOUL body" },
]);
expect(rendered).toBeDefined();
const soulIdx = rendered!.indexOf("SOUL body");
const userIdx = rendered!.indexOf("USER body");
expect(soulIdx).toBeGreaterThanOrEqual(0);
expect(userIdx).toBeGreaterThan(soulIdx);
});
it("adds the SOUL.md hint line only when SOUL.md is present", () => {
const withSoul = renderCopilotWorkspaceBootstrapInstructions([
{ path: "/ws/SOUL.md", content: "S" },
]);
const withoutSoul = renderCopilotWorkspaceBootstrapInstructions([
{ path: "/ws/IDENTITY.md", content: "I" },
]);
expect(withSoul).toContain("SOUL.md: persona/tone");
expect(withoutSoul).not.toContain("SOUL.md: persona/tone");
});
it("includes file path and content for every rendered file", () => {
const rendered = renderCopilotWorkspaceBootstrapInstructions([
{ path: "/ws/IDENTITY.md", content: "I am the agent." },
{ path: "/ws/HEARTBEAT.md", content: "Heartbeat task list." },
]);
expect(rendered).toContain("## /ws/IDENTITY.md");
expect(rendered).toContain("I am the agent.");
expect(rendered).toContain("## /ws/HEARTBEAT.md");
expect(rendered).toContain("Heartbeat task list.");
});
});
describe("COPILOT_NATIVE_PROJECT_DOC_BASENAMES", () => {
it("matches the SDK auto-load list documented in types.d.ts:1036", () => {
// If this set drifts away from the SDK's auto-loaded basenames the
// copilot harness will start duplicating instructions content.
// Keep this list in sync with the SDK release notes for
// `enableConfigDiscovery` / "custom instruction files".
expect([...COPILOT_NATIVE_PROJECT_DOC_BASENAMES]).toEqual(["agents.md"]);
});
});
describe("compareCopilotContextFiles", () => {
it("orders unknown files lexicographically after the ordered set", () => {
const sorted = [
{ path: "/ws/zzz.md", content: "" },
{ path: "/ws/aaa.md", content: "" },
{ path: "/ws/SOUL.md", content: "" },
].toSorted(compareCopilotContextFiles);
expect(sorted.map((file) => file.path)).toEqual(["/ws/SOUL.md", "/ws/aaa.md", "/ws/zzz.md"]);
});
});
describe("resolveCopilotWorkspaceBootstrapContext", () => {
let workspaceDir: string;
beforeEach(async () => {
workspaceDir = await mkdtemp(path.join(tmpdir(), "copilot-bootstrap-"));
});
afterEach(async () => {
await rm(workspaceDir, { force: true, recursive: true });
});
it("returns empty result and undefined instructions when workspaceDir is missing", async () => {
const result = await resolveCopilotWorkspaceBootstrapContext({
attempt: makeAttempt({ workspaceDir: undefined }),
effectiveWorkspaceDir: undefined,
});
expect(result.bootstrapFiles).toEqual([]);
expect(result.contextFiles).toEqual([]);
expect(result.instructions).toBeUndefined();
});
it("loads SOUL.md from the workspace and renders it into instructions", async () => {
await writeFile(path.join(workspaceDir, "SOUL.md"), "Soul voice goes here.");
const result = await resolveCopilotWorkspaceBootstrapContext({
attempt: makeAttempt({ workspaceDir }),
effectiveWorkspaceDir: workspaceDir,
});
expect(result.bootstrapFiles.length).toBeGreaterThan(0);
expect(result.instructions).toBeDefined();
expect(result.instructions).toContain("Soul voice goes here.");
});
it("filters AGENTS.md out of the rendered block (SDK loads it natively)", async () => {
await writeFile(path.join(workspaceDir, "AGENTS.md"), "Follow AGENTS guidance.");
await writeFile(path.join(workspaceDir, "SOUL.md"), "Soul voice goes here.");
const result = await resolveCopilotWorkspaceBootstrapContext({
attempt: makeAttempt({ workspaceDir }),
effectiveWorkspaceDir: workspaceDir,
});
expect(result.instructions).toContain("Soul voice goes here.");
expect(result.instructions).not.toContain("Follow AGENTS guidance.");
expect(result.instructions).toContain("Copilot SDK loads AGENTS.md natively");
});
it("includes [MISSING] placeholders for files that don't exist (parity with PI/codex)", async () => {
await writeFile(path.join(workspaceDir, "AGENTS.md"), "Follow AGENTS guidance.");
const result = await resolveCopilotWorkspaceBootstrapContext({
attempt: makeAttempt({ workspaceDir }),
effectiveWorkspaceDir: workspaceDir,
});
// The shared loader synthesizes `[MISSING] Expected at: <path>`
// entries for every known bootstrap file the workspace hasn't
// provided yet. This is intentional — PI and codex inject the
// same placeholders so the model can see what bootstrap files are
// expected and prompt the user / create them. See
// src/agents/pi-embedded-helpers/bootstrap.ts:293-296.
// We surface these in the rendered block exactly like codex does.
expect(result.instructions).toBeDefined();
expect(result.instructions).toContain("[MISSING] Expected at:");
expect(result.instructions).toContain("SOUL.md");
// AGENTS.md content is still suppressed because the SDK auto-loads
// it natively from workingDirectory.
expect(result.instructions).not.toContain("Follow AGENTS guidance.");
});
});
describe("remapCopilotBootstrapContextFiles (PR #86155 [P2] round-9)", () => {
// The helper mirrors PI's `remapInjectedContextFilesToWorkspace`
// byte-for-byte so a Copilot run with a `ro`/`none` sandbox renders
// bootstrap context paths the same way PI does: in-workspace files
// get their host root rewritten to the sandbox root; out-of-workspace
// (parent-traversal, absolute, sibling) paths stay verbatim so the
// model never sees a pretend-sandboxed path for something that
// actually lives elsewhere.
it("returns input unchanged when source equals target (PI fast path)", () => {
const files = [
{ path: "/host/ws/SOUL.md", content: "soul" },
{ path: "/host/ws/IDENTITY.md", content: "id" },
];
const out = remapCopilotBootstrapContextFiles({
files,
sourceWorkspaceDir: "/host/ws",
targetWorkspaceDir: "/host/ws",
});
expect(out).toBe(files);
});
it("rewrites in-workspace paths but leaves outside-workspace paths intact", () => {
const out = remapCopilotBootstrapContextFiles({
files: [
{ path: "/host/ws/SOUL.md", content: "soul" },
{ path: "/host/ws/.openclaw/agents/main/IDENTITY.md", content: "id" },
{ path: "/host/other/UNRELATED.md", content: "u" },
{ path: "/host/ws", content: "root" },
],
sourceWorkspaceDir: "/host/ws",
targetWorkspaceDir: "/sandbox/copy",
});
expect(out.map((f) => f.path)).toEqual([
"/sandbox/copy/SOUL.md",
"/sandbox/copy/.openclaw/agents/main/IDENTITY.md",
"/host/other/UNRELATED.md",
"/sandbox/copy",
]);
});
});
describe("resolveCopilotWorkspaceBootstrapContext sandbox remap (PR #86155 [P2] round-9)", () => {
let workspaceDir: string;
let sandboxDir: string;
beforeEach(async () => {
workspaceDir = await mkdtemp(path.join(tmpdir(), "copilot-bootstrap-host-"));
sandboxDir = await mkdtemp(path.join(tmpdir(), "copilot-bootstrap-sbx-"));
});
afterEach(async () => {
await rm(workspaceDir, { force: true, recursive: true });
await rm(sandboxDir, { force: true, recursive: true });
});
it("rewrites rendered context paths from host workspace to sandbox workspace when effective differs", async () => {
// Readonly sandbox: bootstrap files live on the host workspace
// (the canonical source of SOUL.md / .openclaw conventions), but
// the SDK session's workingDirectory and bridged tools see the
// sandbox copy. The rendered systemMessage must show the model
// sandbox paths, not host paths, so it matches what the native
// SDK loader and the wrapped tools report.
await writeFile(path.join(workspaceDir, "SOUL.md"), "Soul voice from host.");
const result = await resolveCopilotWorkspaceBootstrapContext({
attempt: makeAttempt({ workspaceDir }),
effectiveWorkspaceDir: sandboxDir,
});
expect(result.instructions).toBeDefined();
expect(result.instructions).toContain("Soul voice from host.");
// Positive: every rendered `## ` file header is now under the
// sandbox root so the model sees a workspace it can actually
// dereference through the bridged tools.
expect(result.instructions).toContain(`## ${path.join(sandboxDir, "SOUL.md")}`);
// Negative: no rendered file header may still point at the
// host workspace root (would otherwise let the model dereference
// a path its tools cannot reach in a readonly sandbox). We scope
// this check to `## ` headers because PI deliberately leaves the
// host path inside any `[MISSING] Expected at: <path>` body — it
// refers to the canonical source location the user should create
// the file at, not the runtime workspace.
const headerLines = (result.instructions ?? "")
.split("\n")
.filter((line) => line.startsWith("## "));
expect(headerLines.length).toBeGreaterThan(0);
for (const line of headerLines) {
expect(line).not.toContain(workspaceDir);
}
// Returned contextFiles array reflects the remap too, so any
// future consumer that reads `contextFiles` directly stays in
// lock-step with `instructions`.
expect(result.contextFiles.map((f) => f.path)).toContain(path.join(sandboxDir, "SOUL.md"));
expect(result.contextFiles.every((f) => !f.path.startsWith(workspaceDir))).toBe(true);
});
});