mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-04 00:34:02 +00:00
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>
269 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|