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

256 lines
9.8 KiB
TypeScript

import path from "node:path";
import type {
AgentHarnessAttemptParams,
EmbeddedContextFile,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
resolveBootstrapContextForRun,
resolveUserPath,
} from "openclaw/plugin-sdk/agent-harness-runtime";
// Filenames the Copilot SDK already loads natively from the working
// directory / instructionDirectories (per
// `@github/copilot-sdk/dist/types.d.ts:1036,1155` —
// "custom instruction files (.github/copilot-instructions.md,
// AGENTS.md, etc.) are always loaded from the working directory").
// Filtering them out of the OpenClaw bootstrap injection avoids
// duplicating their content into `SessionConfig.systemMessage`, which
// would otherwise inflate every prompt with the same text the SDK
// already includes. Mirrors codex's CODEX_NATIVE_PROJECT_DOC_BASENAMES
// (extensions/codex/src/app-server/run-attempt.ts:160).
const COPILOT_NATIVE_PROJECT_DOC_BASENAMES = new Set(["agents.md"]);
// Persona/identity files get sorted to the top of the rendered block
// so they precede the freer-form context like USER.md / MEMORY.md.
// Mirrors codex's CODEX_BOOTSTRAP_CONTEXT_ORDER ordering (same files).
const COPILOT_BOOTSTRAP_CONTEXT_ORDER = new Map<string, number>([
["soul.md", 10],
["identity.md", 20],
["heartbeat.md", 30],
["bootstrap.md", 40],
["tools.md", 50],
["user.md", 60],
["memory.md", 70],
]);
export type CopilotWorkspaceBootstrapResult = {
bootstrapFiles: Awaited<ReturnType<typeof resolveBootstrapContextForRun>>["bootstrapFiles"];
contextFiles: EmbeddedContextFile[];
instructions?: string;
};
/**
* Loads OpenClaw workspace bootstrap files (IDENTITY.md, SOUL.md,
* HEARTBEAT.md, USER.md, TOOLS.md, BOOTSTRAP.md, MEMORY.md, ...) using
* the shared core helper PI and codex both use, then renders them as a
* single string suitable for `SessionConfig.systemMessage.content` on
* the Copilot SDK.
*
* Returns `instructions: undefined` when there are no relevant files
* (after filtering out SDK-native docs) so the caller can omit the
* `systemMessage` field entirely rather than passing an empty string.
*
* Mirrors codex's `buildCodexWorkspaceBootstrapContext` /
* `renderCodexWorkspaceBootstrapInstructions` pair
* (`extensions/codex/src/app-server/run-attempt.ts:2877,3047`). The
* shape divergence — codex returns instructions inside the same object
* as bootstrapFiles+contextFiles for its developerInstructions field;
* copilot exposes the rendered string for SDK `systemMessage` — is the
* intended difference between the two runtimes' system-prompt
* surfaces.
*/
export async function resolveCopilotWorkspaceBootstrapContext(params: {
attempt: AgentHarnessAttemptParams;
/**
* Sandbox-aware working directory the SDK session will run in.
* When this differs from the canonical `attempt.workspaceDir`
* (sandbox `ro` / `none` runs that redirect to a copy), bootstrap
* context file paths are remapped so the rendered `systemMessage`
* shows the model the same workspace the SDK's native loader and
* bridged tools operate on. Pass `undefined` only when no sandbox
* resolution has happened (e.g. tests not exercising sandbox
* redirection). Required so future callers cannot silently miss
* the remap. Mirrors PI's
* `remapInjectedContextFilesToWorkspace` call in
* `src/agents/pi-embedded-runner/run/attempt.ts:1595`.
*/
effectiveWorkspaceDir: string | undefined;
warn?: (message: string) => void;
}): Promise<CopilotWorkspaceBootstrapResult> {
const { attempt } = params;
const workspaceDir = readResolvedWorkspacePath(attempt.workspaceDir);
if (!workspaceDir) {
return { bootstrapFiles: [], contextFiles: [] };
}
try {
const bootstrapContext = await resolveBootstrapContextForRun({
workspaceDir,
config: attempt.config,
sessionKey: readNonEmptyString((attempt as { sessionKey?: unknown }).sessionKey),
sessionId: readNonEmptyString(attempt.sessionId),
agentId: readNonEmptyString(attempt.agentId),
warn: params.warn,
contextMode: attempt.bootstrapContextMode,
runKind: attempt.bootstrapContextRunKind,
});
// Remap context-file paths from the workspace we LOADED them
// from (`workspaceDir`, the canonical host workspace where
// SOUL.md / IDENTITY.md / .openclaw conventions live) onto the
// workspace the SDK session will actually OPERATE in
// (`effectiveWorkspaceDir`). When the two are identical (no
// sandbox, or sandbox `rw`), remap is a no-op. The render below
// and the returned `contextFiles` use the remapped array so the
// model never sees a host path while its native loader and
// bridged tools see only the sandbox copy.
const contextFiles = remapCopilotBootstrapContextFiles({
files: bootstrapContext.contextFiles,
sourceWorkspaceDir: workspaceDir,
targetWorkspaceDir: readResolvedWorkspacePath(params.effectiveWorkspaceDir) ?? workspaceDir,
});
return {
bootstrapFiles: bootstrapContext.bootstrapFiles,
contextFiles,
instructions: renderCopilotWorkspaceBootstrapInstructions(contextFiles),
};
} catch (error) {
params.warn?.(
`[copilot-attempt] failed to load workspace bootstrap instructions: ${
error instanceof Error ? error.message : String(error)
}`,
);
return { bootstrapFiles: [], contextFiles: [] };
}
}
/**
* Rewrites context-file paths from a source workspace root to a
* target workspace root, mirroring PI's
* `remapInjectedContextFilesToWorkspace`
* (`src/agents/pi-embedded-runner/run/attempt.ts:603`). Files whose
* resolved relative path escapes the source workspace (parent
* traversal or absolute) are left untouched so we never pretend a
* file lives inside the sandbox when it does not. Exported for unit
* tests; intentionally local to the Copilot extension (codex keeps
* similar helpers extension-local rather than importing from PI).
*/
export function remapCopilotBootstrapContextFiles(params: {
files: EmbeddedContextFile[];
sourceWorkspaceDir: string;
targetWorkspaceDir: string;
}): EmbeddedContextFile[] {
if (params.sourceWorkspaceDir === params.targetWorkspaceDir) {
return params.files;
}
return params.files.map((file) => {
const relative = path.relative(params.sourceWorkspaceDir, file.path);
if (!isRelativePathInsideOrEqual(relative)) {
return file;
}
return {
...file,
path:
relative === ""
? params.targetWorkspaceDir
: path.join(params.targetWorkspaceDir, relative),
};
});
}
function isRelativePathInsideOrEqual(relativePath: string): boolean {
return (
relativePath === "" ||
(relativePath !== ".." &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath))
);
}
/**
* Renders bootstrap context files into a single string for
* `SessionConfig.systemMessage.content` (append mode). Returns
* `undefined` when no relevant files remain after filtering, so the
* caller can skip setting `systemMessage` altogether.
*
* Files whose basename matches a doc the Copilot SDK already loads
* natively (see {@link COPILOT_NATIVE_PROJECT_DOC_BASENAMES}) are
* dropped to avoid duplication with SDK-managed sections.
*/
export function renderCopilotWorkspaceBootstrapInstructions(
contextFiles: EmbeddedContextFile[],
): string | undefined {
const files = contextFiles
.filter((file) => {
const baseName = getCopilotContextFileBasename(file.path);
return baseName.length > 0 && !COPILOT_NATIVE_PROJECT_DOC_BASENAMES.has(baseName);
})
.toSorted(compareCopilotContextFiles);
if (files.length === 0) {
return undefined;
}
const hasSoulFile = files.some((file) => getCopilotContextFileBasename(file.path) === "soul.md");
const lines: string[] = [
"OpenClaw loaded these user-editable workspace files. Treat them as project/user context. The Copilot SDK loads AGENTS.md natively from its instruction directories, so AGENTS.md is not repeated here.",
"",
"# Project Context",
"",
"The following project context files have been loaded:",
];
if (hasSoulFile) {
lines.push("SOUL.md: persona/tone. Follow it unless higher-priority instructions override.");
}
lines.push("");
for (const file of files) {
lines.push(`## ${file.path}`, "", file.content, "");
}
return lines.join("\n").trim();
}
function compareCopilotContextFiles(left: EmbeddedContextFile, right: EmbeddedContextFile): number {
const leftBase = getCopilotContextFileBasename(left.path);
const rightBase = getCopilotContextFileBasename(right.path);
const leftOrder = COPILOT_BOOTSTRAP_CONTEXT_ORDER.get(leftBase) ?? Number.MAX_SAFE_INTEGER;
const rightOrder = COPILOT_BOOTSTRAP_CONTEXT_ORDER.get(rightBase) ?? Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
const leftPath = normalizeCopilotContextFilePath(left.path);
const rightPath = normalizeCopilotContextFilePath(right.path);
if (leftPath < rightPath) {
return -1;
}
if (leftPath > rightPath) {
return 1;
}
return 0;
}
function normalizeCopilotContextFilePath(filePath: string): string {
return filePath.trim().replaceAll("\\", "/").toLowerCase();
}
function getCopilotContextFileBasename(filePath: string): string {
return normalizeCopilotContextFilePath(filePath).split("/").pop() ?? "";
}
function readNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
function readResolvedWorkspacePath(value: unknown): string | undefined {
const raw = readNonEmptyString(value);
if (!raw) {
return undefined;
}
if (process.platform !== "win32" && /^[A-Za-z]:[\\/]/.test(raw)) {
return raw.trim();
}
return resolveUserPath(raw);
}
export const TESTING_EXPORTS = {
COPILOT_NATIVE_PROJECT_DOC_BASENAMES,
COPILOT_BOOTSTRAP_CONTEXT_ORDER,
compareCopilotContextFiles,
getCopilotContextFileBasename,
};