Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
29
extensions/copilot/README.md
Normal file
29
extensions/copilot/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# GitHub Copilot agent runtime (OpenClaw plugin)
|
||||
|
||||
External OpenClaw plugin that registers a `copilot` agent harness backed by `@github/copilot-sdk` and the GitHub Copilot CLI.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/copilot
|
||||
```
|
||||
|
||||
Restart the Gateway after installing or updating the plugin.
|
||||
|
||||
The harness claims the canonical subscription `github-copilot` provider plus
|
||||
custom BYOK provider entries that the Copilot SDK can represent. Manifest-owned
|
||||
native provider ids stay with their owning runtimes. The harness is opt-in only:
|
||||
selection requires explicit `agentRuntime.id: "copilot"` on a model or provider
|
||||
entry; `auto` never picks it. PI remains the default embedded runtime.
|
||||
|
||||
See [GitHub Copilot agent runtime](../../docs/plugins/copilot.md) for
|
||||
configuration, the doctor contract, transcript mirroring, compaction, side
|
||||
questions, replay, and the supported-surface contract.
|
||||
See [qa/copilot-capabilities.md](../../qa/copilot-capabilities.md)
|
||||
for the SDK capability inventory the harness is pinned to.
|
||||
|
||||
## Package
|
||||
|
||||
- Plugin id: `copilot`
|
||||
- Package: `@openclaw/copilot`
|
||||
- Minimum OpenClaw host: `2026.5.28`
|
||||
43
extensions/copilot/doctor-contract-api.test.ts
Executable file
43
extensions/copilot/doctor-contract-api.test.ts
Executable file
@@ -0,0 +1,43 @@
|
||||
// Copilot tests cover doctor contract api plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
legacyConfigRules,
|
||||
normalizeCompatibilityConfig,
|
||||
sessionRouteStateOwners,
|
||||
} from "./doctor-contract-api.js";
|
||||
|
||||
describe("copilot doctor contract", () => {
|
||||
it("has no legacy config rules at MVP (no retired fields exist yet)", () => {
|
||||
expect(legacyConfigRules).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizeCompatibilityConfig is a structural no-op when no migrations apply", () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: { copilot: { enabled: true, config: { pool: { idleTtlMs: 12345 } } } },
|
||||
},
|
||||
} as unknown as Parameters<typeof normalizeCompatibilityConfig>[0]["cfg"];
|
||||
const result = normalizeCompatibilityConfig({ cfg });
|
||||
expect(result.config).toBe(cfg);
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("declares exactly one session route state owner for copilot", () => {
|
||||
expect(sessionRouteStateOwners).toHaveLength(1);
|
||||
const owner = sessionRouteStateOwners[0];
|
||||
expect(owner.id).toBe("copilot");
|
||||
expect(owner.label).toBe("GitHub Copilot agent runtime");
|
||||
});
|
||||
|
||||
it("claims the subscription Copilot providers (matches attempt.ts SUPPORTED_PROVIDERS)", () => {
|
||||
const owner = sessionRouteStateOwners[0];
|
||||
expect(owner.providerIds).toEqual(["github-copilot"]);
|
||||
});
|
||||
|
||||
it("claims the copilot runtime, session key, and auth profile prefix", () => {
|
||||
const owner = sessionRouteStateOwners[0];
|
||||
expect(owner.runtimeIds).toEqual(["copilot"]);
|
||||
expect(owner.cliSessionKeys).toEqual(["copilot"]);
|
||||
expect(owner.authProfilePrefixes).toEqual(["github-copilot:"]);
|
||||
});
|
||||
});
|
||||
55
extensions/copilot/doctor-contract-api.ts
Executable file
55
extensions/copilot/doctor-contract-api.ts
Executable file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Doctor contract for the copilot extension.
|
||||
*
|
||||
* Mirrors {@link ../codex/doctor-contract-api.ts} so `openclaw doctor`
|
||||
* can:
|
||||
* - Reason about which session-state belongs to this extension
|
||||
* (sessionRouteStateOwners) for cleanup of stale state across
|
||||
* runtime swaps.
|
||||
* - Detect retired config fields and migrate them
|
||||
* (legacyConfigRules + normalizeCompatibilityConfig). No retired
|
||||
* fields exist for copilot yet; the array is empty by design
|
||||
* and normalizeCompatibilityConfig is a structural no-op so
|
||||
* future retirements have a stable in-tree home.
|
||||
*/
|
||||
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { DoctorSessionRouteStateOwner } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
|
||||
type LegacyConfigRule = {
|
||||
path: string[];
|
||||
message: string;
|
||||
match: (value: unknown) => boolean;
|
||||
};
|
||||
|
||||
export const legacyConfigRules: LegacyConfigRule[] = [];
|
||||
|
||||
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
} {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-state ownership claim for the copilot agent runtime.
|
||||
*
|
||||
* - id / label: Identify the extension in doctor output.
|
||||
* - providerIds: The subscription Copilot providers (kept in sync
|
||||
* with `SUPPORTED_PROVIDERS` in attempt.ts).
|
||||
* - runtimeIds: Our harness id (matches harness.ts `id` field).
|
||||
* - cliSessionKeys: Session keys this harness writes; doctor uses
|
||||
* this when pruning stale CLI session state.
|
||||
* - authProfilePrefixes: Conventional prefix for any auth profile
|
||||
* created/consumed by this extension.
|
||||
*/
|
||||
export const sessionRouteStateOwners: DoctorSessionRouteStateOwner[] = [
|
||||
{
|
||||
id: "copilot",
|
||||
label: "GitHub Copilot agent runtime",
|
||||
providerIds: ["github-copilot"],
|
||||
runtimeIds: ["copilot"],
|
||||
cliSessionKeys: ["copilot"],
|
||||
authProfilePrefixes: ["github-copilot:"],
|
||||
},
|
||||
];
|
||||
2621
extensions/copilot/harness.test.ts
Normal file
2621
extensions/copilot/harness.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1134
extensions/copilot/harness.ts
Normal file
1134
extensions/copilot/harness.ts
Normal file
File diff suppressed because it is too large
Load Diff
163
extensions/copilot/index.test.ts
Normal file
163
extensions/copilot/index.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
// Copilot tests cover index plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./harness.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./harness.js")>();
|
||||
return {
|
||||
...actual,
|
||||
createCopilotAgentHarness: vi.fn(actual.createCopilotAgentHarness),
|
||||
};
|
||||
});
|
||||
|
||||
import { createCopilotAgentHarness } from "./harness.js";
|
||||
import plugin from "./index.js";
|
||||
|
||||
function loadManifest(): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function registerWithPluginConfig(pluginConfig: Record<string, unknown> | undefined) {
|
||||
const registerAgentHarness = vi.fn();
|
||||
const sessionStore = {
|
||||
register: vi.fn(),
|
||||
lookup: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
const openSyncKeyedStore = vi.fn(() => sessionStore);
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
id: "copilot",
|
||||
name: "GitHub Copilot agent runtime",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig,
|
||||
runtime: { state: { openSyncKeyedStore } } as never,
|
||||
registerAgentHarness,
|
||||
}),
|
||||
);
|
||||
const harness = registerAgentHarness.mock.calls.at(0)?.at(0) as {
|
||||
id: string;
|
||||
label: string;
|
||||
supports(ctx: {
|
||||
provider: string;
|
||||
modelId?: string;
|
||||
requestedRuntime?: string;
|
||||
}): { supported: true; priority?: number } | { supported: false; reason?: string };
|
||||
};
|
||||
return { registerAgentHarness, harness, openSyncKeyedStore, sessionStore };
|
||||
}
|
||||
|
||||
describe("copilot plugin", () => {
|
||||
it("is opt-in by default and only declares an agent harness activation", () => {
|
||||
const manifest = loadManifest();
|
||||
const activation = manifest.activation as Record<string, unknown>;
|
||||
|
||||
expect(manifest.enabledByDefault).toBeUndefined();
|
||||
expect(activation.onStartup).toBe(false);
|
||||
expect(activation.onAgentHarnesses).toEqual(["copilot"]);
|
||||
expect(manifest.providers).toBeUndefined();
|
||||
expect(typeof manifest.version).toBe("string");
|
||||
expect(manifest.version).not.toBe("");
|
||||
});
|
||||
|
||||
it("registers exactly one copilot agent harness and nothing else", () => {
|
||||
const registerAgentHarness = vi.fn();
|
||||
const registerProvider = vi.fn();
|
||||
const registerModelCatalogProvider = vi.fn();
|
||||
const registerMediaUnderstandingProvider = vi.fn();
|
||||
const registerMigrationProvider = vi.fn();
|
||||
const registerCommand = vi.fn();
|
||||
const registerNodeHostCommand = vi.fn();
|
||||
const registerNodeInvokePolicy = vi.fn();
|
||||
const on = vi.fn();
|
||||
const onConversationBindingResolved = vi.fn();
|
||||
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
id: "copilot",
|
||||
name: "GitHub Copilot agent runtime",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
runtime: { state: { openSyncKeyedStore: vi.fn(() => ({})) } } as never,
|
||||
registerAgentHarness,
|
||||
registerProvider,
|
||||
registerModelCatalogProvider,
|
||||
registerMediaUnderstandingProvider,
|
||||
registerMigrationProvider,
|
||||
registerCommand,
|
||||
registerNodeHostCommand,
|
||||
registerNodeInvokePolicy,
|
||||
on,
|
||||
onConversationBindingResolved,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(registerAgentHarness).toHaveBeenCalledTimes(1);
|
||||
expect(registerAgentHarness).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "copilot", label: "GitHub Copilot agent runtime" }),
|
||||
);
|
||||
expect(registerProvider).not.toHaveBeenCalled();
|
||||
expect(registerModelCatalogProvider).not.toHaveBeenCalled();
|
||||
expect(registerMediaUnderstandingProvider).not.toHaveBeenCalled();
|
||||
expect(registerMigrationProvider).not.toHaveBeenCalled();
|
||||
expect(registerCommand).not.toHaveBeenCalled();
|
||||
expect(registerNodeHostCommand).not.toHaveBeenCalled();
|
||||
expect(registerNodeInvokePolicy).not.toHaveBeenCalled();
|
||||
expect(on).not.toHaveBeenCalled();
|
||||
expect(onConversationBindingResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers a harness hard-bound to the canonical github-copilot provider", () => {
|
||||
const { harness } = registerWithPluginConfig({});
|
||||
|
||||
expect(
|
||||
harness.supports({
|
||||
provider: "github-copilot",
|
||||
modelId: "gpt-4.1",
|
||||
requestedRuntime: "copilot",
|
||||
}),
|
||||
).toEqual({ supported: true, priority: 100 });
|
||||
expect(
|
||||
harness.supports({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4.5",
|
||||
requestedRuntime: "copilot",
|
||||
}),
|
||||
).toEqual({
|
||||
supported: false,
|
||||
reason: "provider is not one of: github-copilot",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through a valid pool idle TTL and ignores malformed values", () => {
|
||||
const createHarness = vi.mocked(createCopilotAgentHarness);
|
||||
createHarness.mockClear();
|
||||
|
||||
registerWithPluginConfig({ pool: { idleTtlMs: 2500 } });
|
||||
registerWithPluginConfig({ pool: { idleTtlMs: 0 } });
|
||||
|
||||
expect(createHarness).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ poolOptions: { idleTtlMs: 2500 } }),
|
||||
);
|
||||
expect(createHarness.mock.calls[1]?.[0]).not.toHaveProperty("poolOptions");
|
||||
});
|
||||
|
||||
it("opens the durable Copilot SDK session binding store", () => {
|
||||
const createHarness = vi.mocked(createCopilotAgentHarness);
|
||||
createHarness.mockClear();
|
||||
const { openSyncKeyedStore, sessionStore } = registerWithPluginConfig({});
|
||||
|
||||
expect(openSyncKeyedStore).toHaveBeenCalledWith({
|
||||
namespace: "sdk-sessions",
|
||||
maxEntries: 5000,
|
||||
defaultTtlMs: 90 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(createHarness).toHaveBeenCalledWith(expect.objectContaining({ sessionStore }));
|
||||
});
|
||||
});
|
||||
50
extensions/copilot/index.ts
Normal file
50
extensions/copilot/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copilot plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { createCopilotAgentHarness, type CopilotSessionBinding } from "./harness.js";
|
||||
|
||||
function readPoolOptions(pluginConfig: unknown): { idleTtlMs: number } | undefined {
|
||||
if (!isRecord(pluginConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const pool = pluginConfig.pool;
|
||||
if (!isRecord(pool)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const idleTtlMs = pool.idleTtlMs;
|
||||
if (typeof idleTtlMs !== "number" || !Number.isFinite(idleTtlMs) || idleTtlMs < 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { idleTtlMs };
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "copilot",
|
||||
name: "GitHub Copilot agent runtime",
|
||||
description: "Registers the GitHub Copilot agent runtime.",
|
||||
register(api) {
|
||||
// Copilot is a full-runtime plugin (registers an agent harness).
|
||||
// Metadata-only registration paths (discovery, cli-metadata, setup-only)
|
||||
// cannot supply a durable session store — skip registration here and let
|
||||
// the full gateway activation path pick it up later.
|
||||
if (api.registrationMode !== "full") {
|
||||
return;
|
||||
}
|
||||
const poolOptions = readPoolOptions(api.pluginConfig);
|
||||
const sessionStore = api.runtime.state.openSyncKeyedStore<CopilotSessionBinding>({
|
||||
namespace: "sdk-sessions",
|
||||
maxEntries: 5000,
|
||||
defaultTtlMs: 90 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
api.registerAgentHarness(
|
||||
createCopilotAgentHarness({
|
||||
...(poolOptions ? { poolOptions } : {}),
|
||||
sessionStore,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
206
extensions/copilot/npm-shrinkwrap.json
generated
Normal file
206
extensions/copilot/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,206 @@
|
||||
{
|
||||
"name": "@openclaw/copilot",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/copilot",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot": {
|
||||
"version": "1.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.68.tgz",
|
||||
"integrity": "sha512-2VPcTlW0RAEsfeS0Ma2ICCkfXgpxy3NL7+SReR8gzvEEPiokSRf0k5JBPlgMbBEFvocSRcJ01S8KvBm84Dw+Fw==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.1.2"
|
||||
},
|
||||
"bin": {
|
||||
"copilot": "npm-loader.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@github/copilot-darwin-arm64": "1.0.68",
|
||||
"@github/copilot-darwin-x64": "1.0.68",
|
||||
"@github/copilot-linux-arm64": "1.0.68",
|
||||
"@github/copilot-linux-x64": "1.0.68",
|
||||
"@github/copilot-linuxmusl-arm64": "1.0.68",
|
||||
"@github/copilot-linuxmusl-x64": "1.0.68",
|
||||
"@github/copilot-win32-arm64": "1.0.68",
|
||||
"@github/copilot-win32-x64": "1.0.68"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-arm64": {
|
||||
"version": "1.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.68.tgz",
|
||||
"integrity": "sha512-0G26AL9dlrwTa5IRTxPEnkX6Kz20CuetIwXzABmWwiXYcsR9rswM/NYICR3k53TOa0zL7aqFPnl98CW7J5XbZw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-darwin-arm64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-darwin-x64": {
|
||||
"version": "1.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.68.tgz",
|
||||
"integrity": "sha512-RoClWH4CPH19pv5jrR0E6pBU6ljgrzL7idb7tV2pPtMYGTuwqW//XrIEE4n/5NVZmhzEiVBeq04THzOBeV493A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-darwin-x64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-arm64": {
|
||||
"version": "1.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.68.tgz",
|
||||
"integrity": "sha512-SXSOz/2xPeUM/ndKypBiQv+QGYaEM7oGytl1i+Yx4tJnOoIwLkkTmIaWUbBNn0n5DTEjUcWyDqHzxxo/42FKRQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linux-arm64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linux-x64": {
|
||||
"version": "1.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.68.tgz",
|
||||
"integrity": "sha512-YdG1chniWyps7XEJ2YHUOJkcOc6BpDQZby/zOKCVdswzRXx7d3WiZ2P9lfDimBBmXXJEJ81Fqhv2ZK5eOmGlUw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linux-x64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linuxmusl-arm64": {
|
||||
"version": "1.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.68.tgz",
|
||||
"integrity": "sha512-LTYZFOHpeLg4rCtsq3A/LMZxxRKFcCLmhnt8F7ovNYLNDJMkh3xdYanoXP0C0PO3uyUMiNJ+p5YXhZiBuo2yfw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linuxmusl-arm64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-linuxmusl-x64": {
|
||||
"version": "1.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.68.tgz",
|
||||
"integrity": "sha512-oOMXZ9HPJAJaKSrXZIYuond4uOiUkK1uRhVPI3Cs74n7uEVKPWpNlTN+j/O754dnBa1+HJcuZSDMulTbnOgirg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-linuxmusl-x64": "copilot"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-sdk": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.5.tgz",
|
||||
"integrity": "sha512-N6Yk2DcpM9orYXWGBcQs5R0FdiVYrCn7UHQ206cUkfJengKYjgcd3f78BvVB6Dot3j0TvO04FnQ85K9/kbRRag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@github/copilot": "^1.0.67",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-arm64": {
|
||||
"version": "1.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.68.tgz",
|
||||
"integrity": "sha512-ZDqpJMP9Y5vqwvRxnIvZrVl8ibx/P66m3JTXQuzv6pitq7rkMEuNKscZ7cjJYN4N+BCOF5++5LKw8O1WHzXAAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-win32-arm64": "copilot.exe"
|
||||
}
|
||||
},
|
||||
"node_modules/@github/copilot-win32-x64": {
|
||||
"version": "1.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.68.tgz",
|
||||
"integrity": "sha512-eplj/Y2B+amMLJ37oNE6G8gx85j8ucAuJz+CjzpzprNiBUq45lFL8ukGeDtaLMRvIeYAEDYdz5yUzu2XtCE7mA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"bin": {
|
||||
"copilot-win32-x64": "copilot.exe"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
|
||||
"integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
extensions/copilot/openclaw.plugin.json
Normal file
40
extensions/copilot/openclaw.plugin.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"id": "copilot",
|
||||
"name": "GitHub Copilot agent runtime",
|
||||
"description": "Registers the GitHub Copilot agent runtime.",
|
||||
"icon": "https://cdn.simpleicons.org/githubcopilot",
|
||||
"version": "2026.6.2",
|
||||
"activation": {
|
||||
"onStartup": false,
|
||||
"onAgentHarnesses": ["copilot"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pool": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"idleTtlMs": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"default": 300000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"pool": {
|
||||
"label": "Client Pool",
|
||||
"help": "Advanced GitHub Copilot agent runtime client pooling controls.",
|
||||
"advanced": true
|
||||
},
|
||||
"pool.idleTtlMs": {
|
||||
"label": "Idle Client TTL",
|
||||
"help": "Milliseconds to keep an idle GitHub Copilot agent runtime client alive before disposal.",
|
||||
"advanced": true
|
||||
}
|
||||
}
|
||||
}
|
||||
40
extensions/copilot/package.json
Normal file
40
extensions/copilot/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@openclaw/copilot",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw GitHub Copilot agent runtime plugin (registers a `github-copilot` AgentHarness backed by @github/copilot-sdk over JSON-RPC to the GitHub Copilot CLI)",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@github/copilot-sdk": "1.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@github/copilot": "1.0.68",
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/copilot",
|
||||
"npmSpec": "@openclaw/copilot",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.5.28"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"bundleRuntimeDependencies": false,
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
214
extensions/copilot/src/attempt.live.test.ts
Normal file
214
extensions/copilot/src/attempt.live.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
// Copilot tests cover attempt plugin behavior.
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { CopilotClient, approveAll } from "@github/copilot-sdk";
|
||||
import type { AgentHarnessAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCopilotAgentHarness, type CopilotClientPool } from "../harness.js";
|
||||
|
||||
const liveToolState = vi.hoisted(() => ({
|
||||
calls: [] as string[],
|
||||
expectedText: "phase-1-green",
|
||||
sentinelPrefix: "copilot-live-smoke:",
|
||||
toolName: "live_echo",
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/agent-harness", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness")>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
createOpenClawCodingTools: vi.fn(() => [
|
||||
{
|
||||
name: liveToolState.toolName,
|
||||
label: liveToolState.toolName,
|
||||
description: "Echo the requested text for the copilot live smoke test.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
text: {
|
||||
type: "string",
|
||||
description: "Text to echo back to the model.",
|
||||
},
|
||||
},
|
||||
required: ["text"],
|
||||
},
|
||||
async execute(_toolCallId: string, params: unknown) {
|
||||
const textInput =
|
||||
params && typeof params === "object" && !Array.isArray(params)
|
||||
? (params as { text?: unknown }).text
|
||||
: undefined;
|
||||
const text = typeof textInput === "string" ? textInput : "";
|
||||
const echoed = `${liveToolState.sentinelPrefix}${text}`;
|
||||
liveToolState.calls.push(text);
|
||||
console.info(
|
||||
`[copilot-live-smoke] ${liveToolState.toolName} ${JSON.stringify({ echoed, text })}`,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: echoed }],
|
||||
details: { echoed },
|
||||
};
|
||||
},
|
||||
},
|
||||
]),
|
||||
};
|
||||
});
|
||||
|
||||
const LIVE = isLiveTestEnabled(["OPENCLAW_COPILOT_AGENT_LIVE_TEST"]);
|
||||
const TOKEN =
|
||||
process.env.OPENCLAW_COPILOT_AGENT_LIVE_TOKEN ||
|
||||
process.env.GITHUB_TOKEN ||
|
||||
process.env.GH_TOKEN ||
|
||||
"";
|
||||
const describeLive = LIVE && TOKEN ? describe : describe.skip;
|
||||
|
||||
function createApproveAllPool(): CopilotClientPool {
|
||||
const activeClients = new Set<CopilotClient>();
|
||||
|
||||
return {
|
||||
async acquire(key, options) {
|
||||
const client = new CopilotClient(options);
|
||||
activeClients.add(client);
|
||||
return {
|
||||
key,
|
||||
client: {
|
||||
createSession: (config: Parameters<CopilotClient["createSession"]>[0]) =>
|
||||
client.createSession({ ...config, onPermissionRequest: approveAll }),
|
||||
resumeSession: (
|
||||
sessionId: Parameters<CopilotClient["resumeSession"]>[0],
|
||||
config: Parameters<CopilotClient["resumeSession"]>[1],
|
||||
) => client.resumeSession(sessionId, { ...config, onPermissionRequest: approveAll }),
|
||||
stop: () => client.stop(),
|
||||
} as unknown as CopilotClient,
|
||||
};
|
||||
},
|
||||
async dispose() {
|
||||
const errors: Error[] = [];
|
||||
for (const client of activeClients) {
|
||||
try {
|
||||
errors.push(...(await client.stop()));
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
activeClients.clear();
|
||||
return errors;
|
||||
},
|
||||
async release() {},
|
||||
size() {
|
||||
return activeClients.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAttemptParams(params: {
|
||||
copilotHome: string;
|
||||
onAssistantDelta: (payload: { text: string }) => void | Promise<void>;
|
||||
prompt: string;
|
||||
}): AgentHarnessAttemptParams {
|
||||
const profileId = "live-smoke-profile";
|
||||
const profileVersion = "v1";
|
||||
const now = Date.now();
|
||||
|
||||
return {
|
||||
agentDir: params.copilotHome,
|
||||
agentId: "copilot-live-smoke",
|
||||
auth: {
|
||||
gitHubToken: TOKEN,
|
||||
profileId,
|
||||
profileVersion,
|
||||
},
|
||||
authProfileId: profileId,
|
||||
copilotHome: params.copilotHome,
|
||||
cwd: process.cwd(),
|
||||
messages: [{ content: params.prompt, role: "user", timestamp: now }],
|
||||
model: {
|
||||
api: "openai-responses",
|
||||
id: "gpt-4.1",
|
||||
provider: "github-copilot",
|
||||
},
|
||||
modelId: "gpt-4.1",
|
||||
onAssistantDelta: params.onAssistantDelta,
|
||||
profileVersion,
|
||||
prompt: params.prompt,
|
||||
provider: "github-copilot",
|
||||
runId: `copilot-live-smoke-${now}`,
|
||||
sessionFile: join(params.copilotHome, "copilot-live-smoke.session.json"),
|
||||
sessionId: `copilot-live-smoke-session-${now}`,
|
||||
timeoutMs: 90_000,
|
||||
workspaceDir: process.cwd(),
|
||||
} as unknown as AgentHarnessAttemptParams;
|
||||
}
|
||||
|
||||
describeLive("copilot agent runtime live smoke", () => {
|
||||
it("runs one turn on gpt-4.1 with one custom tool", async () => {
|
||||
liveToolState.calls.length = 0;
|
||||
const streamedTexts: string[] = [];
|
||||
const prompt = `Use the ${liveToolState.toolName} tool exactly once with text '${liveToolState.expectedText}', then reply with exactly two short sentences totaling at least twelve words.`;
|
||||
const copilotHome = await mkdtemp(join(tmpdir(), "openclaw-copilot-live-"));
|
||||
const harness = createCopilotAgentHarness({ pool: createApproveAllPool() });
|
||||
|
||||
expect(
|
||||
harness.supports({
|
||||
provider: "github-copilot",
|
||||
modelId: "gpt-4.1",
|
||||
requestedRuntime: "copilot",
|
||||
}),
|
||||
).toEqual({ supported: true, priority: 100 });
|
||||
|
||||
try {
|
||||
const result = await harness.runAttempt(
|
||||
createAttemptParams({
|
||||
copilotHome,
|
||||
onAssistantDelta: ({ text }) => {
|
||||
if (text.trim()) {
|
||||
streamedTexts.push(text);
|
||||
}
|
||||
},
|
||||
prompt,
|
||||
}),
|
||||
);
|
||||
const assistantText = result.assistantTexts.join("\n").trim();
|
||||
const hasAssistantText = result.assistantTexts.some((text) => text.trim().length > 0);
|
||||
const matchingCalls = liveToolState.calls.filter(
|
||||
(text) => text === liveToolState.expectedText,
|
||||
);
|
||||
const usage = result.attemptUsage;
|
||||
|
||||
console.info(
|
||||
"[copilot-live-smoke] summary",
|
||||
JSON.stringify(
|
||||
{
|
||||
assistantText,
|
||||
toolCalls: liveToolState.calls,
|
||||
streamedTexts,
|
||||
toolMetas: result.toolMetas,
|
||||
usage,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.promptError).toBeUndefined();
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(matchingCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(hasAssistantText).toBe(true);
|
||||
expect(assistantText.length).toBeGreaterThan(0);
|
||||
expect((usage?.input ?? 0) + (usage?.output ?? 0)).toBeGreaterThan(0);
|
||||
expect(
|
||||
result.toolMetas.some(
|
||||
(toolMeta) =>
|
||||
toolMeta.toolName === liveToolState.toolName &&
|
||||
toolMeta.meta?.includes(liveToolState.sentinelPrefix),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await harness.dispose?.();
|
||||
await rm(copilotHome, { recursive: true, force: true });
|
||||
}
|
||||
}, 90_000);
|
||||
});
|
||||
3533
extensions/copilot/src/attempt.test.ts
Normal file
3533
extensions/copilot/src/attempt.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1745
extensions/copilot/src/attempt.ts
Normal file
1745
extensions/copilot/src/attempt.ts
Normal file
File diff suppressed because it is too large
Load Diff
509
extensions/copilot/src/auth-bridge.test.ts
Executable file
509
extensions/copilot/src/auth-bridge.test.ts
Executable file
@@ -0,0 +1,509 @@
|
||||
// Copilot tests cover auth bridge plugin behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
COPILOT_DEFAULT_AGENT_ID,
|
||||
COPILOT_TOKEN_PROFILE_ERROR,
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
336
extensions/copilot/src/auth-bridge.ts
Executable file
336
extensions/copilot/src/auth-bridge.ts
Executable file
@@ -0,0 +1,336 @@
|
||||
// Copilot plugin module implements auth bridge behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import { homedir as osHomedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Pure functional auth resolver for the copilot agent runtime.
|
||||
*
|
||||
* Scope:
|
||||
*
|
||||
* - Consumes the resolved auth signals that core's harness contract
|
||||
* already carries on `EmbeddedRunAttemptParams` (=
|
||||
* `AgentHarnessAttemptParams`): `resolvedApiKey`, `authProfileId`,
|
||||
* `authProfileIdSource`. Core resolves these from the agent's
|
||||
* `AuthProfileStore` via `provider-usage.auth.ts:resolveProviderAuths`
|
||||
* before invoking the harness, so the harness does not re-perform
|
||||
* the lookup (and could not, due to the package boundary in
|
||||
* `tsconfig.package-boundary.base.json`).
|
||||
* - Reads optional explicit overrides from the harness attempt params
|
||||
* (`auth.useLoggedInUser`, `auth.gitHubToken`) for direct CLI / test
|
||||
* use cases.
|
||||
* - Falls back to OPENCLAW_GITHUB_TOKEN, COPILOT_GITHUB_TOKEN,
|
||||
* GH_TOKEN, or GITHUB_TOKEN env vars (in that precedence) when
|
||||
* no contract-resolved token is given; synthesises a stable,
|
||||
* non-reversible pool fingerprint so token rotation busts the
|
||||
* client pool cleanly.
|
||||
* - Computes a per-agent `copilotHome` default
|
||||
* (`<openClawHome>/.openclaw/agents/<agentId>/copilot`, or
|
||||
* `<agentDir>/copilot` when an agent directory is supplied) that
|
||||
* respects `OPENCLAW_HOME` for the home directory root.
|
||||
* - Defaults to `useLoggedInUser` when no token signal is available.
|
||||
*
|
||||
* Precedence (highest to lowest):
|
||||
* 1. `auth.useLoggedInUser === true` (explicit user opt-in)
|
||||
* 2. `auth.gitHubToken` (explicit override; requires
|
||||
* `profileId` + `profileVersion`)
|
||||
* 3. `resolvedApiKey` + `authProfileId` from the contract (core's
|
||||
* AuthProfileStore-resolved token — the production main path for
|
||||
* a configured `github-copilot` auth profile)
|
||||
* 4. OPENCLAW_GITHUB_TOKEN, then COPILOT_GITHUB_TOKEN, then
|
||||
* GH_TOKEN, then GITHUB_TOKEN env vars (mirrors the
|
||||
* shipped `github-copilot` provider precedence so headless
|
||||
* users who already follow the documented
|
||||
* COPILOT_GITHUB_TOKEN / GH_TOKEN setup get the token they
|
||||
* configured rather than silently falling through to the
|
||||
* logged-in CLI user.)
|
||||
* 5. `useLoggedInUser` (default)
|
||||
*/
|
||||
|
||||
export const COPILOT_TOKEN_PROFILE_ERROR =
|
||||
"[copilot-attempt] gitHubToken auth requires profileId+profileVersion (pool keying safety; per Q5/Q1 decisions)";
|
||||
|
||||
export const COPILOT_DEFAULT_AGENT_ID = "copilot";
|
||||
|
||||
/** Resolved auth shape that the runtime / pool consumes. */
|
||||
export interface ResolvedCopilotAuth {
|
||||
authMode: "useLoggedInUser" | "gitHubToken" | "byok";
|
||||
/** Present only when authMode is "gitHubToken". */
|
||||
gitHubToken?: string;
|
||||
/** Present for token and BYOK auth modes. */
|
||||
authProfileId?: string;
|
||||
/** Present for token and BYOK auth modes. */
|
||||
authProfileVersion?: string;
|
||||
/** Absolute, normalized path. */
|
||||
copilotHome: string;
|
||||
/** Validated agent id used for path defaults and pool keying. */
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export function createCopilotByokAuth(input: {
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
copilotHome?: string;
|
||||
authProfileId?: string;
|
||||
authProfileVersion?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: () => string;
|
||||
}): ResolvedCopilotAuth {
|
||||
const base = resolveCopilotAuth({
|
||||
agentId: input.agentId,
|
||||
agentDir: input.agentDir,
|
||||
workspaceDir: input.workspaceDir,
|
||||
copilotHome: input.copilotHome,
|
||||
env: input.env,
|
||||
homeDir: input.homeDir,
|
||||
auth: { useLoggedInUser: true },
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
authMode: "byok",
|
||||
authProfileId: input.authProfileId?.trim() || "byok:resolved",
|
||||
authProfileVersion: input.authProfileVersion?.trim() || "byok:unfingerprinted",
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResolveCopilotAuthInput {
|
||||
agentId?: string;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
copilotHome?: string;
|
||||
auth?: {
|
||||
gitHubToken?: string;
|
||||
useLoggedInUser?: boolean;
|
||||
profileId?: string;
|
||||
profileVersion?: string;
|
||||
};
|
||||
/**
|
||||
* Contract-resolved token from core's AuthProfileStore lookup,
|
||||
* carried on `EmbeddedRunAttemptParams.resolvedApiKey`. Used as the
|
||||
* production main path when the agent has a configured
|
||||
* `github-copilot` auth profile.
|
||||
*/
|
||||
resolvedApiKey?: string;
|
||||
/**
|
||||
* Contract-resolved auth profile id, carried on
|
||||
* `EmbeddedRunAttemptParams.authProfileId`. Used for pool keying so
|
||||
* concurrent agents with distinct profiles do not share a CLI
|
||||
* session/state.
|
||||
*/
|
||||
authProfileId?: string;
|
||||
/**
|
||||
* Legacy top-level `profileVersion` fallback kept for back-compat
|
||||
* with explicit-token (`auth.gitHubToken`) callers. The
|
||||
* contract-resolved `resolvedApiKey` path synthesises a version from
|
||||
* the token fingerprint because `EmbeddedRunAttemptParams` does not
|
||||
* carry a `profileVersion` field.
|
||||
*/
|
||||
profileVersion?: string;
|
||||
/** Injected for test seams. Defaults to `process.env`. */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Injected for test seams. Defaults to `os.homedir()`. */
|
||||
homeDir?: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve copilot auth + copilotHome.
|
||||
*
|
||||
* Synchronous because we intentionally do not perform any I/O or
|
||||
* cross-package credential lookups here (see file header for rationale).
|
||||
*
|
||||
* Throws if `gitHubToken` is supplied via `params.auth.gitHubToken`
|
||||
* WITHOUT both `profileId` and `profileVersion` (the existing invariant
|
||||
* from attempt.ts; preserves pool-key safety per Q5/Q1).
|
||||
*/
|
||||
export function resolveCopilotAuth(input: ResolveCopilotAuthInput): ResolvedCopilotAuth {
|
||||
const env = input.env ?? process.env;
|
||||
const homeDir = input.homeDir ?? osHomedir;
|
||||
|
||||
const agentId = sanitizeAgentId(input.agentId);
|
||||
const copilotHome = resolveCopilotHome({
|
||||
explicit: readString(input.copilotHome),
|
||||
agentDir: readString(input.agentDir),
|
||||
workspaceDir: readString(input.workspaceDir),
|
||||
agentId,
|
||||
env,
|
||||
homeDir,
|
||||
});
|
||||
|
||||
const explicitToken = readString(input.auth?.gitHubToken);
|
||||
const explicitProfileId = readString(input.auth?.profileId) ?? readString(input.authProfileId);
|
||||
const explicitProfileVersion =
|
||||
readString(input.auth?.profileVersion) ?? readString(input.profileVersion);
|
||||
|
||||
if (input.auth?.useLoggedInUser === true) {
|
||||
return {
|
||||
authMode: "useLoggedInUser",
|
||||
copilotHome,
|
||||
agentId,
|
||||
};
|
||||
}
|
||||
|
||||
if (explicitToken) {
|
||||
if (!explicitProfileId || !explicitProfileVersion) {
|
||||
throw new Error(COPILOT_TOKEN_PROFILE_ERROR);
|
||||
}
|
||||
return {
|
||||
authMode: "gitHubToken",
|
||||
gitHubToken: explicitToken,
|
||||
authProfileId: explicitProfileId,
|
||||
authProfileVersion: explicitProfileVersion,
|
||||
copilotHome,
|
||||
agentId,
|
||||
};
|
||||
}
|
||||
|
||||
// Contract-resolved token from core's AuthProfileStore lookup. This
|
||||
// is the production main path: a configured `github-copilot` auth
|
||||
// profile flows into `EmbeddedRunAttemptParams.resolvedApiKey` and
|
||||
// `authProfileId` upstream of the harness, and we consume both here
|
||||
// so headless / cron / multi-profile runs work without env vars.
|
||||
// We synthesise the pool-key version from the token fingerprint so
|
||||
// rotation busts the cache cleanly (matching the env-fallback
|
||||
// strategy). The contract does not carry a separate `profileVersion`.
|
||||
const contractToken = readString(input.resolvedApiKey);
|
||||
if (contractToken) {
|
||||
const contractProfileId = readString(input.authProfileId);
|
||||
return {
|
||||
authMode: "gitHubToken",
|
||||
gitHubToken: contractToken,
|
||||
authProfileId: contractProfileId ?? "pi:resolved",
|
||||
authProfileVersion: tokenFingerprint(contractToken),
|
||||
copilotHome,
|
||||
agentId,
|
||||
};
|
||||
}
|
||||
|
||||
const envFallback = readEnvTokenFallback(env);
|
||||
if (envFallback) {
|
||||
return {
|
||||
authMode: "gitHubToken",
|
||||
gitHubToken: envFallback.token,
|
||||
authProfileId: envFallback.profileId,
|
||||
authProfileVersion: envFallback.profileVersion,
|
||||
copilotHome,
|
||||
agentId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authMode: "useLoggedInUser",
|
||||
copilotHome,
|
||||
agentId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + sanitise an agent id for use in filesystem paths and pool
|
||||
* keys.
|
||||
*
|
||||
* Mirrors the shape constraints documented by core's `normalizeAgentId`
|
||||
* / `isValidAgentId` in `src/routing/session-key.ts` (alnum + `-_`,
|
||||
* starts with alnum, lowercase, <=64 chars). We re-implement here
|
||||
* because the package boundary prevents importing from `src/`. Any
|
||||
* caller that passes an invalid id falls back to the shared default
|
||||
* (`COPILOT_DEFAULT_AGENT_ID`) rather than throwing - the harness's
|
||||
* job is to keep running with a safe default, not to validate config.
|
||||
*/
|
||||
export function sanitizeAgentId(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
return COPILOT_DEFAULT_AGENT_ID;
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(trimmed)) {
|
||||
return COPILOT_DEFAULT_AGENT_ID;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveCopilotHome(args: {
|
||||
explicit: string | undefined;
|
||||
agentDir: string | undefined;
|
||||
workspaceDir: string | undefined;
|
||||
agentId: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
homeDir: () => string;
|
||||
}): string {
|
||||
if (args.explicit) {
|
||||
return resolve(args.explicit);
|
||||
}
|
||||
// When the host hands us an agent directory we isolate the SDK CLI state
|
||||
// (config.json, logs/, session-store.db, session-state/) under a dedicated
|
||||
// "copilot" subdir so it cannot collide with OpenClaw's own files
|
||||
// (models.json, auth-profiles.json, ...) in the same agent directory.
|
||||
// This matches the documented layout and mirrors how the codex harness
|
||||
// isolates `<agentDir>/codex-home/`.
|
||||
if (args.agentDir) {
|
||||
return resolve(join(args.agentDir, "copilot"));
|
||||
}
|
||||
|
||||
const openClawHome = readString(args.env.OPENCLAW_HOME);
|
||||
const rootHome = openClawHome ? resolve(openClawHome) : safeHomeDir(args.homeDir);
|
||||
// Per-agent isolation per proposal section 3.6:
|
||||
// <openClawHome>/.openclaw/agents/<agentId>/copilot
|
||||
return resolve(join(rootHome, ".openclaw", "agents", args.agentId, "copilot"));
|
||||
}
|
||||
|
||||
function safeHomeDir(homeDir: () => string): string {
|
||||
try {
|
||||
const value = homeDir();
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
function readEnvTokenFallback(
|
||||
env: NodeJS.ProcessEnv,
|
||||
): { token: string; profileId: string; profileVersion: string } | undefined {
|
||||
// OPENCLAW_GITHUB_TOKEN is the harness-specific override and stays at
|
||||
// the top so operators can pin a token without disturbing system-wide
|
||||
// gh / Copilot CLI config. The remaining entries mirror the shipped
|
||||
// `github-copilot` provider precedence
|
||||
// (COPILOT_GITHUB_TOKEN -> GH_TOKEN -> GITHUB_TOKEN, see
|
||||
// extensions/github-copilot/auth.ts:24) and the documented Copilot SDK
|
||||
// setup in docs/providers/github-copilot.md, so a headless user who
|
||||
// already configured COPILOT_GITHUB_TOKEN / GH_TOKEN and opted into
|
||||
// agentRuntime.id: "copilot" gets the token they configured rather
|
||||
// than silently falling through to the logged-in CLI user.
|
||||
const candidates: Array<{ name: string; value: string | undefined }> = [
|
||||
{ name: "OPENCLAW_GITHUB_TOKEN", value: readString(env.OPENCLAW_GITHUB_TOKEN) },
|
||||
{ name: "COPILOT_GITHUB_TOKEN", value: readString(env.COPILOT_GITHUB_TOKEN) },
|
||||
{ name: "GH_TOKEN", value: readString(env.GH_TOKEN) },
|
||||
{ name: "GITHUB_TOKEN", value: readString(env.GITHUB_TOKEN) },
|
||||
];
|
||||
for (const { name, value } of candidates) {
|
||||
if (value) {
|
||||
return {
|
||||
token: value,
|
||||
profileId: `env:${name}`,
|
||||
profileVersion: tokenFingerprint(value),
|
||||
};
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-reversible 12-hex-char fingerprint of a token, prefixed with
|
||||
* `sha256:` for forward-compat. Used as the pool-key profileVersion when
|
||||
* a token comes from env: rotation -> different fingerprint -> pool
|
||||
* entry invalidated cleanly. 48 bits of entropy is sufficient
|
||||
* collision resistance for a per-agent client pool; never log the
|
||||
* fingerprint alongside an account id.
|
||||
*/
|
||||
export function tokenFingerprint(token: string): string {
|
||||
const hex = createHash("sha256").update(token).digest("hex").slice(0, 12);
|
||||
return `sha256:${hex}`;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
245
extensions/copilot/src/byok-proxy.test.ts
Normal file
245
extensions/copilot/src/byok-proxy.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
// Copilot BYOK proxy tests verify SDK-local transport is guarded outbound fetch.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCopilotByokProxy } from "./byok-proxy.js";
|
||||
import { resolveCopilotProvider } from "./provider-bridge.js";
|
||||
|
||||
const ssrfRuntimeMock = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>()),
|
||||
fetchWithSsrFGuard: ssrfRuntimeMock.fetchWithSsrFGuard,
|
||||
}));
|
||||
|
||||
describe("createCopilotByokProxy", () => {
|
||||
afterEach(() => {
|
||||
ssrfRuntimeMock.fetchWithSsrFGuard.mockReset();
|
||||
});
|
||||
|
||||
it("presents a loopback SDK endpoint and forwards through guarded fetch", async () => {
|
||||
const release = vi.fn(async () => undefined);
|
||||
ssrfRuntimeMock.fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: new Response("ok", {
|
||||
status: 201,
|
||||
headers: {
|
||||
"content-encoding": "gzip",
|
||||
"content-length": "999",
|
||||
"x-upstream": "yes",
|
||||
},
|
||||
}),
|
||||
release,
|
||||
});
|
||||
const resolvedProvider = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1?routing=blue",
|
||||
},
|
||||
resolvedApiKey: "secret-key",
|
||||
});
|
||||
|
||||
const proxy = await createCopilotByokProxy(resolvedProvider);
|
||||
expect(proxy?.provider.provider?.baseUrl).toMatch(
|
||||
/^http:\/\/127\.0\.0\.1:\d+\/[a-f0-9]{24}\/v1$/,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${proxy?.provider.provider?.baseUrl}/responses?trace=request`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer secret-key",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ model: "proxy-model" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.headers.get("content-encoding")).toBeNull();
|
||||
expect(response.headers.get("content-length")).toBeNull();
|
||||
expect(response.headers.get("x-upstream")).toBe("yes");
|
||||
expect(await response.text()).toBe("ok");
|
||||
expect(ssrfRuntimeMock.fetchWithSsrFGuard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
auditContext: "copilot-byok-provider",
|
||||
requireHttps: true,
|
||||
url: "https://proxy.example/v1/responses?routing=blue&trace=request",
|
||||
init: expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"accept-encoding": "identity",
|
||||
authorization: "Bearer secret-key",
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await proxy?.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("injects resolved bearer auth when the SDK request omits Authorization", async () => {
|
||||
ssrfRuntimeMock.fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: new Response("ok", { status: 200 }),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
const resolvedProvider = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "tencent-tokenplan",
|
||||
api: "openai-completions",
|
||||
id: "hy3",
|
||||
baseUrl: "https://tokenplan.example/v1",
|
||||
authHeader: true,
|
||||
},
|
||||
resolvedApiKey: "tokenplan-secret",
|
||||
});
|
||||
|
||||
const proxy = await createCopilotByokProxy(resolvedProvider);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${proxy?.provider.provider?.baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ model: "hy3" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(ssrfRuntimeMock.fetchWithSsrFGuard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://tokenplan.example/v1/chat/completions",
|
||||
init: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
authorization: "Bearer tokenplan-secret",
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await proxy?.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts in-flight upstream fetches when the proxy closes", async () => {
|
||||
let upstreamSignal: AbortSignal | undefined;
|
||||
ssrfRuntimeMock.fetchWithSsrFGuard.mockImplementation(async ({ init }: any) => {
|
||||
upstreamSignal = init.signal;
|
||||
await new Promise((_, reject) => {
|
||||
upstreamSignal?.addEventListener("abort", () => reject(new Error("upstream aborted")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
throw new Error("unreachable");
|
||||
});
|
||||
const resolvedProvider = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
},
|
||||
});
|
||||
const proxy = await createCopilotByokProxy(resolvedProvider);
|
||||
|
||||
const responsePromise = fetch(`${proxy?.provider.provider?.baseUrl}/responses`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "proxy-model" }),
|
||||
}).catch((error: unknown) => error);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(upstreamSignal).toBeDefined();
|
||||
});
|
||||
|
||||
await proxy?.close();
|
||||
|
||||
expect(upstreamSignal?.aborted).toBe(true);
|
||||
await responsePromise;
|
||||
});
|
||||
|
||||
it("accepts Azure SDK paths that are rebuilt from the proxy origin", async () => {
|
||||
ssrfRuntimeMock.fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: new Response("azure-ok", { status: 200 }),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
const resolvedProvider = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment-gpt",
|
||||
baseUrl: "https://example.openai.azure.com/openai/v1",
|
||||
},
|
||||
resolvedApiKey: "azure-key",
|
||||
});
|
||||
|
||||
const proxy = await createCopilotByokProxy(resolvedProvider);
|
||||
expect(proxy?.provider.provider?.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${proxy?.provider.provider?.baseUrl}/openai/v1/responses?trace=request`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "api-key": "azure-key" },
|
||||
body: JSON.stringify({ model: "deployment-gpt" }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("azure-ok");
|
||||
expect(ssrfRuntimeMock.fetchWithSsrFGuard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requireHttps: true,
|
||||
url: "https://example.openai.azure.com/openai/v1/responses?trace=request",
|
||||
init: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"accept-encoding": "identity",
|
||||
"api-key": "azure-key",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await proxy?.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not inject bearer auth on nonce-less Azure SDK paths", async () => {
|
||||
ssrfRuntimeMock.fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: new Response("azure-ok", { status: 200 }),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
const resolvedProvider = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment-gpt",
|
||||
baseUrl: "https://example.openai.azure.com/openai/v1",
|
||||
authHeader: true,
|
||||
},
|
||||
resolvedApiKey: "azure-bearer",
|
||||
});
|
||||
|
||||
const proxy = await createCopilotByokProxy(resolvedProvider);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${proxy?.provider.provider?.baseUrl}/openai/v1/responses`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "deployment-gpt" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const call = ssrfRuntimeMock.fetchWithSsrFGuard.mock.calls[0]?.[0] as
|
||||
| { init?: { headers?: Record<string, string> } }
|
||||
| undefined;
|
||||
expect(call?.init?.headers).not.toHaveProperty("authorization");
|
||||
} finally {
|
||||
await proxy?.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
309
extensions/copilot/src/byok-proxy.ts
Normal file
309
extensions/copilot/src/byok-proxy.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
// Copilot BYOK transport proxy keeps OpenClaw in charge of outbound network policy.
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import { finished } from "node:stream/promises";
|
||||
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { ResolvedCopilotProvider } from "./provider-bridge.js";
|
||||
|
||||
const LOOPBACK_HOST = "127.0.0.1";
|
||||
|
||||
export type CopilotByokProxyHandle = {
|
||||
close: () => Promise<void>;
|
||||
provider: ResolvedCopilotProvider;
|
||||
};
|
||||
|
||||
type HeaderValue = string | number | string[] | undefined;
|
||||
type ProviderConfig = NonNullable<ResolvedCopilotProvider["provider"]>;
|
||||
|
||||
export async function createCopilotByokProxy(
|
||||
resolvedProvider: ResolvedCopilotProvider,
|
||||
): Promise<CopilotByokProxyHandle | undefined> {
|
||||
if (resolvedProvider.mode !== "byok") {
|
||||
return undefined;
|
||||
}
|
||||
const providerConfig = resolvedProvider.provider;
|
||||
if (!providerConfig?.baseUrl) {
|
||||
throw new Error("[copilot-attempt] BYOK requires a provider baseUrl");
|
||||
}
|
||||
|
||||
const targetBaseUrl = new URL(providerConfig.baseUrl);
|
||||
const nonce = randomBytes(12).toString("hex");
|
||||
const targetPathPrefix = trimTrailingSlash(targetBaseUrl.pathname);
|
||||
const proxyPathPrefix = `/${nonce}${targetPathPrefix}`;
|
||||
const acceptsAzureSdkPaths = providerConfig.type === "azure";
|
||||
const upstreamBearerAuthorization = resolveUpstreamBearerAuthorization(providerConfig);
|
||||
const activeFetches = new Set<AbortController>();
|
||||
const server = createServer((req, res) => {
|
||||
void handleProxyRequest(req, res, {
|
||||
acceptsAzureSdkPaths,
|
||||
activeFetches,
|
||||
proxyPathPrefix,
|
||||
targetBaseUrl,
|
||||
targetPathPrefix,
|
||||
upstreamBearerAuthorization,
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, LOOPBACK_HOST, () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close();
|
||||
throw new Error("[copilot-attempt] failed to start BYOK network proxy");
|
||||
}
|
||||
|
||||
const proxyBaseUrl = `http://${LOOPBACK_HOST}:${address.port}${proxyPathPrefix}`;
|
||||
const sdkBaseUrl = acceptsAzureSdkPaths
|
||||
? `http://${LOOPBACK_HOST}:${address.port}`
|
||||
: proxyBaseUrl;
|
||||
return {
|
||||
provider: {
|
||||
...resolvedProvider,
|
||||
provider: {
|
||||
...providerConfig,
|
||||
baseUrl: sdkBaseUrl,
|
||||
},
|
||||
},
|
||||
close: async () => {
|
||||
for (const controller of activeFetches) {
|
||||
controller.abort();
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function handleProxyRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
params: {
|
||||
acceptsAzureSdkPaths: boolean;
|
||||
activeFetches: Set<AbortController>;
|
||||
proxyPathPrefix: string;
|
||||
targetBaseUrl: URL;
|
||||
targetPathPrefix: string;
|
||||
upstreamBearerAuthorization: string | undefined;
|
||||
},
|
||||
): Promise<void> {
|
||||
let guarded: Awaited<ReturnType<typeof fetchWithSsrFGuard>> | undefined;
|
||||
const upstreamAbort = new AbortController();
|
||||
params.activeFetches.add(upstreamAbort);
|
||||
const abortUpstream = () => upstreamAbort.abort();
|
||||
req.on("aborted", abortUpstream);
|
||||
res.on("close", () => {
|
||||
if (!res.writableEnded) {
|
||||
abortUpstream();
|
||||
}
|
||||
});
|
||||
try {
|
||||
const canInjectBearerAuthorization = isNonceProtectedProxyRequest(req, params.proxyPathPrefix);
|
||||
const url = resolveTargetUrl(req, params);
|
||||
if (!url) {
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
const body = req.method === "GET" || req.method === "HEAD" ? undefined : await readBody(req);
|
||||
guarded = await fetchWithSsrFGuard({
|
||||
url: url.toString(),
|
||||
init: {
|
||||
method: req.method,
|
||||
headers: buildProxyRequestHeaders(req.headers, {
|
||||
upstreamBearerAuthorization: canInjectBearerAuthorization
|
||||
? params.upstreamBearerAuthorization
|
||||
: undefined,
|
||||
}),
|
||||
signal: upstreamAbort.signal,
|
||||
...(body ? { body: toFetchBody(body) } : {}),
|
||||
},
|
||||
auditContext: "copilot-byok-provider",
|
||||
requireHttps: true,
|
||||
});
|
||||
res.writeHead(
|
||||
guarded.response.status,
|
||||
guarded.response.statusText,
|
||||
normalizeProxyResponseHeaders(guarded.response.headers),
|
||||
);
|
||||
if (!guarded.response.body) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
await finished(
|
||||
Readable.fromWeb(guarded.response.body as unknown as NodeReadableStream<Uint8Array>).pipe(
|
||||
res,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (res.destroyed || res.writableEnded) {
|
||||
return;
|
||||
}
|
||||
if (res.headersSent) {
|
||||
res.destroy(error instanceof Error ? error : undefined);
|
||||
return;
|
||||
}
|
||||
res.writeHead(502);
|
||||
res.end(error instanceof Error ? error.message : "BYOK provider proxy failed");
|
||||
} finally {
|
||||
req.off("aborted", abortUpstream);
|
||||
params.activeFetches.delete(upstreamAbort);
|
||||
await guarded?.release().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTargetUrl(
|
||||
req: IncomingMessage,
|
||||
params: {
|
||||
acceptsAzureSdkPaths: boolean;
|
||||
proxyPathPrefix: string;
|
||||
targetBaseUrl: URL;
|
||||
targetPathPrefix: string;
|
||||
},
|
||||
): URL | undefined {
|
||||
const incomingUrl = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}`);
|
||||
if (
|
||||
incomingUrl.pathname !== params.proxyPathPrefix &&
|
||||
!incomingUrl.pathname.startsWith(`${params.proxyPathPrefix}/`)
|
||||
) {
|
||||
return params.acceptsAzureSdkPaths && isAzureSdkProxyPath(incomingUrl.pathname)
|
||||
? resolveDirectTargetUrl(incomingUrl, params.targetBaseUrl)
|
||||
: undefined;
|
||||
}
|
||||
const suffix = incomingUrl.pathname.slice(params.proxyPathPrefix.length);
|
||||
const targetUrl = new URL(params.targetBaseUrl);
|
||||
targetUrl.pathname = `${params.targetPathPrefix}${suffix}` || "/";
|
||||
for (const [key, value] of incomingUrl.searchParams) {
|
||||
targetUrl.searchParams.append(key, value);
|
||||
}
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
function resolveDirectTargetUrl(incomingUrl: URL, targetBaseUrl: URL): URL {
|
||||
const targetUrl = new URL(targetBaseUrl);
|
||||
targetUrl.pathname = incomingUrl.pathname;
|
||||
for (const [key, value] of incomingUrl.searchParams) {
|
||||
targetUrl.searchParams.append(key, value);
|
||||
}
|
||||
return targetUrl;
|
||||
}
|
||||
|
||||
function isAzureSdkProxyPath(pathname: string): boolean {
|
||||
return pathname === "/openai" || pathname.startsWith("/openai/");
|
||||
}
|
||||
|
||||
function isNonceProtectedProxyRequest(req: IncomingMessage, proxyPathPrefix: string): boolean {
|
||||
const incomingUrl = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}`);
|
||||
return (
|
||||
incomingUrl.pathname === proxyPathPrefix ||
|
||||
incomingUrl.pathname.startsWith(`${proxyPathPrefix}/`)
|
||||
);
|
||||
}
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<Buffer | undefined> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return chunks.length > 0 ? Buffer.concat(chunks) : undefined;
|
||||
}
|
||||
|
||||
function toFetchBody(body: Buffer): Uint8Array<ArrayBuffer> {
|
||||
const copy = new Uint8Array(body.byteLength);
|
||||
copy.set(body);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function normalizeProxyRequestHeaders(headers: IncomingMessage["headers"]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (isHopByHopHeader(key) || key.toLowerCase() === "accept-encoding") {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeHeaderValue(value);
|
||||
if (normalized !== undefined) {
|
||||
out[key] = normalized;
|
||||
}
|
||||
}
|
||||
out["accept-encoding"] = "identity";
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildProxyRequestHeaders(
|
||||
headers: IncomingMessage["headers"],
|
||||
params: { upstreamBearerAuthorization: string | undefined },
|
||||
): Record<string, string> {
|
||||
const out = normalizeProxyRequestHeaders(headers);
|
||||
if (params.upstreamBearerAuthorization && !hasHeader(out, "authorization")) {
|
||||
// The SDK declares bearerToken as Authorization auth, but some BYOK
|
||||
// adapter paths can omit it before reaching our loopback proxy. The proxy
|
||||
// owns the final guarded hop, so inject only when the SDK left it absent.
|
||||
out["authorization"] = params.upstreamBearerAuthorization;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveUpstreamBearerAuthorization(providerConfig: ProviderConfig): string | undefined {
|
||||
const bearerToken = providerConfig.bearerToken?.trim();
|
||||
return bearerToken ? `Bearer ${bearerToken}` : undefined;
|
||||
}
|
||||
|
||||
function normalizeProxyResponseHeaders(headers: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
if (!isHopByHopHeader(key) && !isContentEncodingHeader(key)) {
|
||||
out[key] = value;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeHeaderValue(value: HeaderValue): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.isArray(value) ? value.join(", ") : String(value);
|
||||
}
|
||||
|
||||
function hasHeader(headers: Record<string, string>, target: string): boolean {
|
||||
return Object.keys(headers).some((key) => key.toLowerCase() === target);
|
||||
}
|
||||
|
||||
function isHopByHopHeader(key: string): boolean {
|
||||
switch (key.toLowerCase()) {
|
||||
case "connection":
|
||||
case "host":
|
||||
case "keep-alive":
|
||||
case "proxy-authenticate":
|
||||
case "proxy-authorization":
|
||||
case "te":
|
||||
case "trailer":
|
||||
case "transfer-encoding":
|
||||
case "upgrade":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isContentEncodingHeader(key: string): boolean {
|
||||
switch (key.toLowerCase()) {
|
||||
case "content-encoding":
|
||||
case "content-length":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function trimTrailingSlash(pathname: string): string {
|
||||
const trimmed = pathname.replace(/\/+$/, "");
|
||||
return trimmed === "" ? "" : trimmed;
|
||||
}
|
||||
59
extensions/copilot/src/compaction-bridge.test.ts
Executable file
59
extensions/copilot/src/compaction-bridge.test.ts
Executable file
@@ -0,0 +1,59 @@
|
||||
// Copilot tests cover compaction bridge plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createInfiniteSessionConfig } from "./compaction-bridge.js";
|
||||
|
||||
describe("createInfiniteSessionConfig", () => {
|
||||
it("returns undefined when no options provided", () => {
|
||||
expect(createInfiniteSessionConfig()).toBeUndefined();
|
||||
expect(createInfiniteSessionConfig(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when options is an empty object", () => {
|
||||
expect(createInfiniteSessionConfig({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves explicit enabled:false to disable infinite sessions", () => {
|
||||
expect(createInfiniteSessionConfig({ enabled: false })).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it("preserves explicit enabled:true", () => {
|
||||
expect(createInfiniteSessionConfig({ enabled: true })).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("forwards threshold fields when set", () => {
|
||||
expect(
|
||||
createInfiniteSessionConfig({
|
||||
backgroundCompactionThreshold: 0.7,
|
||||
bufferExhaustionThreshold: 0.9,
|
||||
}),
|
||||
).toEqual({
|
||||
backgroundCompactionThreshold: 0.7,
|
||||
bufferExhaustionThreshold: 0.9,
|
||||
});
|
||||
});
|
||||
|
||||
it("combines enabled and thresholds", () => {
|
||||
expect(
|
||||
createInfiniteSessionConfig({
|
||||
enabled: true,
|
||||
backgroundCompactionThreshold: 0.5,
|
||||
bufferExhaustionThreshold: 0.85,
|
||||
}),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
backgroundCompactionThreshold: 0.5,
|
||||
bufferExhaustionThreshold: 0.85,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits undefined fields without coercing them", () => {
|
||||
const result = createInfiniteSessionConfig({
|
||||
enabled: undefined,
|
||||
backgroundCompactionThreshold: 0.6,
|
||||
bufferExhaustionThreshold: undefined,
|
||||
});
|
||||
expect(result).toEqual({ backgroundCompactionThreshold: 0.6 });
|
||||
expect(result).not.toHaveProperty("enabled");
|
||||
expect(result).not.toHaveProperty("bufferExhaustionThreshold");
|
||||
});
|
||||
});
|
||||
53
extensions/copilot/src/compaction-bridge.ts
Executable file
53
extensions/copilot/src/compaction-bridge.ts
Executable file
@@ -0,0 +1,53 @@
|
||||
// Copilot plugin module implements compaction bridge behavior.
|
||||
import type { SessionConfig } from "@github/copilot-sdk";
|
||||
|
||||
// Compaction bridge for the GitHub Copilot agent runtime.
|
||||
//
|
||||
// Shapes `SessionConfig.infiniteSessions` from a typed options bag so
|
||||
// attempt.ts can opt the SDK in to background auto-compaction at session
|
||||
// creation. The SDK manages the actual compaction under the `infiniteSessions`
|
||||
// config and the session-scoped history compaction RPC.
|
||||
//
|
||||
// Host back-pointers (NOT imported here to keep the package boundary
|
||||
// clean):
|
||||
// - `src/agents/pi-embedded-runner/compact.types.ts` — canonical
|
||||
// `CompactEmbeddedPiSessionParams`.
|
||||
// - `src/agents/pi-embedded-runner/types.ts` — canonical
|
||||
// `EmbeddedPiCompactResult`.
|
||||
|
||||
type SdkInfiniteSessionConfig = NonNullable<SessionConfig["infiniteSessions"]>;
|
||||
|
||||
export type { SdkInfiniteSessionConfig as CopilotInfiniteSessionConfig };
|
||||
|
||||
export interface CopilotInfiniteSessionOptions {
|
||||
enabled?: boolean;
|
||||
backgroundCompactionThreshold?: number;
|
||||
bufferExhaustionThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape an `InfiniteSessionConfig` for `SessionConfig.infiniteSessions`.
|
||||
* Returns `undefined` when no fields were supplied so callers can
|
||||
* spread conditionally and let the SDK apply its own defaults
|
||||
* (`enabled: true`, background 0.80, buffer 0.95). Any explicitly-set
|
||||
* value (including `enabled: false` to disable infinite sessions) is
|
||||
* preserved.
|
||||
*/
|
||||
export function createInfiniteSessionConfig(
|
||||
options?: CopilotInfiniteSessionOptions,
|
||||
): SdkInfiniteSessionConfig | undefined {
|
||||
if (!options) {
|
||||
return undefined;
|
||||
}
|
||||
const result: SdkInfiniteSessionConfig = {};
|
||||
if (options.enabled !== undefined) {
|
||||
result.enabled = options.enabled;
|
||||
}
|
||||
if (options.backgroundCompactionThreshold !== undefined) {
|
||||
result.backgroundCompactionThreshold = options.backgroundCompactionThreshold;
|
||||
}
|
||||
if (options.bufferExhaustionThreshold !== undefined) {
|
||||
result.bufferExhaustionThreshold = options.bufferExhaustionThreshold;
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
402
extensions/copilot/src/dual-write-transcripts.test.ts
Executable file
402
extensions/copilot/src/dual-write-transcripts.test.ts
Executable file
@@ -0,0 +1,402 @@
|
||||
// Copilot tests cover dual write transcripts plugin behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
} from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import {
|
||||
castAgentMessage,
|
||||
makeAgentAssistantMessage,
|
||||
makeAgentUserMessage,
|
||||
} from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
attachCopilotMirrorIdentity,
|
||||
dualWriteCopilotTranscriptBestEffort,
|
||||
mirrorCopilotTranscript,
|
||||
} from "./dual-write-transcripts.js";
|
||||
|
||||
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
|
||||
|
||||
function expectedFingerprint(message: MirroredAgentMessage): string {
|
||||
const payload = JSON.stringify({ role: message.role, content: message.content });
|
||||
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
resetGlobalHookRunner();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createTempSessionFile() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-copilot-mirror-"));
|
||||
tempDirs.push(dir);
|
||||
return path.join(dir, "session.jsonl");
|
||||
}
|
||||
|
||||
async function makeRoot(prefix: string): Promise<string> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function parseJsonLines<T>(raw: string): T[] {
|
||||
const records: T[] = [];
|
||||
for (const line of raw.trim().split("\n")) {
|
||||
if (line.length > 0) {
|
||||
records.push(JSON.parse(line) as T);
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
describe("mirrorCopilotTranscript", () => {
|
||||
it("mirrors user, assistant, and tool result messages into the OpenClaw transcript", async () => {
|
||||
const sessionFile = await createTempSessionFile();
|
||||
const userMessage = makeAgentUserMessage({
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const assistantMessage = makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "hi there" }],
|
||||
timestamp: Date.now() + 1,
|
||||
});
|
||||
const toolResultMessage = castAgentMessage({
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
content: [
|
||||
{
|
||||
type: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
content: "read output",
|
||||
},
|
||||
],
|
||||
timestamp: Date.now() + 2,
|
||||
}) as MirroredAgentMessage;
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userMessage, assistantMessage, toolResultMessage],
|
||||
idempotencyScope: "copilot:session-1",
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(sessionFile, "utf8");
|
||||
expect(raw).toContain('"role":"user"');
|
||||
expect(raw).toContain('"role":"assistant"');
|
||||
expect(raw).toContain('"role":"toolResult"');
|
||||
expect(raw).toContain('"toolCallId":"call-1"');
|
||||
expect(raw).toContain(
|
||||
`"idempotencyKey":"copilot:session-1:user:${expectedFingerprint(userMessage)}"`,
|
||||
);
|
||||
expect(raw).toContain(
|
||||
`"idempotencyKey":"copilot:session-1:assistant:${expectedFingerprint(assistantMessage)}"`,
|
||||
);
|
||||
expect(raw).toContain(
|
||||
`"idempotencyKey":"copilot:session-1:toolResult:${expectedFingerprint(toolResultMessage)}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the transcript directory on first mirror", async () => {
|
||||
const root = await makeRoot("openclaw-copilot-mirror-missing-dir-");
|
||||
const sessionFile = path.join(root, "nested", "sessions", "session.jsonl");
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "first mirror" }],
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
],
|
||||
idempotencyScope: "copilot:session-1",
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(sessionFile, "utf8");
|
||||
expect(raw).toContain('"role":"assistant"');
|
||||
expect(raw).toContain('"content":[{"type":"text","text":"first mirror"}]');
|
||||
});
|
||||
|
||||
it("deduplicates re-emits by idempotency scope", async () => {
|
||||
const sessionFile = await createTempSessionFile();
|
||||
const messages = [
|
||||
makeAgentUserMessage({
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "hi there" }],
|
||||
timestamp: Date.now() + 1,
|
||||
}),
|
||||
] as const;
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [...messages],
|
||||
idempotencyScope: "copilot:session-1",
|
||||
});
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [...messages],
|
||||
idempotencyScope: "copilot:session-1",
|
||||
});
|
||||
|
||||
const records = parseJsonLines<{ type?: string; message?: { role?: string } }>(
|
||||
await fs.readFile(sessionFile, "utf8"),
|
||||
);
|
||||
// First "header" record may or may not appear depending on migration.
|
||||
// What matters is that the second mirror call adds zero new messages.
|
||||
const messageRecords = records.filter((r) => r.message?.role !== undefined);
|
||||
expect(messageRecords).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("runs before_message_write before appending mirrored messages", async () => {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{
|
||||
hookName: "before_message_write",
|
||||
handler: (event) => ({
|
||||
message: castAgentMessage({
|
||||
...((event as { message: unknown }).message as Record<string, unknown>),
|
||||
content: [{ type: "text", text: "hello [hooked]" }],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const sessionFile = await createTempSessionFile();
|
||||
const sourceMessage = makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [sourceMessage],
|
||||
idempotencyScope: "copilot:session-1",
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(sessionFile, "utf8");
|
||||
expect(raw).toContain('"content":[{"type":"text","text":"hello [hooked]"}]');
|
||||
expect(raw).toContain(
|
||||
`"idempotencyKey":"copilot:session-1:assistant:${expectedFingerprint(sourceMessage)}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("respects before_message_write blocking decisions", async () => {
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([
|
||||
{
|
||||
hookName: "before_message_write",
|
||||
handler: () => ({ block: true }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const sessionFile = await createTempSessionFile();
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "should not persist" }],
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
],
|
||||
idempotencyScope: "copilot:session-1",
|
||||
});
|
||||
|
||||
await expect(fs.readFile(sessionFile, "utf8")).rejects.toHaveProperty("code", "ENOENT");
|
||||
});
|
||||
|
||||
it("is a no-op when no mirrorable messages are present", async () => {
|
||||
const sessionFile = await createTempSessionFile();
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [],
|
||||
idempotencyScope: "copilot:session-1",
|
||||
});
|
||||
|
||||
await expect(fs.readFile(sessionFile, "utf8")).rejects.toHaveProperty("code", "ENOENT");
|
||||
});
|
||||
|
||||
it("uses content fingerprint when no explicit mirror identity is attached", async () => {
|
||||
const sessionFile = await createTempSessionFile();
|
||||
const message = makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "fp" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
messages: [message],
|
||||
idempotencyScope: "scope-fp",
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(sessionFile, "utf8");
|
||||
expect(raw).toContain(`"idempotencyKey":"scope-fp:assistant:${expectedFingerprint(message)}"`);
|
||||
});
|
||||
|
||||
it("uses attached identity instead of content fingerprint when provided", async () => {
|
||||
const sessionFile = await createTempSessionFile();
|
||||
const baseMessage = makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "explicit" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const tagged = attachCopilotMirrorIdentity(baseMessage, "sdk-session-1:assistant:0");
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
messages: [tagged],
|
||||
idempotencyScope: "copilot:openclaw-session-1",
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(sessionFile, "utf8");
|
||||
expect(raw).toContain(
|
||||
'"idempotencyKey":"copilot:openclaw-session-1:sdk-session-1:assistant:0"',
|
||||
);
|
||||
expect(raw).not.toContain(expectedFingerprint(baseMessage));
|
||||
});
|
||||
|
||||
it("omits idempotencyKey when no idempotencyScope is provided", async () => {
|
||||
const sessionFile = await createTempSessionFile();
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "no scope" }],
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(sessionFile, "utf8");
|
||||
expect(raw).toContain('"content":[{"type":"text","text":"no scope"}]');
|
||||
expect(raw).not.toContain("idempotencyKey");
|
||||
});
|
||||
|
||||
it("filters out non-mirrorable roles", async () => {
|
||||
const sessionFile = await createTempSessionFile();
|
||||
const userMessage = makeAgentUserMessage({
|
||||
content: [{ type: "text", text: "u" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const systemLike = castAgentMessage({
|
||||
role: "system" as never,
|
||||
content: [{ type: "text", text: "system note" }],
|
||||
timestamp: Date.now() + 1,
|
||||
});
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
messages: [userMessage, systemLike],
|
||||
idempotencyScope: "scope",
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(sessionFile, "utf8");
|
||||
expect(raw).toContain('"role":"user"');
|
||||
expect(raw).not.toContain("system note");
|
||||
});
|
||||
|
||||
it("preserves explicit identity across attachCopilotMirrorIdentity overrides", async () => {
|
||||
const sessionFile = await createTempSessionFile();
|
||||
const base = makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "x" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const first = attachCopilotMirrorIdentity(base, "id-1");
|
||||
const second = attachCopilotMirrorIdentity(first, "id-2");
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
messages: [second],
|
||||
idempotencyScope: "scope",
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(sessionFile, "utf8");
|
||||
expect(raw).toContain('"idempotencyKey":"scope:id-2"');
|
||||
expect(raw).not.toContain('"idempotencyKey":"scope:id-1"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("dualWriteCopilotTranscriptBestEffort", () => {
|
||||
it("returns normally when mirror succeeds", async () => {
|
||||
const sessionFile = await createTempSessionFile();
|
||||
await expect(
|
||||
dualWriteCopilotTranscriptBestEffort({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
],
|
||||
idempotencyScope: "scope",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
const raw = await fs.readFile(sessionFile, "utf8");
|
||||
expect(raw).toContain('"role":"assistant"');
|
||||
});
|
||||
|
||||
it("swallows infrastructure failures and never rejects", async () => {
|
||||
const root = await makeRoot("openclaw-copilot-mirror-invalid-");
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = root;
|
||||
try {
|
||||
await expect(
|
||||
dualWriteCopilotTranscriptBestEffort({
|
||||
agentId: "main",
|
||||
sessionFile: "",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "should-not-throw" }],
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
],
|
||||
idempotencyScope: "scope",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
fs.access(path.join(root, "agents", "main", "sessions", "session-1.jsonl")),
|
||||
).rejects.toHaveProperty("code", "ENOENT");
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
230
extensions/copilot/src/dual-write-transcripts.ts
Executable file
230
extensions/copilot/src/dual-write-transcripts.ts
Executable file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Mirrors the AgentMessages produced by the copilot agent runtime into the
|
||||
* OpenClaw audit transcript that sits next to (but is distinct from) the
|
||||
* SDK's own session storage.
|
||||
*
|
||||
* The OpenClaw shell (src/agents/command/attempt-execution.ts) already
|
||||
* writes the user prompt and the terminal assistant text into the
|
||||
* transcript at the end of each attempt. That is the bare minimum to
|
||||
* keep `/history` working. It does NOT capture tool calls, tool
|
||||
* results, or intermediate assistant turns — those live only in the
|
||||
* SDK's own session file.
|
||||
*
|
||||
* For audit/compliance and for the codex-parity guarantees we promised
|
||||
* in the proposal, we mirror the full `messagesSnapshot` (user +
|
||||
* assistant + toolResult) into the OpenClaw transcript via the same
|
||||
* plugin-sdk primitives that the codex extension uses
|
||||
* (extensions/codex/src/app-server/transcript-mirror.ts). Both writers
|
||||
* cooperate via idempotency-key dedupe: each mirrored entry carries a
|
||||
* stable `${idempotencyScope}:${identity}` key, and we skip any key
|
||||
* already present in the transcript on disk before appending. Both
|
||||
* attempt-execution's untagged entries (no idempotencyKey) and our
|
||||
* tagged mirror entries can coexist; attempt-execution dedupes its own
|
||||
* final-assistant append via `embeddedAssistantGapFill` content match.
|
||||
*
|
||||
* Failures (lock contention, fs errors, etc.) are swallowed by the
|
||||
* caller-side `dualWriteCopilotTranscriptBestEffort` wrapper used
|
||||
* in attempt.ts so they cannot break the attempt; this module itself
|
||||
* throws on infrastructure failure so callers can choose policy.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
runAgentHarnessBeforeMessageWriteHook,
|
||||
type AgentMessage,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
publishSessionTranscriptUpdateByIdentity,
|
||||
withSessionTranscriptWriteLock,
|
||||
type SessionTranscriptTargetParams,
|
||||
type SessionTranscriptWriteLockParams,
|
||||
} from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
|
||||
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
|
||||
|
||||
const MIRROR_IDENTITY_META_KEY = "mirrorIdentity" as const;
|
||||
|
||||
/**
|
||||
* Tag a message with a stable logical identity for mirror dedupe.
|
||||
* Callers should use a value that is invariant for the same logical
|
||||
* message across re-emits (e.g. `${sdkSessionId}:assistant:${turnIndex}`)
|
||||
* but distinct for genuinely-distinct messages. When present this
|
||||
* identity replaces the role/content fingerprint in the idempotency
|
||||
* key, so the dedupe survives caller-scope rotation without collapsing
|
||||
* distinct same-content turns. Symmetric to
|
||||
* `attachCodexMirrorIdentity` in the codex extension.
|
||||
*/
|
||||
export function attachCopilotMirrorIdentity<T extends AgentMessage>(
|
||||
message: T,
|
||||
identity: string,
|
||||
): T {
|
||||
const record = message as unknown as Record<string, unknown>;
|
||||
const existing = record["__openclaw"];
|
||||
const baseMeta =
|
||||
existing && typeof existing === "object" && !Array.isArray(existing)
|
||||
? (existing as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...record,
|
||||
__openclaw: { ...baseMeta, [MIRROR_IDENTITY_META_KEY]: identity },
|
||||
} as unknown as T;
|
||||
}
|
||||
|
||||
function readMirrorIdentity(message: MirroredAgentMessage): string | undefined {
|
||||
const record = message as unknown as { __openclaw?: unknown };
|
||||
const meta = record["__openclaw"];
|
||||
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
|
||||
return undefined;
|
||||
}
|
||||
const id = (meta as Record<string, unknown>)[MIRROR_IDENTITY_META_KEY];
|
||||
return typeof id === "string" && id.length > 0 ? id : undefined;
|
||||
}
|
||||
|
||||
function fingerprintMirrorMessageContent(message: MirroredAgentMessage): string {
|
||||
const payload = JSON.stringify({ role: message.role, content: message.content });
|
||||
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string {
|
||||
const explicit = readMirrorIdentity(message);
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
return `${message.role}:${fingerprintMirrorMessageContent(message)}`;
|
||||
}
|
||||
|
||||
export interface MirrorCopilotTranscriptParams {
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
messages: AgentMessage[];
|
||||
/**
|
||||
* Stable per-harness/per-thread scope. The codex equivalent uses
|
||||
* `codex-app-server:${threadId}`; we use `copilot:${sessionId}`
|
||||
* by convention (see attempt.ts call site). Keeping the scope
|
||||
* thread-stable (not per-turn) is what lets a re-emitted prior-turn
|
||||
* entry collide with its existing on-disk key and be a true no-op.
|
||||
*/
|
||||
idempotencyScope?: string;
|
||||
config?: SessionTranscriptWriteLockParams["config"];
|
||||
}
|
||||
|
||||
export async function mirrorCopilotTranscript(
|
||||
params: MirrorCopilotTranscriptParams,
|
||||
): Promise<void> {
|
||||
const messages = params.messages.filter(
|
||||
(message): message is MirroredAgentMessage =>
|
||||
message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
||||
);
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transcriptTarget = resolveCopilotMirrorTranscriptTarget(params);
|
||||
const didAppend = await withSessionTranscriptWriteLock(
|
||||
{ ...transcriptTarget, config: params.config },
|
||||
async (transcript) => {
|
||||
let didAppendMessage = false;
|
||||
const existingIdempotencyKeys = readTranscriptIdempotencyKeys(await transcript.readEvents());
|
||||
for (const message of messages) {
|
||||
const dedupeIdentity = buildMirrorDedupeIdentity(message);
|
||||
const idempotencyKey = params.idempotencyScope
|
||||
? `${params.idempotencyScope}:${dedupeIdentity}`
|
||||
: undefined;
|
||||
if (idempotencyKey && existingIdempotencyKeys.has(idempotencyKey)) {
|
||||
continue;
|
||||
}
|
||||
const transcriptMessage = {
|
||||
...message,
|
||||
...(idempotencyKey ? { idempotencyKey } : {}),
|
||||
} as AgentMessage;
|
||||
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
|
||||
message: transcriptMessage,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!nextMessage) {
|
||||
continue;
|
||||
}
|
||||
const messageToAppend = (
|
||||
idempotencyKey
|
||||
? {
|
||||
...(nextMessage as unknown as Record<string, unknown>),
|
||||
idempotencyKey,
|
||||
}
|
||||
: nextMessage
|
||||
) as AgentMessage;
|
||||
const appended = await transcript.appendMessage({
|
||||
message: messageToAppend,
|
||||
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
|
||||
});
|
||||
if (!appended) {
|
||||
continue;
|
||||
}
|
||||
didAppendMessage = true;
|
||||
if (idempotencyKey) {
|
||||
existingIdempotencyKeys.add(idempotencyKey);
|
||||
}
|
||||
}
|
||||
return didAppendMessage;
|
||||
},
|
||||
);
|
||||
|
||||
if (didAppend) {
|
||||
await publishSessionTranscriptUpdateByIdentity({
|
||||
...transcriptTarget,
|
||||
update: params.sessionKey ? { sessionKey: params.sessionKey } : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCopilotMirrorTranscriptTarget(params: {
|
||||
agentId?: string;
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
}): SessionTranscriptTargetParams {
|
||||
const sessionFile = params.sessionFile.trim();
|
||||
if (!sessionFile) {
|
||||
throw new Error("Copilot transcript mirror requires a sessionFile target");
|
||||
}
|
||||
return {
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
sessionFile,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function readTranscriptIdempotencyKeys(events: unknown[]): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const event of events) {
|
||||
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
||||
continue;
|
||||
}
|
||||
const parsed = event as { message?: { idempotencyKey?: unknown } };
|
||||
if (typeof parsed.message?.idempotencyKey === "string") {
|
||||
keys.add(parsed.message.idempotencyKey);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Caller-side wrapper that swallows mirror failures. attempt.ts uses
|
||||
* this so that a transient transcript-mirror failure (lock contention,
|
||||
* disk full, etc.) never breaks an otherwise-successful attempt. The
|
||||
* SDK's own session file remains the source of truth in that case;
|
||||
* the OpenClaw audit trail just misses the intermediate messages for
|
||||
* this turn.
|
||||
*/
|
||||
export async function dualWriteCopilotTranscriptBestEffort(
|
||||
params: MirrorCopilotTranscriptParams,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await mirrorCopilotTranscript(params);
|
||||
} catch (error) {
|
||||
console.warn("[copilot-attempt] dual-write transcript mirror failed", error);
|
||||
}
|
||||
}
|
||||
1127
extensions/copilot/src/event-bridge.test.ts
Normal file
1127
extensions/copilot/src/event-bridge.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
622
extensions/copilot/src/event-bridge.ts
Normal file
622
extensions/copilot/src/event-bridge.ts
Normal file
@@ -0,0 +1,622 @@
|
||||
// Copilot plugin module implements event bridge behavior.
|
||||
import type { MessageOptions, SessionEvent, SessionEventType } from "@github/copilot-sdk";
|
||||
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
buildCopilotAssistantUsage,
|
||||
normalizeCopilotUsage,
|
||||
type CopilotUsageSnapshot,
|
||||
} from "./usage-bridge.js";
|
||||
|
||||
export type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
|
||||
|
||||
export type AssistantUsageSnapshot = CopilotUsageSnapshot;
|
||||
|
||||
export interface OnAssistantDeltaPayload {
|
||||
delta: string;
|
||||
sessionId?: string;
|
||||
text: string;
|
||||
usage?: AssistantUsageSnapshot;
|
||||
}
|
||||
|
||||
export interface SessionLike {
|
||||
abort(): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
id?: string;
|
||||
off?: (eventType: string, handler: (...args: unknown[]) => void) => void;
|
||||
on: {
|
||||
<K extends SessionEventType>(
|
||||
eventType: K,
|
||||
handler: (event: Extract<SessionEvent, { type: K }>) => void,
|
||||
): (() => void) | void;
|
||||
(eventType: string, handler: (event: SessionEvent) => void): (() => void) | void;
|
||||
};
|
||||
rpc?: {
|
||||
history?: {
|
||||
cancelBackgroundCompaction?: () => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
sendAndWait(options: MessageOptions, timeout?: number): Promise<SessionEvent | undefined>;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface EventBridgeOptions {
|
||||
onAssistantDelta?: (payload: OnAssistantDeltaPayload) => void | Promise<void>;
|
||||
onAgentEvent?: (event: {
|
||||
stream: "item" | "plan";
|
||||
data: Record<string, unknown>;
|
||||
}) => void | Promise<void>;
|
||||
onNativeSubagentEvent?: (
|
||||
event: Extract<
|
||||
SessionEvent,
|
||||
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
|
||||
>,
|
||||
) => void;
|
||||
onCompactionComplete?: (payload: {
|
||||
messagesRemoved?: number;
|
||||
success: boolean;
|
||||
}) => void | Promise<void>;
|
||||
onCompactionStart?: () => void | Promise<void>;
|
||||
getSdkSessionId: () => string | undefined;
|
||||
isAborted: () => boolean;
|
||||
}
|
||||
|
||||
export interface EventBridgeSnapshot {
|
||||
readonly assistantTexts: readonly string[];
|
||||
readonly completedCount: number;
|
||||
readonly lastAssistantEvent: Extract<SessionEvent, { type: "assistant.message" }> | undefined;
|
||||
readonly startedCount: number;
|
||||
readonly streamError: Error | undefined;
|
||||
readonly toolMetas: ReadonlyArray<{ meta?: string; toolName: string }>;
|
||||
readonly usage: AssistantUsageSnapshot | undefined;
|
||||
}
|
||||
|
||||
export interface BuildAssistantMessageArgs {
|
||||
modelRef: { api?: string; id: string; provider: string };
|
||||
now: () => number;
|
||||
}
|
||||
|
||||
export interface EventBridgeController {
|
||||
recordSendResult(result: SessionEvent | undefined): boolean;
|
||||
awaitCompactionChain(): Promise<void>;
|
||||
awaitCompactionCompletion(): Promise<void>;
|
||||
awaitSessionIdle(): Promise<void>;
|
||||
settleCompactionWait(): void;
|
||||
awaitDeltaChain(): Promise<void>;
|
||||
awaitAgentEventChain(): Promise<void>;
|
||||
hasObservedCompaction(): boolean;
|
||||
hasObservedSessionIdle(): boolean;
|
||||
isCompacting(): boolean;
|
||||
snapshot(): EventBridgeSnapshot;
|
||||
buildAssistantMessage(args: BuildAssistantMessageArgs): AssistantMessage | undefined;
|
||||
finalizeAssistantTexts(): string[];
|
||||
detach(): void;
|
||||
}
|
||||
|
||||
type MessageAccumulator = { messageId: string; text: string };
|
||||
type PromptErrorWithCode = Error & { code?: string; cause?: unknown };
|
||||
|
||||
export function attachEventBridge(
|
||||
session: SessionLike,
|
||||
options: EventBridgeOptions,
|
||||
): EventBridgeController {
|
||||
const messageOrder: string[] = [];
|
||||
const messagesById = new Map<string, MessageAccumulator>();
|
||||
const reasoningOrder: string[] = [];
|
||||
const reasoningById = new Map<string, string>();
|
||||
let lastAssistantEvent: Extract<SessionEvent, { type: "assistant.message" }> | undefined;
|
||||
let usage: AssistantUsageSnapshot | undefined;
|
||||
let streamError: Error | undefined;
|
||||
const toolMetas: Array<{ meta?: string; toolName: string }> = [];
|
||||
const toolNamesByCallId = new Map<string, string>();
|
||||
let startedCount = 0;
|
||||
let completedCount = 0;
|
||||
let activeCompactionCount = 0;
|
||||
let observedCompaction = false;
|
||||
let deltaQueue = Promise.resolve();
|
||||
let deltaChain = Promise.resolve();
|
||||
let agentEventChain = Promise.resolve();
|
||||
let compactionChain = Promise.resolve();
|
||||
let compactionIdle = Promise.resolve();
|
||||
let resolveCompactionIdle: (() => void) | undefined;
|
||||
let observedSessionIdle = false;
|
||||
let resolveSessionIdle: (() => void) | undefined;
|
||||
const sessionIdle = new Promise<void>((resolve) => {
|
||||
resolveSessionIdle = resolve;
|
||||
});
|
||||
let firstDeltaError: unknown;
|
||||
let detached = false;
|
||||
const unsubscribeFns: Array<() => void> = [];
|
||||
|
||||
registerListener(session, unsubscribeFns, "assistant.message_delta", (event) => {
|
||||
if (!isRootSessionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
const messageId = readString(event.data.messageId) ?? "assistant-message";
|
||||
const delta = event.data.deltaContent;
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
const entry = ensureMessageAccumulator(messagesById, messageOrder, messageId);
|
||||
entry.text += delta;
|
||||
const onAssistantDelta = options.onAssistantDelta;
|
||||
if (!onAssistantDelta) {
|
||||
return;
|
||||
}
|
||||
const payload: OnAssistantDeltaPayload = {
|
||||
delta,
|
||||
sessionId: options.getSdkSessionId(),
|
||||
text: entry.text,
|
||||
usage,
|
||||
};
|
||||
deltaQueue = deltaQueue
|
||||
.then(
|
||||
() => onAssistantDelta(payload),
|
||||
() => onAssistantDelta(payload),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
firstDeltaError ??= error;
|
||||
});
|
||||
deltaChain = deltaQueue.then(() => {
|
||||
if (firstDeltaError !== undefined) {
|
||||
throw toLintErrorObject(firstDeltaError, "Non-Error thrown");
|
||||
}
|
||||
});
|
||||
void deltaChain.catch(() => undefined);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "assistant.reasoning_delta", (event) => {
|
||||
if (!isRootSessionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
const reasoningId = readString(event.data.reasoningId) ?? "assistant-reasoning";
|
||||
const delta = event.data.deltaContent;
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
if (!reasoningById.has(reasoningId)) {
|
||||
reasoningById.set(reasoningId, "");
|
||||
reasoningOrder.push(reasoningId);
|
||||
}
|
||||
reasoningById.set(reasoningId, `${reasoningById.get(reasoningId) ?? ""}${delta}`);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "assistant.message", (event) => {
|
||||
if (!isRootSessionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
lastAssistantEvent = event;
|
||||
const entry = ensureMessageAccumulator(messagesById, messageOrder, event.data.messageId);
|
||||
if (typeof event.data.content === "string" && event.data.content.length >= entry.text.length) {
|
||||
entry.text = event.data.content;
|
||||
}
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "assistant.usage", (event) => {
|
||||
if (!isRootSessionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
usage = normalizeCopilotUsage(event.data);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "tool.execution_start", (event) => {
|
||||
if (isRootSessionEvent(event)) {
|
||||
startedCount += 1;
|
||||
}
|
||||
toolNamesByCallId.set(event.data.toolCallId, event.data.toolName);
|
||||
toolMetas.push({ toolName: event.data.toolName });
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "tool.execution_complete", (event) => {
|
||||
if (isRootSessionEvent(event)) {
|
||||
completedCount += 1;
|
||||
}
|
||||
const toolName = toolNamesByCallId.get(event.data.toolCallId);
|
||||
const meta = event.data.success
|
||||
? (event.data.result?.detailedContent ?? event.data.result?.content)
|
||||
: event.data.error?.message;
|
||||
if (toolName) {
|
||||
toolMetas.push({ meta, toolName });
|
||||
}
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.plan_changed", (event) => {
|
||||
enqueueAgentEvent({
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan updated",
|
||||
source: "copilot-sdk",
|
||||
operation: event.data.operation,
|
||||
...(event.agentId ? { agentId: event.agentId } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "exit_plan_mode.requested", (event) => {
|
||||
const steps = splitPlanText(event.data.planContent);
|
||||
enqueueAgentEvent({
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan updated",
|
||||
source: "copilot-sdk",
|
||||
...(event.data.summary ? { explanation: event.data.summary } : {}),
|
||||
...(steps.length > 0 ? { steps } : {}),
|
||||
...(event.data.actions.length > 0 ? { actions: event.data.actions } : {}),
|
||||
...(event.data.requestId ? { requestId: event.data.requestId } : {}),
|
||||
...(event.data.recommendedAction
|
||||
? { recommendedAction: event.data.recommendedAction }
|
||||
: {}),
|
||||
...(event.agentId ? { agentId: event.agentId } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "exit_plan_mode.completed", (event) => {
|
||||
enqueueAgentEvent({
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan decision",
|
||||
source: "copilot-sdk",
|
||||
requestId: event.data.requestId,
|
||||
...(event.data.approved !== undefined ? { approved: event.data.approved } : {}),
|
||||
...(event.data.autoApproveEdits !== undefined
|
||||
? { autoApproveEdits: event.data.autoApproveEdits }
|
||||
: {}),
|
||||
...(event.data.feedback ? { feedback: event.data.feedback } : {}),
|
||||
...(event.data.selectedAction ? { selectedAction: event.data.selectedAction } : {}),
|
||||
...(event.agentId ? { agentId: event.agentId } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "subagent.started", (event) => {
|
||||
forwardNativeSubagentEvent(event);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "subagent.completed", (event) => {
|
||||
forwardNativeSubagentEvent(event);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "subagent.failed", (event) => {
|
||||
forwardNativeSubagentEvent(event);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.compaction_start", (event) => {
|
||||
if (!isRootCompactionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
observedCompaction = true;
|
||||
if (activeCompactionCount === 0) {
|
||||
compactionIdle = new Promise<void>((resolve) => {
|
||||
resolveCompactionIdle = resolve;
|
||||
});
|
||||
}
|
||||
activeCompactionCount += 1;
|
||||
enqueueCompactionCallback(options.onCompactionStart);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.compaction_complete", (event) => {
|
||||
if (!isRootCompactionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
activeCompactionCount = Math.max(0, activeCompactionCount - 1);
|
||||
enqueueCompactionCallback(() =>
|
||||
options.onCompactionComplete?.({
|
||||
...(event.data.messagesRemoved !== undefined
|
||||
? { messagesRemoved: event.data.messagesRemoved }
|
||||
: {}),
|
||||
success: event.data.success,
|
||||
}),
|
||||
);
|
||||
if (activeCompactionCount === 0) {
|
||||
resolveCompactionIdle?.();
|
||||
resolveCompactionIdle = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.idle", (event) => {
|
||||
if (!isRootCompactionEvent(event)) {
|
||||
return;
|
||||
}
|
||||
observedSessionIdle = true;
|
||||
resolveSessionIdle?.();
|
||||
resolveSessionIdle = undefined;
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.error", (event) => {
|
||||
if (!options.isAborted()) {
|
||||
streamError = createPromptError(
|
||||
event.data.errorCode ?? event.data.errorType,
|
||||
event.data.message,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "abort", (event) => {
|
||||
if (!options.isAborted()) {
|
||||
streamError = createPromptError(
|
||||
"session_aborted",
|
||||
`[copilot-attempt] session aborted: ${event.data.reason}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
recordSendResult(result) {
|
||||
if (!isAssistantMessageEvent(result)) {
|
||||
return false;
|
||||
}
|
||||
lastAssistantEvent = result;
|
||||
return true;
|
||||
},
|
||||
awaitCompactionChain() {
|
||||
return compactionChain;
|
||||
},
|
||||
async awaitCompactionCompletion() {
|
||||
await awaitStableCompaction();
|
||||
},
|
||||
awaitSessionIdle() {
|
||||
return observedSessionIdle ? Promise.resolve() : sessionIdle;
|
||||
},
|
||||
settleCompactionWait() {
|
||||
activeCompactionCount = 0;
|
||||
resolveCompactionIdle?.();
|
||||
resolveCompactionIdle = undefined;
|
||||
},
|
||||
awaitDeltaChain() {
|
||||
return deltaChain;
|
||||
},
|
||||
awaitAgentEventChain() {
|
||||
return agentEventChain;
|
||||
},
|
||||
hasObservedCompaction() {
|
||||
return observedCompaction;
|
||||
},
|
||||
hasObservedSessionIdle() {
|
||||
return observedSessionIdle;
|
||||
},
|
||||
isCompacting() {
|
||||
return activeCompactionCount > 0;
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
assistantTexts: finalizeAssistantTexts(messageOrder, messagesById, lastAssistantEvent),
|
||||
completedCount,
|
||||
lastAssistantEvent,
|
||||
startedCount,
|
||||
streamError,
|
||||
toolMetas: toolMetas.map((toolMeta) => Object.assign({}, toolMeta)),
|
||||
usage: usage ? { ...usage } : undefined,
|
||||
};
|
||||
},
|
||||
buildAssistantMessage(args) {
|
||||
return buildAssistantMessage({
|
||||
event: lastAssistantEvent,
|
||||
modelRef: args.modelRef,
|
||||
now: args.now,
|
||||
reasoningById,
|
||||
reasoningOrder,
|
||||
usage,
|
||||
assistantTexts: finalizeAssistantTexts(messageOrder, messagesById, lastAssistantEvent),
|
||||
});
|
||||
},
|
||||
finalizeAssistantTexts() {
|
||||
return finalizeAssistantTexts(messageOrder, messagesById, lastAssistantEvent);
|
||||
},
|
||||
detach() {
|
||||
if (detached) {
|
||||
return;
|
||||
}
|
||||
detached = true;
|
||||
for (const unsubscribe of [...unsubscribeFns].toReversed()) {
|
||||
try {
|
||||
unsubscribe();
|
||||
} catch {
|
||||
// best-effort cleanup only
|
||||
}
|
||||
}
|
||||
unsubscribeFns.length = 0;
|
||||
},
|
||||
};
|
||||
|
||||
function enqueueCompactionCallback(callback: (() => void | Promise<void>) | undefined): void {
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
const queued = compactionChain.then(callback, callback);
|
||||
compactionChain = queued.catch(() => undefined);
|
||||
}
|
||||
|
||||
function enqueueAgentEvent(event: {
|
||||
stream: "item" | "plan";
|
||||
data: Record<string, unknown>;
|
||||
}): void {
|
||||
const callback = options.onAgentEvent;
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
const invoke = () => callback(event);
|
||||
agentEventChain = agentEventChain.then(invoke, invoke).catch(() => undefined);
|
||||
}
|
||||
|
||||
function forwardNativeSubagentEvent(
|
||||
event: Extract<
|
||||
SessionEvent,
|
||||
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
|
||||
>,
|
||||
): void {
|
||||
try {
|
||||
options.onNativeSubagentEvent?.(event);
|
||||
} catch {
|
||||
// Native task mirroring must not corrupt the Copilot turn.
|
||||
}
|
||||
}
|
||||
|
||||
async function awaitStableCompaction(): Promise<void> {
|
||||
const idle = activeCompactionCount > 0 ? compactionIdle : undefined;
|
||||
if (idle) {
|
||||
await idle;
|
||||
}
|
||||
const callbacks = compactionChain;
|
||||
await callbacks;
|
||||
// Compaction events can arrive while an earlier hook callback settles.
|
||||
// Recheck both queues before teardown so the root observer stays attached.
|
||||
if (activeCompactionCount > 0 || compactionChain !== callbacks) {
|
||||
await awaitStableCompaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildAssistantMessage(params: {
|
||||
assistantTexts: string[];
|
||||
event?: Extract<SessionEvent, { type: "assistant.message" }>;
|
||||
modelRef: { api?: string; id: string; provider: string };
|
||||
now: () => number;
|
||||
reasoningById: Map<string, string>;
|
||||
reasoningOrder: string[];
|
||||
usage?: AssistantUsageSnapshot;
|
||||
}): AssistantMessage | undefined {
|
||||
const event = params.event;
|
||||
const text = event
|
||||
? event.data.content || params.assistantTexts[params.assistantTexts.length - 1] || ""
|
||||
: "";
|
||||
const reasoningText =
|
||||
event?.data.reasoningText ?? joinReasoning(params.reasoningOrder, params.reasoningById);
|
||||
const toolRequests = event?.data.toolRequests ?? [];
|
||||
if (!text && !reasoningText && toolRequests.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const content: AssistantMessage["content"] = [];
|
||||
if (reasoningText) {
|
||||
content.push({ thinking: reasoningText, type: "thinking" });
|
||||
}
|
||||
if (text) {
|
||||
content.push({ text, type: "text" });
|
||||
}
|
||||
for (const request of toolRequests) {
|
||||
content.push({
|
||||
arguments: request.arguments ?? {},
|
||||
id: request.toolCallId,
|
||||
name: request.name,
|
||||
type: "toolCall",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
api: params.modelRef.api ?? "openai-responses",
|
||||
content,
|
||||
model: event?.data.model ?? params.modelRef.id,
|
||||
provider: params.modelRef.provider,
|
||||
role: "assistant",
|
||||
stopReason: toolRequests.length > 0 ? "toolUse" : "stop",
|
||||
timestamp: params.now(),
|
||||
usage: buildCopilotAssistantUsage({
|
||||
fallbackOutputTokens: event?.data.outputTokens,
|
||||
usage: params.usage,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createPromptError(code: string, message: string, cause?: unknown): PromptErrorWithCode {
|
||||
const error = new Error(message) as PromptErrorWithCode;
|
||||
error.code = code;
|
||||
if (cause !== undefined) {
|
||||
error.cause = cause;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function ensureMessageAccumulator(
|
||||
messagesById: Map<string, MessageAccumulator>,
|
||||
messageOrder: string[],
|
||||
messageId: string,
|
||||
): MessageAccumulator {
|
||||
let entry = messagesById.get(messageId);
|
||||
if (!entry) {
|
||||
entry = { messageId, text: "" };
|
||||
messagesById.set(messageId, entry);
|
||||
messageOrder.push(messageId);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function finalizeAssistantTexts(
|
||||
messageOrder: string[],
|
||||
messagesById: Map<string, MessageAccumulator>,
|
||||
event?: Extract<SessionEvent, { type: "assistant.message" }>,
|
||||
): string[] {
|
||||
const texts = messageOrder
|
||||
.map((messageId) => messagesById.get(messageId)?.text ?? "")
|
||||
.filter((text) => text.length > 0);
|
||||
if (texts.length > 0) {
|
||||
return texts;
|
||||
}
|
||||
if (event?.data.content) {
|
||||
return [event.data.content];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function isAssistantMessageEvent(
|
||||
event: SessionEvent | undefined,
|
||||
): event is Extract<SessionEvent, { type: "assistant.message" }> {
|
||||
return event?.type === "assistant.message";
|
||||
}
|
||||
|
||||
function isRootSessionEvent(event: { agentId?: string }): boolean {
|
||||
return event.agentId === undefined;
|
||||
}
|
||||
|
||||
function isRootCompactionEvent(event: { agentId?: string }): boolean {
|
||||
// SDK session events include subagent compaction; only root compaction
|
||||
// affects the pooled root session's cleanup and reuse lifecycle.
|
||||
return isRootSessionEvent(event);
|
||||
}
|
||||
|
||||
function joinReasoning(order: string[], reasoningById: Map<string, string>): string {
|
||||
return order.map((reasoningId) => reasoningById.get(reasoningId) ?? "").join("");
|
||||
}
|
||||
|
||||
function splitPlanText(text: string | undefined): string[] {
|
||||
return (text ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim().replace(/^[-*]\s+/, ""))
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function registerListener<K extends SessionEventType>(
|
||||
session: SessionLike,
|
||||
unsubscribeFns: Array<() => void>,
|
||||
eventType: K,
|
||||
handler: (event: Extract<SessionEvent, { type: K }>) => void,
|
||||
): void {
|
||||
const maybeUnsubscribe = session.on(eventType, handler);
|
||||
if (typeof maybeUnsubscribe === "function") {
|
||||
unsubscribeFns.push(maybeUnsubscribe);
|
||||
return;
|
||||
}
|
||||
unsubscribeFns.push(() => {
|
||||
session.off?.(eventType, handler as (...args: unknown[]) => void);
|
||||
});
|
||||
}
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
172
extensions/copilot/src/hooks-bridge.test.ts
Executable file
172
extensions/copilot/src/hooks-bridge.test.ts
Executable file
@@ -0,0 +1,172 @@
|
||||
// Copilot tests cover native SDK hook compatibility.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createHooksBridge, type CopilotHooksConfig } from "./hooks-bridge.js";
|
||||
|
||||
describe("createHooksBridge", () => {
|
||||
const hookBase = {
|
||||
sessionId: "runtime-session",
|
||||
timestamp: new Date(0),
|
||||
cwd: "/",
|
||||
workingDirectory: "/",
|
||||
};
|
||||
|
||||
it("returns undefined when no handlers are configured", () => {
|
||||
expect(createHooksBridge()).toBeUndefined();
|
||||
expect(createHooksBridge({})).toBeUndefined();
|
||||
expect(createHooksBridge({ onHookError: () => undefined })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes only configured native handlers", () => {
|
||||
const hooks = createHooksBridge({
|
||||
onPreToolUse: vi.fn(),
|
||||
onSessionStart: vi.fn(),
|
||||
})!;
|
||||
|
||||
expect(typeof hooks.onPreToolUse).toBe("function");
|
||||
expect(typeof hooks.onSessionStart).toBe("function");
|
||||
expect(hooks.onPreMcpToolCall).toBeUndefined();
|
||||
expect(hooks.onPostToolUse).toBeUndefined();
|
||||
expect(hooks.onPostToolUseFailure).toBeUndefined();
|
||||
expect(hooks.onUserPromptSubmitted).toBeUndefined();
|
||||
expect(hooks.onSessionEnd).toBeUndefined();
|
||||
expect(hooks.onErrorOccurred).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards arguments and return values from a successful handler", async () => {
|
||||
const onPreToolUse = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ permissionDecision: "allow" as const, additionalContext: "ok" });
|
||||
const hooks = createHooksBridge({ onPreToolUse })!;
|
||||
const input = {
|
||||
...hookBase,
|
||||
cwd: "/tmp",
|
||||
workingDirectory: "/tmp",
|
||||
toolName: "bash",
|
||||
toolArgs: { cmd: "ls" },
|
||||
};
|
||||
|
||||
await expect(hooks.onPreToolUse!(input, { sessionId: "sess-1" })).resolves.toEqual({
|
||||
permissionDecision: "allow",
|
||||
additionalContext: "ok",
|
||||
});
|
||||
expect(onPreToolUse).toHaveBeenCalledWith(input, { sessionId: "sess-1" });
|
||||
});
|
||||
|
||||
it("reports the effective prompt after a native prompt hook completes", async () => {
|
||||
const onUserPromptSubmitted = vi.fn().mockResolvedValue({
|
||||
additionalContext: "Use the approved repository.",
|
||||
modifiedPrompt: "Review the authentication change.",
|
||||
});
|
||||
const observedPrompt = vi.fn();
|
||||
const hooks = createHooksBridge(
|
||||
{ onUserPromptSubmitted },
|
||||
{ onUserPromptSubmitted: observedPrompt },
|
||||
)!;
|
||||
|
||||
await expect(
|
||||
hooks.onUserPromptSubmitted!({ ...hookBase, prompt: "hello" }, { sessionId: "s" }),
|
||||
).resolves.toEqual({
|
||||
additionalContext: "Use the approved repository.",
|
||||
modifiedPrompt: "Review the authentication change.",
|
||||
});
|
||||
expect(observedPrompt).toHaveBeenCalledWith({
|
||||
additionalContext: "Use the approved repository.",
|
||||
prompt: "Review the authentication change.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the original prompt when a native prompt hook fails", async () => {
|
||||
const observedPrompt = vi.fn();
|
||||
const hooks = createHooksBridge(
|
||||
{
|
||||
onUserPromptSubmitted: async () => {
|
||||
throw new Error("prompt hook failed");
|
||||
},
|
||||
onHookError: () => undefined,
|
||||
},
|
||||
{ onUserPromptSubmitted: observedPrompt },
|
||||
)!;
|
||||
|
||||
await expect(
|
||||
hooks.onUserPromptSubmitted!({ ...hookBase, prompt: "hello" }, { sessionId: "s" }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(observedPrompt).toHaveBeenCalledWith({ prompt: "hello" });
|
||||
});
|
||||
|
||||
it("isolates synchronous and asynchronous handler failures", async () => {
|
||||
const onHookError = vi.fn();
|
||||
const hooks = createHooksBridge({
|
||||
onPostToolUse: () => {
|
||||
throw new Error("post boom");
|
||||
},
|
||||
onUserPromptSubmitted: async () => {
|
||||
throw new Error("prompt boom");
|
||||
},
|
||||
onHookError,
|
||||
})!;
|
||||
|
||||
await expect(
|
||||
hooks.onPostToolUse!(
|
||||
{ ...hookBase, toolName: "x", toolArgs: {}, toolResult: {} as never },
|
||||
{ sessionId: "s" },
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
hooks.onUserPromptSubmitted!({ ...hookBase, prompt: "hi" }, { sessionId: "s" }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(onHookError).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("never lets the error notifier throw into the SDK", async () => {
|
||||
const hooks = createHooksBridge({
|
||||
onSessionEnd: () => {
|
||||
throw new Error("hook boom");
|
||||
},
|
||||
onHookError: () => {
|
||||
throw new Error("notifier boom");
|
||||
},
|
||||
})!;
|
||||
|
||||
await expect(
|
||||
hooks.onSessionEnd!({ ...hookBase, reason: "complete" }, { sessionId: "s" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves native MCP and failed-tool callbacks", async () => {
|
||||
const onPreMcpToolCall = vi.fn();
|
||||
const onPostToolUseFailure = vi.fn();
|
||||
const hooks = createHooksBridge({
|
||||
onPreMcpToolCall,
|
||||
onPostToolUseFailure,
|
||||
})!;
|
||||
|
||||
await hooks.onPreMcpToolCall!({} as never, { sessionId: "s" });
|
||||
await hooks.onPostToolUseFailure!({} as never, { sessionId: "s" });
|
||||
|
||||
expect(onPreMcpToolCall).toHaveBeenCalledTimes(1);
|
||||
expect(onPostToolUseFailure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves all supported SDK hook handlers", () => {
|
||||
const config: CopilotHooksConfig = {
|
||||
onPreToolUse: vi.fn().mockResolvedValue({ suppressOutput: true }),
|
||||
onPreMcpToolCall: vi.fn(),
|
||||
onPostToolUse: vi.fn().mockResolvedValue({ suppressOutput: false }),
|
||||
onPostToolUseFailure: vi.fn(),
|
||||
onUserPromptSubmitted: vi.fn().mockResolvedValue({ modifiedPrompt: "trimmed" }),
|
||||
onSessionStart: vi.fn().mockResolvedValue({ additionalContext: "context" }),
|
||||
onSessionEnd: vi.fn().mockResolvedValue({ sessionSummary: "done" }),
|
||||
onErrorOccurred: vi.fn().mockResolvedValue({ errorHandling: "retry" as const }),
|
||||
};
|
||||
const hooks = createHooksBridge(config)!;
|
||||
|
||||
expect(typeof hooks.onPreToolUse).toBe("function");
|
||||
expect(typeof hooks.onPreMcpToolCall).toBe("function");
|
||||
expect(typeof hooks.onPostToolUse).toBe("function");
|
||||
expect(typeof hooks.onPostToolUseFailure).toBe("function");
|
||||
expect(typeof hooks.onUserPromptSubmitted).toBe("function");
|
||||
expect(typeof hooks.onSessionStart).toBe("function");
|
||||
expect(typeof hooks.onSessionEnd).toBe("function");
|
||||
expect(typeof hooks.onErrorOccurred).toBe("function");
|
||||
});
|
||||
});
|
||||
136
extensions/copilot/src/hooks-bridge.ts
Executable file
136
extensions/copilot/src/hooks-bridge.ts
Executable file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Compatibility adapter for native Copilot SDK SessionHooks.
|
||||
*
|
||||
* `hooksConfig` is a shipped Copilot-specific per-attempt API. It remains
|
||||
* separate from OpenClaw's generic lifecycle hooks because the SDK callbacks
|
||||
* expose native events and decisions that the portable hook contract does not.
|
||||
*/
|
||||
import type { SessionConfig } from "@github/copilot-sdk";
|
||||
|
||||
type SdkSessionHooks = NonNullable<SessionConfig["hooks"]>;
|
||||
type PreToolUseHandler = NonNullable<SdkSessionHooks["onPreToolUse"]>;
|
||||
type PreMcpToolCallHandler = NonNullable<SdkSessionHooks["onPreMcpToolCall"]>;
|
||||
type PostToolUseHandler = NonNullable<SdkSessionHooks["onPostToolUse"]>;
|
||||
type PostToolUseFailureHandler = NonNullable<SdkSessionHooks["onPostToolUseFailure"]>;
|
||||
type UserPromptSubmittedHandler = NonNullable<SdkSessionHooks["onUserPromptSubmitted"]>;
|
||||
type SessionStartHandler = NonNullable<SdkSessionHooks["onSessionStart"]>;
|
||||
type SessionEndHandler = NonNullable<SdkSessionHooks["onSessionEnd"]>;
|
||||
type ErrorOccurredHandler = NonNullable<SdkSessionHooks["onErrorOccurred"]>;
|
||||
|
||||
export interface CopilotHooksBridgeOptions {
|
||||
onUserPromptSubmitted?: (submission: { prompt: string; additionalContext?: string }) => void;
|
||||
}
|
||||
|
||||
export interface CopilotHooksConfig {
|
||||
onPreToolUse?: PreToolUseHandler;
|
||||
onPreMcpToolCall?: PreMcpToolCallHandler;
|
||||
onPostToolUse?: PostToolUseHandler;
|
||||
onPostToolUseFailure?: PostToolUseFailureHandler;
|
||||
onUserPromptSubmitted?: UserPromptSubmittedHandler;
|
||||
onSessionStart?: SessionStartHandler;
|
||||
onSessionEnd?: SessionEndHandler;
|
||||
onErrorOccurred?: ErrorOccurredHandler;
|
||||
/**
|
||||
* Called when a native SDK hook handler throws. Defaults to console.warn so
|
||||
* native hook failures do not terminate the SDK session.
|
||||
*/
|
||||
onHookError?: (info: { hookName: keyof SdkSessionHooks; error: unknown }) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_HOOK_ERROR_HANDLER: NonNullable<CopilotHooksConfig["onHookError"]> = ({
|
||||
hookName,
|
||||
error,
|
||||
}) => {
|
||||
console.warn(`[copilot hooks-bridge] ${hookName} handler threw:`, error);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap a native handler so it cannot throw into the SDK. Returning undefined
|
||||
* leaves the SDK's default decision in place.
|
||||
*/
|
||||
function isolate<TArgs extends readonly unknown[], TResult>(
|
||||
hookName: keyof SdkSessionHooks,
|
||||
handler: ((...args: TArgs) => TResult | Promise<TResult>) | undefined,
|
||||
onError: NonNullable<CopilotHooksConfig["onHookError"]>,
|
||||
): ((...args: TArgs) => Promise<TResult | undefined>) | undefined {
|
||||
if (!handler) {
|
||||
return undefined;
|
||||
}
|
||||
return async (...args: TArgs) => {
|
||||
try {
|
||||
return await handler(...args);
|
||||
} catch (error) {
|
||||
try {
|
||||
onError({ hookName, error });
|
||||
} catch {
|
||||
// Never let the error notifier itself throw into the SDK.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SDK-shaped hook object from native per-attempt configuration.
|
||||
* Omit the SDK hook subsystem when no handlers were configured.
|
||||
*/
|
||||
export function createHooksBridge(
|
||||
config?: CopilotHooksConfig,
|
||||
options?: CopilotHooksBridgeOptions,
|
||||
): SdkSessionHooks | undefined {
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
const onError = config.onHookError ?? DEFAULT_HOOK_ERROR_HANDLER;
|
||||
const hooks: SdkSessionHooks = {};
|
||||
const pre = isolate("onPreToolUse", config.onPreToolUse, onError);
|
||||
const preMcp = isolate("onPreMcpToolCall", config.onPreMcpToolCall, onError);
|
||||
const post = isolate("onPostToolUse", config.onPostToolUse, onError);
|
||||
const postFailure = isolate("onPostToolUseFailure", config.onPostToolUseFailure, onError);
|
||||
const userPrompt = isolate("onUserPromptSubmitted", config.onUserPromptSubmitted, onError);
|
||||
const sessionStart = isolate("onSessionStart", config.onSessionStart, onError);
|
||||
const sessionEnd = isolate("onSessionEnd", config.onSessionEnd, onError);
|
||||
const errorOccurred = isolate("onErrorOccurred", config.onErrorOccurred, onError);
|
||||
|
||||
if (pre) {
|
||||
hooks.onPreToolUse = pre as PreToolUseHandler;
|
||||
}
|
||||
if (preMcp) {
|
||||
hooks.onPreMcpToolCall = preMcp as PreMcpToolCallHandler;
|
||||
}
|
||||
if (post) {
|
||||
hooks.onPostToolUse = post as PostToolUseHandler;
|
||||
}
|
||||
if (postFailure) {
|
||||
hooks.onPostToolUseFailure = postFailure as PostToolUseFailureHandler;
|
||||
}
|
||||
if (userPrompt) {
|
||||
hooks.onUserPromptSubmitted = async (input, invocation) => {
|
||||
const output = await userPrompt(input, invocation);
|
||||
try {
|
||||
options?.onUserPromptSubmitted?.({
|
||||
prompt: output?.modifiedPrompt ?? input.prompt,
|
||||
...(output?.additionalContext ? { additionalContext: output.additionalContext } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
onError({ hookName: "onUserPromptSubmitted", error });
|
||||
} catch {
|
||||
// Never let an observer or its error notifier throw into the SDK.
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
}
|
||||
if (sessionStart) {
|
||||
hooks.onSessionStart = sessionStart as SessionStartHandler;
|
||||
}
|
||||
if (sessionEnd) {
|
||||
hooks.onSessionEnd = sessionEnd as SessionEndHandler;
|
||||
}
|
||||
if (errorOccurred) {
|
||||
hooks.onErrorOccurred = errorOccurred as ErrorOccurredHandler;
|
||||
}
|
||||
|
||||
return Object.keys(hooks).length > 0 ? hooks : undefined;
|
||||
}
|
||||
200
extensions/copilot/src/native-subagent-task-mirror.test.ts
Normal file
200
extensions/copilot/src/native-subagent-task-mirror.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { SessionEvent } from "@github/copilot-sdk";
|
||||
import type {
|
||||
AgentHarnessTaskRecord,
|
||||
AgentHarnessTaskRuntime,
|
||||
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CopilotNativeSubagentTaskMirror,
|
||||
createCopilotNativeSubagentTaskMirror,
|
||||
} from "./native-subagent-task-mirror.js";
|
||||
|
||||
type NativeSubagentEventType = "subagent.started" | "subagent.completed" | "subagent.failed";
|
||||
|
||||
function makeEvent<T extends NativeSubagentEventType>(
|
||||
type: T,
|
||||
data: Extract<SessionEvent, { type: T }>["data"],
|
||||
agentId?: string,
|
||||
): Extract<SessionEvent, { type: T }> {
|
||||
return {
|
||||
data,
|
||||
id: `${type}-id`,
|
||||
parentId: null,
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
type,
|
||||
...(agentId ? { agentId } : {}),
|
||||
} as Extract<SessionEvent, { type: T }>;
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
const task = {} as AgentHarnessTaskRecord;
|
||||
return {
|
||||
tryCreateRunningTaskRun: vi.fn(() => task),
|
||||
recordTaskRunProgressByRunId: vi.fn(() => []),
|
||||
finalizeTaskRunByRunId: vi.fn(() => []),
|
||||
} satisfies Pick<
|
||||
AgentHarnessTaskRuntime,
|
||||
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
|
||||
>;
|
||||
}
|
||||
|
||||
describe("CopilotNativeSubagentTaskMirror", () => {
|
||||
it("does not create a mirror without a host-issued task scope", () => {
|
||||
expect(createCopilotNativeSubagentTaskMirror({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("mirrors start and completion using agentId with toolCallId fallback", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CopilotNativeSubagentTaskMirror(
|
||||
{ agentId: "parent-agent", now: () => 100 },
|
||||
runtime,
|
||||
);
|
||||
|
||||
mirror.handleEvent(
|
||||
makeEvent(
|
||||
"subagent.started",
|
||||
{
|
||||
agentDescription: "inspect the repository",
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
"child-1",
|
||||
),
|
||||
);
|
||||
mirror.handleEvent(
|
||||
makeEvent(
|
||||
"subagent.completed",
|
||||
{
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-1",
|
||||
totalToolCalls: 2,
|
||||
totalTokens: 30,
|
||||
},
|
||||
"child-1",
|
||||
),
|
||||
);
|
||||
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
|
||||
sourceId: "call-1",
|
||||
agentId: "parent-agent",
|
||||
runId: "copilot-agent:child-1",
|
||||
label: "Researcher",
|
||||
task: "inspect the repository",
|
||||
notifyPolicy: "silent",
|
||||
deliveryStatus: "not_applicable",
|
||||
preferMetadata: true,
|
||||
startedAt: 100,
|
||||
lastEventAt: 100,
|
||||
progressSummary: "Copilot native subagent started.",
|
||||
});
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
|
||||
runId: "copilot-agent:child-1",
|
||||
status: "succeeded",
|
||||
endedAt: 100,
|
||||
lastEventAt: 100,
|
||||
progressSummary: "Copilot native subagent completed.",
|
||||
terminalSummary: "Copilot native subagent completed (2 tool calls, 30 tokens).",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses toolCallId when the SDK omits agentId", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 200 }, runtime);
|
||||
|
||||
mirror.handleEvent(
|
||||
makeEvent("subagent.started", {
|
||||
agentDescription: "",
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-2",
|
||||
}),
|
||||
);
|
||||
mirror.handleEvent(
|
||||
makeEvent("subagent.failed", {
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
error: "failed",
|
||||
toolCallId: "call-2",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: "copilot-agent:call-2",
|
||||
status: "failed",
|
||||
error: "failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps parallel subagents distinct when they share a parent tool call", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 250 }, runtime);
|
||||
|
||||
for (const agentId of ["child-1", "child-2"]) {
|
||||
mirror.handleEvent(
|
||||
makeEvent(
|
||||
"subagent.started",
|
||||
{
|
||||
agentDescription: `inspect ${agentId}`,
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-shared",
|
||||
},
|
||||
agentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const agentId of ["child-1", "child-2"]) {
|
||||
mirror.handleEvent(
|
||||
makeEvent(
|
||||
"subagent.completed",
|
||||
{
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-shared",
|
||||
},
|
||||
agentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ runId: "copilot-agent:child-1" }),
|
||||
);
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ runId: "copilot-agent:child-2" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("finalizes active tasks when the parent attempt tears down", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 300 }, runtime);
|
||||
|
||||
mirror.handleEvent(
|
||||
makeEvent("subagent.started", {
|
||||
agentDescription: "inspect",
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-3",
|
||||
}),
|
||||
);
|
||||
mirror.finalizeActiveRuns();
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
|
||||
runId: "copilot-agent:call-3",
|
||||
status: "cancelled",
|
||||
endedAt: 300,
|
||||
lastEventAt: 300,
|
||||
error: "Copilot native subagent ended with its parent attempt.",
|
||||
progressSummary: "Copilot native subagent cancelled with its parent attempt.",
|
||||
terminalSummary: "Copilot native subagent cancelled.",
|
||||
});
|
||||
});
|
||||
});
|
||||
199
extensions/copilot/src/native-subagent-task-mirror.ts
Normal file
199
extensions/copilot/src/native-subagent-task-mirror.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { SessionEvent } from "@github/copilot-sdk";
|
||||
import {
|
||||
createAgentHarnessTaskRuntime,
|
||||
type AgentHarnessTaskRuntime,
|
||||
type AgentHarnessTaskRuntimeScope,
|
||||
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
|
||||
|
||||
const COPILOT_NATIVE_SUBAGENT_TASK_KIND = "copilot-native";
|
||||
const COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX = "copilot-agent:";
|
||||
|
||||
type CopilotNativeSubagentEvent = Extract<
|
||||
SessionEvent,
|
||||
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
|
||||
>;
|
||||
|
||||
type TaskLifecycleRuntime = Pick<
|
||||
AgentHarnessTaskRuntime,
|
||||
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
|
||||
>;
|
||||
|
||||
export function createCopilotNativeSubagentTaskMirror(params: {
|
||||
agentId?: string;
|
||||
now?: () => number;
|
||||
scope?: AgentHarnessTaskRuntimeScope;
|
||||
}): CopilotNativeSubagentTaskMirror | undefined {
|
||||
if (!params.scope) {
|
||||
return undefined;
|
||||
}
|
||||
return new CopilotNativeSubagentTaskMirror(
|
||||
{
|
||||
agentId: params.agentId,
|
||||
now: params.now,
|
||||
},
|
||||
createAgentHarnessTaskRuntime({
|
||||
runtime: "subagent",
|
||||
taskKind: COPILOT_NATIVE_SUBAGENT_TASK_KIND,
|
||||
scope: params.scope,
|
||||
runIdPrefix: COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export class CopilotNativeSubagentTaskMirror {
|
||||
private readonly runIdByAgentId = new Map<string, string>();
|
||||
private readonly runIdByToolCallId = new Map<string, string>();
|
||||
private readonly terminalRunIds = new Set<string>();
|
||||
private readonly activeRunIds = new Set<string>();
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
private readonly params: { agentId?: string; now?: () => number },
|
||||
private readonly runtime: TaskLifecycleRuntime,
|
||||
) {
|
||||
this.now = params.now ?? Date.now;
|
||||
}
|
||||
|
||||
handleEvent(event: CopilotNativeSubagentEvent): void {
|
||||
const toolCallId = event.data.toolCallId.trim();
|
||||
if (!toolCallId) {
|
||||
return;
|
||||
}
|
||||
const runId = this.resolveRunId(event);
|
||||
if (event.type === "subagent.started") {
|
||||
this.handleStarted(event, runId, toolCallId);
|
||||
return;
|
||||
}
|
||||
if (event.type === "subagent.completed") {
|
||||
this.handleCompleted(event, runId);
|
||||
return;
|
||||
}
|
||||
this.handleFailed(event, runId);
|
||||
}
|
||||
|
||||
finalizeActiveRuns(): void {
|
||||
const eventAt = this.now();
|
||||
for (const runId of this.activeRunIds) {
|
||||
this.terminalRunIds.add(runId);
|
||||
this.runtime.finalizeTaskRunByRunId({
|
||||
runId,
|
||||
status: "cancelled",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
error: "Copilot native subagent ended with its parent attempt.",
|
||||
progressSummary: "Copilot native subagent cancelled with its parent attempt.",
|
||||
terminalSummary: "Copilot native subagent cancelled.",
|
||||
});
|
||||
}
|
||||
this.activeRunIds.clear();
|
||||
}
|
||||
|
||||
private handleStarted(
|
||||
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.started" }>,
|
||||
runId: string,
|
||||
toolCallId: string,
|
||||
): void {
|
||||
const agentId = event.agentId?.trim();
|
||||
const existingRunId = agentId
|
||||
? this.runIdByAgentId.get(agentId)
|
||||
: this.runIdByToolCallId.get(toolCallId);
|
||||
if (existingRunId) {
|
||||
return;
|
||||
}
|
||||
const eventAt = this.now();
|
||||
const label = event.data.agentDisplayName.trim() || event.data.agentName.trim();
|
||||
const task = event.data.agentDescription.trim() || `Copilot native subagent ${label}`;
|
||||
const taskRecord = this.runtime.tryCreateRunningTaskRun({
|
||||
sourceId: toolCallId,
|
||||
agentId: this.params.agentId,
|
||||
runId,
|
||||
label: label || "Copilot subagent",
|
||||
task,
|
||||
notifyPolicy: "silent",
|
||||
deliveryStatus: "not_applicable",
|
||||
preferMetadata: true,
|
||||
startedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
progressSummary: "Copilot native subagent started.",
|
||||
});
|
||||
if (!taskRecord) {
|
||||
return;
|
||||
}
|
||||
if (agentId) {
|
||||
this.runIdByAgentId.set(agentId, runId);
|
||||
} else {
|
||||
this.runIdByToolCallId.set(toolCallId, runId);
|
||||
}
|
||||
this.terminalRunIds.delete(runId);
|
||||
this.activeRunIds.add(runId);
|
||||
}
|
||||
|
||||
private handleCompleted(
|
||||
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.completed" }>,
|
||||
runId: string,
|
||||
): void {
|
||||
if (this.terminalRunIds.has(runId)) {
|
||||
return;
|
||||
}
|
||||
const eventAt = this.now();
|
||||
this.terminalRunIds.add(runId);
|
||||
this.activeRunIds.delete(runId);
|
||||
this.runtime.finalizeTaskRunByRunId({
|
||||
runId,
|
||||
status: "succeeded",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
progressSummary: "Copilot native subagent completed.",
|
||||
terminalSummary: buildCompletionSummary(event),
|
||||
});
|
||||
}
|
||||
|
||||
private handleFailed(
|
||||
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.failed" }>,
|
||||
runId: string,
|
||||
): void {
|
||||
if (this.terminalRunIds.has(runId)) {
|
||||
return;
|
||||
}
|
||||
const eventAt = this.now();
|
||||
this.terminalRunIds.add(runId);
|
||||
this.activeRunIds.delete(runId);
|
||||
this.runtime.finalizeTaskRunByRunId({
|
||||
runId,
|
||||
status: "failed",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
error: event.data.error,
|
||||
progressSummary: "Copilot native subagent failed.",
|
||||
terminalSummary: "Copilot native subagent failed.",
|
||||
});
|
||||
}
|
||||
|
||||
private resolveRunId(event: CopilotNativeSubagentEvent): string {
|
||||
const agentId = event.agentId?.trim();
|
||||
if (agentId) {
|
||||
const existing = this.runIdByAgentId.get(agentId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
const existing = this.runIdByToolCallId.get(event.data.toolCallId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const identity = agentId || event.data.toolCallId.trim();
|
||||
return `${COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX}${identity}`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCompletionSummary(
|
||||
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.completed" }>,
|
||||
): string {
|
||||
const details = [
|
||||
event.data.totalToolCalls !== undefined ? `${event.data.totalToolCalls} tool calls` : undefined,
|
||||
event.data.totalTokens !== undefined ? `${event.data.totalTokens} tokens` : undefined,
|
||||
].filter((value): value is string => value !== undefined);
|
||||
return details.length > 0
|
||||
? `Copilot native subagent completed (${details.join(", ")}).`
|
||||
: "Copilot native subagent completed.";
|
||||
}
|
||||
105
extensions/copilot/src/permission-bridge.test.ts
Executable file
105
extensions/copilot/src/permission-bridge.test.ts
Executable file
@@ -0,0 +1,105 @@
|
||||
// Copilot tests cover permission bridge plugin behavior.
|
||||
import type {
|
||||
PermissionRequest as SdkPermissionRequest,
|
||||
} from "@github/copilot-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createPermissionBridge,
|
||||
rejectAllPolicy,
|
||||
REJECT_ALL_FEEDBACK,
|
||||
type CopilotPermissionContext,
|
||||
type CopilotPermissionPolicy,
|
||||
} from "./permission-bridge.js";
|
||||
|
||||
function makeRequest(overrides: Partial<SdkPermissionRequest> = {}): SdkPermissionRequest {
|
||||
if (overrides.kind && overrides.kind !== "shell") {
|
||||
return {
|
||||
toolCallId: "call-1",
|
||||
...overrides,
|
||||
} as SdkPermissionRequest;
|
||||
}
|
||||
return {
|
||||
canOfferSessionApproval: false,
|
||||
commands: [],
|
||||
fullCommandText: "echo test",
|
||||
hasWriteFileRedirection: false,
|
||||
intention: "test command",
|
||||
kind: "shell",
|
||||
possiblePaths: [],
|
||||
possibleUrls: [],
|
||||
toolCallId: "call-1",
|
||||
...overrides,
|
||||
} as SdkPermissionRequest;
|
||||
}
|
||||
|
||||
function makeCtx(overrides: Partial<CopilotPermissionContext> = {}): CopilotPermissionContext {
|
||||
return {
|
||||
request: makeRequest(),
|
||||
sessionId: "sess-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("rejectAllPolicy", () => {
|
||||
it("returns reject with the fail-closed feedback", async () => {
|
||||
const result = await rejectAllPolicy(makeCtx());
|
||||
expect(result).toEqual({ kind: "reject", feedback: REJECT_ALL_FEEDBACK });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPermissionBridge", () => {
|
||||
it("adapts a policy to the SDK PermissionHandler shape", async () => {
|
||||
const handler = createPermissionBridge(() => ({ kind: "approve-once" }));
|
||||
const result = await handler(makeRequest(), { sessionId: "sess-1" });
|
||||
expect(result).toEqual({ kind: "approve-once" });
|
||||
});
|
||||
|
||||
it("defaults to rejectAllPolicy when no policy is passed", async () => {
|
||||
const handler = createPermissionBridge();
|
||||
const result = await handler(makeRequest({ kind: "shell" }), { sessionId: "sess-1" });
|
||||
expect(result).toEqual({ kind: "reject", feedback: REJECT_ALL_FEEDBACK });
|
||||
});
|
||||
|
||||
it("forwards the SDK sessionId into the policy context", async () => {
|
||||
const policy = vi.fn<CopilotPermissionPolicy>(() => ({ kind: "approve-once" }));
|
||||
const handler = createPermissionBridge(policy);
|
||||
await handler(makeRequest({ kind: "read" }), { sessionId: "sess-xyz" });
|
||||
expect(policy).toHaveBeenCalledTimes(1);
|
||||
expect(policy.mock.calls[0]?.[0]).toEqual({
|
||||
sessionId: "sess-xyz",
|
||||
request: { kind: "read", toolCallId: "call-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("never throws when policy throws; returns reject with the error message instead", async () => {
|
||||
const handler = createPermissionBridge(() => {
|
||||
throw new Error("policy boom");
|
||||
});
|
||||
const result = await handler(makeRequest(), { sessionId: "sess-1" });
|
||||
expect(result?.kind).toBe("reject");
|
||||
expect((result as { feedback?: string }).feedback).toContain("policy boom");
|
||||
});
|
||||
|
||||
it("never returns undefined: a policy returning undefined yields fail-closed reject", async () => {
|
||||
const handler = createPermissionBridge(() => undefined);
|
||||
const result = await handler(makeRequest(), { sessionId: "sess-1" });
|
||||
expect(result).toEqual({ kind: "reject", feedback: REJECT_ALL_FEEDBACK });
|
||||
});
|
||||
|
||||
it("handles all SDK permission kinds without throwing", async () => {
|
||||
const handler = createPermissionBridge(() => ({ kind: "approve-once" }));
|
||||
for (const kind of [
|
||||
"shell",
|
||||
"write",
|
||||
"mcp",
|
||||
"read",
|
||||
"url",
|
||||
"custom-tool",
|
||||
"memory",
|
||||
"hook",
|
||||
] as const) {
|
||||
const result = await handler(makeRequest({ kind }), { sessionId: "sess-1" });
|
||||
expect(result).toEqual({ kind: "approve-once" });
|
||||
}
|
||||
});
|
||||
});
|
||||
102
extensions/copilot/src/permission-bridge.ts
Executable file
102
extensions/copilot/src/permission-bridge.ts
Executable file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Permission bridge for the copilot agent runtime.
|
||||
*
|
||||
* BACK-POINTER: The full runtime-neutral permission/tool-policy logic
|
||||
* lives in `src/agents/pi-tools.before-tool-call.ts` (820 LOC, exports
|
||||
* `runBeforeToolCallHook`, `BeforeToolCallBlockedError`, etc.). Per Q4
|
||||
* (proposal section 3.4), we deliberately do NOT extract a shared helper
|
||||
* - PI source stays untouched. Instead, this module:
|
||||
*
|
||||
* 1. Defines a small `CopilotPermissionPolicy` contract that the
|
||||
* host can implement to mirror PI's policy decisions for the
|
||||
* copilot agent runtime.
|
||||
* 2. Adapts the resulting policy into the SDK's
|
||||
* `PermissionHandler` shape via `createPermissionBridge(policy)`.
|
||||
*
|
||||
* Cross-package boundary note: the heavy `pi-tools.before-tool-call`
|
||||
* surface cannot be imported here (`tsconfig.package-boundary.base.json`).
|
||||
* The host bridges core PI logic into this module by injecting a
|
||||
* `CopilotPermissionPolicy` from the core wiring layer that constructs
|
||||
* `AgentHarnessAttemptParams` for the copilot agent runtime.
|
||||
*
|
||||
* If PI's permission semantics change materially, the contract here
|
||||
* must be revisited in lockstep. The unit tests in
|
||||
* `permission-bridge.test.ts` exercise the SDK-shaped decision
|
||||
* envelope so any silent drift in the SDK type is caught at typecheck.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PermissionHandler,
|
||||
PermissionRequest as SdkPermissionRequest,
|
||||
PermissionRequestResult as SdkPermissionRequestResult,
|
||||
} from "@github/copilot-sdk";
|
||||
|
||||
/** Request shape forwarded to host-implemented policies. */
|
||||
export interface CopilotPermissionContext {
|
||||
/** SDK session id that originated the request. */
|
||||
sessionId: string;
|
||||
/** Original SDK request payload. */
|
||||
request: SdkPermissionRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Policy contract. Implementors return an SDK-shaped decision (or a
|
||||
* Promise of one).
|
||||
*
|
||||
* Returning `undefined` is treated as "no opinion" and falls through to
|
||||
* the default fail-closed decision (`reject` with `REJECT_ALL_FEEDBACK`).
|
||||
* This keeps composition trivial without requiring explicit `reject`
|
||||
* returns from every code path.
|
||||
*/
|
||||
export type CopilotPermissionPolicy = (
|
||||
ctx: CopilotPermissionContext,
|
||||
) => SdkPermissionRequestResult | undefined | Promise<SdkPermissionRequestResult | undefined>;
|
||||
|
||||
/** Built-in fail-closed default. Mirrors the pre-bridge attempt.ts stub. */
|
||||
export const REJECT_ALL_FEEDBACK =
|
||||
"copilot agent runtime: no permission policy installed (fail-closed default)";
|
||||
|
||||
export const rejectAllPolicy: CopilotPermissionPolicy = () => ({
|
||||
kind: "reject",
|
||||
feedback: REJECT_ALL_FEEDBACK,
|
||||
});
|
||||
|
||||
/**
|
||||
* Adapt a `CopilotPermissionPolicy` to the SDK's
|
||||
* `PermissionHandler` shape. The returned handler always resolves
|
||||
* (never rejects), defaulting to fail-closed when the policy returns
|
||||
* undefined or throws.
|
||||
*/
|
||||
export function createPermissionBridge(
|
||||
policy: CopilotPermissionPolicy = rejectAllPolicy,
|
||||
): PermissionHandler {
|
||||
return async (request, invocation) => {
|
||||
const ctx: CopilotPermissionContext = {
|
||||
request,
|
||||
sessionId: invocation.sessionId,
|
||||
};
|
||||
try {
|
||||
const result = await policy(ctx);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: "reject",
|
||||
feedback: `copilot permission policy threw: ${formatError(error)}`,
|
||||
};
|
||||
}
|
||||
return { kind: "reject", feedback: REJECT_ALL_FEEDBACK };
|
||||
};
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
398
extensions/copilot/src/provider-bridge.test.ts
Normal file
398
extensions/copilot/src/provider-bridge.test.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
// Copilot tests cover BYOK provider mapping behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
COPILOT_BYOK_PROVIDER_ERROR,
|
||||
COPILOT_BYOK_ENDPOINT_POLICY_ERROR,
|
||||
COPILOT_BYOK_TRANSPORT_POLICY_ERROR,
|
||||
resolveCopilotProvider,
|
||||
supportsCopilotByokProviderShape,
|
||||
} from "./provider-bridge.js";
|
||||
|
||||
describe("resolveCopilotProvider", () => {
|
||||
it("keeps the subscription provider on the native Copilot auth path", () => {
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "github-copilot",
|
||||
api: "github-copilot",
|
||||
id: "gpt-5",
|
||||
baseUrl: "https://ignored.example",
|
||||
},
|
||||
resolvedApiKey: "ignored",
|
||||
}),
|
||||
).toEqual({ mode: "github-copilot" });
|
||||
});
|
||||
|
||||
it("maps OpenAI Responses BYOK with a bearer token and stable limits", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "local-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
authHeader: true,
|
||||
contextTokens: 12_000,
|
||||
maxTokens: 512,
|
||||
headers: { "X-Trace": "test" },
|
||||
},
|
||||
resolvedApiKey: "secret-key",
|
||||
authProfileId: "local-proxy:main",
|
||||
});
|
||||
|
||||
expect(result.mode).toBe("byok");
|
||||
expect(result.authProfileId).toBe("local-proxy:main");
|
||||
expect(result.authProfileVersion).toMatch(/^sha256:/);
|
||||
expect(result.provider).toEqual({
|
||||
type: "openai",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
modelId: "proxy-model",
|
||||
wireModel: "proxy-model",
|
||||
bearerToken: "secret-key",
|
||||
headers: { "X-Trace": "test" },
|
||||
maxPromptTokens: 12_000,
|
||||
maxOutputTokens: 512,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults custom BYOK providers without an api to OpenAI Responses", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
},
|
||||
resolvedApiKey: "secret-key",
|
||||
});
|
||||
|
||||
expect(result.provider).toMatchObject({
|
||||
type: "openai",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
});
|
||||
expect(supportsCopilotByokProviderShape({ baseUrl: "https://proxy.example/v1" })).toBe(true);
|
||||
});
|
||||
|
||||
it("maps OpenAI Chat Completions BYOK bearer auth with the provider-local model id", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "tencent-tokenplan",
|
||||
api: "openai-completions",
|
||||
id: "hy3",
|
||||
baseUrl: "https://tokenplan.example/v1",
|
||||
authHeader: true,
|
||||
},
|
||||
resolvedApiKey: "secret-key",
|
||||
});
|
||||
|
||||
expect(result.provider).toMatchObject({
|
||||
type: "openai",
|
||||
wireApi: "completions",
|
||||
baseUrl: "https://tokenplan.example/v1",
|
||||
modelId: "hy3",
|
||||
wireModel: "hy3",
|
||||
bearerToken: "secret-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("changes the BYOK compatibility fingerprint when token limits change", () => {
|
||||
const base = {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
};
|
||||
|
||||
const small = resolveCopilotProvider({
|
||||
model: { ...base, contextTokens: 8_000, maxTokens: 512 },
|
||||
resolvedApiKey: "secret-key",
|
||||
});
|
||||
const large = resolveCopilotProvider({
|
||||
model: { ...base, contextTokens: 16_000, maxTokens: 1024 },
|
||||
resolvedApiKey: "secret-key",
|
||||
});
|
||||
|
||||
expect(small.authProfileVersion).not.toBe(large.authProfileVersion);
|
||||
});
|
||||
|
||||
it("maps Anthropic and Ollama-compatible APIs", () => {
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "anthropic-proxy",
|
||||
api: "anthropic-messages",
|
||||
id: "claude",
|
||||
baseUrl: "https://anthropic.example",
|
||||
},
|
||||
}).provider,
|
||||
).toMatchObject({ type: "anthropic", baseUrl: "https://anthropic.example" });
|
||||
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "ollama-compatible",
|
||||
api: "ollama",
|
||||
id: "qwen",
|
||||
baseUrl: "https://ollama-compatible.example/v1",
|
||||
},
|
||||
}).provider,
|
||||
).toMatchObject({ type: "openai", wireApi: "completions" });
|
||||
});
|
||||
|
||||
it("normalizes Azure OpenAI Responses config for the Copilot SDK provider contract", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment-gpt",
|
||||
baseUrl: "https://example.openai.azure.com/openai/v1",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
},
|
||||
resolvedApiKey: "azure-key",
|
||||
});
|
||||
|
||||
expect(result.provider).toEqual({
|
||||
type: "azure",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://example.openai.azure.com",
|
||||
modelId: "deployment-gpt",
|
||||
wireModel: "deployment-gpt",
|
||||
apiKey: "azure-key",
|
||||
azure: { apiVersion: "2025-01-01-preview" },
|
||||
});
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment-gpt",
|
||||
baseUrl: "https://example.cognitiveservices.azure.com/openai/v1",
|
||||
},
|
||||
}).provider,
|
||||
).toMatchObject({
|
||||
type: "azure",
|
||||
baseUrl: "https://example.cognitiveservices.azure.com",
|
||||
});
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment",
|
||||
baseUrl: "https://example.cognitiveservices.azure.com/openai/v1",
|
||||
},
|
||||
}).provider,
|
||||
).not.toHaveProperty("azure");
|
||||
expect(
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-azure",
|
||||
api: "azure-openai-responses",
|
||||
id: "deployment-gpt",
|
||||
baseUrl: "https://project.services.ai.azure.com/api/projects/demo/openai/v1",
|
||||
},
|
||||
resolvedApiKey: "azure-key",
|
||||
}).provider,
|
||||
).toEqual({
|
||||
type: "openai",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://project.services.ai.azure.com/api/projects/demo/openai/v1",
|
||||
modelId: "deployment-gpt",
|
||||
wireModel: "deployment-gpt",
|
||||
apiKey: "azure-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not forward local auth markers or null no-auth headers", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "local-proxy",
|
||||
api: "openai-completions",
|
||||
id: "local-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
authHeader: true,
|
||||
headers: {
|
||||
Authorization: null,
|
||||
"X-Local": "true",
|
||||
},
|
||||
},
|
||||
resolvedApiKey: "custom-local",
|
||||
});
|
||||
|
||||
expect(result.provider).toEqual({
|
||||
type: "openai",
|
||||
wireApi: "completions",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
modelId: "local-model",
|
||||
wireModel: "local-model",
|
||||
headers: { "X-Local": "true" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not synthesize SDK apiKey auth when request auth already prepared headers", () => {
|
||||
const result = resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-header-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
headers: { "x-api-key": "header-secret" },
|
||||
requestAuthMode: "header",
|
||||
},
|
||||
resolvedApiKey: "header-secret",
|
||||
});
|
||||
|
||||
expect(result.provider).toEqual({
|
||||
type: "openai",
|
||||
wireApi: "responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
modelId: "proxy-model",
|
||||
wireModel: "proxy-model",
|
||||
headers: { "x-api-key": "header-secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects request transport policy the SDK provider config cannot enforce", () => {
|
||||
for (const model of [
|
||||
{ requestProxy: { mode: "env-proxy" } },
|
||||
{ requestTls: { ca: "ca-pem" } },
|
||||
{ requestAllowPrivateNetwork: false },
|
||||
]) {
|
||||
expect(() =>
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
...model,
|
||||
},
|
||||
}),
|
||||
).toThrow(COPILOT_BYOK_TRANSPORT_POLICY_ERROR);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects BYOK endpoints blocked by OpenClaw SSRF policy", () => {
|
||||
for (const baseUrl of [
|
||||
"file://public.example/v1",
|
||||
"ftp://public.example/v1",
|
||||
"http://proxy.example/v1",
|
||||
"https://user:pass@proxy.example/v1",
|
||||
"https://proxy.example/v1?api_key=secret",
|
||||
"https://proxy.example/v1?x-api-key=secret",
|
||||
"https://proxy.example/v1?x-auth-token=secret",
|
||||
"https://proxy.example/v1?password=secret",
|
||||
"https://proxy.example/v1?client%5Fse%E2%80%8Bcret=secret",
|
||||
"http://169.254.169.254/v1",
|
||||
"http://metadata.google.internal/v1",
|
||||
"http://localhost:11434/v1",
|
||||
]) {
|
||||
expect(() =>
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom-proxy",
|
||||
api: "openai-responses",
|
||||
id: "proxy-model",
|
||||
baseUrl,
|
||||
},
|
||||
}),
|
||||
).toThrow(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
});
|
||||
|
||||
it("advertises support only for representable BYOK provider shapes", () => {
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "azure-openai-responses",
|
||||
baseUrl: "https://example.openai.azure.com/openai/v1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "azure-openai-responses",
|
||||
baseUrl: "https://project.services.ai.azure.com/api/projects/demo/openai/v1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "azure-openai-responses",
|
||||
baseUrl: "https://project.services.ai.azure.com/api/projects/demo",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "google-generative-ai",
|
||||
baseUrl: "https://google.example",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "file://public.example/v1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "http://proxy.example/v1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://user:pass@proxy.example/v1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1?api_key=secret",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1?x-api-key=secret",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(supportsCopilotByokProviderShape({ api: "openai-responses" })).toBe(false);
|
||||
expect(
|
||||
supportsCopilotByokProviderShape({
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://proxy.example/v1",
|
||||
requestProxy: { mode: "env-proxy" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects provider APIs the SDK adapter cannot represent", () => {
|
||||
expect(() =>
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "google",
|
||||
api: "google-generative-ai",
|
||||
id: "gemini",
|
||||
baseUrl: "https://google.example",
|
||||
},
|
||||
}),
|
||||
).toThrow(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
});
|
||||
|
||||
it("requires an endpoint for non-subscription providers", () => {
|
||||
expect(() =>
|
||||
resolveCopilotProvider({
|
||||
model: {
|
||||
provider: "custom",
|
||||
api: "openai-completions",
|
||||
id: "model",
|
||||
},
|
||||
}),
|
||||
).toThrow(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
});
|
||||
});
|
||||
339
extensions/copilot/src/provider-bridge.ts
Normal file
339
extensions/copilot/src/provider-bridge.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
// Copilot plugin module implements BYOK provider mapping.
|
||||
import type { ProviderConfig } from "@github/copilot-sdk";
|
||||
import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { tokenFingerprint } from "./auth-bridge.js";
|
||||
|
||||
export const COPILOT_BYOK_PROVIDER_ERROR =
|
||||
"[copilot-attempt] BYOK requires an OpenAI-compatible or Anthropic model api and a non-empty baseUrl";
|
||||
export const COPILOT_BYOK_TRANSPORT_POLICY_ERROR =
|
||||
"[copilot-attempt] BYOK does not support OpenClaw provider request proxy, TLS, or private-network policy overrides";
|
||||
export const COPILOT_BYOK_ENDPOINT_POLICY_ERROR =
|
||||
"[copilot-attempt] BYOK endpoint is blocked by OpenClaw SSRF policy";
|
||||
|
||||
const CREDENTIAL_QUERY_PARAM_NAMES = new Set([
|
||||
"accesstoken",
|
||||
"appsecret",
|
||||
"auth",
|
||||
"authtoken",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"clientsecret",
|
||||
"code",
|
||||
"credential",
|
||||
"hooktoken",
|
||||
"idtoken",
|
||||
"jwt",
|
||||
"key",
|
||||
"pass",
|
||||
"passwd",
|
||||
"password",
|
||||
"privatekey",
|
||||
"refreshtoken",
|
||||
"secret",
|
||||
"session",
|
||||
"sig",
|
||||
"signature",
|
||||
"token",
|
||||
"xapikey",
|
||||
"xaccesstoken",
|
||||
"xamzsecuritytoken",
|
||||
"xamzsignature",
|
||||
"xauthtoken",
|
||||
]);
|
||||
const QUERY_PARAM_NAME_SEPARATOR_RE = /[\p{C}\p{Z}\u115F\u1160\u3164\uFFA0+]/gu;
|
||||
|
||||
export type CopilotProviderMode = "github-copilot" | "byok";
|
||||
|
||||
export type CopilotModelProviderInput = {
|
||||
api?: string;
|
||||
id: string;
|
||||
provider: string;
|
||||
baseUrl?: string;
|
||||
azureApiVersion?: string;
|
||||
headers?: Record<string, string | null | undefined>;
|
||||
authHeader?: boolean;
|
||||
requestAuthMode?: string;
|
||||
requestProxy?: unknown;
|
||||
requestTls?: unknown;
|
||||
requestAllowPrivateNetwork?: unknown;
|
||||
contextTokens?: number;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
};
|
||||
|
||||
export type ResolvedCopilotProvider = {
|
||||
mode: CopilotProviderMode;
|
||||
provider?: ProviderConfig;
|
||||
authProfileId?: string;
|
||||
authProfileVersion?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps OpenClaw's prepared model facts into the Copilot SDK's session-level
|
||||
* provider contract. The SDK owns the wire request; OpenClaw only supplies
|
||||
* the already-resolved endpoint, model, headers, and credential.
|
||||
*/
|
||||
export function resolveCopilotProvider(params: {
|
||||
model: CopilotModelProviderInput;
|
||||
resolvedApiKey?: string;
|
||||
authProfileId?: string;
|
||||
}): ResolvedCopilotProvider {
|
||||
if (params.model.provider.trim().toLowerCase() === "github-copilot") {
|
||||
return { mode: "github-copilot" };
|
||||
}
|
||||
|
||||
const baseUrl = readString(params.model.baseUrl);
|
||||
if (!baseUrl) {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
assertByokEndpointAllowed(baseUrl);
|
||||
if (hasUnsupportedTransportPolicy(params.model)) {
|
||||
throw new Error(COPILOT_BYOK_TRANSPORT_POLICY_ERROR);
|
||||
}
|
||||
|
||||
const api = readString(params.model.api)?.toLowerCase() ?? "openai-responses";
|
||||
const provider = resolveProviderType(api, baseUrl, params.model.azureApiVersion);
|
||||
const resolvedApiKey = resolveProviderCredential(params.resolvedApiKey);
|
||||
const headers = resolveProviderHeaders(params.model.headers);
|
||||
const requestAuthMode = readString(params.model.requestAuthMode)?.toLowerCase();
|
||||
const usePreparedRequestAuth =
|
||||
requestAuthMode !== undefined && requestAuthMode !== "provider-default";
|
||||
const providerConfig: ProviderConfig = {
|
||||
type: provider.type,
|
||||
...(provider.wireApi ? { wireApi: provider.wireApi } : {}),
|
||||
baseUrl: provider.baseUrl,
|
||||
modelId: params.model.id,
|
||||
wireModel: params.model.id,
|
||||
...(resolvedApiKey && !usePreparedRequestAuth
|
||||
? params.model.authHeader
|
||||
? { bearerToken: resolvedApiKey }
|
||||
: { apiKey: resolvedApiKey }
|
||||
: {}),
|
||||
...(headers ? { headers } : {}),
|
||||
...(provider.azure ? { azure: provider.azure } : {}),
|
||||
...((params.model.contextTokens ?? params.model.contextWindow)
|
||||
? { maxPromptTokens: params.model.contextTokens ?? params.model.contextWindow }
|
||||
: {}),
|
||||
...(params.model.maxTokens ? { maxOutputTokens: params.model.maxTokens } : {}),
|
||||
};
|
||||
const authProfileId = params.authProfileId?.trim() || `byok:${params.model.provider}`;
|
||||
const authProfileVersion = tokenFingerprint(
|
||||
stableSerialize({
|
||||
api,
|
||||
baseUrl: provider.baseUrl,
|
||||
azureApiVersion: provider.azure?.apiVersion,
|
||||
headers,
|
||||
authHeader: params.model.authHeader,
|
||||
requestAuthMode: params.model.requestAuthMode,
|
||||
apiKey: resolvedApiKey,
|
||||
modelId: params.model.id,
|
||||
maxPromptTokens: params.model.contextTokens ?? params.model.contextWindow,
|
||||
maxOutputTokens: params.model.maxTokens,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
mode: "byok",
|
||||
provider: providerConfig,
|
||||
authProfileId,
|
||||
authProfileVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCopilotByokUnsupportedProviderError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message === COPILOT_BYOK_PROVIDER_ERROR ||
|
||||
error.message === COPILOT_BYOK_TRANSPORT_POLICY_ERROR ||
|
||||
error.message === COPILOT_BYOK_ENDPOINT_POLICY_ERROR)
|
||||
);
|
||||
}
|
||||
|
||||
export function supportsCopilotByokProviderShape(
|
||||
model: Pick<
|
||||
CopilotModelProviderInput,
|
||||
"api" | "baseUrl" | "requestProxy" | "requestTls" | "requestAllowPrivateNetwork"
|
||||
>,
|
||||
): boolean {
|
||||
if (!readString(model.baseUrl) || hasUnsupportedTransportPolicy(model)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
resolveProviderType(
|
||||
readString(model.api)?.toLowerCase() ?? "openai-responses",
|
||||
readString(model.baseUrl)!,
|
||||
undefined,
|
||||
);
|
||||
assertByokEndpointHostAllowed(readString(model.baseUrl)!);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasUnsupportedTransportPolicy(
|
||||
model: Pick<
|
||||
CopilotModelProviderInput,
|
||||
"requestProxy" | "requestTls" | "requestAllowPrivateNetwork"
|
||||
>,
|
||||
): boolean {
|
||||
return (
|
||||
model.requestProxy !== undefined ||
|
||||
model.requestTls !== undefined ||
|
||||
model.requestAllowPrivateNetwork !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function assertByokEndpointHostAllowed(baseUrl: string): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseUrl);
|
||||
} catch {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
throw new Error(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new Error(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
for (const key of url.searchParams.keys()) {
|
||||
if (CREDENTIAL_QUERY_PARAM_NAMES.has(normalizeCredentialQueryParamName(key))) {
|
||||
throw new Error(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
}
|
||||
const hostname = url.hostname.toLowerCase().replace(/\.+$/, "");
|
||||
if (isBlockedHostnameOrIp(hostname)) {
|
||||
throw new Error(COPILOT_BYOK_ENDPOINT_POLICY_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCredentialQueryParamName(name: string): string {
|
||||
const stripped = name.replace(QUERY_PARAM_NAME_SEPARATOR_RE, "");
|
||||
try {
|
||||
return decodeURIComponent(stripped)
|
||||
.replace(QUERY_PARAM_NAME_SEPARATOR_RE, "")
|
||||
.toLowerCase()
|
||||
.replace(/[-_]/g, "");
|
||||
} catch {
|
||||
return stripped.toLowerCase().replace(/[-_]/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
function assertByokEndpointAllowed(baseUrl: string): void {
|
||||
assertByokEndpointHostAllowed(baseUrl);
|
||||
}
|
||||
|
||||
function resolveProviderType(
|
||||
api: string | undefined,
|
||||
baseUrl: string,
|
||||
azureApiVersion: string | undefined,
|
||||
): {
|
||||
type: NonNullable<ProviderConfig["type"]>;
|
||||
wireApi?: NonNullable<ProviderConfig["wireApi"]>;
|
||||
baseUrl: string;
|
||||
azure?: NonNullable<ProviderConfig["azure"]>;
|
||||
} {
|
||||
switch (api) {
|
||||
case "anthropic-messages":
|
||||
return { type: "anthropic", baseUrl };
|
||||
case "azure-openai-responses":
|
||||
return resolveAzureProviderType(baseUrl, azureApiVersion);
|
||||
case "openai-responses":
|
||||
return { type: "openai", wireApi: "responses", baseUrl };
|
||||
case "openai-completions":
|
||||
case "ollama":
|
||||
return { type: "openai", wireApi: "completions", baseUrl };
|
||||
default:
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAzureProviderType(
|
||||
baseUrl: string,
|
||||
apiVersion: string | undefined,
|
||||
): {
|
||||
type: NonNullable<ProviderConfig["type"]>;
|
||||
wireApi: NonNullable<ProviderConfig["wireApi"]>;
|
||||
baseUrl: string;
|
||||
azure?: NonNullable<ProviderConfig["azure"]>;
|
||||
} {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(baseUrl);
|
||||
} catch {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
if (isOpenAICompatibleAzureResponsesBaseUrl(url)) {
|
||||
return { type: "openai", wireApi: "responses", baseUrl };
|
||||
}
|
||||
if (!isTraditionalAzureOpenAIHost(url.hostname)) {
|
||||
throw new Error(COPILOT_BYOK_PROVIDER_ERROR);
|
||||
}
|
||||
url.pathname = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
const resolvedApiVersion = readString(apiVersion);
|
||||
return {
|
||||
type: "azure",
|
||||
wireApi: "responses",
|
||||
baseUrl: url.toString().replace(/\/+$/, ""),
|
||||
...(resolvedApiVersion ? { azure: { apiVersion: resolvedApiVersion } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isTraditionalAzureOpenAIHost(hostname: string): boolean {
|
||||
return (
|
||||
hostname.endsWith(".openai.azure.com") || hostname.endsWith(".cognitiveservices.azure.com")
|
||||
);
|
||||
}
|
||||
|
||||
function isOpenAICompatibleAzureResponsesBaseUrl(url: URL): boolean {
|
||||
if (isTraditionalAzureOpenAIHost(url.hostname)) {
|
||||
return false;
|
||||
}
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const isFoundryHost =
|
||||
hostname.endsWith(".services.ai.azure.com") ||
|
||||
hostname.endsWith(".api.cognitive.microsoft.com");
|
||||
if (!isFoundryHost) {
|
||||
return false;
|
||||
}
|
||||
const normalizedPath = url.pathname.replace(/\/+$/, "");
|
||||
return normalizedPath === "/openai/v1" || normalizedPath.endsWith("/openai/v1");
|
||||
}
|
||||
|
||||
function stableSerialize(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableSerialize).join(",")}]`;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.toSorted(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entry]) => `${JSON.stringify(key)}:${stableSerialize(entry)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function resolveProviderCredential(value: string | undefined): string | undefined {
|
||||
const credential = readString(value);
|
||||
return credential && !isNonSecretApiKeyMarker(credential) ? credential : undefined;
|
||||
}
|
||||
|
||||
function resolveProviderHeaders(
|
||||
headers: Record<string, string | null | undefined> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(headers).filter(([, value]) => typeof value === "string"),
|
||||
) as Record<string, string>;
|
||||
return Object.keys(resolved).length > 0 ? resolved : undefined;
|
||||
}
|
||||
305
extensions/copilot/src/replay-shim.test.ts
Executable file
305
extensions/copilot/src/replay-shim.test.ts
Executable file
@@ -0,0 +1,305 @@
|
||||
// Copilot tests cover replay shim plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyResumeFailure,
|
||||
computeReplayMetadata,
|
||||
copilotToolMetasHavePotentialSideEffects,
|
||||
decideReplayAction,
|
||||
} from "./replay-shim.js";
|
||||
|
||||
describe("decideReplayAction", () => {
|
||||
it("returns create when no input is supplied", () => {
|
||||
const decision = decideReplayAction();
|
||||
expect(decision).toEqual({
|
||||
action: "create",
|
||||
downgradedFromResume: false,
|
||||
downgradeReason: "no-replay-state",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns create when sdkSessionId is absent", () => {
|
||||
expect(decideReplayAction({})).toEqual({
|
||||
action: "create",
|
||||
downgradedFromResume: false,
|
||||
downgradeReason: "no-sdk-session-id",
|
||||
});
|
||||
expect(decideReplayAction({ replayInvalid: false })).toEqual({
|
||||
action: "create",
|
||||
downgradedFromResume: false,
|
||||
downgradeReason: "no-sdk-session-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns create for empty or whitespace-only sdkSessionId", () => {
|
||||
for (const sdkSessionId of ["", " ", "\t\n"]) {
|
||||
expect(decideReplayAction({ sdkSessionId })).toMatchObject({
|
||||
action: "create",
|
||||
downgradeReason: "no-sdk-session-id",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns resume when sdkSessionId is present and replayInvalid is not true", () => {
|
||||
expect(decideReplayAction({ sdkSessionId: "sess-1" })).toEqual({
|
||||
action: "resume",
|
||||
sdkSessionId: "sess-1",
|
||||
downgradedFromResume: false,
|
||||
});
|
||||
expect(decideReplayAction({ sdkSessionId: "sess-2", replayInvalid: false })).toEqual({
|
||||
action: "resume",
|
||||
sdkSessionId: "sess-2",
|
||||
downgradedFromResume: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("trims whitespace around sdkSessionId before resuming", () => {
|
||||
expect(decideReplayAction({ sdkSessionId: " sess-3 " })).toEqual({
|
||||
action: "resume",
|
||||
sdkSessionId: "sess-3",
|
||||
downgradedFromResume: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("downgrades to create when replayInvalid is true even with sdkSessionId", () => {
|
||||
expect(decideReplayAction({ sdkSessionId: "sess-4", replayInvalid: true })).toEqual({
|
||||
action: "create",
|
||||
downgradedFromResume: true,
|
||||
downgradeReason: "replay-invalid",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyResumeFailure", () => {
|
||||
it("treats undefined / null as unrecoverable", () => {
|
||||
expect(classifyResumeFailure(undefined)).toEqual({
|
||||
recoverable: false,
|
||||
kind: "unknown",
|
||||
});
|
||||
expect(classifyResumeFailure(null)).toEqual({
|
||||
recoverable: false,
|
||||
kind: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a generic Error as unrecoverable", () => {
|
||||
expect(classifyResumeFailure(new Error("boom"))).toEqual({
|
||||
recoverable: false,
|
||||
kind: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a non-Error throw value as unrecoverable", () => {
|
||||
expect(classifyResumeFailure("string-error")).toEqual({
|
||||
recoverable: false,
|
||||
kind: "unknown",
|
||||
});
|
||||
expect(classifyResumeFailure(42)).toEqual({
|
||||
recoverable: false,
|
||||
kind: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies status:404 errors as missing/recoverable", () => {
|
||||
const error = Object.assign(new Error("Not Found"), { status: 404 });
|
||||
expect(classifyResumeFailure(error)).toEqual({
|
||||
recoverable: true,
|
||||
kind: "missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies statusCode:404 errors as missing/recoverable", () => {
|
||||
const error = Object.assign(new Error("Not Found"), { statusCode: 404 });
|
||||
expect(classifyResumeFailure(error)).toEqual({
|
||||
recoverable: true,
|
||||
kind: "missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies recognised code strings as missing/recoverable", () => {
|
||||
for (const code of ["SESSION_NOT_FOUND", "session_not_found", "NotFound", "ENOENT"]) {
|
||||
const error = Object.assign(new Error("session gone"), { code });
|
||||
expect(classifyResumeFailure(error)).toEqual({
|
||||
recoverable: true,
|
||||
kind: "missing",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies recognised message patterns as missing/recoverable", () => {
|
||||
const messages = [
|
||||
"session not found",
|
||||
"Session sess-1 not found",
|
||||
"Unknown session id sess-1",
|
||||
"session id sess-1 does not exist",
|
||||
"no such session",
|
||||
];
|
||||
for (const message of messages) {
|
||||
expect(classifyResumeFailure(new Error(message))).toEqual({
|
||||
recoverable: true,
|
||||
kind: "missing",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("does not over-match unrelated errors", () => {
|
||||
expect(classifyResumeFailure(new Error("network ECONNRESET"))).toEqual({
|
||||
recoverable: false,
|
||||
kind: "unknown",
|
||||
});
|
||||
expect(classifyResumeFailure(new Error("Unauthorized"))).toEqual({
|
||||
recoverable: false,
|
||||
kind: "unknown",
|
||||
});
|
||||
expect(classifyResumeFailure(new Error("rate limit exceeded"))).toEqual({
|
||||
recoverable: false,
|
||||
kind: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads message from plain objects with a message string", () => {
|
||||
const error = { message: "session not found" };
|
||||
expect(classifyResumeFailure(error)).toEqual({
|
||||
recoverable: true,
|
||||
kind: "missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers structured signals over message heuristics", () => {
|
||||
// status:404 wins even when message is unrelated
|
||||
const error = Object.assign(new Error("Internal server error"), { status: 404 });
|
||||
expect(classifyResumeFailure(error)).toEqual({
|
||||
recoverable: true,
|
||||
kind: "missing",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeReplayMetadata", () => {
|
||||
it("clean attempt with no prior state → replaySafe true", () => {
|
||||
expect(computeReplayMetadata({})).toEqual({
|
||||
hadPotentialSideEffects: false,
|
||||
replaySafe: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("timeout flips both flags", () => {
|
||||
expect(computeReplayMetadata({ thisAttemptTimedOut: true })).toEqual({
|
||||
hadPotentialSideEffects: true,
|
||||
replaySafe: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("prior side effects propagate forward", () => {
|
||||
expect(computeReplayMetadata({ priorHadPotentialSideEffects: true })).toEqual({
|
||||
hadPotentialSideEffects: true,
|
||||
replaySafe: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("current attempt side effects make replay unsafe", () => {
|
||||
expect(computeReplayMetadata({ thisAttemptHadPotentialSideEffects: true })).toEqual({
|
||||
hadPotentialSideEffects: true,
|
||||
replaySafe: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("prior replayInvalid invalidates replay even without side effects", () => {
|
||||
expect(computeReplayMetadata({ priorReplayInvalid: true })).toEqual({
|
||||
hadPotentialSideEffects: false,
|
||||
replaySafe: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("downgradedFromResume invalidates replay even without side effects", () => {
|
||||
expect(computeReplayMetadata({ thisAttemptDowngradedFromResume: true })).toEqual({
|
||||
hadPotentialSideEffects: false,
|
||||
replaySafe: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("resumeFailureRecovered invalidates replay even without side effects", () => {
|
||||
expect(computeReplayMetadata({ thisAttemptResumeFailureRecovered: true })).toEqual({
|
||||
hadPotentialSideEffects: false,
|
||||
replaySafe: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("combinations: prior side effects + timeout still hadSideEffects:true (no double-count)", () => {
|
||||
expect(
|
||||
computeReplayMetadata({
|
||||
priorHadPotentialSideEffects: true,
|
||||
thisAttemptTimedOut: true,
|
||||
}),
|
||||
).toEqual({
|
||||
hadPotentialSideEffects: true,
|
||||
replaySafe: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("combinations: clean attempt with prior replayInvalid+sideEffects propagates both invariants", () => {
|
||||
expect(
|
||||
computeReplayMetadata({
|
||||
priorReplayInvalid: true,
|
||||
priorHadPotentialSideEffects: true,
|
||||
}),
|
||||
).toEqual({
|
||||
hadPotentialSideEffects: true,
|
||||
replaySafe: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats explicit false flags as if they were absent", () => {
|
||||
expect(
|
||||
computeReplayMetadata({
|
||||
priorReplayInvalid: false,
|
||||
priorHadPotentialSideEffects: false,
|
||||
thisAttemptTimedOut: false,
|
||||
thisAttemptDowngradedFromResume: false,
|
||||
thisAttemptResumeFailureRecovered: false,
|
||||
}),
|
||||
).toEqual({
|
||||
hadPotentialSideEffects: false,
|
||||
replaySafe: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("copilotToolMetasHavePotentialSideEffects", () => {
|
||||
it("detects mutating tool names", () => {
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "write" }])).toBe(true);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "message_send" }])).toBe(true);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "browser" }])).toBe(true);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "file_fetch" }])).toBe(true);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "file_write" }])).toBe(true);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "read_and_delete" }])).toBe(true);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "search_and_replace" }])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "session_status" }])).toBe(true);
|
||||
});
|
||||
|
||||
it("treats read-only tool names as replay-safe", () => {
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "read" }])).toBe(false);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "search" }])).toBe(false);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "status" }])).toBe(false);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "file_read" }])).toBe(false);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "memory_get" }])).toBe(false);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "sessions_history" }])).toBe(
|
||||
false,
|
||||
);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "sessions_list" }])).toBe(false);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "tool_search" }])).toBe(false);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "web_fetch" }])).toBe(false);
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "web_search" }])).toBe(false);
|
||||
});
|
||||
|
||||
it("treats memory_search recall tracking as a potential side effect", () => {
|
||||
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "memory_search" }])).toBe(true);
|
||||
});
|
||||
|
||||
it("detects async-started tools even without a mutating name", () => {
|
||||
expect(
|
||||
copilotToolMetasHavePotentialSideEffects([{ asyncStarted: true, toolName: "read" }]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
251
extensions/copilot/src/replay-shim.ts
Executable file
251
extensions/copilot/src/replay-shim.ts
Executable file
@@ -0,0 +1,251 @@
|
||||
// Replay-shim for the GitHub Copilot agent runtime.
|
||||
//
|
||||
// Owns three concerns:
|
||||
// 1. Pre-call: should this attempt resume an existing SDK session or
|
||||
// start a new one? Honours `initialReplayState.sdkSessionId` and
|
||||
// `initialReplayState.replayInvalid`.
|
||||
// 2. Post-call: if `resumeSession` fails, was the failure recoverable
|
||||
// (session-gone) so we should downgrade to `createSession`, or
|
||||
// unrecoverable so the error should surface as a prompt error?
|
||||
// 3. Result-time: compute the `replayMetadata` to attach to the attempt
|
||||
// result, propagating prior state with worst-case-wins semantics so
|
||||
// the orchestrator never replays an attempt that may have committed
|
||||
// partial side effects.
|
||||
//
|
||||
// Host back-pointers (NOT imported here to keep the package boundary
|
||||
// clean):
|
||||
// - `src/agents/pi-embedded-runner/replay-state.ts` — canonical
|
||||
// `EmbeddedRunReplayState` / `EmbeddedRunReplayMetadata` shapes
|
||||
// and `replayMetadataFromState`.
|
||||
// - `src/agents/pi-embedded-runner/run/types.ts` —
|
||||
// `AgentHarnessAttemptResult.replayMetadata` field requirement.
|
||||
|
||||
export type ReplayDecision =
|
||||
| {
|
||||
readonly action: "resume";
|
||||
readonly sdkSessionId: string;
|
||||
readonly downgradedFromResume: false;
|
||||
}
|
||||
| {
|
||||
readonly action: "create";
|
||||
readonly downgradedFromResume: boolean;
|
||||
readonly downgradeReason: "no-replay-state" | "no-sdk-session-id" | "replay-invalid";
|
||||
};
|
||||
|
||||
export interface ReplayShimInput {
|
||||
readonly sdkSessionId?: string;
|
||||
readonly replayInvalid?: boolean;
|
||||
}
|
||||
|
||||
function normalizeSdkSessionId(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure pre-call decision: should attempt.ts call resumeSession or
|
||||
* createSession?
|
||||
*
|
||||
* Rules:
|
||||
* - No input → create (no-replay-state)
|
||||
* - No (trimmed) sdkSessionId → create (no-sdk-session-id)
|
||||
* - sdkSessionId + replayInvalid=true → create (replay-invalid),
|
||||
* downgradedFromResume=true
|
||||
* - sdkSessionId + replayInvalid=false → resume
|
||||
*/
|
||||
export function decideReplayAction(input?: ReplayShimInput): ReplayDecision {
|
||||
if (!input) {
|
||||
return {
|
||||
action: "create",
|
||||
downgradedFromResume: false,
|
||||
downgradeReason: "no-replay-state",
|
||||
};
|
||||
}
|
||||
const sdkSessionId = normalizeSdkSessionId(input.sdkSessionId);
|
||||
if (!sdkSessionId) {
|
||||
return {
|
||||
action: "create",
|
||||
downgradedFromResume: false,
|
||||
downgradeReason: "no-sdk-session-id",
|
||||
};
|
||||
}
|
||||
if (input.replayInvalid === true) {
|
||||
return {
|
||||
action: "create",
|
||||
downgradedFromResume: true,
|
||||
downgradeReason: "replay-invalid",
|
||||
};
|
||||
}
|
||||
return {
|
||||
action: "resume",
|
||||
sdkSessionId,
|
||||
downgradedFromResume: false,
|
||||
};
|
||||
}
|
||||
|
||||
export type ResumeFailureKind = "missing" | "unknown";
|
||||
|
||||
export interface ResumeFailureClassification {
|
||||
readonly recoverable: boolean;
|
||||
readonly kind: ResumeFailureKind;
|
||||
}
|
||||
|
||||
const MISSING_SESSION_CODES = new Set([
|
||||
"SESSION_NOT_FOUND",
|
||||
"session_not_found",
|
||||
"NotFound",
|
||||
"ENOENT",
|
||||
]);
|
||||
|
||||
const MISSING_SESSION_MESSAGE_PATTERNS: readonly RegExp[] = [
|
||||
/\bsession not found\b/i,
|
||||
/\bsession .* not found\b/i,
|
||||
/\bunknown session id\b/i,
|
||||
/\bsession id .* (does not exist|not found)\b/i,
|
||||
/\bsession .* does not exist\b/i,
|
||||
/\bno such session\b/i,
|
||||
];
|
||||
|
||||
function readErrorField(error: unknown, key: string): unknown {
|
||||
if (!error || typeof error !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return (error as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-call: classify a resumeSession() failure so attempt.ts can
|
||||
* decide whether to downgrade silently to createSession.
|
||||
*
|
||||
* Conservative: only treats clearly session-gone signals as recoverable.
|
||||
* Structured signals (status === 404, recognised code strings) are
|
||||
* checked first; message matching is a fallback because SDK error
|
||||
* messages are not part of the typed contract.
|
||||
*
|
||||
* Everything else (transport errors, auth failures, generic Error) is
|
||||
* unrecoverable and should surface to the outer attempt.ts try/catch
|
||||
* which converts it to a prompt error.
|
||||
*/
|
||||
export function classifyResumeFailure(error: unknown): ResumeFailureClassification {
|
||||
if (error === undefined || error === null) {
|
||||
return { recoverable: false, kind: "unknown" };
|
||||
}
|
||||
|
||||
const status = readErrorField(error, "status");
|
||||
if (status === 404) {
|
||||
return { recoverable: true, kind: "missing" };
|
||||
}
|
||||
const statusCode = readErrorField(error, "statusCode");
|
||||
if (statusCode === 404) {
|
||||
return { recoverable: true, kind: "missing" };
|
||||
}
|
||||
|
||||
const code = readErrorField(error, "code");
|
||||
if (typeof code === "string" && MISSING_SESSION_CODES.has(code)) {
|
||||
return { recoverable: true, kind: "missing" };
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "object"
|
||||
? typeof (error as { message?: unknown }).message === "string"
|
||||
? (error as { message: string }).message
|
||||
: undefined
|
||||
: undefined;
|
||||
if (typeof message === "string") {
|
||||
for (const pattern of MISSING_SESSION_MESSAGE_PATTERNS) {
|
||||
if (pattern.test(message)) {
|
||||
return { recoverable: true, kind: "missing" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { recoverable: false, kind: "unknown" };
|
||||
}
|
||||
|
||||
export interface ReplayMetadataComputeInput {
|
||||
readonly priorReplayInvalid?: boolean;
|
||||
readonly priorHadPotentialSideEffects?: boolean;
|
||||
readonly thisAttemptTimedOut?: boolean;
|
||||
readonly thisAttemptHadPotentialSideEffects?: boolean;
|
||||
readonly thisAttemptDowngradedFromResume?: boolean;
|
||||
readonly thisAttemptResumeFailureRecovered?: boolean;
|
||||
}
|
||||
|
||||
export interface ComputedReplayMetadata {
|
||||
readonly hadPotentialSideEffects: boolean;
|
||||
readonly replaySafe: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the `EmbeddedRunReplayMetadata` to attach to the attempt
|
||||
* result. Worst-case-wins:
|
||||
*
|
||||
* hadPotentialSideEffects = priorHadPotentialSideEffects OR timedOut
|
||||
* OR thisAttemptHadPotentialSideEffects
|
||||
* (timeout means we cannot prove the prompt was not partially
|
||||
* committed server-side; treat as side-effecting so the
|
||||
* orchestrator will not blindly re-issue the same prompt).
|
||||
*
|
||||
* replaySafe = NOT (
|
||||
* priorReplayInvalid
|
||||
* OR thisAttemptDowngradedFromResume
|
||||
* OR thisAttemptResumeFailureRecovered
|
||||
* OR hadPotentialSideEffects
|
||||
* )
|
||||
*
|
||||
* Matches the parity rule in
|
||||
* `src/agents/pi-embedded-runner/replay-state.ts#replayMetadataFromState`.
|
||||
*/
|
||||
export function computeReplayMetadata(input: ReplayMetadataComputeInput): ComputedReplayMetadata {
|
||||
const priorReplayInvalid = input.priorReplayInvalid === true;
|
||||
const priorHadPotentialSideEffects = input.priorHadPotentialSideEffects === true;
|
||||
const timedOut = input.thisAttemptTimedOut === true;
|
||||
const thisAttemptHadPotentialSideEffects = input.thisAttemptHadPotentialSideEffects === true;
|
||||
const downgraded = input.thisAttemptDowngradedFromResume === true;
|
||||
const recovered = input.thisAttemptResumeFailureRecovered === true;
|
||||
const hadPotentialSideEffects =
|
||||
priorHadPotentialSideEffects || timedOut || thisAttemptHadPotentialSideEffects;
|
||||
const replaySafe = !(priorReplayInvalid || downgraded || recovered || hadPotentialSideEffects);
|
||||
return { hadPotentialSideEffects, replaySafe };
|
||||
}
|
||||
|
||||
const COPILOT_REPLAY_SAFE_READ_ONLY_TOOL_NAMES = new Set([
|
||||
"get",
|
||||
"file_read",
|
||||
"glob",
|
||||
"grep",
|
||||
"inspect",
|
||||
"list",
|
||||
"ls",
|
||||
"memory_get",
|
||||
"probe",
|
||||
"query",
|
||||
"read",
|
||||
"search",
|
||||
"sessions_history",
|
||||
"sessions_list",
|
||||
"status",
|
||||
"tool_search",
|
||||
"update_plan",
|
||||
"view",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
]);
|
||||
|
||||
export function copilotToolMetasHavePotentialSideEffects(
|
||||
toolMetas?: readonly { asyncStarted?: boolean; toolName: string }[],
|
||||
): boolean {
|
||||
return (toolMetas ?? []).some(
|
||||
(entry) => entry.asyncStarted === true || !isReplaySafeReadOnlyToolName(entry.toolName),
|
||||
);
|
||||
}
|
||||
|
||||
function isReplaySafeReadOnlyToolName(toolName: string): boolean {
|
||||
const normalized = toolName.trim().toLowerCase();
|
||||
return COPILOT_REPLAY_SAFE_READ_ONLY_TOOL_NAMES.has(normalized);
|
||||
}
|
||||
502
extensions/copilot/src/runtime.test.ts
Normal file
502
extensions/copilot/src/runtime.test.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
// Copilot tests cover runtime plugin behavior.
|
||||
import { normalize, resolve, sep } from "node:path";
|
||||
import type { CopilotClient, CopilotClientOptions } from "@github/copilot-sdk";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ClientCreateOptions, PoolKey } from "./runtime.js";
|
||||
import { createCopilotClientPool } from "./runtime.js";
|
||||
|
||||
interface FakeClient {
|
||||
readonly id: number;
|
||||
readonly copilotHome: string;
|
||||
readonly start: ReturnType<typeof vi.fn>;
|
||||
readonly stop: ReturnType<typeof vi.fn>;
|
||||
readonly createSession: ReturnType<typeof vi.fn>;
|
||||
readonly disconnect: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
interface FakeFactoryOptions {
|
||||
readonly create?: (
|
||||
opts: CopilotClientOptions,
|
||||
id: number,
|
||||
) => CopilotClient | Promise<CopilotClient>;
|
||||
readonly stop?: (client: FakeClient) => Promise<Error[]> | Error[];
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolveValue: ((value: T | PromiseLike<T>) => void) | undefined;
|
||||
let rejectValue: ((reason?: unknown) => void) | undefined;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolveValue = resolvePromise;
|
||||
rejectValue = rejectPromise;
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
resolve(value: T) {
|
||||
resolveValue?.(value);
|
||||
},
|
||||
reject(reason: unknown) {
|
||||
rejectValue?.(reason);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHomeForTest(copilotHome: string): string {
|
||||
let normalizedHome = resolve(copilotHome);
|
||||
normalizedHome = normalize(normalizedHome);
|
||||
if (normalizedHome.endsWith(sep) && normalizedHome.length > 1) {
|
||||
normalizedHome = normalizedHome.slice(0, -1);
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
normalizedHome = normalizedHome.toLowerCase();
|
||||
}
|
||||
return normalizedHome;
|
||||
}
|
||||
|
||||
function makeKey(overrides: Partial<PoolKey> = {}): PoolKey {
|
||||
return {
|
||||
agentId: overrides.agentId ?? "agent-1",
|
||||
copilotHome: overrides.copilotHome ?? "copilot-home",
|
||||
authMode: overrides.authMode ?? "useLoggedInUser",
|
||||
authProfileId: overrides.authProfileId,
|
||||
authProfileVersion: overrides.authProfileVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function makeOptions(overrides: Partial<ClientCreateOptions> = {}): ClientCreateOptions {
|
||||
return {
|
||||
copilotHome: overrides.copilotHome ?? "copilot-home",
|
||||
useLoggedInUser: overrides.useLoggedInUser ?? true,
|
||||
gitHubToken: overrides.gitHubToken,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFake(options: FakeFactoryOptions = {}) {
|
||||
const stops: number[] = [];
|
||||
const ctorCalls: CopilotClientOptions[] = [];
|
||||
const instances: FakeClient[] = [];
|
||||
let nextId = 0;
|
||||
|
||||
const fake = async (clientOptions: CopilotClientOptions) => {
|
||||
ctorCalls.push(clientOptions);
|
||||
const id = ++nextId;
|
||||
if (options.create) {
|
||||
return options.create(clientOptions, id);
|
||||
}
|
||||
|
||||
const client: FakeClient = {
|
||||
id,
|
||||
copilotHome: clientOptions.baseDirectory ?? "",
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => {
|
||||
stops.push(id);
|
||||
if (options.stop) {
|
||||
return options.stop(client);
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
createSession: vi.fn(async () => ({})),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
instances.push(client);
|
||||
return client as unknown as CopilotClient;
|
||||
};
|
||||
|
||||
return { fake, stops, ctorCalls, instances };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("createCopilotClientPool", () => {
|
||||
it("same key reuses client", async () => {
|
||||
const sdk = makeFake();
|
||||
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
|
||||
const key = makeKey();
|
||||
const options = makeOptions();
|
||||
|
||||
const first = await pool.acquire(key, options);
|
||||
const second = await pool.acquire(key, options);
|
||||
|
||||
expect(first.client).toBe(second.client);
|
||||
expect(first.key).toEqual(second.key);
|
||||
expect(sdk.ctorCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("different agentId same copilotHome creates distinct clients", async () => {
|
||||
const sdk = makeFake();
|
||||
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
|
||||
const options = makeOptions();
|
||||
|
||||
const first = await pool.acquire(makeKey({ agentId: "agent-a" }), options);
|
||||
const second = await pool.acquire(makeKey({ agentId: "agent-b" }), options);
|
||||
|
||||
expect(first.client).not.toBe(second.client);
|
||||
expect(sdk.ctorCalls.length).toBe(2);
|
||||
});
|
||||
|
||||
it("different authProfileVersion creates distinct clients", async () => {
|
||||
const sdk = makeFake();
|
||||
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
|
||||
const options = makeOptions({ gitHubToken: "token-a", useLoggedInUser: false });
|
||||
|
||||
const first = await pool.acquire(
|
||||
makeKey({ authMode: "gitHubToken", authProfileId: "profile", authProfileVersion: "v1" }),
|
||||
options,
|
||||
);
|
||||
const second = await pool.acquire(
|
||||
makeKey({ authMode: "gitHubToken", authProfileId: "profile", authProfileVersion: "v2" }),
|
||||
options,
|
||||
);
|
||||
|
||||
expect(first.client).not.toBe(second.client);
|
||||
expect(sdk.ctorCalls.length).toBe(2);
|
||||
});
|
||||
|
||||
it("release decrements; non-zero refcount keeps client alive", async () => {
|
||||
const sdk = makeFake();
|
||||
const pool = createCopilotClientPool({ idleTtlMs: 100, sdkFactory: sdk.fake });
|
||||
const key = makeKey();
|
||||
const options = makeOptions();
|
||||
|
||||
const first = await pool.acquire(key, options);
|
||||
const second = await pool.acquire(key, options);
|
||||
await pool.release(first);
|
||||
|
||||
expect(first.client).toBe(second.client);
|
||||
expect(sdk.stops).toEqual([]);
|
||||
expect(pool.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("release to zero schedules idle teardown; teardown fires after idleTtlMs and calls stop() exactly once", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sdk = makeFake();
|
||||
const pool = createCopilotClientPool({ idleTtlMs: 50, sdkFactory: sdk.fake });
|
||||
const handle = await pool.acquire(makeKey(), makeOptions());
|
||||
|
||||
await pool.release(handle);
|
||||
await vi.advanceTimersByTimeAsync(49);
|
||||
expect(sdk.stops).toEqual([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(sdk.stops).toEqual([1]);
|
||||
expect(pool.size()).toBe(0);
|
||||
expect(sdk.instances[0]?.start.mock.calls.length).toBe(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(sdk.stops).toEqual([1]);
|
||||
});
|
||||
|
||||
it("acquire during idle window cancels teardown and reuses", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sdk = makeFake();
|
||||
const pool = createCopilotClientPool({ idleTtlMs: 50, sdkFactory: sdk.fake });
|
||||
const key = makeKey();
|
||||
const options = makeOptions();
|
||||
|
||||
const first = await pool.acquire(key, options);
|
||||
await pool.release(first);
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
const second = await pool.acquire(key, options);
|
||||
|
||||
expect(second.client).toBe(first.client);
|
||||
expect(sdk.ctorCalls.length).toBe(1);
|
||||
expect(sdk.stops).toEqual([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(sdk.stops).toEqual([]);
|
||||
|
||||
await pool.release(second);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(sdk.stops).toEqual([1]);
|
||||
});
|
||||
|
||||
it("acquire during stopping awaits stop(), then creates fresh client", async () => {
|
||||
vi.useFakeTimers();
|
||||
const stopDeferred = createDeferred<Error[]>();
|
||||
const sdk = makeFake({
|
||||
stop: async () => stopDeferred.promise,
|
||||
});
|
||||
const pool = createCopilotClientPool({ idleTtlMs: 10, sdkFactory: sdk.fake });
|
||||
const key = makeKey();
|
||||
const options = makeOptions();
|
||||
|
||||
const first = await pool.acquire(key, options);
|
||||
await pool.release(first);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
let settled = false;
|
||||
const secondPromise = pool.acquire(key, options).then((value) => {
|
||||
settled = true;
|
||||
return value;
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(settled).toBe(false);
|
||||
expect(sdk.stops).toEqual([1]);
|
||||
|
||||
stopDeferred.resolve([]);
|
||||
const second = await secondPromise;
|
||||
|
||||
expect(settled).toBe(true);
|
||||
expect(second.client).not.toBe(first.client);
|
||||
expect(sdk.ctorCalls.length).toBe(2);
|
||||
});
|
||||
|
||||
it("concurrent acquire dedupes", async () => {
|
||||
const clientDeferred = createDeferred<CopilotClient>();
|
||||
const sdkFactory = vi.fn(async () => clientDeferred.promise);
|
||||
const pool = createCopilotClientPool({ sdkFactory });
|
||||
const key = makeKey();
|
||||
const options = makeOptions();
|
||||
|
||||
const firstPromise = pool.acquire(key, options);
|
||||
const secondPromise = pool.acquire(key, options);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sdkFactory.mock.calls.length).toBe(1);
|
||||
|
||||
const client = {
|
||||
id: 1,
|
||||
copilotHome: "copilot-home",
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => []),
|
||||
createSession: vi.fn(async () => ({})),
|
||||
disconnect: vi.fn(),
|
||||
} as unknown as CopilotClient;
|
||||
clientDeferred.resolve(client);
|
||||
const [first, second] = await Promise.all([firstPromise, secondPromise]);
|
||||
|
||||
expect(first.client).toBe(second.client);
|
||||
expect(sdkFactory.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it("constructor failure is not cached", async () => {
|
||||
let attempt = 0;
|
||||
const sdkFactory = async (clientOptions: CopilotClientOptions) => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) {
|
||||
throw new Error(`constructor failed for ${String(clientOptions.baseDirectory)}`);
|
||||
}
|
||||
return {
|
||||
id: attempt,
|
||||
copilotHome: clientOptions.baseDirectory,
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => []),
|
||||
createSession: vi.fn(async () => ({})),
|
||||
disconnect: vi.fn(),
|
||||
} as unknown as CopilotClient;
|
||||
};
|
||||
const pool = createCopilotClientPool({ sdkFactory });
|
||||
|
||||
await expect(pool.acquire(makeKey(), makeOptions())).rejects.toThrow("constructor failed for");
|
||||
|
||||
const second = await pool.acquire(makeKey(), makeOptions());
|
||||
|
||||
expect(attempt).toBe(2);
|
||||
expect(second.key.agentId).toBe("agent-1");
|
||||
});
|
||||
|
||||
it("double release is a no-op", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sdk = makeFake();
|
||||
const pool = createCopilotClientPool({ idleTtlMs: 100, sdkFactory: sdk.fake });
|
||||
const handle = await pool.acquire(makeKey(), makeOptions());
|
||||
|
||||
await pool.release(handle);
|
||||
await pool.release(handle);
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
|
||||
expect(sdk.stops).toEqual([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(sdk.stops).toEqual([1]);
|
||||
});
|
||||
|
||||
it("dispose stops all clients exactly once, aggregates errors, clears the map", async () => {
|
||||
const sdk = makeFake({
|
||||
stop: (client) => [new Error(`stop-${client.id}-a`), new Error(`stop-${client.id}-b`)],
|
||||
});
|
||||
const pool = createCopilotClientPool({ idleTtlMs: 1000, sdkFactory: sdk.fake });
|
||||
|
||||
const first = await pool.acquire(
|
||||
makeKey({ agentId: "agent-a", copilotHome: "home-a" }),
|
||||
makeOptions({ copilotHome: "home-a" }),
|
||||
);
|
||||
const second = await pool.acquire(
|
||||
makeKey({ agentId: "agent-b", copilotHome: "home-b" }),
|
||||
makeOptions({ copilotHome: "home-b" }),
|
||||
);
|
||||
await pool.acquire(
|
||||
makeKey({ agentId: "agent-c", copilotHome: "home-c" }),
|
||||
makeOptions({ copilotHome: "home-c" }),
|
||||
);
|
||||
await pool.release(second);
|
||||
|
||||
const errors = await pool.dispose();
|
||||
|
||||
expect(errors.map((error) => error.message)).toEqual([
|
||||
"stop-1-a",
|
||||
"stop-1-b",
|
||||
"stop-2-a",
|
||||
"stop-2-b",
|
||||
"stop-3-a",
|
||||
"stop-3-b",
|
||||
]);
|
||||
expect(sdk.stops).toEqual([1, 2, 3]);
|
||||
expect(pool.size()).toBe(0);
|
||||
|
||||
const secondDispose = await pool.dispose();
|
||||
expect(secondDispose).toEqual([]);
|
||||
expect(sdk.stops).toEqual([1, 2, 3]);
|
||||
await pool.release(first);
|
||||
});
|
||||
|
||||
it("dispose during in-flight acquire", async () => {
|
||||
const clientDeferred = createDeferred<CopilotClient>();
|
||||
const stopped: number[] = [];
|
||||
const sdkFactory = async () => {
|
||||
const client = {
|
||||
id: 1,
|
||||
copilotHome: "copilot-home",
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => {
|
||||
stopped.push(1);
|
||||
return [];
|
||||
}),
|
||||
createSession: vi.fn(async () => ({})),
|
||||
disconnect: vi.fn(),
|
||||
} as unknown as CopilotClient;
|
||||
await clientDeferred.promise;
|
||||
return client;
|
||||
};
|
||||
const pool = createCopilotClientPool({ sdkFactory });
|
||||
|
||||
const acquirePromise = pool.acquire(makeKey(), makeOptions());
|
||||
const disposePromise = pool.dispose();
|
||||
const client = {
|
||||
id: 1,
|
||||
copilotHome: "copilot-home",
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => []),
|
||||
createSession: vi.fn(async () => ({})),
|
||||
disconnect: vi.fn(),
|
||||
} as unknown as CopilotClient;
|
||||
clientDeferred.resolve(client);
|
||||
|
||||
await expect(acquirePromise).rejects.toThrow("[copilot-pool] pool disposed");
|
||||
expect(await disposePromise).toEqual([]);
|
||||
expect(stopped).toEqual([1]);
|
||||
await expect(pool.acquire(makeKey(), makeOptions())).rejects.toThrow(
|
||||
"[copilot-pool] pool disposed",
|
||||
);
|
||||
});
|
||||
|
||||
it("concurrent dispose waits for the in-flight shutdown and does not duplicate errors", async () => {
|
||||
const stopDeferred = createDeferred<Error[]>();
|
||||
const sdk = makeFake({
|
||||
stop: async () => stopDeferred.promise,
|
||||
});
|
||||
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
|
||||
|
||||
await pool.acquire(makeKey(), makeOptions());
|
||||
|
||||
const firstDisposePromise = pool.dispose();
|
||||
const secondDisposePromise = pool.dispose();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sdk.stops).toEqual([1]);
|
||||
|
||||
stopDeferred.resolve([new Error("stop failed")]);
|
||||
const firstErrors = await firstDisposePromise;
|
||||
const secondErrors = await secondDisposePromise;
|
||||
|
||||
expect(firstErrors.map((error) => error.message)).toEqual(["stop failed"]);
|
||||
expect(secondErrors).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes non-Error stop failures during dispose", async () => {
|
||||
const sdk = makeFake({
|
||||
stop: () => {
|
||||
throw toLintErrorObject("stop-string", "Non-Error thrown");
|
||||
},
|
||||
});
|
||||
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
|
||||
|
||||
await pool.acquire(makeKey(), makeOptions());
|
||||
|
||||
const errors = await pool.dispose();
|
||||
|
||||
expect(errors.map((error) => error.message)).toEqual(["stop-string"]);
|
||||
});
|
||||
|
||||
it("treats Windows copilotHome paths as case-insensitive when keying the pool", async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
|
||||
|
||||
try {
|
||||
const sdk = makeFake();
|
||||
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
|
||||
const firstHome = "C:/Users/Tester/CopilotHome/";
|
||||
const secondHome = "c:/users/tester/copilothome";
|
||||
|
||||
const first = await pool.acquire(
|
||||
makeKey({ copilotHome: firstHome }),
|
||||
makeOptions({ copilotHome: firstHome }),
|
||||
);
|
||||
const second = await pool.acquire(
|
||||
makeKey({ copilotHome: secondHome }),
|
||||
makeOptions({ copilotHome: secondHome }),
|
||||
);
|
||||
|
||||
const normalizedHome = normalizeHomeForTest(firstHome);
|
||||
expect(first.client).toBe(second.client);
|
||||
expect(first.key.copilotHome).toBe(normalizedHome);
|
||||
expect(second.key.copilotHome).toBe(normalizedHome);
|
||||
expect(String(sdk.ctorCalls[0]?.baseDirectory)).toBe(normalizedHome);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
|
||||
}
|
||||
});
|
||||
|
||||
it("path normalization", async () => {
|
||||
const sdk = makeFake();
|
||||
const pool = createCopilotClientPool({ sdkFactory: sdk.fake });
|
||||
const firstHome =
|
||||
process.platform === "win32" ? "C:\\Users\\Tester\\CopilotHome\\" : "copilot-home/";
|
||||
const secondHome =
|
||||
process.platform === "win32" ? "c:\\users\\tester\\copilothome" : "copilot-home";
|
||||
|
||||
const first = await pool.acquire(
|
||||
makeKey({ copilotHome: firstHome }),
|
||||
makeOptions({ copilotHome: firstHome }),
|
||||
);
|
||||
const second = await pool.acquire(
|
||||
makeKey({ copilotHome: secondHome }),
|
||||
makeOptions({ copilotHome: secondHome }),
|
||||
);
|
||||
|
||||
const normalizedHome = normalizeHomeForTest(firstHome);
|
||||
expect(first.client).toBe(second.client);
|
||||
expect(first.key.copilotHome).toBe(normalizedHome);
|
||||
expect(second.key.copilotHome).toBe(normalizedHome);
|
||||
expect(sdk.ctorCalls.length).toBe(1);
|
||||
expect(String(sdk.ctorCalls[0]?.baseDirectory)).toBe(normalizedHome);
|
||||
});
|
||||
});
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
389
extensions/copilot/src/runtime.ts
Normal file
389
extensions/copilot/src/runtime.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
// Copilot plugin module implements runtime behavior.
|
||||
import { normalize, resolve, sep } from "node:path";
|
||||
import type { CopilotClient, CopilotClientOptions } from "@github/copilot-sdk";
|
||||
import { loadCopilotSdk } from "./sdk-loader.js";
|
||||
|
||||
// SAFETY: The pool reuses CopilotClient instances per normalized PoolKey and does not
|
||||
// serialize concurrent client.createSession() calls. attempt-bridge MUST treat shared
|
||||
// CopilotClients as having safe concurrent multi-session semantics that are NOT YET PROVEN;
|
||||
// if probe q4 reveals concurrency hazards, attempt-bridge must add per-key serialization.
|
||||
|
||||
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
|
||||
const POOL_DISPOSED_MESSAGE = "[copilot-pool] pool disposed";
|
||||
|
||||
export interface PoolKey {
|
||||
readonly agentId: string;
|
||||
readonly copilotHome: string;
|
||||
readonly authMode: "useLoggedInUser" | "gitHubToken" | "byok";
|
||||
readonly authProfileId?: string;
|
||||
readonly authProfileVersion?: string;
|
||||
}
|
||||
|
||||
export interface ClientCreateOptions extends Omit<
|
||||
CopilotClientOptions,
|
||||
"baseDirectory" | "workingDirectory" | "useLoggedInUser" | "gitHubToken"
|
||||
> {
|
||||
readonly copilotHome: string;
|
||||
readonly useLoggedInUser?: boolean;
|
||||
readonly gitHubToken?: string;
|
||||
}
|
||||
|
||||
export interface PooledClient {
|
||||
readonly key: PoolKey;
|
||||
readonly client: CopilotClient;
|
||||
}
|
||||
|
||||
export interface CopilotClientPoolOptions {
|
||||
readonly sdkFactory?: (opts: CopilotClientOptions) => CopilotClient | Promise<CopilotClient>;
|
||||
readonly idleTtlMs?: number;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export interface CopilotClientPool {
|
||||
acquire(key: PoolKey, options: ClientCreateOptions): Promise<PooledClient>;
|
||||
release(handle: PooledClient): Promise<void>;
|
||||
dispose(): Promise<Error[]>;
|
||||
size(): number;
|
||||
}
|
||||
|
||||
type EntryState =
|
||||
| { kind: "creating"; promise: Promise<CopilotClient> }
|
||||
| { kind: "ready"; client: CopilotClient }
|
||||
| {
|
||||
kind: "idle";
|
||||
client: CopilotClient;
|
||||
idleTimer: ReturnType<typeof setTimeout>;
|
||||
idleSinceMs: number;
|
||||
}
|
||||
| { kind: "stopping"; client: CopilotClient; promise: Promise<Error[]> }
|
||||
| { kind: "stopped" };
|
||||
|
||||
interface PoolEntry {
|
||||
readonly key: PoolKey;
|
||||
readonly cacheKey: string;
|
||||
refCount: number;
|
||||
stopRan: boolean;
|
||||
state: EntryState;
|
||||
}
|
||||
|
||||
export function createCopilotClientPool(options: CopilotClientPoolOptions = {}): CopilotClientPool {
|
||||
const sdkFactory =
|
||||
options.sdkFactory ??
|
||||
(async (clientOptions: CopilotClientOptions) => {
|
||||
// Lazy-load the SDK so packaged installs without @github/copilot-sdk
|
||||
// (the default; see sdk-loader.ts for rationale) crash with an
|
||||
// actionable install message instead of a generic MODULE_NOT_FOUND
|
||||
// at import time. The loader caches the resolved module after the
|
||||
// first successful load.
|
||||
const sdk = await loadCopilotSdk();
|
||||
return new sdk.CopilotClient(clientOptions);
|
||||
});
|
||||
const idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
||||
const now = options.now ?? Date.now;
|
||||
const entries = new Map<string, PoolEntry>();
|
||||
const releasedHandles = new WeakSet<PooledClient>();
|
||||
let disposed = false;
|
||||
let disposePromise: Promise<Error[]> | undefined;
|
||||
let disposeCompleted = false;
|
||||
|
||||
const createDisposedError = () => new Error(POOL_DISPOSED_MESSAGE);
|
||||
|
||||
const maybeDeleteEntry = (entry: PoolEntry) => {
|
||||
if (entries.get(entry.cacheKey) === entry) {
|
||||
entries.delete(entry.cacheKey);
|
||||
}
|
||||
};
|
||||
|
||||
const stopReadyOrIdleEntry = (
|
||||
entry: PoolEntry,
|
||||
client: CopilotClient,
|
||||
idleTimer?: ReturnType<typeof setTimeout>,
|
||||
) => {
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer);
|
||||
}
|
||||
if (entry.stopRan) {
|
||||
if (entry.state.kind === "stopping") {
|
||||
return entry.state.promise;
|
||||
}
|
||||
if (entry.state.kind === "stopped") {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
|
||||
entry.stopRan = true;
|
||||
const stopPromise = (async () => {
|
||||
try {
|
||||
return await client.stop();
|
||||
} catch (error: unknown) {
|
||||
return [toError(error)];
|
||||
} finally {
|
||||
entry.state = { kind: "stopped" };
|
||||
maybeDeleteEntry(entry);
|
||||
}
|
||||
})();
|
||||
|
||||
entry.state = { kind: "stopping", client, promise: stopPromise };
|
||||
return stopPromise;
|
||||
};
|
||||
|
||||
const stopEntry = async (entry: PoolEntry): Promise<Error[]> => {
|
||||
switch (entry.state.kind) {
|
||||
case "creating": {
|
||||
try {
|
||||
await entry.state.promise;
|
||||
} catch (error: unknown) {
|
||||
maybeDeleteEntry(entry);
|
||||
return [toError(error)];
|
||||
}
|
||||
return stopEntry(entry);
|
||||
}
|
||||
case "ready":
|
||||
return stopReadyOrIdleEntry(entry, entry.state.client);
|
||||
case "idle":
|
||||
return stopReadyOrIdleEntry(entry, entry.state.client, entry.state.idleTimer);
|
||||
case "stopping":
|
||||
return entry.state.promise;
|
||||
case "stopped":
|
||||
return [];
|
||||
default: {
|
||||
const exhaustive: never = entry.state;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleIdleStop = (entry: PoolEntry, client: CopilotClient) => {
|
||||
const idleTimer = setTimeout(() => {
|
||||
void stopEntry(entry);
|
||||
}, idleTtlMs);
|
||||
entry.state = {
|
||||
kind: "idle",
|
||||
client,
|
||||
idleTimer,
|
||||
idleSinceMs: now(),
|
||||
};
|
||||
};
|
||||
|
||||
const createEntry = (key: PoolKey, cacheKey: string, clientOptions: CopilotClientOptions) => {
|
||||
const entry: PoolEntry = {
|
||||
key,
|
||||
cacheKey,
|
||||
refCount: 1,
|
||||
stopRan: false,
|
||||
state: {
|
||||
kind: "creating",
|
||||
promise: Promise.resolve(undefined as unknown as CopilotClient),
|
||||
},
|
||||
};
|
||||
|
||||
const createPromise = (async () => {
|
||||
try {
|
||||
const client = await sdkFactory(clientOptions);
|
||||
entry.state = { kind: "ready", client };
|
||||
return client;
|
||||
} catch (error: unknown) {
|
||||
entry.state = { kind: "stopped" };
|
||||
maybeDeleteEntry(entry);
|
||||
throw toError(error);
|
||||
}
|
||||
})();
|
||||
|
||||
entry.state = { kind: "creating", promise: createPromise };
|
||||
entries.set(cacheKey, entry);
|
||||
return { entry, createPromise };
|
||||
};
|
||||
|
||||
const acquire = async (
|
||||
inputKey: PoolKey,
|
||||
optionsForCreate: ClientCreateOptions,
|
||||
): Promise<PooledClient> => {
|
||||
const key = normalizePoolKey(inputKey, optionsForCreate.copilotHome);
|
||||
const cacheKey = JSON.stringify(key);
|
||||
const clientOptions = normalizeClientCreateOptions(optionsForCreate, key.copilotHome);
|
||||
|
||||
while (true) {
|
||||
if (disposed) {
|
||||
throw createDisposedError();
|
||||
}
|
||||
|
||||
const existing = entries.get(cacheKey);
|
||||
if (!existing) {
|
||||
const created = createEntry(key, cacheKey, clientOptions);
|
||||
try {
|
||||
const client = await created.createPromise;
|
||||
if (disposed) {
|
||||
await stopEntry(created.entry);
|
||||
throw createDisposedError();
|
||||
}
|
||||
return { key: created.entry.key, client };
|
||||
} catch (error: unknown) {
|
||||
throw toError(error);
|
||||
}
|
||||
}
|
||||
|
||||
switch (existing.state.kind) {
|
||||
case "creating": {
|
||||
existing.refCount += 1;
|
||||
try {
|
||||
const client = await existing.state.promise;
|
||||
if (disposed) {
|
||||
await stopEntry(existing);
|
||||
throw createDisposedError();
|
||||
}
|
||||
return { key: existing.key, client };
|
||||
} catch (error: unknown) {
|
||||
throw toError(error);
|
||||
}
|
||||
}
|
||||
case "ready":
|
||||
existing.refCount += 1;
|
||||
return { key: existing.key, client: existing.state.client };
|
||||
case "idle": {
|
||||
const client = existing.state.client;
|
||||
clearTimeout(existing.state.idleTimer);
|
||||
existing.refCount += 1;
|
||||
existing.state = { kind: "ready", client };
|
||||
return { key: existing.key, client };
|
||||
}
|
||||
case "stopping":
|
||||
await existing.state.promise;
|
||||
continue;
|
||||
case "stopped":
|
||||
maybeDeleteEntry(existing);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const release = async (handle: PooledClient): Promise<void> => {
|
||||
if (releasedHandles.has(handle)) {
|
||||
return;
|
||||
}
|
||||
releasedHandles.add(handle);
|
||||
|
||||
const entry = entries.get(JSON.stringify(handle.key));
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (entry.state.kind) {
|
||||
case "creating":
|
||||
case "stopping":
|
||||
case "stopped":
|
||||
return;
|
||||
case "ready":
|
||||
case "idle":
|
||||
if (entry.state.client !== handle.client) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (entry.refCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.refCount -= 1;
|
||||
if (entry.refCount > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposed) {
|
||||
await stopEntry(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.state.kind === "ready") {
|
||||
scheduleIdleStop(entry, entry.state.client);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.state.kind === "idle") {
|
||||
clearTimeout(entry.state.idleTimer);
|
||||
scheduleIdleStop(entry, entry.state.client);
|
||||
}
|
||||
};
|
||||
|
||||
const dispose = async (): Promise<Error[]> => {
|
||||
if (disposeCompleted) {
|
||||
return [];
|
||||
}
|
||||
if (disposePromise) {
|
||||
await disposePromise;
|
||||
return [];
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
const snapshot = [...entries.values()];
|
||||
for (const entry of snapshot) {
|
||||
if (entry.state.kind === "idle") {
|
||||
clearTimeout(entry.state.idleTimer);
|
||||
}
|
||||
}
|
||||
|
||||
disposePromise = (async () => {
|
||||
const errors: Error[] = [];
|
||||
for (const entry of snapshot) {
|
||||
const stopErrors = await stopEntry(entry);
|
||||
errors.push(...stopErrors);
|
||||
}
|
||||
entries.clear();
|
||||
disposeCompleted = true;
|
||||
return errors;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await disposePromise;
|
||||
} finally {
|
||||
disposePromise = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
acquire,
|
||||
release,
|
||||
dispose,
|
||||
size: () => entries.size,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePoolKey(key: PoolKey, rawCopilotHome: string): PoolKey {
|
||||
return {
|
||||
agentId: key.agentId,
|
||||
copilotHome: normalizeCopilotHome(rawCopilotHome),
|
||||
authMode: key.authMode,
|
||||
authProfileId: key.authProfileId,
|
||||
authProfileVersion: key.authProfileVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeClientCreateOptions(
|
||||
options: ClientCreateOptions,
|
||||
normalizedCopilotHome: string,
|
||||
): CopilotClientOptions {
|
||||
const { copilotHome: _copilotHome, ...clientOptions } = options;
|
||||
return {
|
||||
...clientOptions,
|
||||
baseDirectory: normalizedCopilotHome,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCopilotHome(copilotHome: string): string {
|
||||
let normalizedHome = resolve(copilotHome);
|
||||
normalizedHome = normalize(normalizedHome);
|
||||
if (normalizedHome.endsWith(sep) && normalizedHome.length > 1) {
|
||||
normalizedHome = normalizedHome.slice(0, -1);
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
normalizedHome = normalizedHome.toLowerCase();
|
||||
}
|
||||
return normalizedHome;
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
return new Error(String(error));
|
||||
}
|
||||
221
extensions/copilot/src/sdk-loader.test.ts
Executable file
221
extensions/copilot/src/sdk-loader.test.ts
Executable file
@@ -0,0 +1,221 @@
|
||||
// Copilot tests cover sdk loader plugin behavior.
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
COPILOT_SDK_SPEC,
|
||||
resetCopilotSdkCacheForTests,
|
||||
loadCopilotSdk,
|
||||
resolveCopilotSdkFallbackDir,
|
||||
} from "./sdk-loader.js";
|
||||
|
||||
const FAKE_SDK = {
|
||||
CopilotClient: class FakeCopilotClient {
|
||||
_fake = true;
|
||||
},
|
||||
} as unknown as typeof import("@github/copilot-sdk");
|
||||
|
||||
describe("sdk-loader", () => {
|
||||
beforeEach(() => {
|
||||
resetCopilotSdkCacheForTests();
|
||||
});
|
||||
|
||||
it("returns the primary import when it succeeds", async () => {
|
||||
const primaryImport = vi.fn(async () => FAKE_SDK);
|
||||
const fallbackImport = vi.fn(async () => {
|
||||
throw new Error("should not be called");
|
||||
});
|
||||
|
||||
const sdk = await loadCopilotSdk({
|
||||
cache: false,
|
||||
fallbackDir: "/dev/null/does-not-exist",
|
||||
primaryImport,
|
||||
fallbackImport,
|
||||
});
|
||||
|
||||
expect(sdk).toBe(FAKE_SDK);
|
||||
expect(primaryImport).toHaveBeenCalledTimes(1);
|
||||
expect(fallbackImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the on-demand install location when primary import fails", async () => {
|
||||
const tmp = mkdtempSync(path.join(tmpdir(), "copilot-sdk-loader-"));
|
||||
try {
|
||||
// Materialize the fallback path so the existsSync check passes.
|
||||
const fallbackPath = path.join(tmp, "node_modules", "@github", "copilot-sdk");
|
||||
mkdirSync(fallbackPath, { recursive: true });
|
||||
writeFileSync(path.join(fallbackPath, "index.js"), "// placeholder");
|
||||
|
||||
const primaryImport = vi.fn(async () => {
|
||||
const err = new Error("Cannot find module '@github/copilot-sdk'") as Error & {
|
||||
code: string;
|
||||
};
|
||||
err.code = "ERR_MODULE_NOT_FOUND";
|
||||
throw err;
|
||||
});
|
||||
const fallbackImport = vi.fn(async (abs: string) => {
|
||||
expect(abs).toBe(fallbackPath);
|
||||
return FAKE_SDK;
|
||||
});
|
||||
|
||||
const sdk = await loadCopilotSdk({
|
||||
cache: false,
|
||||
fallbackDir: tmp,
|
||||
primaryImport,
|
||||
fallbackImport,
|
||||
});
|
||||
|
||||
expect(sdk).toBe(FAKE_SDK);
|
||||
expect(primaryImport).toHaveBeenCalledTimes(1);
|
||||
expect(fallbackImport).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("default fallback importer resolves and imports the installed SDK entry", async () => {
|
||||
// Exercise the real default fallback importer (no fallbackImport injection)
|
||||
// to prove it imports a concrete entry file rather than the package
|
||||
// directory, which Node ESM would reject with ERR_UNSUPPORTED_DIR_IMPORT.
|
||||
const tmp = mkdtempSync(path.join(tmpdir(), "copilot-sdk-loader-default-"));
|
||||
try {
|
||||
const pkgDir = path.join(tmp, "node_modules", "@github", "copilot-sdk");
|
||||
mkdirSync(pkgDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(pkgDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@github/copilot-sdk",
|
||||
version: "0.0.0-test",
|
||||
main: "./index.cjs",
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(pkgDir, "index.cjs"),
|
||||
"module.exports = { openclawDefaultImporterSentinel: true };",
|
||||
);
|
||||
|
||||
const primaryImport = vi.fn(async () => {
|
||||
const err = new Error("Cannot find module '@github/copilot-sdk'") as Error & {
|
||||
code: string;
|
||||
};
|
||||
err.code = "ERR_MODULE_NOT_FOUND";
|
||||
throw err;
|
||||
});
|
||||
|
||||
const sdk = (await loadCopilotSdk({
|
||||
cache: false,
|
||||
fallbackDir: tmp,
|
||||
primaryImport,
|
||||
// Intentionally NOT injecting fallbackImport; exercise the default.
|
||||
})) as unknown as { openclawDefaultImporterSentinel?: boolean };
|
||||
|
||||
expect(sdk.openclawDefaultImporterSentinel).toBe(true);
|
||||
expect(primaryImport).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws an actionable error with plugin install instructions when both probes fail", async () => {
|
||||
const primaryImport = vi.fn(async () => {
|
||||
throw new Error("Cannot find module '@github/copilot-sdk'");
|
||||
});
|
||||
const fallbackImport = vi.fn(async () => {
|
||||
throw new Error("should not be called when fallback dir does not exist");
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadCopilotSdk({
|
||||
cache: false,
|
||||
fallbackDir: path.join(tmpdir(), "copilot-sdk-loader-missing-" + Date.now()),
|
||||
primaryImport,
|
||||
fallbackImport,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "COPILOT_SDK_MISSING",
|
||||
message: expect.stringContaining("openclaw plugins install @openclaw/copilot"),
|
||||
});
|
||||
|
||||
expect(fallbackImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("error message includes the fallback path and underlying primary error", async () => {
|
||||
const primaryImport = vi.fn(async () => {
|
||||
throw new Error("primary boom");
|
||||
});
|
||||
|
||||
const fallbackDir = path.join(tmpdir(), "copilot-sdk-loader-missing-" + Date.now());
|
||||
let captured: Error | undefined;
|
||||
try {
|
||||
await loadCopilotSdk({
|
||||
cache: false,
|
||||
fallbackDir,
|
||||
primaryImport,
|
||||
});
|
||||
} catch (err) {
|
||||
captured = err as Error;
|
||||
}
|
||||
expect(captured).toBeDefined();
|
||||
const message = captured?.message ?? "";
|
||||
expect(message).toContain("primary boom");
|
||||
expect(message).toContain(path.join(fallbackDir, "node_modules", "@github", "copilot-sdk"));
|
||||
expect(message).toContain(COPILOT_SDK_SPEC);
|
||||
expect(message).toContain("openclaw plugins install @openclaw/copilot");
|
||||
});
|
||||
|
||||
it("caches successful loads across calls when cache is enabled", async () => {
|
||||
const primaryImport = vi.fn(async () => FAKE_SDK);
|
||||
|
||||
const a = await loadCopilotSdk({ primaryImport, fallbackDir: "/dev/null/does-not-exist" });
|
||||
const b = await loadCopilotSdk({ primaryImport, fallbackDir: "/dev/null/does-not-exist" });
|
||||
|
||||
expect(a).toBe(FAKE_SDK);
|
||||
expect(b).toBe(FAKE_SDK);
|
||||
expect(primaryImport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not poison the cache after a failed load", async () => {
|
||||
const primaryImport = vi
|
||||
.fn<typeof Promise>()
|
||||
.mockRejectedValueOnce(new Error("first boom"))
|
||||
.mockResolvedValueOnce(FAKE_SDK);
|
||||
|
||||
await expect(
|
||||
loadCopilotSdk({
|
||||
primaryImport: primaryImport as unknown as () => Promise<
|
||||
typeof import("@github/copilot-sdk")
|
||||
>,
|
||||
fallbackDir: "/dev/null/does-not-exist",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(Error);
|
||||
|
||||
const sdk = await loadCopilotSdk({
|
||||
primaryImport: primaryImport as unknown as () => Promise<
|
||||
typeof import("@github/copilot-sdk")
|
||||
>,
|
||||
fallbackDir: "/dev/null/does-not-exist",
|
||||
});
|
||||
expect(sdk).toBe(FAKE_SDK);
|
||||
expect(primaryImport).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("resolves the fallback dir from OPENCLAW_STATE_DIR for relocated profiles", () => {
|
||||
expect(
|
||||
resolveCopilotSdkFallbackDir({
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: "/tmp/openclaw-state",
|
||||
}),
|
||||
).toBe(path.join("/tmp/openclaw-state", "npm-runtime", "copilot"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetCopilotSdkCacheForTests();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdk dependency constants", () => {
|
||||
it("COPILOT_SDK_SPEC pins the canonical SDK spec", () => {
|
||||
expect(COPILOT_SDK_SPEC).toBe("@github/copilot-sdk@1.0.5");
|
||||
});
|
||||
});
|
||||
125
extensions/copilot/src/sdk-loader.ts
Executable file
125
extensions/copilot/src/sdk-loader.ts
Executable file
@@ -0,0 +1,125 @@
|
||||
// Copilot plugin module implements sdk loader behavior.
|
||||
import { existsSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type * as Sdk from "@github/copilot-sdk";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
|
||||
export function resolveCopilotSdkFallbackDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(resolveStateDir(env), "npm-runtime", "copilot");
|
||||
}
|
||||
|
||||
export const COPILOT_SDK_SPEC = "@github/copilot-sdk@1.0.5";
|
||||
|
||||
let cached: Promise<typeof Sdk> | undefined;
|
||||
|
||||
export interface LoadCopilotSdkOptions {
|
||||
readonly fallbackDir?: string;
|
||||
readonly primaryImport?: () => Promise<typeof Sdk>;
|
||||
readonly fallbackImport?: (absolutePath: string) => Promise<typeof Sdk>;
|
||||
readonly cache?: boolean;
|
||||
}
|
||||
|
||||
export async function loadCopilotSdk(options: LoadCopilotSdkOptions = {}): Promise<typeof Sdk> {
|
||||
const useCache = options.cache !== false;
|
||||
if (useCache && cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const promise = doLoad(options);
|
||||
if (useCache) {
|
||||
cached = promise.catch((err: unknown) => {
|
||||
cached = undefined;
|
||||
throw err;
|
||||
});
|
||||
return cached;
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function resetCopilotSdkCacheForTests(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
|
||||
async function doLoad(options: LoadCopilotSdkOptions): Promise<typeof Sdk> {
|
||||
const fallbackDir = options.fallbackDir ?? resolveCopilotSdkFallbackDir();
|
||||
const primaryImport = options.primaryImport ?? (async () => await import("@github/copilot-sdk"));
|
||||
|
||||
let primaryErr: unknown;
|
||||
try {
|
||||
return await primaryImport();
|
||||
} catch (err) {
|
||||
primaryErr = err;
|
||||
}
|
||||
|
||||
const fallbackPath = path.join(fallbackDir, "node_modules", "@github", "copilot-sdk");
|
||||
if (!existsSync(fallbackPath)) {
|
||||
throw createMissingSdkError(primaryErr, undefined, fallbackPath);
|
||||
}
|
||||
|
||||
const fallbackImport =
|
||||
options.fallbackImport ??
|
||||
(async () => {
|
||||
// Node ESM rejects directory imports (ERR_UNSUPPORTED_DIR_IMPORT), so
|
||||
// resolve the package's real entry through Node's module resolver
|
||||
// anchored at fallbackDir before importing.
|
||||
const requireFromFallback = createRequire(path.join(fallbackDir, "package.json"));
|
||||
const entry = requireFromFallback.resolve("@github/copilot-sdk");
|
||||
return (await import(pathToFileURL(entry).href)) as typeof Sdk;
|
||||
});
|
||||
|
||||
try {
|
||||
return await fallbackImport(fallbackPath);
|
||||
} catch (fallbackErr) {
|
||||
throw createMissingSdkError(primaryErr, fallbackErr, fallbackPath);
|
||||
}
|
||||
}
|
||||
|
||||
function createMissingSdkError(
|
||||
primaryErr: unknown,
|
||||
fallbackErr: unknown,
|
||||
fallbackPath: string,
|
||||
): Error {
|
||||
const lines = [
|
||||
"[copilot] @github/copilot-sdk is not installed.",
|
||||
"",
|
||||
"The external @openclaw/copilot plugin depends on @github/copilot-sdk",
|
||||
"(~260 MB after pulling its platform-specific @github/copilot CLI binary).",
|
||||
"Reinstall the plugin once with:",
|
||||
"",
|
||||
" openclaw plugins install @openclaw/copilot",
|
||||
"",
|
||||
"For source checkouts or offline repair, install the SDK directly:",
|
||||
"",
|
||||
` npm install ${COPILOT_SDK_SPEC}`,
|
||||
"",
|
||||
`The legacy fallback location is still probed at\n ${fallbackPath}`,
|
||||
"",
|
||||
"Primary resolution error:",
|
||||
` ${summarizeError(primaryErr)}`,
|
||||
];
|
||||
if (fallbackErr !== undefined) {
|
||||
lines.push("", "Fallback resolution error:", ` ${summarizeError(fallbackErr)}`);
|
||||
}
|
||||
const err = new Error(lines.join("\n"));
|
||||
(err as Error & { code?: string }).code = "COPILOT_SDK_MISSING";
|
||||
return err;
|
||||
}
|
||||
|
||||
function summarizeError(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "(none)";
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
return value.message || String(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return Object.prototype.toString.call(value);
|
||||
}
|
||||
}
|
||||
1729
extensions/copilot/src/tool-bridge.test.ts
Normal file
1729
extensions/copilot/src/tool-bridge.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
872
extensions/copilot/src/tool-bridge.ts
Normal file
872
extensions/copilot/src/tool-bridge.ts
Normal file
@@ -0,0 +1,872 @@
|
||||
// Copilot plugin module implements tool bridge behavior.
|
||||
import type { Tool as SdkTool, ToolInvocation, ToolResultObject } from "@github/copilot-sdk";
|
||||
import type {
|
||||
AnyAgentTool,
|
||||
EmbeddedRunAttemptParams,
|
||||
SandboxContext,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
applyEmbeddedAttemptToolsAllow,
|
||||
buildEmbeddedAttemptToolRunContext,
|
||||
extractToolErrorMessage,
|
||||
getPluginToolMeta,
|
||||
isSubagentSessionKey,
|
||||
isToolResultError,
|
||||
resolveAttemptSpawnWorkspaceDir,
|
||||
resolveEmbeddedAttemptToolConstructionPlan,
|
||||
resolveModelAuthMode,
|
||||
sanitizeToolResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { createAgentHarnessToolSurfaceRuntime } from "openclaw/plugin-sdk/agent-harness-tool-runtime";
|
||||
|
||||
type CreateOpenClawCodingTools =
|
||||
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
|
||||
type OpenClawCodingToolsOptions = NonNullable<Parameters<CreateOpenClawCodingTools>[0]>;
|
||||
type AgentHarnessToolSurfaceRuntime = ReturnType<typeof createAgentHarnessToolSurfaceRuntime>;
|
||||
type CatalogExecuteParams = Parameters<
|
||||
NonNullable<AgentHarnessToolSurfaceRuntime["toolSearchCatalogExecutor"]>
|
||||
>[0];
|
||||
|
||||
type AgentToolResultLike = {
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutable holder populated by `attempt.ts` *after* `client.createSession()`
|
||||
* (or `client.resumeSession()`) succeeds, so that the tool bridge — which is
|
||||
* constructed *before* the SDK session exists — can route `onYield` events
|
||||
* to the live session's `abort()` later in the run. Bridged tools cannot
|
||||
* execute before the SDK session is up, so reading `current === undefined`
|
||||
* inside `onYield` is a no-op by design.
|
||||
*/
|
||||
export interface CopilotSessionHolder {
|
||||
current: { abort?: () => unknown } | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural subset of `EmbeddedRunAttemptParams` carried into the tool
|
||||
* bridge for PI-parity tool context (see
|
||||
* `src/agents/pi-embedded-runner/run/attempt.ts:1029-1117` — the
|
||||
* authoritative `createOpenClawCodingTools({...})` call shape).
|
||||
*
|
||||
* Declared as `Partial<EmbeddedRunAttemptParams>` (imported from the
|
||||
* `openclaw/plugin-sdk/agent-harness-runtime` boundary, *not* from
|
||||
* `attempt.ts` in this extension) to avoid an `attempt.ts` ↔
|
||||
* `tool-bridge.ts` import cycle while keeping the field shapes
|
||||
* authoritative. Production callers pass the live attempt params; test
|
||||
* fixtures may omit this field entirely and fall back to the flat
|
||||
* fields below for minimal-config wiring.
|
||||
*/
|
||||
export type CopilotToolAttemptParams = Partial<EmbeddedRunAttemptParams>;
|
||||
|
||||
export type CopilotToolCompletion = {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
export interface CopilotToolBridgeInput {
|
||||
allowModelTools?: boolean;
|
||||
modelProvider: string;
|
||||
modelId: string;
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
cwd?: string;
|
||||
/**
|
||||
* Sandbox context resolved by the caller (typically `attempt.ts` via
|
||||
* `resolveSandboxContext` from the plugin-sdk). When provided, wrapped
|
||||
* tools see the same sandbox-aware behavior PI provides. `null` (or
|
||||
* omitted) means sandbox is disabled.
|
||||
*/
|
||||
sandbox?: SandboxContext | null;
|
||||
/**
|
||||
* Pre-computed `spawnWorkspaceDir` for subagent inheritance. The caller
|
||||
* derives this from the *original* workspace via
|
||||
* `resolveAttemptSpawnWorkspaceDir({ sandbox, resolvedWorkspace })`.
|
||||
* When omitted, the bridge falls back to computing it from the
|
||||
* (possibly sandbox-effective) `workspaceDir` it sees; production
|
||||
* callers should pass it explicitly so `ro`/`none` sandboxes are
|
||||
* handled correctly.
|
||||
*/
|
||||
spawnWorkspaceDir?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
/**
|
||||
* Full PI-parity attempt parameters. When set, the bridge forwards
|
||||
* identity, channel, owner/policy, auth-profile, message-routing,
|
||||
* model, and run-trace fields to `createOpenClawCodingTools` so the
|
||||
* wrapped-tool enforcement layer
|
||||
* (`src/agents/pi-tools.before-tool-call.ts`) receives the same
|
||||
* context the in-tree PI runner provides. See
|
||||
* `src/agents/pi-embedded-runner/run/attempt.ts:1029-1117`.
|
||||
*/
|
||||
attemptParams?: CopilotToolAttemptParams;
|
||||
/**
|
||||
* Mutable session holder used to wire `onYield` to the live
|
||||
* `session.abort()` once the SDK session is established. See
|
||||
* {@link CopilotSessionHolder}.
|
||||
*/
|
||||
sessionRef?: CopilotSessionHolder;
|
||||
/**
|
||||
* Invoked when a wrapped tool fires `sessions_yield`. The bridge
|
||||
* always also calls `sessionRef.current?.abort?.()` to interrupt
|
||||
* the in-flight SDK session; this callback lets the caller track
|
||||
* the yield so the final attempt result can carry
|
||||
* `yieldDetected: true` (the parent runner uses it to mark
|
||||
* liveness as paused and stop_reason as `end_turn`). Mirrors
|
||||
* the PI/codex contract — see
|
||||
* `src/agents/pi-embedded-runner/run/attempt.ts:1107-1113` and
|
||||
* `extensions/codex/src/app-server/run-attempt.ts:539-541`.
|
||||
*/
|
||||
onYieldDetected?: (message?: string) => void;
|
||||
onToolCompleted?: (completion: CopilotToolCompletion) => void | Promise<void>;
|
||||
createOpenClawCodingTools?: (opts: unknown) => AnyAgentTool[] | Promise<AnyAgentTool[]>;
|
||||
beforeExecute?: (ctx: {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
args: unknown;
|
||||
sourceTool: AnyAgentTool;
|
||||
invocation: ToolInvocation;
|
||||
}) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface CopilotToolBridge {
|
||||
cleanup?: () => void;
|
||||
sdkTools: SdkTool[];
|
||||
sourceTools: AnyAgentTool[];
|
||||
}
|
||||
|
||||
export const SUPPORTED_TOOL_PROVIDERS: ReadonlySet<string> = new Set(["github-copilot"]);
|
||||
const BASE_COPILOT_CODING_TOOL_NAMES = new Set(["edit", "read", "write"]);
|
||||
const SHELL_COPILOT_CODING_TOOL_NAMES = new Set(["apply_patch", "exec", "process"]);
|
||||
|
||||
export function supportsModelTools(modelProvider: string): boolean {
|
||||
return SUPPORTED_TOOL_PROVIDERS.has(modelProvider);
|
||||
}
|
||||
|
||||
export async function createCopilotToolBridge(
|
||||
input: CopilotToolBridgeInput,
|
||||
): Promise<CopilotToolBridge> {
|
||||
if (!input.allowModelTools && !supportsModelTools(input.modelProvider)) {
|
||||
return { sdkTools: [], sourceTools: [] };
|
||||
}
|
||||
|
||||
const attemptParams = input.attemptParams ?? ({} as CopilotToolAttemptParams);
|
||||
const toolPlan = resolveEmbeddedAttemptToolConstructionPlan({
|
||||
disableTools: attemptParams.disableTools,
|
||||
forceMessageTool: shouldForceCopilotMessageTool(attemptParams),
|
||||
isRawModelRun: isCopilotRawModelRun(attemptParams),
|
||||
toolsAllow: attemptParams.toolsAllow,
|
||||
});
|
||||
const effectiveToolPlan = hasNonWildcardGlobAllowlist(toolPlan.runtimeToolAllowlist)
|
||||
? {
|
||||
...toolPlan,
|
||||
codingToolConstructionPlan: {
|
||||
includeBaseCodingTools: true,
|
||||
includeChannelTools: true,
|
||||
includeOpenClawTools: true,
|
||||
includePluginTools: true,
|
||||
includeShellTools: true,
|
||||
},
|
||||
constructTools: true,
|
||||
includeCoreTools: true,
|
||||
}
|
||||
: toolPlan;
|
||||
if (!effectiveToolPlan.constructTools) {
|
||||
return { sdkTools: [], sourceTools: [] };
|
||||
}
|
||||
|
||||
const createOpenClawCodingTools =
|
||||
input.createOpenClawCodingTools ??
|
||||
(await import("openclaw/plugin-sdk/agent-harness")).createOpenClawCodingTools;
|
||||
|
||||
const toolSurfaceRuntime = createAgentHarnessToolSurfaceRuntime({
|
||||
abortSignal: input.abortSignal,
|
||||
agentId: input.agentId,
|
||||
config: attemptParams.config,
|
||||
disableTools: attemptParams.disableTools,
|
||||
executeTool: (toolParams) => executeCatalogTool(input, toolParams),
|
||||
forceMessageTool: shouldForceCopilotMessageTool(attemptParams),
|
||||
isRawModelRun: isCopilotRawModelRun(attemptParams),
|
||||
modelToolsEnabled: true,
|
||||
prompt: attemptParams.prompt,
|
||||
runId: attemptParams.runId,
|
||||
runtimeToolAllowlist: effectiveToolPlan.runtimeToolAllowlist,
|
||||
sessionId: input.sessionId,
|
||||
sessionKey: attemptParams.sandboxSessionKey ?? attemptParams.sessionKey ?? input.sessionKey,
|
||||
sourceReplyDeliveryMode: attemptParams.sourceReplyDeliveryMode,
|
||||
toolsAllow: attemptParams.toolsAllow,
|
||||
});
|
||||
const toolOptions = buildOpenClawCodingToolsOptions(
|
||||
input,
|
||||
{
|
||||
...effectiveToolPlan,
|
||||
runtimeToolAllowlist: toolSurfaceRuntime.runtimeToolAllowlist,
|
||||
},
|
||||
toolSurfaceRuntime,
|
||||
);
|
||||
|
||||
let sourceTools: unknown;
|
||||
try {
|
||||
sourceTools = await createOpenClawCodingTools(toolOptions);
|
||||
} catch (error: unknown) {
|
||||
throw createError(
|
||||
`[copilot-tool-bridge] createOpenClawCodingTools failed: ${toError(error).message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(sourceTools)) {
|
||||
throw new Error(
|
||||
"[copilot-tool-bridge] createOpenClawCodingTools must return an array of tools",
|
||||
);
|
||||
}
|
||||
|
||||
const allowedSourceTools = filterCopilotToolsForAllowlist(
|
||||
sourceTools as AnyAgentTool[],
|
||||
toolSurfaceRuntime.runtimeToolAllowlist,
|
||||
);
|
||||
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools);
|
||||
const plannedTools = filterCopilotToolsForConstructionPlan(
|
||||
compactedTools.tools,
|
||||
effectiveToolPlan.codingToolConstructionPlan,
|
||||
{ preserveToolNames: toolSurfaceRuntime.runtimeToolAllowlist },
|
||||
);
|
||||
const filteredTools = filterCopilotToolsForAllowlist(
|
||||
plannedTools,
|
||||
toolSurfaceRuntime.runtimeToolAllowlist,
|
||||
);
|
||||
|
||||
// Run duplicate detection after filtering so a duplicate in a
|
||||
// suppressed tool does not fail a narrow run (PI parity: PI never
|
||||
// sees the duplicate either when the allowlist excludes it).
|
||||
const duplicateNames = findDuplicateToolNames(filteredTools);
|
||||
if (duplicateNames.length > 0) {
|
||||
throw new Error(`[copilot-tool-bridge] duplicate tool names: ${duplicateNames.join(", ")}`);
|
||||
}
|
||||
|
||||
return {
|
||||
cleanup: toolSurfaceRuntime.cleanup,
|
||||
sdkTools: filteredTools.map((sourceTool) =>
|
||||
convertOpenClawToolToSdkTool(sourceTool, {
|
||||
abortSignal: input.abortSignal,
|
||||
beforeExecute: input.beforeExecute,
|
||||
onAgentToolResult: input.attemptParams?.onAgentToolResult,
|
||||
onToolCompleted: input.onToolCompleted,
|
||||
}),
|
||||
),
|
||||
sourceTools: filteredTools,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the full `createOpenClawCodingTools` options bag mirroring the
|
||||
* PI in-tree call at `src/agents/pi-embedded-runner/run/attempt.ts:1029-1117`.
|
||||
*
|
||||
* Why PI parity matters: bridged OpenClaw tools register with the SDK
|
||||
* as `overridesBuiltInTool: true, skipPermission: true` (see
|
||||
* `convertOpenClawToolToSdkTool` below). That means the wrapped-tool
|
||||
* enforcement layer
|
||||
* (`src/agents/pi-tools.before-tool-call.ts → wrapToolWithBeforeToolCallHook`)
|
||||
* is the single gate for permission, owner-only allowlists, loop
|
||||
* detection, trusted-plugin policies, and two-phase plugin approvals.
|
||||
* That layer reads its context from the fields forwarded here; missing
|
||||
* fields silently degrade policy decisions. See docs/plugins/copilot.md.
|
||||
*
|
||||
* The shared embedded-runner tool plan is forwarded so the bridge does
|
||||
* not construct broad tool families only to filter them later. That
|
||||
* preserves PI allowlist semantics such as `write` not materializing
|
||||
* `apply_patch`.
|
||||
* Sandbox is forwarded via the explicit `sandbox` field on
|
||||
* {@link CopilotToolBridgeInput}; callers resolve it via
|
||||
* `resolveSandboxContext` before constructing the bridge.
|
||||
*/
|
||||
function buildOpenClawCodingToolsOptions(
|
||||
input: CopilotToolBridgeInput,
|
||||
toolPlan: ReturnType<typeof resolveEmbeddedAttemptToolConstructionPlan>,
|
||||
toolSurfaceRuntime?: ReturnType<typeof createAgentHarnessToolSurfaceRuntime>,
|
||||
): OpenClawCodingToolsOptions {
|
||||
const a = input.attemptParams ?? ({} as CopilotToolAttemptParams);
|
||||
|
||||
// Mirror PI's `sandboxSessionKey` derivation (attempt.ts:873-874) so
|
||||
// wrapped tools see the same policy key PI uses. When the attempt
|
||||
// exposes neither sandboxSessionKey nor sessionKey, fall back to the
|
||||
// flat input.sessionKey/sessionId.
|
||||
const sandboxSessionKey =
|
||||
a.sandboxSessionKey?.trim() || a.sessionKey?.trim() || input.sessionKey || input.sessionId;
|
||||
|
||||
// When sandboxSessionKey differs from the real run session key (e.g.
|
||||
// Telegram direct peer key vs `agent:main:main`), pass the live key
|
||||
// so `session_status: "current"` resolves to the active run session,
|
||||
// not the stale sandbox key. Mirrors PI attempt.ts:1057-1060.
|
||||
const liveSessionKey = a.sessionKey ?? input.sessionKey;
|
||||
const runSessionKey =
|
||||
liveSessionKey && liveSessionKey !== sandboxSessionKey ? liveSessionKey : undefined;
|
||||
|
||||
const workspaceDir = input.workspaceDir ?? a.workspaceDir;
|
||||
const cwd = input.cwd ?? a.cwd;
|
||||
const agentDir = input.agentDir ?? a.agentDir;
|
||||
// Sandbox forwarded from the caller (attempt.ts derives it via
|
||||
// `resolveSandboxContext`). Wrapped tools that opt into sandbox-aware
|
||||
// behavior now see the same policy PI provides. Spawn workspace falls
|
||||
// through to the caller-provided value when supplied; otherwise we
|
||||
// derive it locally from the (possibly sandbox-effective) workspaceDir
|
||||
// — sufficient for legacy/test fixtures that didn't pre-compute it.
|
||||
const sandbox = input.sandbox ?? undefined;
|
||||
const spawnWorkspaceDir =
|
||||
input.spawnWorkspaceDir ??
|
||||
(workspaceDir
|
||||
? resolveAttemptSpawnWorkspaceDir({
|
||||
sandbox,
|
||||
resolvedWorkspace: workspaceDir,
|
||||
})
|
||||
: undefined);
|
||||
|
||||
const model = a.model;
|
||||
const modelHasVision = Array.isArray(model?.input) && model.input.includes("image");
|
||||
const modelCompat =
|
||||
model &&
|
||||
typeof model === "object" &&
|
||||
"compat" in model &&
|
||||
model.compat &&
|
||||
typeof model.compat === "object"
|
||||
? (model.compat as OpenClawCodingToolsOptions["modelCompat"])
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
agentId: input.agentId,
|
||||
...buildEmbeddedAttemptToolRunContext({
|
||||
trigger: a.trigger,
|
||||
jobId: a.jobId,
|
||||
memoryFlushWritePath: a.memoryFlushWritePath,
|
||||
toolsAllow: a.toolsAllow,
|
||||
}),
|
||||
exec: {
|
||||
...a.execOverrides,
|
||||
elevated: a.bashElevated,
|
||||
},
|
||||
messageProvider: a.messageProvider ?? a.messageChannel,
|
||||
agentAccountId: a.agentAccountId,
|
||||
messageTo: a.messageTo,
|
||||
messageThreadId: a.messageThreadId,
|
||||
groupId: a.groupId,
|
||||
groupChannel: a.groupChannel,
|
||||
groupSpace: a.groupSpace,
|
||||
memberRoleIds: a.memberRoleIds,
|
||||
spawnedBy: a.spawnedBy,
|
||||
senderId: a.senderId,
|
||||
senderName: a.senderName,
|
||||
senderUsername: a.senderUsername,
|
||||
senderE164: a.senderE164,
|
||||
senderIsOwner: a.senderIsOwner,
|
||||
allowGatewaySubagentBinding: a.allowGatewaySubagentBinding,
|
||||
sessionKey: sandboxSessionKey,
|
||||
runSessionKey,
|
||||
sessionId: input.sessionId,
|
||||
runId: a.runId,
|
||||
agentDir,
|
||||
workspaceDir,
|
||||
cwd,
|
||||
// Sandbox parity with PI
|
||||
// (`src/agents/pi-embedded-runner/run/attempt.ts:1238-1262`):
|
||||
// forwarded from the caller (attempt.ts derives it via
|
||||
// `resolveSandboxContext`).
|
||||
sandbox,
|
||||
spawnWorkspaceDir,
|
||||
config: toolSurfaceRuntime?.config ?? a.config,
|
||||
abortSignal: input.abortSignal,
|
||||
modelProvider: input.modelProvider,
|
||||
modelId: input.modelId,
|
||||
includeCoreTools: toolPlan.includeCoreTools,
|
||||
includeToolSearchControls: toolSurfaceRuntime?.includeToolSearchControls,
|
||||
toolSearchCatalogRef: toolSurfaceRuntime?.toolSearchCatalogRef,
|
||||
toolSearchCatalogExecutor: toolSurfaceRuntime?.toolSearchCatalogExecutor,
|
||||
runtimeToolAllowlist: toolPlan.runtimeToolAllowlist,
|
||||
toolConstructionPlan: toolPlan.codingToolConstructionPlan,
|
||||
modelCompat,
|
||||
modelApi: model?.api,
|
||||
modelContextWindowTokens: model?.contextWindow,
|
||||
modelAuthMode: resolveModelAuthMode(input.modelProvider, a.config, undefined, {
|
||||
workspaceDir,
|
||||
}),
|
||||
currentChannelId: a.currentChannelId,
|
||||
currentMessagingTarget: a.currentMessagingTarget,
|
||||
currentThreadTs: a.currentThreadTs,
|
||||
currentMessageId: a.currentMessageId,
|
||||
replyToMode: a.replyToMode,
|
||||
hasRepliedRef: a.hasRepliedRef,
|
||||
modelHasVision,
|
||||
requireExplicitMessageTarget:
|
||||
a.requireExplicitMessageTarget ?? isSubagentSessionKey(liveSessionKey),
|
||||
sourceReplyDeliveryMode: a.sourceReplyDeliveryMode,
|
||||
disableMessageTool: a.disableMessageTool,
|
||||
forceMessageTool: a.forceMessageTool,
|
||||
enableHeartbeatTool: a.enableHeartbeatTool,
|
||||
forceHeartbeatTool: a.forceHeartbeatTool,
|
||||
authProfileStore: a.toolAuthProfileStore ?? a.authProfileStore,
|
||||
// recordToolPrepStage intentionally omitted: copilot does not
|
||||
// surface attempt-stage telemetry yet. Codex omits this too.
|
||||
onToolOutcome: a.onToolOutcome,
|
||||
onYield: (message) => {
|
||||
// Notify the caller first so the final attempt result can carry
|
||||
// yieldDetected even if the abort below races a concurrent
|
||||
// settle path. Errors thrown by the caller's handler must not
|
||||
// skip the abort, so wrap defensively. Mirrors PI (`attempt.ts`
|
||||
// sets `yieldDetected = true; yieldMessage = message;` before
|
||||
// calling abort) and codex (`onYieldDetected()` runs before the
|
||||
// run-abort controller fires).
|
||||
try {
|
||||
input.onYieldDetected?.(message);
|
||||
} catch (error) {
|
||||
console.warn("[copilot-tool-bridge] onYieldDetected handler threw; continuing", error);
|
||||
}
|
||||
// The SDK session does not exist at bridge-construction time, so
|
||||
// we route yield events through a mutable holder populated by
|
||||
// attempt.ts immediately after `createSession()` /
|
||||
// `resumeSession()` resolves. Bridged tools cannot execute before
|
||||
// the SDK session is up, so a missing `current` is a no-op by
|
||||
// design (e.g. early aborts handled by the abortSignal path).
|
||||
const target = input.sessionRef?.current;
|
||||
void target?.abort?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function convertOpenClawToolToSdkTool(
|
||||
sourceTool: AnyAgentTool,
|
||||
ctx: {
|
||||
abortSignal?: AbortSignal;
|
||||
beforeExecute?: CopilotToolBridgeInput["beforeExecute"];
|
||||
onAgentToolResult?: CopilotToolAttemptParams["onAgentToolResult"];
|
||||
onToolCompleted?: CopilotToolBridgeInput["onToolCompleted"];
|
||||
},
|
||||
): SdkTool {
|
||||
if (typeof sourceTool.name !== "string" || sourceTool.name.trim().length === 0) {
|
||||
throw new Error("[copilot-tool-bridge] tool name must be a non-empty string");
|
||||
}
|
||||
|
||||
if (typeof sourceTool.execute !== "function") {
|
||||
throw new Error(
|
||||
`[copilot-tool-bridge] tool '${sourceTool.name}' must define an execute function`,
|
||||
);
|
||||
}
|
||||
|
||||
let sequentialLock = Promise.resolve();
|
||||
const notifyToolResult = (result: unknown, isError: boolean) => {
|
||||
try {
|
||||
ctx.onAgentToolResult?.({ toolName: sourceTool.name, result, isError });
|
||||
} catch (error) {
|
||||
console.warn("[copilot-tool-bridge] onAgentToolResult handler threw; continuing", error);
|
||||
}
|
||||
};
|
||||
const notifyToolCompleted = (completion: CopilotToolCompletion) => {
|
||||
try {
|
||||
void Promise.resolve(ctx.onToolCompleted?.(completion)).catch((error: unknown) => {
|
||||
console.warn("[copilot-tool-bridge] onToolCompleted handler threw; continuing", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[copilot-tool-bridge] onToolCompleted handler threw; continuing", error);
|
||||
}
|
||||
};
|
||||
const failureResult = (
|
||||
executedArgs: unknown,
|
||||
invocation: ToolInvocation,
|
||||
startedAt: number,
|
||||
message: string,
|
||||
error: unknown,
|
||||
): ToolResultObject => {
|
||||
const errorMessage = toError(error).message;
|
||||
notifyToolResult(
|
||||
sanitizeToolResult({
|
||||
content: [{ type: "text", text: message }],
|
||||
details: { status: "failed", error: errorMessage },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
notifyToolCompleted({
|
||||
toolName: sourceTool.name,
|
||||
toolCallId: invocation.toolCallId,
|
||||
args: toToolStartArgs(executedArgs),
|
||||
error: errorMessage,
|
||||
startedAt,
|
||||
});
|
||||
return createFailureResult(message, error);
|
||||
};
|
||||
const executeOnce = async (
|
||||
args: unknown,
|
||||
invocation: ToolInvocation,
|
||||
): Promise<ToolResultObject> => {
|
||||
const startedAt = Date.now();
|
||||
if (ctx.abortSignal?.aborted) {
|
||||
const error = new Error("[copilot-tool-bridge] aborted before execution");
|
||||
return failureResult(args, invocation, startedAt, error.message, error);
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.beforeExecute?.({
|
||||
args,
|
||||
invocation,
|
||||
sourceTool,
|
||||
toolCallId: invocation.toolCallId,
|
||||
toolName: sourceTool.name,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return failureResult(
|
||||
args,
|
||||
invocation,
|
||||
startedAt,
|
||||
`[copilot-tool-bridge] beforeExecute failed for tool '${sourceTool.name}': ${toError(error).message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
let preparedArgs;
|
||||
try {
|
||||
preparedArgs = sourceTool.prepareArguments ? sourceTool.prepareArguments(args) : args;
|
||||
} catch (error: unknown) {
|
||||
return failureResult(
|
||||
args,
|
||||
invocation,
|
||||
startedAt,
|
||||
`[copilot-tool-bridge] prepareArguments failed for tool '${sourceTool.name}': ${toError(error).message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
let result: AgentToolResultLike;
|
||||
try {
|
||||
result = await sourceTool.execute(
|
||||
invocation.toolCallId,
|
||||
preparedArgs,
|
||||
ctx.abortSignal,
|
||||
undefined,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
return failureResult(
|
||||
preparedArgs,
|
||||
invocation,
|
||||
startedAt,
|
||||
`[copilot-tool-bridge] tool '${sourceTool.name}' failed: ${toError(error).message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
const sdkResult = agentToolResultToSdk(result);
|
||||
const sanitizedResult = sanitizeToolResult(result);
|
||||
const resultIsError = sdkResult.resultType === "failure" || isToolResultError(sanitizedResult);
|
||||
const resultError = resultIsError ? extractToolErrorMessage(sanitizedResult) : undefined;
|
||||
notifyToolResult(sanitizedResult, resultIsError);
|
||||
notifyToolCompleted({
|
||||
toolName: sourceTool.name,
|
||||
toolCallId: invocation.toolCallId,
|
||||
args: toToolStartArgs(preparedArgs),
|
||||
result: sanitizedResult,
|
||||
...(resultError ? { error: resultError } : {}),
|
||||
startedAt,
|
||||
});
|
||||
return sdkResult;
|
||||
};
|
||||
|
||||
const handler =
|
||||
sourceTool.executionMode === "sequential"
|
||||
? (args: unknown, invocation: ToolInvocation) => {
|
||||
const run = sequentialLock.then(
|
||||
() => executeOnce(args, invocation),
|
||||
() => executeOnce(args, invocation),
|
||||
);
|
||||
sequentialLock = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return run;
|
||||
}
|
||||
: executeOnce;
|
||||
|
||||
return {
|
||||
description: sourceTool.description,
|
||||
handler,
|
||||
name: sourceTool.name,
|
||||
// OpenClaw owns its bridged tools by design (the harness docs:
|
||||
// "OpenClaw still owns ... OpenClaw dynamic tools (bridged)"). The bundled
|
||||
// Copilot CLI ships built-in tools whose names (edit, read, write, bash,
|
||||
// ...) collide with OpenClaw's coding-tool set. Mark every bridged tool as
|
||||
// an explicit override so the SDK accepts the registration rather than
|
||||
// throwing "External tool 'edit' conflicts with a built-in tool of the
|
||||
// same name." OpenClaw's tool layer is the source of truth for these
|
||||
// names within a copilot attempt.
|
||||
overridesBuiltInTool: true,
|
||||
parameters: sourceTool.parameters as Record<string, unknown> | undefined,
|
||||
// Bridged OpenClaw tools enforce their own permission/policy decisions
|
||||
// inside `wrapToolWithBeforeToolCallHook` (see
|
||||
// `src/agents/pi-tools.before-tool-call.ts` — the same hook PI itself
|
||||
// uses, providing loop detection, trusted plugin policies,
|
||||
// before-tool-call hooks, and two-phase plugin approvals via the
|
||||
// gateway). Asking the SDK to fire `onPermissionRequest` for
|
||||
// `kind: "custom-tool"` would either short-circuit OpenClaw's richer
|
||||
// enforcement (if we allow-all) or block every call (if we
|
||||
// reject-all) — neither matches PI parity. The in-tree codex harness
|
||||
// takes the same approach: bridged OpenClaw tools are wrapped with
|
||||
// `wrapToolWithBeforeToolCallHook` and the SDK gate is bypassed
|
||||
// (see `extensions/codex/src/app-server/dynamic-tools.ts`).
|
||||
skipPermission: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeCatalogTool(
|
||||
input: CopilotToolBridgeInput,
|
||||
params: CatalogExecuteParams,
|
||||
): Promise<Awaited<ReturnType<AnyAgentTool["execute"]>>> {
|
||||
const sourceTool = params.tool as AnyAgentTool;
|
||||
const startedAt = Date.now();
|
||||
let preparedArgs: unknown = params.input;
|
||||
try {
|
||||
preparedArgs = sourceTool.prepareArguments
|
||||
? sourceTool.prepareArguments(params.input)
|
||||
: params.input;
|
||||
const result = await sourceTool.execute(
|
||||
params.toolCallId,
|
||||
preparedArgs,
|
||||
params.signal ?? input.abortSignal,
|
||||
params.onUpdate,
|
||||
);
|
||||
const sanitizedResult = sanitizeToolResult(result);
|
||||
const isError = isToolResultError(sanitizedResult);
|
||||
input.attemptParams?.onAgentToolResult?.({
|
||||
toolName: params.toolName,
|
||||
result: sanitizedResult,
|
||||
isError,
|
||||
});
|
||||
await input.onToolCompleted?.({
|
||||
toolName: params.toolName,
|
||||
toolCallId: params.toolCallId,
|
||||
args: toToolStartArgs(preparedArgs),
|
||||
result: sanitizedResult,
|
||||
...(isError
|
||||
? { error: extractToolErrorMessage(sanitizedResult) ?? "tool returned an error" }
|
||||
: {}),
|
||||
startedAt,
|
||||
});
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const message = toError(error).message;
|
||||
const failure = sanitizeToolResult({
|
||||
content: [{ type: "text", text: message }],
|
||||
details: { status: "failed", error: message },
|
||||
});
|
||||
input.attemptParams?.onAgentToolResult?.({
|
||||
toolName: params.toolName,
|
||||
result: failure,
|
||||
isError: true,
|
||||
});
|
||||
await input.onToolCompleted?.({
|
||||
toolName: params.toolName,
|
||||
toolCallId: params.toolCallId,
|
||||
args: toToolStartArgs(preparedArgs),
|
||||
error: message,
|
||||
startedAt,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function toToolStartArgs(args: unknown): Record<string, unknown> {
|
||||
return args && typeof args === "object" && !Array.isArray(args)
|
||||
? (args as Record<string, unknown>)
|
||||
: { value: args };
|
||||
}
|
||||
|
||||
function agentToolResultToSdk(result: AgentToolResultLike | undefined): ToolResultObject {
|
||||
const content = result?.content;
|
||||
if (content == null) {
|
||||
return createSuccessResult("");
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return createUnsupportedContentFailure(typeof content);
|
||||
}
|
||||
|
||||
const textParts: string[] = [];
|
||||
const binaryResults: Array<Record<string, string>> = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
return createUnsupportedContentFailure(typeof block);
|
||||
}
|
||||
|
||||
const kind = readString((block as { type?: unknown }).type);
|
||||
if (kind === "text") {
|
||||
const text = readString((block as { text?: unknown }).text, { allowEmpty: true });
|
||||
if (text === undefined) {
|
||||
return createUnsupportedContentFailure(kind);
|
||||
}
|
||||
textParts.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kind === "image") {
|
||||
const base64Data = readString((block as { data?: unknown }).data);
|
||||
const mimeType = readString((block as { mimeType?: unknown }).mimeType);
|
||||
if (!base64Data || !mimeType) {
|
||||
return createUnsupportedContentFailure(kind);
|
||||
}
|
||||
binaryResults.push({
|
||||
base64Data,
|
||||
data: base64Data,
|
||||
mimeType,
|
||||
type: "image",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
return createUnsupportedContentFailure(kind ?? typeof block);
|
||||
}
|
||||
|
||||
return {
|
||||
...(binaryResults.length > 0
|
||||
? { binaryResultsForLlm: binaryResults as ToolResultObject["binaryResultsForLlm"] }
|
||||
: {}),
|
||||
resultType: "success",
|
||||
textResultForLlm: textParts.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function createUnsupportedContentFailure(kind: string): ToolResultObject {
|
||||
const message = `[copilot-tool-bridge] unsupported AgentToolResult content shape: ${kind}`;
|
||||
return createFailureResult(message, new Error(message));
|
||||
}
|
||||
|
||||
function createSuccessResult(textResultForLlm: string): ToolResultObject {
|
||||
return {
|
||||
resultType: "success",
|
||||
textResultForLlm,
|
||||
};
|
||||
}
|
||||
|
||||
function createFailureResult(message: string, error: unknown): ToolResultObject {
|
||||
// ToolResultObject.error is typed as `string | undefined` in the SDK contract
|
||||
// (see `node_modules/@github/copilot-sdk/dist/types.d.ts`). Returning an
|
||||
// Error object would produce a non-serializable JSON-RPC payload, so we
|
||||
// surface the message string instead.
|
||||
return {
|
||||
error: toError(error).message,
|
||||
resultType: "failure",
|
||||
textResultForLlm: message,
|
||||
};
|
||||
}
|
||||
|
||||
function createError(message: string, cause: unknown): Error {
|
||||
const error = new Error(message) as Error & { cause?: unknown };
|
||||
error.cause = cause;
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the attempt was launched as a raw-model run, which
|
||||
* suppresses tool construction in PI
|
||||
* (`src/agents/pi-embedded-runner/run/attempt.ts:1305-1310` and
|
||||
* `attempt-tool-construction-plan.ts:165-184`). A run is raw when the
|
||||
* caller explicitly sets `modelRun: true` or asks for no system prompt
|
||||
* via `promptMode: "none"`.
|
||||
*/
|
||||
function isCopilotRawModelRun(params: CopilotToolAttemptParams): boolean {
|
||||
return params.modelRun === true || params.promptMode === "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors PI's `shouldForceMessageTool` semantics: a message tool is
|
||||
* forced when the caller asked for it explicitly or when the source
|
||||
* reply delivery mode is `message_tool_only`, but never when
|
||||
* `disableMessageTool` is set (the suppress flag always wins). Compare
|
||||
* `src/agents/pi-embedded-runner/run/attempt.ts:1361-1366` and the
|
||||
* codex equivalent at
|
||||
* `extensions/codex/src/app-server/run-attempt.ts:4253-4258`.
|
||||
*/
|
||||
function shouldForceCopilotMessageTool(params: CopilotToolAttemptParams): boolean {
|
||||
if (params.disableMessageTool === true) {
|
||||
return false;
|
||||
}
|
||||
return params.forceMessageTool === true || params.sourceReplyDeliveryMode === "message_tool_only";
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors PI's `applyEmbeddedAttemptToolsAllow`
|
||||
* (`src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts`)
|
||||
* so final filtering keeps aliases, groups, plugin policies, and glob
|
||||
* semantics identical to the in-tree embedded runner.
|
||||
*/
|
||||
function filterCopilotToolsForAllowlist<T extends { name: string }>(
|
||||
tools: T[],
|
||||
toolsAllow?: string[],
|
||||
): T[] {
|
||||
return applyEmbeddedAttemptToolsAllow(tools, toolsAllow, {
|
||||
toolMeta: (tool) =>
|
||||
getPluginToolMeta(tool as unknown as AnyAgentTool) ?? readInlinePluginToolMeta(tool),
|
||||
});
|
||||
}
|
||||
|
||||
function filterCopilotToolsForConstructionPlan<T extends { name: string }>(
|
||||
tools: T[],
|
||||
plan: ReturnType<typeof resolveEmbeddedAttemptToolConstructionPlan>["codingToolConstructionPlan"],
|
||||
options: { preserveToolNames?: readonly string[] } = {},
|
||||
): T[] {
|
||||
if (plan.includeBaseCodingTools && plan.includeShellTools) {
|
||||
return tools;
|
||||
}
|
||||
const preserveToolNames = new Set(options.preserveToolNames);
|
||||
return tools.filter((tool) => {
|
||||
if (preserveToolNames.has(tool.name)) {
|
||||
return true;
|
||||
}
|
||||
if (!plan.includeBaseCodingTools && BASE_COPILOT_CODING_TOOL_NAMES.has(tool.name)) {
|
||||
return false;
|
||||
}
|
||||
if (!plan.includeShellTools && SHELL_COPILOT_CODING_TOOL_NAMES.has(tool.name)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function hasNonWildcardGlobAllowlist(toolsAllow: string[] | undefined): boolean {
|
||||
return (toolsAllow ?? []).some((entry) => {
|
||||
const trimmed = entry.trim();
|
||||
return trimmed !== "*" && trimmed.includes("*");
|
||||
});
|
||||
}
|
||||
|
||||
function readInlinePluginToolMeta(tool: { name: string }): { pluginId: string } | undefined {
|
||||
const pluginId = (tool as { pluginId?: unknown }).pluginId;
|
||||
return typeof pluginId === "string" && pluginId.trim() ? { pluginId } : undefined;
|
||||
}
|
||||
|
||||
function findDuplicateToolNames(sourceTools: AnyAgentTool[]): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const sourceTool of sourceTools) {
|
||||
if (typeof sourceTool.name !== "string" || sourceTool.name.length === 0) {
|
||||
continue;
|
||||
}
|
||||
counts.set(sourceTool.name, (counts.get(sourceTool.name) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([name]) => name)
|
||||
.toSorted();
|
||||
}
|
||||
|
||||
function readString(value: unknown, options: { allowEmpty?: boolean } = {}): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
if (options.allowEmpty || value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
253
extensions/copilot/src/usage-bridge.test.ts
Normal file
253
extensions/copilot/src/usage-bridge.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
// Copilot tests cover usage bridge plugin behavior.
|
||||
import type { NormalizedUsage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCopilotAssistantUsage,
|
||||
normalizeCopilotUsage,
|
||||
} from "./usage-bridge.js";
|
||||
|
||||
const ZERO_SNAPSHOT: NormalizedUsage = {
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
describe("usage-bridge", () => {
|
||||
describe("normalizeCopilotUsage", () => {
|
||||
it("normalizes SDK inputTokens and outputTokens into NormalizedUsage", () => {
|
||||
expect(normalizeCopilotUsage({ inputTokens: 10, outputTokens: 5 })).toEqual({
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
input: 10,
|
||||
output: 5,
|
||||
total: 15,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes SDK cacheReadTokens and cacheWriteTokens when present", () => {
|
||||
expect(normalizeCopilotUsage({ cacheReadTokens: 3, cacheWriteTokens: 4 })).toEqual({
|
||||
cacheRead: 3,
|
||||
cacheWrite: 4,
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
total: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves missing cache token fields undefined rather than zero", () => {
|
||||
const usage = normalizeCopilotUsage({ inputTokens: 2 });
|
||||
|
||||
expect(usage).toEqual({
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
input: 2,
|
||||
output: undefined,
|
||||
total: 2,
|
||||
});
|
||||
expect(usage?.cacheRead).toBeUndefined();
|
||||
expect(usage?.cacheWrite).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a defined zero-snapshot when SDK event is an object with no valid fields", () => {
|
||||
expect(normalizeCopilotUsage({})).toEqual(ZERO_SNAPSHOT);
|
||||
expect(normalizeCopilotUsage({ inputTokens: undefined })).toEqual(ZERO_SNAPSHOT);
|
||||
});
|
||||
|
||||
it("returns undefined for null / non-object input", () => {
|
||||
expect(normalizeCopilotUsage(null)).toBeUndefined();
|
||||
expect(normalizeCopilotUsage(undefined)).toBeUndefined();
|
||||
expect(normalizeCopilotUsage("usage")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores string-typed token counts", () => {
|
||||
expect(normalizeCopilotUsage({ inputTokens: "5" })).toEqual(ZERO_SNAPSHOT);
|
||||
});
|
||||
|
||||
it("ignores NaN and Infinity token counts", () => {
|
||||
expect(normalizeCopilotUsage({ inputTokens: Number.NaN })).toEqual(ZERO_SNAPSHOT);
|
||||
expect(normalizeCopilotUsage({ outputTokens: Number.POSITIVE_INFINITY })).toEqual(
|
||||
ZERO_SNAPSHOT,
|
||||
);
|
||||
expect(normalizeCopilotUsage({ cacheReadTokens: Number.NEGATIVE_INFINITY })).toEqual(
|
||||
ZERO_SNAPSHOT,
|
||||
);
|
||||
expect(normalizeCopilotUsage({ inputTokens: 2, outputTokens: Number.NaN })).toEqual({
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
input: 2,
|
||||
output: undefined,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps negative token counts to zero", () => {
|
||||
expect(normalizeCopilotUsage({ inputTokens: -3 })).toEqual({
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
input: 0,
|
||||
output: undefined,
|
||||
total: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("truncates fractional token counts", () => {
|
||||
expect(normalizeCopilotUsage({ inputTokens: 3.7 })).toEqual({
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
input: 3,
|
||||
output: undefined,
|
||||
total: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives total from normalized SDK component counts for compatibility", () => {
|
||||
expect(
|
||||
normalizeCopilotUsage({
|
||||
cacheReadTokens: 3,
|
||||
cacheWriteTokens: 4,
|
||||
inputTokens: 1,
|
||||
outputTokens: 2,
|
||||
}),
|
||||
).toEqual({
|
||||
cacheRead: 3,
|
||||
cacheWrite: 4,
|
||||
input: 1,
|
||||
output: 2,
|
||||
total: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the caller-provided SDK event data", () => {
|
||||
const data = Object.freeze({ inputTokens: 4, outputTokens: 6 });
|
||||
|
||||
expect(normalizeCopilotUsage(data)).toEqual({
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
input: 4,
|
||||
output: 6,
|
||||
total: 10,
|
||||
});
|
||||
expect(data).toEqual({ inputTokens: 4, outputTokens: 6 });
|
||||
});
|
||||
|
||||
it("only whitelists known SDK fields and ignores unrelated input keys", () => {
|
||||
expect(
|
||||
normalizeCopilotUsage({
|
||||
inputTokens: 5,
|
||||
malicious_field: 999,
|
||||
outputTokens: "bad",
|
||||
prompt_tokens: 100,
|
||||
}),
|
||||
).toEqual({
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
input: 5,
|
||||
output: undefined,
|
||||
total: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCopilotAssistantUsage", () => {
|
||||
it("builds rich AssistantMessage usage with zero cost fields", () => {
|
||||
expect(
|
||||
buildCopilotAssistantUsage({
|
||||
usage: { cacheRead: 3, cacheWrite: 4, input: 1, output: 2, total: 10 },
|
||||
}),
|
||||
).toEqual({
|
||||
cacheRead: 3,
|
||||
cacheWrite: 4,
|
||||
cost: {
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
},
|
||||
input: 1,
|
||||
output: 2,
|
||||
totalTokens: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults missing usage fields to zero in the rich block only", () => {
|
||||
expect(
|
||||
buildCopilotAssistantUsage({
|
||||
usage: { input: 4 },
|
||||
}),
|
||||
).toEqual({
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: {
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
},
|
||||
input: 4,
|
||||
output: 0,
|
||||
totalTokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses fallback outputTokens when no usage event was captured", () => {
|
||||
expect(buildCopilotAssistantUsage({ fallbackOutputTokens: 7 })).toEqual({
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: {
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
},
|
||||
input: 0,
|
||||
output: 7,
|
||||
totalTokens: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not use fallback outputTokens when normalized usage is already present", () => {
|
||||
expect(
|
||||
buildCopilotAssistantUsage({
|
||||
fallbackOutputTokens: 9,
|
||||
usage: { input: 4, total: 4 },
|
||||
}),
|
||||
).toEqual({
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: {
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
},
|
||||
input: 4,
|
||||
output: 0,
|
||||
totalTokens: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an all-zero block when both usage and fallback are missing", () => {
|
||||
expect(buildCopilotAssistantUsage({})).toEqual({
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: {
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
},
|
||||
input: 0,
|
||||
output: 0,
|
||||
totalTokens: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
74
extensions/copilot/src/usage-bridge.ts
Normal file
74
extensions/copilot/src/usage-bridge.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copilot plugin module implements usage bridge behavior.
|
||||
import type { AgentMessage, NormalizedUsage } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
|
||||
type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
|
||||
type AssistantUsage = NonNullable<AssistantMessage["usage"]>;
|
||||
|
||||
type CopilotUsageSource = {
|
||||
cacheReadTokens?: unknown;
|
||||
cacheWriteTokens?: unknown;
|
||||
inputTokens?: unknown;
|
||||
outputTokens?: unknown;
|
||||
};
|
||||
|
||||
export type CopilotUsageSnapshot = NormalizedUsage;
|
||||
|
||||
function isCopilotUsageSource(data: unknown): data is CopilotUsageSource {
|
||||
return typeof data === "object" && data !== null;
|
||||
}
|
||||
|
||||
function buildZeroCost(): AssistantUsage["cost"] {
|
||||
return {
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function coerceTokenCount(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? Math.max(0, Math.trunc(value))
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function normalizeCopilotUsage(data: unknown): NormalizedUsage | undefined {
|
||||
if (!isCopilotUsageSource(data)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// SDK usage events only expose these four fields. Keep coercion identical to
|
||||
// the prior event-bridge implementation so invalid object-shaped events still
|
||||
// overwrite state with the legacy all-zero snapshot.
|
||||
const input = coerceTokenCount(data.inputTokens);
|
||||
const output = coerceTokenCount(data.outputTokens);
|
||||
const cacheRead = coerceTokenCount(data.cacheReadTokens);
|
||||
const cacheWrite = coerceTokenCount(data.cacheWriteTokens);
|
||||
const total = (input ?? 0) + (output ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0);
|
||||
|
||||
return {
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
input,
|
||||
output,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCopilotAssistantUsage(params: {
|
||||
usage?: NormalizedUsage;
|
||||
fallbackOutputTokens?: unknown;
|
||||
}): AssistantMessage["usage"] {
|
||||
const usage =
|
||||
params.usage ?? normalizeCopilotUsage({ outputTokens: params.fallbackOutputTokens });
|
||||
|
||||
return {
|
||||
cacheRead: usage?.cacheRead ?? 0,
|
||||
cacheWrite: usage?.cacheWrite ?? 0,
|
||||
cost: buildZeroCost(),
|
||||
input: usage?.input ?? 0,
|
||||
output: usage?.output ?? 0,
|
||||
totalTokens: usage?.total ?? 0,
|
||||
};
|
||||
}
|
||||
121
extensions/copilot/src/user-input-bridge.test.ts
Normal file
121
extensions/copilot/src/user-input-bridge.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copilot tests cover SDK ask_user bridge behavior.
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCopilotUserInputBridge } from "./user-input-bridge.js";
|
||||
|
||||
function createParams(): EmbeddedRunAttemptParams {
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
onBlockReply: vi.fn(),
|
||||
} as unknown as EmbeddedRunAttemptParams;
|
||||
}
|
||||
|
||||
function expectFirstBlockReplyText(params: EmbeddedRunAttemptParams): string {
|
||||
const onBlockReply = params.onBlockReply;
|
||||
if (!onBlockReply) {
|
||||
throw new Error("Expected onBlockReply callback");
|
||||
}
|
||||
const payload = vi.mocked(onBlockReply).mock.calls[0]?.[0];
|
||||
if (typeof payload?.text !== "string") {
|
||||
throw new Error("Expected first block reply text");
|
||||
}
|
||||
return payload.text;
|
||||
}
|
||||
|
||||
describe("Copilot user input bridge", () => {
|
||||
it("prompts through OpenClaw and resolves the SDK request from the next queued message", async () => {
|
||||
const params = createParams();
|
||||
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
|
||||
|
||||
const response = bridge.onUserInputRequest(
|
||||
{
|
||||
question: "Pick a mode",
|
||||
choices: ["Fast", "Deep"],
|
||||
allowFreeform: false,
|
||||
},
|
||||
{ sessionId: "sdk-session-1" },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
|
||||
expect(expectFirstBlockReplyText(params)).toContain("Pick a mode");
|
||||
expect(bridge.handleQueuedMessage("2")).toBe(true);
|
||||
|
||||
await expect(response).resolves.toEqual({ answer: "Deep", wasFreeform: false });
|
||||
});
|
||||
|
||||
it("returns free-form answers when Copilot allows them", async () => {
|
||||
const params = createParams();
|
||||
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
|
||||
|
||||
const response = bridge.onUserInputRequest(
|
||||
{
|
||||
question: "Which branch?",
|
||||
allowFreeform: true,
|
||||
},
|
||||
{ sessionId: "sdk-session-1" },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
|
||||
expect(bridge.handleQueuedMessage("fix/harness-parity")).toBe(true);
|
||||
|
||||
await expect(response).resolves.toEqual({
|
||||
answer: "fix/harness-parity",
|
||||
wasFreeform: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("escapes SDK-controlled prompt text before channel delivery", async () => {
|
||||
const params = createParams();
|
||||
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
|
||||
|
||||
void bridge.onUserInputRequest(
|
||||
{
|
||||
question: "Pick [trusted](https://evil) <@U123> @here\u202e",
|
||||
choices: ["One @everyone", "Two `code`"],
|
||||
allowFreeform: false,
|
||||
},
|
||||
{ sessionId: "sdk-session-1" },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
|
||||
const text = expectFirstBlockReplyText(params);
|
||||
expect(text).not.toContain("@here");
|
||||
expect(text).not.toContain("@everyone");
|
||||
expect(text).not.toContain("<@U123>");
|
||||
expect(text).not.toContain("[trusted](https://evil)");
|
||||
expect(text).not.toContain("`code`");
|
||||
expect(text).toContain("\uff20here");
|
||||
expect(text).toContain("\uff3btrusted\uff3d");
|
||||
});
|
||||
|
||||
it("rejects queued messages when no ask_user request is pending", () => {
|
||||
const bridge = createCopilotUserInputBridge({ paramsForRun: createParams() });
|
||||
|
||||
expect(bridge.handleQueuedMessage("late")).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves pending requests with an empty answer when aborted", async () => {
|
||||
const params = createParams();
|
||||
const controller = new AbortController();
|
||||
const bridge = createCopilotUserInputBridge({
|
||||
paramsForRun: params,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const response = bridge.onUserInputRequest(
|
||||
{
|
||||
question: "Continue?",
|
||||
choices: ["Yes", "No"],
|
||||
allowFreeform: false,
|
||||
},
|
||||
{ sessionId: "sdk-session-1" },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
|
||||
controller.abort();
|
||||
|
||||
await expect(response).resolves.toEqual({ answer: "", wasFreeform: true });
|
||||
expect(bridge.handleQueuedMessage("1")).toBe(false);
|
||||
});
|
||||
});
|
||||
161
extensions/copilot/src/user-input-bridge.ts
Normal file
161
extensions/copilot/src/user-input-bridge.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { SessionConfig } from "@github/copilot-sdk";
|
||||
import {
|
||||
buildAgentHarnessUserInputAnswers,
|
||||
deliverAgentHarnessUserInputPrompt,
|
||||
embeddedAgentLog,
|
||||
type AgentHarnessUserInputQuestion,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
|
||||
type PendingCopilotUserInput = {
|
||||
question: AgentHarnessUserInputQuestion;
|
||||
resolve: (value: CopilotUserInputResponse) => void;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
type CopilotUserInputHandler = NonNullable<SessionConfig["onUserInputRequest"]>;
|
||||
type CopilotUserInputRequest = Parameters<CopilotUserInputHandler>[0];
|
||||
type CopilotUserInputResponse = Awaited<ReturnType<CopilotUserInputHandler>>;
|
||||
|
||||
type CopilotUserInputBridge = {
|
||||
onUserInputRequest: CopilotUserInputHandler;
|
||||
handleQueuedMessage: (text: string) => boolean;
|
||||
cancelPending: () => void;
|
||||
};
|
||||
|
||||
const COPILOT_USER_INPUT_QUESTION_ID = "answer";
|
||||
|
||||
export function createCopilotUserInputBridge(params: {
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
signal?: AbortSignal;
|
||||
}): CopilotUserInputBridge {
|
||||
let pending: PendingCopilotUserInput | undefined;
|
||||
|
||||
const resolvePending = (value: CopilotUserInputResponse) => {
|
||||
const current = pending;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
pending = undefined;
|
||||
current.cleanup();
|
||||
current.resolve(value);
|
||||
};
|
||||
|
||||
return {
|
||||
onUserInputRequest(request) {
|
||||
const question = toQuestion(request);
|
||||
resolvePending(emptyCopilotUserInputResponse());
|
||||
return new Promise<CopilotUserInputResponse>((resolve) => {
|
||||
const abortListener = () => resolvePending(emptyCopilotUserInputResponse());
|
||||
const cleanup = () => params.signal?.removeEventListener("abort", abortListener);
|
||||
pending = { question, resolve, cleanup };
|
||||
params.signal?.addEventListener("abort", abortListener, { once: true });
|
||||
if (params.signal?.aborted) {
|
||||
resolvePending(emptyCopilotUserInputResponse());
|
||||
return;
|
||||
}
|
||||
void deliverAgentHarnessUserInputPrompt(params.paramsForRun, [question], {
|
||||
intro: "Copilot needs input:",
|
||||
formatText: formatCopilotDisplayText,
|
||||
}).catch((error: unknown) => {
|
||||
embeddedAgentLog.warn("failed to deliver copilot user input prompt", { error });
|
||||
});
|
||||
});
|
||||
},
|
||||
handleQueuedMessage(text) {
|
||||
const current = pending;
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
resolvePending(buildCopilotUserInputResponse(current.question, text));
|
||||
return true;
|
||||
},
|
||||
cancelPending() {
|
||||
resolvePending(emptyCopilotUserInputResponse());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toQuestion(request: CopilotUserInputRequest): AgentHarnessUserInputQuestion {
|
||||
return {
|
||||
id: COPILOT_USER_INPUT_QUESTION_ID,
|
||||
header: "Copilot needs input",
|
||||
question: request.question,
|
||||
isOther: request.allowFreeform !== false,
|
||||
isSecret: false,
|
||||
options:
|
||||
request.choices && request.choices.length > 0
|
||||
? request.choices.map((choice: string) => ({ label: choice }))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCopilotUserInputResponse(
|
||||
question: AgentHarnessUserInputQuestion,
|
||||
inputText: string,
|
||||
): CopilotUserInputResponse {
|
||||
const rawAnswers = buildAgentHarnessUserInputAnswers([question], inputText);
|
||||
const selected = rawAnswers.answers[COPILOT_USER_INPUT_QUESTION_ID]?.answers[0] ?? "";
|
||||
return {
|
||||
answer: selected,
|
||||
wasFreeform: !isChoiceAnswer(question, selected),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyCopilotUserInputResponse(): CopilotUserInputResponse {
|
||||
return { answer: "", wasFreeform: true };
|
||||
}
|
||||
|
||||
function isChoiceAnswer(question: AgentHarnessUserInputQuestion, answer: string): boolean {
|
||||
return Boolean(
|
||||
answer &&
|
||||
question.options?.some((option) => option.label.toLowerCase() === answer.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
function formatCopilotDisplayText(value: string): string {
|
||||
const safe = sanitizeCopilotDisplayText(value).trim();
|
||||
return escapeCopilotChatText(safe || "<unknown>");
|
||||
}
|
||||
|
||||
function sanitizeCopilotDisplayText(value: string): string {
|
||||
let safe = "";
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0);
|
||||
safe += codePoint != null && isUnsafeDisplayCodePoint(codePoint) ? "?" : character;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
function escapeCopilotChatText(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("@", "\uff20")
|
||||
.replaceAll("`", "\uff40")
|
||||
.replaceAll("[", "\uff3b")
|
||||
.replaceAll("]", "\uff3d")
|
||||
.replaceAll("(", "\uff08")
|
||||
.replaceAll(")", "\uff09")
|
||||
.replaceAll("*", "\u2217")
|
||||
.replaceAll("_", "\uff3f")
|
||||
.replaceAll("~", "\uff5e")
|
||||
.replaceAll("|", "\uff5c");
|
||||
}
|
||||
|
||||
function isUnsafeDisplayCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
codePoint <= 0x001f ||
|
||||
(codePoint >= 0x007f && codePoint <= 0x009f) ||
|
||||
codePoint === 0x00ad ||
|
||||
codePoint === 0x061c ||
|
||||
codePoint === 0x180e ||
|
||||
(codePoint >= 0x200b && codePoint <= 0x200f) ||
|
||||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
||||
(codePoint >= 0x2060 && codePoint <= 0x206f) ||
|
||||
codePoint === 0xfeff ||
|
||||
(codePoint >= 0xfff9 && codePoint <= 0xfffb) ||
|
||||
(codePoint >= 0xe0000 && codePoint <= 0xe007f)
|
||||
);
|
||||
}
|
||||
269
extensions/copilot/src/workspace-bootstrap.test.ts
Normal file
269
extensions/copilot/src/workspace-bootstrap.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
// Copilot tests cover workspace bootstrap plugin behavior.
|
||||
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);
|
||||
});
|
||||
});
|
||||
256
extensions/copilot/src/workspace-bootstrap.ts
Normal file
256
extensions/copilot/src/workspace-bootstrap.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// Copilot plugin module implements workspace bootstrap behavior.
|
||||
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,
|
||||
};
|
||||
16
extensions/copilot/tsconfig.json
Normal file
16
extensions/copilot/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user