feat(copilot): add GitHub Copilot agent runtime

Adds the opt-in bundled GitHub Copilot agent runtime, pinned SDK install path, docs/inventory, SDK/tool/sandbox/auth wiring, and replay/tool-safety fixes.

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

Co-authored-by: Ramraj Balasubramanian <ramrajba@microsoft.com>
This commit is contained in:
Ramrajprabu
2026-05-28 21:15:22 -07:00
committed by GitHub
parent 15772c527a
commit f3cfd752d3
114 changed files with 18524 additions and 228 deletions

5
.github/labeler.yml vendored
View File

@@ -405,6 +405,11 @@
- "extensions/codex-supervisor/**"
- "docs/plugins/reference/codex-supervisor.md"
- "docs/specs/claw-supervisor.md"
"extensions: copilot":
- changed-files:
- any-glob-to-any-file:
- "extensions/copilot/**"
- "docs/plugins/copilot.md"
"extensions: kimi-coding":
- changed-files:
- any-glob-to-any-file:

1
.gitignore vendored
View File

@@ -178,6 +178,7 @@ mantis/
/local/
/client_secret_*.json
package-lock.json
!src/commands/copilot-sdk-install-manifest/package-lock.json
.claude/
.agent/
skills-lock.json

View File

@@ -1,2 +1,2 @@
91cb45dc1e8aaa3dac9a2c1d3c98c8ff22112e41c305de17f30d0d4420635ee4 plugin-sdk-api-baseline.json
3aa4802ffcb68c4f15e367030994eae10e73b55b5f14c8e23d4e9467fae325fe plugin-sdk-api-baseline.jsonl
28bbd7e0a05747ef3d17ae25e6dac5002d6cc9ad3256f1c4e58ee8e45014e397 plugin-sdk-api-baseline.json
d1d3fe6599e6cbc64f069737d08099d6b2586bc2b9d8d2ddb00d9f6e35c87cc7 plugin-sdk-api-baseline.jsonl

View File

@@ -175,6 +175,26 @@
"source": "Agent harness plugins",
"target": "Agent harness plugins"
},
{
"source": "Agent harness plugins (SDK reference)",
"target": "Agent harness plugins (SDK reference)"
},
{
"source": "Copilot SDK harness",
"target": "Copilot SDK harness"
},
{
"source": "Copilot plugin",
"target": "Copilot plugin"
},
{
"source": "GitHub Copilot agent runtime",
"target": "GitHub Copilot agent runtime"
},
{
"source": "copilot",
"target": "copilot"
},
{
"source": "Agent loop",
"target": "Agent loop"

View File

@@ -14,12 +14,12 @@ the finished turn to OpenClaw.
Runtimes are easy to confuse with providers because both show up near model
configuration. They are different layers:
| Layer | Examples | What it means |
| ------------- | ------------------------------------- | ------------------------------------------------------------------- |
| Provider | `openai`, `anthropic`, `openai-codex` | How OpenClaw authenticates, discovers models, and names model refs. |
| Model | `gpt-5.5`, `claude-opus-4-6` | The model selected for the agent turn. |
| Agent runtime | `openclaw`, `codex`, `claude-cli` | The low level loop or backend that executes the prepared turn. |
| Channel | Telegram, Discord, Slack, WhatsApp | Where messages enter and leave OpenClaw. |
| Layer | Examples | What it means |
| ------------- | -------------------------------------------- | ------------------------------------------------------------------- |
| Provider | `openai`, `anthropic`, `openai-codex` | How OpenClaw authenticates, discovers models, and names model refs. |
| Model | `gpt-5.5`, `claude-opus-4-6` | The model selected for the agent turn. |
| Agent runtime | `openclaw`, `codex`, `copilot`, `claude-cli` | The low level loop or backend that executes the prepared turn. |
| Channel | Telegram, Discord, Slack, WhatsApp | Where messages enter and leave OpenClaw. |
You will also see the word **harness** in code. A harness is the implementation
that provides an agent runtime. For example, the bundled Codex harness
@@ -33,13 +33,17 @@ There are two runtime families:
- **Embedded harnesses** run inside OpenClaw's prepared agent loop. Today this
is the built-in `openclaw` runtime plus registered plugin harnesses such as
`codex`.
`codex` and `copilot`.
- **CLI backends** run a local CLI process while keeping the model ref
canonical. For example, `anthropic/claude-opus-4-7` with
a model-scoped `agentRuntime.id: "claude-cli"` means "select the Anthropic
model, execute through Claude CLI." `claude-cli` is not an embedded harness id
and must not be passed to AgentHarness selection.
The `copilot` harness is a separate, opt-in plugin harness for the
GitHub Copilot CLI; see [GitHub Copilot agent runtime](/plugins/copilot)
for the user-facing decision between PI, Codex, and GitHub Copilot agent runtime.
## Codex surfaces
Most confusion comes from several different surfaces sharing the Codex name:
@@ -201,6 +205,34 @@ If `openclaw doctor` warns that the `codex` plugin is enabled while
`openai-codex/*` remains in config, treat that as legacy route state. Run
`openclaw doctor --fix` to rewrite it to `openai/*` with the Codex runtime.
## GitHub Copilot agent runtime
The bundled `copilot` extension registers an opt-in `copilot` runtime
backed by the GitHub Copilot CLI (`@github/copilot-sdk`). It claims the
canonical subscription `github-copilot` provider and is **never** selected by
`auto`. Opt in per-model or per-provider via `agentRuntime.id`:
```json5
{
agents: {
defaults: {
model: "github-copilot/gpt-5.5",
models: {
"github-copilot/gpt-5.5": {
agentRuntime: { id: "copilot" },
},
},
},
},
}
```
The harness claims its provider, runtime, CLI session key, and auth profile
prefix in `extensions/copilot/doctor-contract-api.ts`, which
`openclaw doctor` auto-loads. For configuration, auth, transcript mirroring,
compaction, the doctor probe surface, and the broader PI vs Codex vs Copilot
SDK decision, see [GitHub Copilot agent runtime](/plugins/copilot).
## Compatibility contract
When a runtime is not OpenClaw, it should document what OpenClaw surfaces it supports.
@@ -236,6 +268,7 @@ runtime policy first. Legacy session runtime pins no longer decide routing.
- [Codex harness](/plugins/codex-harness)
- [Codex harness runtime](/plugins/codex-harness-runtime)
- [GitHub Copilot agent runtime](/plugins/copilot)
- [OpenAI](/providers/openai)
- [Agent harness plugins](/plugins/sdk-agent-harness)
- [Agent loop](/concepts/agent-loop)

View File

@@ -23,7 +23,7 @@ sidebarTitle: "Models CLI"
</Card>
</CardGroup>
Model refs choose a provider and model. They do not usually choose the low-level agent runtime. OpenAI agent refs are the main exception: `openai/gpt-5.5` runs through the Codex app-server runtime by default on the official OpenAI provider. Explicit runtime overrides belong on provider/model policy, not on the whole agent or session. In Codex runtime mode, the `openai/gpt-*` ref does not imply API-key billing; auth can come from a Codex account or `openai-codex` auth profile. See [Agent runtimes](/concepts/agent-runtimes).
Model refs choose a provider and model. They do not usually choose the low-level agent runtime. OpenAI agent refs are the main exception: `openai/gpt-5.5` runs through the Codex app-server runtime by default on the official OpenAI provider. Subscription Copilot refs (`github-copilot/*`) can additionally be opted into the bundled GitHub Copilot agent runtime — that path stays explicit (no `auto` fallback). Explicit runtime overrides belong on provider/model policy, not on the whole agent or session. In Codex runtime mode, the `openai/gpt-*` ref does not imply API-key billing; auth can come from a Codex account or `openai-codex` auth profile. See [Agent runtimes](/concepts/agent-runtimes) and [GitHub Copilot agent runtime](/plugins/copilot).
## How model selection works

374
docs/plugins/copilot.md Executable file
View File

@@ -0,0 +1,374 @@
---
summary: "Run OpenClaw embedded agent turns through the bundled GitHub Copilot SDK harness"
title: "Copilot SDK harness"
read_when:
- You want to use the bundled GitHub Copilot SDK harness for an agent
- You need configuration examples for the `copilot` runtime
- You are wiring an agent to subscription Copilot (github / openclaw / copilot) and want it to run through the Copilot CLI
---
The bundled `copilot` extension lets OpenClaw run embedded subscription
Copilot agent turns through the GitHub Copilot CLI (`@github/copilot-sdk`)
instead of the built-in PI harness.
Use the Copilot SDK harness when you want the Copilot CLI session to own the
low-level agent loop: native tool execution, native compaction
(`infiniteSessions`), and CLI-managed thread state under `copilotHome`.
OpenClaw still owns chat channels, session files, model selection, OpenClaw
dynamic tools (bridged), approvals, media delivery, the visible transcript
mirror, `/btw` side questions (handled by the in-tree PI fallback — see
[Side questions (`/btw`)](#side-questions-btw)), and `openclaw doctor`.
For the broader model/provider/runtime split, start with
[Agent runtimes](/concepts/agent-runtimes).
## Requirements
- OpenClaw with the bundled `copilot` extension available.
- If your config uses `plugins.allow`, include `copilot` (the manifest
id in `extensions/copilot/openclaw.plugin.json`). A restrictive
allowlist that uses the npm-style `@openclaw/copilot` package name
will leave the bundled plugin blocked and the runtime will not load
even with `agentRuntime.id: "copilot"`.
- A GitHub Copilot subscription that can drive the Copilot CLI (or a
`gitHubToken` env / auth-profile entry for headless / cron runs).
- A writable `copilotHome` directory. The harness defaults to
`~/.openclaw/agents/<agentId>/copilot` for full per-agent isolation. The
platform default (`%APPDATA%\copilot` on Windows, `$XDG_CONFIG_HOME/copilot`
or `~/.config/copilot` elsewhere) is used as the doctor probe fallback when
no explicit home is set.
`openclaw doctor` runs the bundled
[doctor contract](#doctor-and-probes) for the extension; failures there are
the canonical way to confirm the environment is ready before opting an agent
in.
## On-demand SDK install
The Copilot agent runtime ships its small TypeScript code bundled inside
the openclaw tarball, but the underlying `@github/copilot-sdk` package
(and its platform-specific `@github/copilot-<platform>-<arch>` CLI
binary) is **not** installed by default — together they add ~260 MB to
your openclaw install footprint, and most openclaw users do not select
a Copilot model.
The wizard offers to install the SDK the first time you select a
`github-copilot/*` model **and** your config opts the model (or its
provider) into the Copilot agent runtime via
`agentRuntime: { id: "copilot" }` (see [Quickstart](#quickstart) below).
Without the opt-in, openclaw uses its built-in GitHub Copilot provider
and never prompts for the SDK install:
```
The Copilot agent runtime needs @github/copilot-sdk (~260 MB on first
install, downloads the @github/copilot CLI binary for your platform).
Install now? [Y/n]
```
If you accept, the SDK is installed into
`~/.openclaw/npm-runtime/copilot/` and detected on subsequent runs. The
install runs `npm ci` against a checked-in `package-lock.json` shipped
with openclaw at
`src/commands/copilot-sdk-install-manifest/package-lock.json`, so the
exact transitive graph reviewed for this release lands on disk on every
user machine.
If you decline, the runtime will fail at first invocation with an
actionable install message; re-run `openclaw setup` to retry the install
(or copy the pinned manifest into `~/.openclaw/npm-runtime/copilot/` and
run `npm ci` yourself if you need to install offline).
The runtime resolves the SDK in this order:
1. `import("@github/copilot-sdk")` against the host openclaw install
(covers source/dev checkouts and any environment that pre-installs
the SDK alongside openclaw).
2. The well-known fallback dir `~/.openclaw/npm-runtime/copilot/` (the
wizard install target).
A missing SDK surfaces a single error with code `COPILOT_SDK_MISSING`
and the manual install command above.
## Quickstart
Pin one model (or one provider) to the harness:
```json5
{
agents: {
defaults: {
model: "github-copilot/gpt-5.5",
models: {
"github-copilot/gpt-5.5": {
agentRuntime: { id: "copilot" },
},
},
},
},
}
```
Both routes are equivalent. Use `agentRuntime.id` on a single model entry
when only that model should be routed through the harness; set
`agentRuntime.id` on a provider when every model under that provider should
use it.
## Supported providers
The harness advertises support for the canonical `github-copilot` provider
(the same id owned by `extensions/github-copilot`):
- `github-copilot`
Anything outside that set falls through `selection.ts`'s `auto_pi` branch back
to PI.
## Auth
Per-agent precedence, applied during `runCopilotAttempt`:
1. **Explicit `useLoggedInUser: true`** on the attempt input. Uses the Copilot
CLI's logged-in user resolved under the agent's `copilotHome`.
2. **Explicit `gitHubToken`** on the attempt input (with `profileId` +
`profileVersion`). Useful for direct CLI invocations and tests where the
caller wants to bypass auth-profile resolution.
3. **Contract-resolved `resolvedApiKey` + `authProfileId`** from the
`EmbeddedRunAttemptParams` shape. This is the **production main path**:
core resolves the agent's configured `github-copilot` auth profile
(via `src/infra/provider-usage.auth.ts:resolveProviderAuths`) before
invoking the harness, and the harness consumes both fields directly.
This makes a `github-copilot:<profile>` auth profile work end-to-end
for headless / cron / multi-profile setups without env vars.
4. **Env-var fallback** for direct CLI / dogfood runs where no auth
profile is configured. The runtime checks the following vars in
precedence order, mirroring the shipped `github-copilot` provider
(`extensions/github-copilot/auth.ts`) and the documented Copilot SDK
setup:
1. `OPENCLAW_GITHUB_TOKEN` -- harness-specific override; set this
to pin a token for the OpenClaw harness without disturbing
system-wide `gh` / Copilot CLI config.
2. `COPILOT_GITHUB_TOKEN` -- standard Copilot SDK / CLI env var.
3. `GH_TOKEN` -- standard `gh` CLI env var (matches the existing
`github-copilot` provider precedence).
4. `GITHUB_TOKEN` -- generic GitHub token fallback.
The first non-empty value wins; empty strings are treated as
absent. The synthesised pool profile id is `env:<NAME>` and the
profileVersion is a non-reversible sha256 fingerprint of the
token, so rotating the env value cleanly busts the client pool.
5. **Default `useLoggedInUser`** when no token signal is available.
Each agent gets a dedicated `copilotHome` so Copilot CLI tokens, sessions, and
config do not leak between agents on the same machine. The default is
`<agentDir>/copilot` when the host hands the harness an agent directory
(isolating SDK state from OpenClaw's `models.json` / `auth-profiles.json` in
the same directory), or `~/.openclaw/agents/<agentId>/copilot` otherwise.
Override with `copilotHome: <path>` on the attempt input when you need a
custom location (for example, a shared mount for migration).
`probeCopilotAuthShape` (see [Doctor and probes](#doctor-and-probes)) is the
pure shape check that validates which of the modes above will be used.
It does not perform a live SDK handshake.
## Configuration surface
The harness reads its config from per-attempt input
(`runCopilotAttempt({...})`) plus a small set of env defaults inside
`extensions/copilot/src/`:
- `copilotHome` — per-agent CLI state directory (defaults documented above).
- `model` — string or `{ provider, id, api? }`. When omitted, OpenClaw uses
the agent's normal model selection and the harness verifies the resolved
provider is in the supported set.
- `reasoningEffort``"low" | "medium" | "high" | "xhigh"`. Maps from
OpenClaw's `ThinkLevel` / `ReasoningLevel` resolution in
`auto-reply/thinking.ts`.
- `infiniteSessionConfig` — optional override for the SDK
`infiniteSessions` block driven by `harness.compact`. Defaults are safe to
leave as-is.
- `hooksConfig` — optional bridge config exposing OpenClaw
before/after-message-write hooks to the SDK loop.
- `permissionPolicy` — optional override for the SDK's
`onPermissionRequest` handler used for built-in SDK tool kinds
(`shell`, `write`, `read`, `url`, `mcp`, `memory`, `hook`). Defaults
to `rejectAllPolicy` as a safety net; in practice the SDK never
invokes any of those kinds because every bridged OpenClaw tool is
registered with `overridesBuiltInTool: true` and
`skipPermission: true` so 100% of tool calls flow through OpenClaw's
wrapped `execute()`. See [Permissions and ask_user](#permissions-and-ask_user).
- `enableSessionTelemetry` — opt-in OpenTelemetry routing via
`telemetry-bridge.ts`.
Nothing in the rest of OpenClaw needs to know about these fields. Other
plugins, channels, and core code only see the standard
`AgentHarnessAttemptParams` / `AgentHarnessAttemptResult` shape.
## Compaction
When `harness.compact` runs, the Copilot SDK harness:
1. Enables `infiniteSessions` on the SDK session.
2. Lets the SDK perform its native compaction.
3. Writes an OpenClaw-shaped marker at
`workspacePath/files/openclaw-compaction-<ts>.json` so existing OpenClaw
transcript readers still see a familiar artifact.
The OpenClaw side transcript mirror (see below) continues to receive the
post-compaction messages, so user-facing chat history stays consistent.
## Transcript mirroring
`runCopilotAttempt` dual-writes each turn's mirrorable messages into the
OpenClaw audit transcript via
`extensions/copilot/src/dual-write-transcripts.ts`. The mirror is
per-session scoped (`copilot:${sessionId}`) and uses a per-message
identity (`${role}:${sha256_16(role,content)}`) so re-emits of prior-turn
entries collide with existing on-disk keys and do not duplicate.
The mirror is wrapped in two layers of failure containment so a transcript
write failure cannot fail the attempt: an internal best-effort wrapper and a
defense-in-depth `.catch(...)` at the attempt level. Failures are logged but
not surfaced.
## Side questions (`/btw`)
`/btw` is **not** native on this harness. `createCopilotAgentHarness()`
deliberately leaves `harness.runSideQuestion` undefined, so OpenClaw's `/btw`
dispatcher (`src/agents/btw.ts`) falls through to the same in-tree PI fallback
path it uses for every non-Codex runtime: the configured model provider is
called directly with a short side-question prompt and streamed back via
`streamSimple` (no CLI session, no extra pool slot).
This keeps Copilot CLI sessions reserved for the agent's main turn loop, and
keeps `/btw` behavior identical to other PI-backed runtimes. The contract is
asserted in
[`extensions/copilot/harness.test.ts`](https://github.com/openclaw/openclaw/blob/main/extensions/copilot/harness.test.ts)
under `describe("runSideQuestion")`.
## Doctor and probes
`extensions/copilot/doctor-contract-api.ts` is auto-loaded by
`src/plugins/doctor-contract-registry.ts`. It contributes:
- An empty `legacyConfigRules` (no retired fields at MVP).
- A no-op `normalizeCompatibilityConfig` (kept so future field retirements
have a stable in-tree home).
- One `sessionRouteStateOwners` entry claiming provider `github-copilot`;
runtime `copilot`; CLI session key `copilot`; auth profile
prefix `github-copilot:`.
`extensions/copilot/src/doctor-probes.ts` exports three imperative probes
that hosts (including `openclaw doctor`) can call to verify the environment:
| Probe | What it checks | Reasons it can fail |
| -------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `probeCopilotCliVersion` | `copilot --version` exits 0 with a non-empty version string | `non-zero-exit`, `empty-version`, `spawn-failed`, `spawn-error`, `probe-timeout` |
| `probeCopilotHomeWritable` | `mkdir -p copilotHome` + write + rm a marker file | `copilothome-not-writable` (with the underlying fs error in `details.rawError`) |
| `probeCopilotAuthShape` | At least one of `useLoggedInUser`, `gitHubToken`, or `profileId`+`profileVersion` | `no-auth-source` |
Each probe accepts a DI seam (`spawnFn`, `fsApi`) so tests do not spawn the
real Copilot CLI or touch the host fs.
## Limitations
- The harness only claims the canonical `github-copilot` provider at MVP.
Additional providers (BYOK or otherwise) should land in follow-up PRs that
ship the adapter alongside the wire-up.
- The harness does not deliver TUI; PI's TUI is unaffected and remains the
fallback for whatever runtimes do not have a peer surface.
- PI session state is not migrated when an agent switches to `copilot`.
Selection is per attempt; existing PI sessions remain valid.
- **Interactive `ask_user` is not yet wired.** The SDK's
`onUserInputRequest` handler is intentionally not registered, which
per the SDK contract hides the `ask_user` tool from the model
entirely. Agents running under this harness make best-judgment
decisions from the initial prompt rather than asking clarifying
questions mid-turn. A follow-up will port the codex pattern at
`extensions/codex/src/app-server/user-input-bridge.ts` to route SDK
`UserInputRequest`s through the OpenClaw channel/TUI prompt path; the
dormant scaffolding in `extensions/copilot/src/user-input-bridge.ts`
is the surface that follow-up will wire.
## Permissions and ask_user
Permission enforcement for bridged OpenClaw tools happens **inside the
tool wrapper**, not via the SDK's `onPermissionRequest` callback. The
same `wrapToolWithBeforeToolCallHook` that PI uses
(`src/agents/pi-tools.before-tool-call.ts`) is applied by
`createOpenClawCodingTools` to every coding tool: loop detection,
trusted plugin policies, before-tool-call hooks, and two-phase plugin
approvals via the gateway (`plugin.approval.request`) all run with the
exact same code path as native PI attempts.
To let that wrapper own the decision, the SDK Tool returned by
`convertOpenClawToolToSdkTool` is marked with:
- `overridesBuiltInTool: true` — replaces the Copilot CLI's built-in
tool of the same name (edit, read, write, bash, …) so every tool
invocation routes back to OpenClaw.
- `skipPermission: true` — tells the SDK not to fire
`onPermissionRequest({kind: "custom-tool"})` before invoking the tool.
The wrapped `execute()` performs the richer OpenClaw policy check
internally; an SDK-level prompt would either short-circuit OpenClaw's
enforcement (if we allow-all) or block every tool call (if we
reject-all) — neither matches PI parity.
The in-tree codex harness uses the same split: bridged OpenClaw tools
are wrapped (`extensions/codex/src/app-server/dynamic-tools.ts`) and
the codex-app-server's _own_ native approval kinds
(`item/commandExecution/requestApproval`,
`item/fileChange/requestApproval`,
`item/permissions/requestApproval`) are routed through
`plugin.approval.request`
(`extensions/codex/src/app-server/approval-bridge.ts`). The Copilot SDK
equivalent — fail-closed `rejectAllPolicy` for any non-`custom-tool`
kind that ever reaches `onPermissionRequest` — is the same safety net,
and it does not fire in practice because `overridesBuiltInTool: true`
displaces every built-in.
For the wrapped-tool layer to make policy decisions equivalent to PI,
the harness forwards the full PI attempt-tool context to
`createOpenClawCodingTools` — identity (`senderIsOwner`,
`memberRoleIds`, `ownerOnlyToolAllowlist`, …), channel/routing
(`groupId`, `currentChannelId`, `replyToMode`, message-tool toggles),
auth (`authProfileStore`), run identity
(`sessionKey`/`runSessionKey` derived from `sandboxSessionKey`,
`runId`), model context (`modelApi`, `modelContextWindowTokens`,
`modelCompat`, `modelHasVision`), and run hooks (`onToolOutcome`,
`onYield`). Without those fields, owner-only allowlists silently
behave as deny-by-default, plugin-trust policies cannot resolve to the
right scope, and `session_status: "current"` resolves to a stale
sandbox key. The bridge builder is in
`extensions/copilot/src/tool-bridge.ts` and mirrors the PI
authoritative call at
`src/agents/pi-embedded-runner/run/attempt.ts:1029-1117`. Two PI fields
are intentionally **not** forwarded at MVP and tracked as follow-ups:
`sandbox` (the harness does not yet route through `resolveSandboxContext`)
and the PI tool-search/code-mode machinery
(`toolSearchCatalogRef`, `includeCoreTools`,
`includeToolSearchControls`, `toolSearchCatalogExecutor`,
`toolConstructionPlan`), which has no analog at the SDK boundary.
### Session-level GitHub token
The Copilot SDK contract distinguishes the **client-level** GitHub
token (`CopilotClientOptions.gitHubToken`, used to authenticate the
CLI process itself) from the **session-level** token
(`SessionConfig.gitHubToken`, which determines content exclusion,
model routing, and quota for that session and is honored on both
`createSession` and `resumeSession`). The harness resolves auth once
via `resolveCopilotAuth` and sets both fields when the auth mode is
`gitHubToken` (an explicit `auth.gitHubToken` or a contract-resolved
`resolvedApiKey` from a configured `github-copilot` auth profile).
When the resolved mode is `useLoggedInUser`, the session-level field
is omitted so the SDK keeps deriving identity from the logged-in
identity.
`ask_user` is intentionally hidden — see Limitations above.
## Related
- [Agent runtimes](/concepts/agent-runtimes)
- [Codex harness](/plugins/codex-harness)
- [Agent harness plugins (SDK reference)](/plugins/sdk-agent-harness)

View File

@@ -66,6 +66,7 @@ commands.
| [cloudflare-ai-gateway](/plugins/reference/cloudflare-ai-gateway) | Adds Cloudflare AI Gateway model provider support to OpenClaw. | `@openclaw/cloudflare-ai-gateway-provider`<br />included in OpenClaw | providers: cloudflare-ai-gateway |
| [codex-supervisor](/plugins/reference/codex-supervisor) | Supervise Codex app-server sessions from OpenClaw. | `@openclaw/codex-supervisor`<br />included in OpenClaw | contracts: tools |
| [comfy](/plugins/reference/comfy) | Adds ComfyUI model provider support to OpenClaw. | `@openclaw/comfy-provider`<br />included in OpenClaw | providers: comfy; contracts: imageGenerationProviders, musicGenerationProviders, videoGenerationProviders |
| [copilot](/plugins/reference/copilot) | Registers the GitHub Copilot agent runtime. | `@openclaw/copilot`<br />included in OpenClaw | plugin |
| [copilot-proxy](/plugins/reference/copilot-proxy) | Adds Copilot Proxy model provider support to OpenClaw. | `@openclaw/copilot-proxy`<br />included in OpenClaw | providers: copilot-proxy |
| [deepgram](/plugins/reference/deepgram) | Adds media understanding provider support. Adds realtime transcription provider support. | `@openclaw/deepgram-provider`<br />included in OpenClaw | contracts: mediaUnderstandingProviders, realtimeTranscriptionProviders |
| [deepinfra](/plugins/reference/deepinfra) | Adds DeepInfra model provider support to OpenClaw. | `@openclaw/deepinfra-provider`<br />included in OpenClaw | providers: deepinfra; contracts: imageGenerationProviders, mediaUnderstandingProviders, memoryEmbeddingProviders, speechProviders, videoGenerationProviders |

View File

@@ -38,6 +38,7 @@ pnpm plugins:inventory:gen
| [codex](/plugins/reference/codex) | OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog. | `@openclaw/codex`<br />npm; ClawHub | providers: codex; contracts: mediaUnderstandingProviders, migrationProviders |
| [codex-supervisor](/plugins/reference/codex-supervisor) | Supervise Codex app-server sessions from OpenClaw. | `@openclaw/codex-supervisor`<br />included in OpenClaw | contracts: tools |
| [comfy](/plugins/reference/comfy) | Adds ComfyUI model provider support to OpenClaw. | `@openclaw/comfy-provider`<br />included in OpenClaw | providers: comfy; contracts: imageGenerationProviders, musicGenerationProviders, videoGenerationProviders |
| [copilot](/plugins/reference/copilot) | Registers the GitHub Copilot agent runtime. | `@openclaw/copilot`<br />included in OpenClaw | plugin |
| [copilot-proxy](/plugins/reference/copilot-proxy) | Adds Copilot Proxy model provider support to OpenClaw. | `@openclaw/copilot-proxy`<br />included in OpenClaw | providers: copilot-proxy |
| [deepgram](/plugins/reference/deepgram) | Adds media understanding provider support. Adds realtime transcription provider support. | `@openclaw/deepgram-provider`<br />included in OpenClaw | contracts: mediaUnderstandingProviders, realtimeTranscriptionProviders |
| [deepinfra](/plugins/reference/deepinfra) | Adds DeepInfra model provider support to OpenClaw. | `@openclaw/deepinfra-provider`<br />included in OpenClaw | providers: deepinfra; contracts: imageGenerationProviders, mediaUnderstandingProviders, memoryEmbeddingProviders, speechProviders, videoGenerationProviders |

View File

@@ -0,0 +1,23 @@
---
summary: "Registers the GitHub Copilot agent runtime."
read_when:
- You are installing, configuring, or auditing the copilot plugin
title: "Copilot plugin"
---
# Copilot plugin
Registers the GitHub Copilot agent runtime.
## Distribution
- Package: `@openclaw/copilot`
- Install route: included in OpenClaw
## Surface
plugin
## Related docs
- [copilot](/plugins/copilot)

View File

@@ -0,0 +1,15 @@
# GitHub Copilot agent runtime (OpenClaw plugin)
Bundled OpenClaw plugin that registers a `copilot` agent harness backed
by `@github/copilot-sdk` and the GitHub Copilot CLI.
The harness claims the canonical subscription `github-copilot` provider and
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, doctor probes, 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.

View File

@@ -0,0 +1,42 @@
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:"]);
});
});

View File

@@ -0,0 +1,63 @@
/**
* 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.
*
* The deeper runtime probes (copilot CLI version, copilot auth,
* copilotHome writability) live in {@link ./src/doctor-probes.ts}
* because they have side effects (subprocess spawn, fs touch) and
* need to be invoked imperatively, not declaratively, from the
* doctor command. They are exported separately so callers can opt
* in. Auto-discovery of doctor-contract-api.ts at the plugin root
* keeps this file purely declarative.
*/
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:"],
},
];

View File

@@ -0,0 +1,879 @@
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CopilotClientPool } from "./harness.js";
import { createCopilotAgentHarness } from "./harness.js";
const mocks = vi.hoisted(() => ({
runCopilotAttempt: vi.fn(),
createCopilotClientPool: vi.fn(),
}));
vi.mock("./src/attempt.js", () => ({
runCopilotAttempt: mocks.runCopilotAttempt,
}));
vi.mock("./src/runtime.js", () => ({
createCopilotClientPool: mocks.createCopilotClientPool,
}));
const ATTEMPT_PARAMS = { provider: "github-copilot", model: "gpt-4.1" } as any;
const ATTEMPT_RESULT = { ok: true } as any;
function makePoolMock(): CopilotClientPool {
return {
acquire: vi.fn(),
release: vi.fn(),
dispose: vi.fn().mockResolvedValue([]),
size: vi.fn().mockReturnValue(0),
};
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
async function flushAsyncWork() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("createCopilotAgentHarness", () => {
beforeEach(() => {
mocks.runCopilotAttempt.mockReset();
mocks.createCopilotClientPool.mockReset();
mocks.runCopilotAttempt.mockResolvedValue(ATTEMPT_RESULT);
mocks.createCopilotClientPool.mockImplementation(() => makePoolMock());
});
it("returns the copilot id and default label", () => {
const harness = createCopilotAgentHarness();
expect(harness.id).toBe("copilot");
expect(harness.label).toBe("GitHub Copilot agent runtime");
});
it("accepts custom id and label from options", () => {
const harness = createCopilotAgentHarness({ id: "sdk", label: "SDK Harness" });
expect(harness.id).toBe("sdk");
expect(harness.label).toBe("SDK Harness");
});
it("supports returns false in auto runtime even for github provider", () => {
const harness = createCopilotAgentHarness();
expect(
harness.supports({
provider: "github-copilot",
modelId: "gpt-4.1",
requestedRuntime: "auto",
}),
).toEqual({
supported: false,
reason: "copilot is opt-in only",
});
});
it("supports returns false in pi runtime", () => {
const harness = createCopilotAgentHarness();
expect(
harness.supports({ provider: "github-copilot", modelId: "gpt-4.1", requestedRuntime: "pi" }),
).toEqual({
supported: false,
reason: "copilot is opt-in only",
});
});
it("supports returns true for requestedRuntime copilot with github-copilot provider", () => {
const harness = createCopilotAgentHarness();
expect(
harness.supports({
provider: "github-copilot",
modelId: "gpt-4.1",
requestedRuntime: "copilot",
}),
).toEqual({ supported: true, priority: 100 });
});
it("supports normalizes provider casing and whitespace", () => {
const harness = createCopilotAgentHarness();
expect(
harness.supports({
provider: " GitHub-Copilot ",
modelId: "gpt-4.1",
requestedRuntime: "copilot",
}),
).toEqual({ supported: true, priority: 100 });
});
it("supports normalizes requestedRuntime casing", () => {
const harness = createCopilotAgentHarness();
expect(
harness.supports({
provider: "github-copilot",
modelId: "gpt-4.1",
requestedRuntime: " COPILOT " as any,
}),
).toEqual({ supported: true, priority: 100 });
});
it("supports rejects providers outside the whitelist", () => {
const harness = createCopilotAgentHarness();
expect(
harness.supports({
provider: "anthropic",
modelId: "claude-sonnet-4.5",
requestedRuntime: "copilot",
}),
).toEqual({
supported: false,
reason: "provider is not one of: github-copilot",
});
// Legacy aspirational ids should not be claimed by the harness.
for (const legacyId of ["github", "openclaw", "copilot"]) {
expect(
harness.supports({
provider: legacyId,
modelId: "gpt-4.1",
requestedRuntime: "copilot",
}),
).toEqual({
supported: false,
reason: "provider is not one of: github-copilot",
});
}
});
it("runAttempt lazy-imports attempt by waiting until invocation to create a pool", async () => {
const pool = makePoolMock();
mocks.createCopilotClientPool.mockReturnValue(pool);
const harness = createCopilotAgentHarness();
expect(mocks.createCopilotClientPool).not.toHaveBeenCalled();
expect(mocks.runCopilotAttempt).not.toHaveBeenCalled();
await expect(harness.runAttempt(ATTEMPT_PARAMS)).resolves.toBe(ATTEMPT_RESULT);
expect(mocks.createCopilotClientPool).toHaveBeenCalledTimes(1);
expect(mocks.runCopilotAttempt).toHaveBeenCalledTimes(1);
});
it("runAttempt creates one pool lazily and reuses it across two attempts on the same harness", async () => {
const pool = makePoolMock();
const firstResult = { attempt: 1 } as any;
const secondResult = { attempt: 2 } as any;
mocks.createCopilotClientPool.mockReturnValue(pool);
mocks.runCopilotAttempt.mockResolvedValueOnce(firstResult).mockResolvedValueOnce(secondResult);
const harness = createCopilotAgentHarness();
await expect(harness.runAttempt(ATTEMPT_PARAMS)).resolves.toBe(firstResult);
await expect(harness.runAttempt(ATTEMPT_PARAMS)).resolves.toBe(secondResult);
expect(mocks.createCopilotClientPool).toHaveBeenCalledTimes(1);
expect(mocks.runCopilotAttempt).toHaveBeenNthCalledWith(
1,
ATTEMPT_PARAMS,
expect.objectContaining({ pool }),
);
expect(mocks.runCopilotAttempt).toHaveBeenNthCalledWith(
2,
ATTEMPT_PARAMS,
expect.objectContaining({ pool }),
);
});
it("multiple harness instances create independent pools", async () => {
const poolOne = makePoolMock();
const poolTwo = makePoolMock();
mocks.createCopilotClientPool.mockReturnValueOnce(poolOne).mockReturnValueOnce(poolTwo);
const firstHarness = createCopilotAgentHarness();
const secondHarness = createCopilotAgentHarness();
await expect(firstHarness.runAttempt(ATTEMPT_PARAMS)).resolves.toBe(ATTEMPT_RESULT);
await expect(secondHarness.runAttempt(ATTEMPT_PARAMS)).resolves.toBe(ATTEMPT_RESULT);
expect(mocks.createCopilotClientPool).toHaveBeenCalledTimes(2);
expect(mocks.runCopilotAttempt).toHaveBeenNthCalledWith(
1,
ATTEMPT_PARAMS,
expect.objectContaining({ pool: poolOne }),
);
expect(mocks.runCopilotAttempt).toHaveBeenNthCalledWith(
2,
ATTEMPT_PARAMS,
expect.objectContaining({ pool: poolTwo }),
);
});
it("runAttempt does not serialize concurrent attempts", async () => {
const pool = makePoolMock();
const firstResult = { attempt: 1 } as any;
const secondResult = { attempt: 2 } as any;
mocks.createCopilotClientPool.mockReturnValue(pool);
mocks.runCopilotAttempt.mockResolvedValueOnce(firstResult).mockResolvedValueOnce(secondResult);
const harness = createCopilotAgentHarness();
await expect(harness.runAttempt(ATTEMPT_PARAMS)).resolves.toBe(firstResult);
await expect(harness.runAttempt(ATTEMPT_PARAMS)).resolves.toBe(secondResult);
expect(mocks.createCopilotClientPool).toHaveBeenCalledTimes(1);
expect(mocks.runCopilotAttempt).toHaveBeenCalledTimes(2);
});
it("dispose before first runAttempt does not create a pool", async () => {
const harness = createCopilotAgentHarness();
await expect(harness.dispose?.()).resolves.toBeUndefined();
expect(mocks.createCopilotClientPool).not.toHaveBeenCalled();
});
it("dispose during lazy startup prevents the attempt from creating a pool", async () => {
const harness = createCopilotAgentHarness();
const attemptPromise = harness.runAttempt(ATTEMPT_PARAMS);
const disposePromise = harness.dispose?.();
await expect(attemptPromise).rejects.toThrow(
"[copilot] harness was disposed while starting an attempt",
);
await expect(disposePromise).resolves.toBeUndefined();
expect(mocks.createCopilotClientPool).not.toHaveBeenCalled();
expect(mocks.runCopilotAttempt).not.toHaveBeenCalled();
});
it("dispose after pool creation calls pool.dispose once even when called twice", async () => {
const pool = makePoolMock();
mocks.createCopilotClientPool.mockReturnValue(pool);
const harness = createCopilotAgentHarness();
await harness.runAttempt(ATTEMPT_PARAMS);
const firstDispose = harness.dispose?.();
const secondDispose = harness.dispose?.();
await expect(firstDispose).resolves.toBeUndefined();
await expect(secondDispose).resolves.toBeUndefined();
expect(pool.dispose).toHaveBeenCalledTimes(1);
});
it("dispose waits for in-flight runAttempt before disposing", async () => {
const pool = makePoolMock();
const deferred = createDeferred<any>();
mocks.createCopilotClientPool.mockReturnValue(pool);
mocks.runCopilotAttempt.mockImplementation(() => deferred.promise);
const harness = createCopilotAgentHarness();
const attemptPromise = harness.runAttempt(ATTEMPT_PARAMS);
await flushAsyncWork();
const disposePromise = harness.dispose?.();
let disposeSettled = false;
void disposePromise?.then(() => {
disposeSettled = true;
});
await flushAsyncWork();
expect(pool.dispose).not.toHaveBeenCalled();
expect(disposeSettled).toBe(false);
deferred.resolve(ATTEMPT_RESULT);
await expect(attemptPromise).resolves.toBe(ATTEMPT_RESULT);
await expect(disposePromise).resolves.toBeUndefined();
expect(pool.dispose).toHaveBeenCalledTimes(1);
});
it("runAttempt after dispose rejects without creating a new pool", async () => {
const harness = createCopilotAgentHarness();
await harness.dispose?.();
await expect(harness.runAttempt(ATTEMPT_PARAMS)).rejects.toThrow(
"[copilot] harness has been disposed; cannot start new attempts",
);
expect(mocks.createCopilotClientPool).not.toHaveBeenCalled();
});
it("dispose surfaces pool.dispose errors as AggregateError", async () => {
const pool = makePoolMock();
const errors = [new Error("first"), new Error("second")];
pool.dispose = vi.fn().mockResolvedValue(errors);
mocks.createCopilotClientPool.mockReturnValue(pool);
const harness = createCopilotAgentHarness();
await harness.runAttempt(ATTEMPT_PARAMS);
try {
await harness.dispose?.();
throw new Error("expected dispose to throw");
} catch (error) {
expect(error).toBeInstanceOf(AggregateError);
expect((error as AggregateError).message).toBe("[copilot] pool disposal errors");
expect((error as AggregateError).errors).toEqual(errors);
}
});
it("dispose does not dispose a caller-supplied pool", async () => {
const pool = makePoolMock();
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(ATTEMPT_PARAMS);
await expect(harness.dispose?.()).resolves.toBeUndefined();
expect(pool.dispose).not.toHaveBeenCalled();
});
it("uses options.pool when supplied", async () => {
const pool = makePoolMock();
const harness = createCopilotAgentHarness({ pool });
await expect(harness.runAttempt(ATTEMPT_PARAMS)).resolves.toBe(ATTEMPT_RESULT);
expect(mocks.createCopilotClientPool).not.toHaveBeenCalled();
expect(mocks.runCopilotAttempt).toHaveBeenCalledWith(
ATTEMPT_PARAMS,
expect.objectContaining({ pool }),
);
});
describe("reset", () => {
it("is a no-op when params.sessionId is missing", async () => {
const pool = makePoolMock();
const harness = createCopilotAgentHarness({ pool });
await expect(harness.reset?.({})).resolves.toBeUndefined();
});
it("is a no-op when the session was never tracked", async () => {
const pool = makePoolMock();
const harness = createCopilotAgentHarness({ pool });
await expect(harness.reset?.({ sessionId: "unknown" })).resolves.toBeUndefined();
});
it("calls deleteSession on the client that created the session", async () => {
const pool = makePoolMock();
const deleteSession = vi.fn().mockResolvedValue(undefined);
const client = { deleteSession } as any;
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-123",
pooledClient: { key: {} as any, client },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-sess-1" });
await harness.reset?.({ sessionId: "oc-sess-1" });
expect(deleteSession).toHaveBeenCalledTimes(1);
expect(deleteSession).toHaveBeenCalledWith("sdk-sess-123");
});
it("does not call deleteSession when no sdkSessionId was reported", async () => {
const pool = makePoolMock();
const deleteSession = vi.fn().mockResolvedValue(undefined);
mocks.runCopilotAttempt.mockImplementation(async (_params, _deps) => ATTEMPT_RESULT);
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-sess-2" });
await harness.reset?.({ sessionId: "oc-sess-2" });
expect(deleteSession).not.toHaveBeenCalled();
});
it("swallows errors thrown by client.deleteSession", async () => {
const pool = makePoolMock();
const deleteSession = vi.fn().mockRejectedValue(new Error("session not found"));
const client = { deleteSession } as any;
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-err",
pooledClient: { key: {} as any, client },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-sess-3" });
await expect(harness.reset?.({ sessionId: "oc-sess-3" })).resolves.toBeUndefined();
expect(deleteSession).toHaveBeenCalledTimes(1);
});
it("forgets the session after reset; a second reset is a no-op", async () => {
const pool = makePoolMock();
const deleteSession = vi.fn().mockResolvedValue(undefined);
const client = { deleteSession } as any;
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-x",
pooledClient: { key: {} as any, client },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-sess-4" });
await harness.reset?.({ sessionId: "oc-sess-4" });
await harness.reset?.({ sessionId: "oc-sess-4" });
expect(deleteSession).toHaveBeenCalledTimes(1);
});
it("does not invoke deleteSession for a session belonging to a different openclawSessionId", async () => {
const pool = makePoolMock();
const deleteSession = vi.fn().mockResolvedValue(undefined);
const client = { deleteSession } as any;
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-y",
pooledClient: { key: {} as any, client },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-A" });
await harness.reset?.({ sessionId: "oc-B" });
expect(deleteSession).not.toHaveBeenCalled();
});
});
it("dispose clears tracked sessions so subsequent reset is a no-op", async () => {
const pool = makePoolMock();
const deleteSession = vi.fn().mockResolvedValue(undefined);
const client = { deleteSession } as any;
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-d",
pooledClient: { key: {} as any, client },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-disp" });
await harness.dispose?.();
await harness.reset?.({ sessionId: "oc-disp" });
expect(deleteSession).not.toHaveBeenCalled();
});
describe("session reuse across turns (dogfood finding #4)", () => {
// These tests pin the harness's session-reuse contract: subsequent
// `runAttempt` calls within the same OpenClaw session should pass
// the tracked `sdkSessionId` to the attempt via `initialReplayState`
// so the SDK can `resumeSession` and keep its prompt cache + thread
// history warm. Compatibility-fingerprint mismatch (provider/model/
// cwd/auth) starts a fresh SDK session instead, and any caller-
// provided `replayInvalid: true` must survive untouched.
function makeAttemptParams(overrides: Record<string, unknown> = {}): any {
return {
provider: "github-copilot",
model: { provider: "github-copilot", id: "gpt-4.1" },
cwd: "/ws",
workspaceDir: "/ws",
agentDir: "/home",
copilotHome: "/copilot-home",
auth: { useLoggedInUser: true },
sessionId: "oc-sess-reuse",
...overrides,
};
}
it("seeds initialReplayState.sdkSessionId from trackedSessions on the second turn", async () => {
const pool = makePoolMock();
const client = { deleteSession: vi.fn() } as any;
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-warm",
pooledClient: { key: {} as any, client },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeAttemptParams({ runId: "t1" }));
await harness.runAttempt(makeAttemptParams({ runId: "t2" }));
expect(mocks.runCopilotAttempt).toHaveBeenCalledTimes(2);
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
initialReplayState?: { sdkSessionId?: string; replayInvalid?: boolean };
};
expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-warm");
// Must not synthesize a replayInvalid signal: undefined → resumable.
expect(secondCallParams.initialReplayState?.replayInvalid).toBeUndefined();
});
it("does not seed sdkSessionId on the first turn (nothing tracked yet)", async () => {
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-cold",
pooledClient: { key: {} as any, client: {} as any },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeAttemptParams({ runId: "t1" }));
const firstCallParams = mocks.runCopilotAttempt.mock.calls[0]?.[0] as {
initialReplayState?: { sdkSessionId?: string };
};
expect(firstCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
});
it("does not seed when compatibility fingerprint differs (model change)", async () => {
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-gpt4",
pooledClient: { key: {} as any, client: {} as any },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(
makeAttemptParams({ runId: "t1", model: { provider: "github-copilot", id: "gpt-4.1" } }),
);
await harness.runAttempt(
makeAttemptParams({
runId: "t2",
model: { provider: "github-copilot", id: "claude-sonnet-4.5" },
}),
);
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
initialReplayState?: { sdkSessionId?: string };
};
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
});
it("does not seed when compatibility fingerprint differs (legacy auth.gitHubToken rotation)", async () => {
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-auth1",
pooledClient: { key: {} as any, client: {} as any },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
// Use the explicit-token auth branch (which carries gitHubToken
// + profileId + profileVersion through resolveCopilotAuth and
// surfaces the version into authProfileVersion) so a profile
// version bump is a real auth rotation, not a no-op fall-through
// to useLoggedInUser.
await harness.runAttempt(
makeAttemptParams({
runId: "t1",
auth: { gitHubToken: "tok-1", profileId: "p1", profileVersion: "v1" },
}),
);
await harness.runAttempt(
makeAttemptParams({
runId: "t2",
auth: { gitHubToken: "tok-1", profileId: "p1", profileVersion: "v2" },
}),
);
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
initialReplayState?: { sdkSessionId?: string };
};
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
});
it("G3: does not seed when top-level authProfileId rotates (production path)", async () => {
// The production main path (EmbeddedRunAttemptParams) carries
// top-level `authProfileId` + `resolvedApiKey`, not the legacy
// `auth.*` sub-object. computeSessionCompatKey delegates to
// resolveCopilotAuth so both paths produce the same effective
// auth identity. Rotating the top-level profile id must
// invalidate session reuse.
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-p1",
pooledClient: { key: {} as any, client: {} as any },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(
makeAttemptParams({
runId: "t1",
auth: undefined,
authProfileId: "p1",
resolvedApiKey: "tok-same",
}),
);
await harness.runAttempt(
makeAttemptParams({
runId: "t2",
auth: undefined,
authProfileId: "p2",
resolvedApiKey: "tok-same",
}),
);
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
initialReplayState?: { sdkSessionId?: string };
};
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
});
it("G3: does not seed when top-level resolvedApiKey rotates (token fingerprint changes)", async () => {
// Same authProfileId but the resolved token bytes change.
// resolveCopilotAuth synthesizes authProfileVersion via
// tokenFingerprint(resolvedApiKey) for the contract path, so
// rotating the bytes flips the fingerprint and therefore the
// compat key. Important for cases where an upstream auth
// store re-issues a token under the same profile id.
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-tok1",
pooledClient: { key: {} as any, client: {} as any },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(
makeAttemptParams({
runId: "t1",
auth: undefined,
authProfileId: "p1",
resolvedApiKey: "tok-a",
}),
);
await harness.runAttempt(
makeAttemptParams({
runId: "t2",
auth: undefined,
authProfileId: "p1",
resolvedApiKey: "tok-b",
}),
);
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
initialReplayState?: { sdkSessionId?: string };
};
expect(secondCallParams.initialReplayState?.sdkSessionId).toBeUndefined();
});
it("preserves caller-provided initialReplayState.replayInvalid:true (does not overwrite)", async () => {
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-tracked",
pooledClient: { key: {} as any, client: {} as any },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeAttemptParams({ runId: "t1" }));
await harness.runAttempt(
makeAttemptParams({
runId: "t2",
initialReplayState: { replayInvalid: true },
}),
);
const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as {
initialReplayState?: { sdkSessionId?: string; replayInvalid?: boolean };
};
// sdkSessionId is still injected from tracking, but replayInvalid
// must remain true so replay-shim treats this as create-not-resume.
expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-tracked");
expect(secondCallParams.initialReplayState?.replayInvalid).toBe(true);
});
it("updates the tracked session when onSessionEstablished reports a new sdkSessionId", async () => {
const pool = makePoolMock();
const deleteSession = vi.fn();
const client = { deleteSession } as any;
let nextSdkId = "sdk-sess-1";
mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: nextSdkId,
pooledClient: { key: {} as any, client },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt(makeAttemptParams({ runId: "t1" }));
nextSdkId = "sdk-sess-2"; // Simulate downgraded resume → new SDK session.
await harness.runAttempt(makeAttemptParams({ runId: "t2" }));
await harness.reset?.({ sessionId: "oc-sess-reuse" });
expect(deleteSession).toHaveBeenCalledTimes(1);
// The newer sdkSessionId must be the one targeted by reset, not
// the stale first-turn id.
expect(deleteSession).toHaveBeenCalledWith("sdk-sess-2");
});
});
describe("compact", () => {
it("returns ok:false when sessionId is missing", async () => {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
const result = await harness.compact?.({ workspaceDir: "/ws" } as any);
expect(result).toEqual({
ok: false,
compacted: false,
reason: "missing-required-params",
});
});
it("returns ok:false when workspaceDir is missing", async () => {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
const result = await harness.compact?.({ sessionId: "s" } as any);
expect(result).toEqual({
ok: false,
compacted: false,
reason: "missing-required-params",
});
});
it("writes an OpenClaw marker under <workspaceDir>/files and returns ok:true,compacted:false", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-harness-compact-"));
try {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
const result = await harness.compact?.({
sessionId: "oc-sess-compact-1",
workspaceDir,
trigger: "budget",
currentTokenCount: 12345,
} as any);
expect(result).toEqual({
ok: true,
compacted: false,
reason: "deferred-to-sdk-infinite-sessions",
});
const files = await readdir(join(workspaceDir, "files"));
const marker = files.find((f) => f.startsWith("openclaw-compaction-"));
expect(marker).toBeDefined();
expect(marker).toMatch(/openclaw-compaction-\d+-oc-sess-compact-1\.json/);
const contents = JSON.parse(await readFile(join(workspaceDir, "files", marker!), "utf8"));
expect(contents).toMatchObject({
version: 1,
source: "copilot-harness",
sessionId: "oc-sess-compact-1",
compacted: false,
trigger: "budget",
currentTokenCount: 12345,
reason: "deferred-to-sdk-infinite-sessions",
});
} finally {
await rm(workspaceDir, { recursive: true, force: true });
}
});
it("records the tracked sdkSessionId in the marker when an attempt has run", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-harness-compact-tracked-"));
try {
const pool = makePoolMock();
mocks.runCopilotAttempt.mockImplementation(async (params, deps) => {
deps.onSessionEstablished?.({
sdkSessionId: "sdk-sess-tracked",
pooledClient: { key: {} as any, client: { deleteSession: vi.fn() } as any },
});
return ATTEMPT_RESULT;
});
const harness = createCopilotAgentHarness({ pool });
await harness.runAttempt({ ...ATTEMPT_PARAMS, sessionId: "oc-sess-tracked" });
await harness.compact?.({
sessionId: "oc-sess-tracked",
workspaceDir,
trigger: "manual",
} as any);
const files = await readdir(join(workspaceDir, "files"));
const marker = files.find((f) => f.startsWith("openclaw-compaction-"))!;
const contents = JSON.parse(await readFile(join(workspaceDir, "files", marker), "utf8"));
expect(contents.sdkSessionId).toBe("sdk-sess-tracked");
} finally {
await rm(workspaceDir, { recursive: true, force: true });
}
});
it("records force:true in the marker and surfaces a force-specific reason", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-harness-compact-force-"));
try {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
const result = await harness.compact?.({
sessionId: "oc-sess-force",
workspaceDir,
force: true,
} as any);
expect(result).toEqual({
ok: true,
compacted: false,
reason: "force-requested-but-sdk-has-no-synchronous-compact-api",
});
const files = await readdir(join(workspaceDir, "files"));
const marker = files.find((f) => f.startsWith("openclaw-compaction-"))!;
const contents = JSON.parse(await readFile(join(workspaceDir, "files", marker), "utf8"));
expect(contents.force).toBe(true);
expect(contents.reason).toBe("force-requested-but-sdk-has-no-synchronous-compact-api");
} finally {
await rm(workspaceDir, { recursive: true, force: true });
}
});
it("returns ok:false with structured failure when the marker write throws", async () => {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
// Use a path with a NUL character which Node rejects synchronously
// on every platform, simulating a write failure that the harness
// must convert into a structured failure instead of throwing.
const badWorkspace = "/this\u0000is/illegal";
const result = await harness.compact?.({
sessionId: "oc-sess-bad",
workspaceDir: badWorkspace,
} as any);
expect(result?.ok).toBe(false);
expect(result?.compacted).toBe(false);
expect(result?.reason).toBe("marker-write-failed");
expect(result?.failure?.reason).toBe("marker-write-failed");
expect(typeof result?.failure?.rawError).toBe("string");
expect(result?.failure?.rawError?.length ?? 0).toBeGreaterThan(0);
});
});
describe("runSideQuestion", () => {
it("is not implemented; /btw falls through to the in-tree PI fallback path", () => {
const harness = createCopilotAgentHarness({ pool: makePoolMock() });
expect(harness.runSideQuestion).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,339 @@
import type { CopilotClient } from "@github/copilot-sdk";
import type {
AgentHarness,
AgentHarnessAttemptParams,
AgentHarnessAttemptResult,
AgentHarnessCompactParams,
AgentHarnessCompactResult,
AgentHarnessResetParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveCopilotAuth } from "./src/auth-bridge.js";
import { writeOpenClawCompactionMarker } from "./src/compaction-bridge.js";
import type { CopilotClientPool, CopilotClientPoolOptions, PooledClient } from "./src/runtime.js";
export type { CopilotClientPool, CopilotClientPoolOptions };
const COPILOT_PROVIDER_IDS: ReadonlySet<string> = new Set(["github-copilot"]);
export interface CreateCopilotAgentHarnessOptions {
id?: string;
label?: string;
pluginConfig?: unknown;
pool?: CopilotClientPool;
poolOptions?: CopilotClientPoolOptions;
}
interface TrackedSession {
sdkSessionId: string;
client: CopilotClient;
// Compatibility fingerprint of the params that created the SDK
// session. We only reuse the tracked SDK session when the next
// attempt's fingerprint matches — different provider/model/cwd/auth
// configurations should start a fresh SDK session rather than resume
// one bound to incompatible state. Mismatch falls back to
// `createSession` (no resume injection) and the new sdkSessionId
// replaces this entry via `onSessionEstablished`.
compatKey: string;
}
// Build a string fingerprint of the attempt params that must agree
// across turns for SDK-session reuse to be safe. Keep this list
// conservative: any field whose change would invalidate the SDK
// session's bound state belongs here. Token / auth profile rotation
// produces a new fingerprint so we don't replay a session against a
// stale credential.
//
// Auth identity is derived from `resolveCopilotAuth(...)` — the same
// function `resolvePoolAcquire` uses to build the pool key. That
// ensures the compat key tracks the EFFECTIVE auth (which can come
// from the legacy `auth.*` subobject, the contract-resolved
// top-level `resolvedApiKey` + `authProfileId`, or the env-var
// fallback) rather than any single one of those raw inputs. The
// `authProfileVersion` field is a non-secret sha256 fingerprint of
// the token (see `tokenFingerprint` in `src/auth-bridge.ts`), so
// rotating the token under the same profile id still invalidates
// the compat key without ever serializing the raw credential.
function computeSessionCompatKey(params: AgentHarnessAttemptParams): string {
const p = params as AgentHarnessAttemptParams & {
auth?: {
gitHubToken?: string;
profileId?: string;
profileVersion?: string;
useLoggedInUser?: boolean;
};
agentId?: string;
authProfileId?: string;
copilotHome?: string;
cwd?: string;
model?: string | { api?: string; id?: string; provider?: string };
profileVersion?: string;
resolvedApiKey?: string;
workspaceDir?: string;
};
const modelObj: { api?: string; id?: string; provider?: string } =
p.model && typeof p.model === "object"
? p.model
: { id: typeof p.model === "string" ? p.model : undefined };
// resolveCopilotAuth can throw when an explicit `auth.gitHubToken`
// is supplied without profileId + profileVersion (the existing
// pool-key safety invariant). That same error would surface
// immediately afterwards from `resolvePoolAcquire` inside
// `runCopilotAttempt`, so we don't want to mask it here — but
// we also can't include random / time-based data in the compat key
// (would break the deterministic equality check). Use a stable
// sentinel that will never match any previously-tracked compat key.
let authParts: string[];
try {
const resolved = resolveCopilotAuth({
agentId: typeof p.agentId === "string" ? p.agentId : undefined,
agentDir: typeof p.agentDir === "string" ? p.agentDir : undefined,
workspaceDir: typeof p.workspaceDir === "string" ? p.workspaceDir : undefined,
copilotHome: typeof p.copilotHome === "string" ? p.copilotHome : undefined,
auth: p.auth,
resolvedApiKey: typeof p.resolvedApiKey === "string" ? p.resolvedApiKey : undefined,
authProfileId: typeof p.authProfileId === "string" ? p.authProfileId : undefined,
profileVersion: typeof p.profileVersion === "string" ? p.profileVersion : undefined,
});
authParts = [
`auth.mode=${resolved.authMode}`,
`auth.profileId=${resolved.authProfileId ?? ""}`,
`auth.profileVersion=${resolved.authProfileVersion ?? ""}`,
];
} catch {
authParts = ["auth=unresolvable"];
}
const parts = [
`provider=${modelObj.provider ?? ""}`,
`model=${modelObj.id ?? ""}`,
`api=${modelObj.api ?? ""}`,
`cwd=${p.cwd ?? p.workspaceDir ?? ""}`,
`agentDir=${p.agentDir ?? ""}`,
`copilotHome=${p.copilotHome ?? ""}`,
...authParts,
];
return parts.join("|");
}
export function createCopilotAgentHarness(
options?: CreateCopilotAgentHarnessOptions,
): AgentHarness {
let poolPromise: Promise<CopilotClientPool> | undefined;
let createdPool: CopilotClientPool | undefined;
let disposed = false;
let disposePromise: Promise<void> | undefined;
const inFlight = new Set<Promise<unknown>>();
// Maps OpenClaw session id (from AgentHarnessAttemptParams.sessionId) to
// the SDK session id + client that owns it. Populated by
// runCopilotAttempt via the onSessionEstablished callback so that
// reset(params) can call client.deleteSession on the right client.
const trackedSessions = new Map<string, TrackedSession>();
async function getPool(): Promise<CopilotClientPool> {
if (options?.pool) {
return options.pool;
}
if (!poolPromise) {
poolPromise = (async () => {
const { createCopilotClientPool } = await import("./src/runtime.js");
createdPool = createCopilotClientPool(options?.poolOptions);
return createdPool;
})();
}
return poolPromise;
}
return {
id: options?.id ?? "copilot",
label: options?.label ?? "GitHub Copilot agent runtime",
supports(ctx) {
const requestedRuntime = String(ctx.requestedRuntime ?? "")
.trim()
.toLowerCase();
if (requestedRuntime !== "copilot") {
return { supported: false, reason: "copilot is opt-in only" };
}
const provider = ctx.provider.trim().toLowerCase();
if (!COPILOT_PROVIDER_IDS.has(provider)) {
return {
supported: false,
reason: `provider is not one of: ${[...COPILOT_PROVIDER_IDS].toSorted().join(", ")}`,
};
}
return { supported: true, priority: 100 };
},
async runAttempt(params: AgentHarnessAttemptParams): Promise<AgentHarnessAttemptResult> {
const attemptPromise = (async () => {
if (disposed) {
throw new Error("[copilot] harness has been disposed; cannot start new attempts");
}
const { runCopilotAttempt } = await import("./src/attempt.js");
if (disposed) {
throw new Error("[copilot] harness was disposed while starting an attempt");
}
const pool = await getPool();
if (disposed) {
throw new Error("[copilot] harness was disposed while starting an attempt");
}
const openclawSessionId =
typeof params.sessionId === "string" ? params.sessionId : undefined;
// Dogfood finding #4: reuse the SDK session across turns within
// the same OpenClaw session so that the GitHub Copilot agent runtime's prompt
// cache, tool-call history, and any server-side compaction state
// survive turn boundaries. Without this, every turn called
// `createSession()` and lost cache + thread continuity — the
// smoking gun was distinct `${sdkSessionId}` scopes per turn in
// the playground transcript.
//
// Safety:
// - Only inject when the tracked compatKey still matches the
// current attempt's fingerprint (provider/model/cwd/auth).
// Mismatch falls through to `createSession` and the new SDK
// session replaces the tracked entry below.
// - Preserve any caller-provided `replayInvalid: true` — never
// downgrade an orchestrator-issued safety signal to false.
// `decideReplayAction` treats undefined as resumable already.
// - On resume failure, `attempt.ts` recovers via the
// `replay-shim` (`resumeFailureRecovered:true`) and falls
// back to `createSession`, so a stale-session error never
// surfaces as a prompt error.
const currentCompatKey = computeSessionCompatKey(params);
const tracked = openclawSessionId ? trackedSessions.get(openclawSessionId) : undefined;
const resumableSessionId =
tracked && tracked.compatKey === currentCompatKey ? tracked.sdkSessionId : undefined;
const effectiveParams: AgentHarnessAttemptParams = resumableSessionId
? ({
...params,
initialReplayState: {
...params.initialReplayState,
sdkSessionId: resumableSessionId,
},
} as AgentHarnessAttemptParams)
: params;
return runCopilotAttempt(effectiveParams, {
pool,
onSessionEstablished: openclawSessionId
? ({
sdkSessionId,
pooledClient,
}: {
sdkSessionId: string;
pooledClient: PooledClient;
}) => {
trackedSessions.set(openclawSessionId, {
sdkSessionId,
client: pooledClient.client,
compatKey: currentCompatKey,
});
}
: undefined,
});
})();
inFlight.add(attemptPromise);
try {
return await attemptPromise;
} finally {
inFlight.delete(attemptPromise);
}
},
async reset(params: AgentHarnessResetParams): Promise<void> {
const openclawSessionId = typeof params.sessionId === "string" ? params.sessionId : undefined;
if (!openclawSessionId) {
return;
}
const tracked = trackedSessions.get(openclawSessionId);
if (!tracked) {
// Session was created by a different harness, or already reset.
return;
}
trackedSessions.delete(openclawSessionId);
try {
await tracked.client.deleteSession(tracked.sdkSessionId);
} catch {
// Best-effort: client may be stopped, session may not exist
// server-side, or the SDK may report a transient error. The
// registry already logs broadcast reset failures; swallow here
// so one harness cannot block the reset broadcast.
}
},
async compact(
params: AgentHarnessCompactParams,
): Promise<AgentHarnessCompactResult | undefined> {
// The GitHub Copilot agent runtime manages compaction automatically via
// `SessionConfig.infiniteSessions` (background-async when
// utilization crosses `backgroundCompactionThreshold`). There is
// no synchronous compact RPC, so the harness cannot honour
// `params.force === true` directly. Instead this method writes
// an OpenClaw-shaped marker file under
// `<workspaceDir>/files/openclaw-compaction-<ts>-<sessionId>.json`
// so existing OpenClaw transcript readers see a familiar
// compaction artifact when the host calls compact(). See
// src/compaction-bridge.ts for the bridge boundary.
const openclawSessionId = typeof params.sessionId === "string" ? params.sessionId : undefined;
const workspaceDir =
typeof params.workspaceDir === "string" ? params.workspaceDir : undefined;
if (!openclawSessionId || !workspaceDir) {
return {
ok: false,
compacted: false,
reason: "missing-required-params",
};
}
const tracked = trackedSessions.get(openclawSessionId);
const reason = params.force
? "force-requested-but-sdk-has-no-synchronous-compact-api"
: "deferred-to-sdk-infinite-sessions";
try {
await writeOpenClawCompactionMarker({
sessionId: openclawSessionId,
workspaceDir,
trigger: params.trigger,
currentTokenCount: params.currentTokenCount,
sdkSessionId: tracked?.sdkSessionId,
force: params.force,
reason,
});
} catch (err) {
return {
ok: false,
compacted: false,
reason: "marker-write-failed",
failure: {
reason: "marker-write-failed",
rawError: err instanceof Error ? err.message : String(err),
},
};
}
return {
ok: true,
compacted: false,
reason,
};
},
async dispose() {
if (disposePromise) {
return disposePromise;
}
disposed = true;
disposePromise = (async () => {
if (inFlight.size > 0) {
await Promise.allSettled(inFlight);
}
trackedSessions.clear();
if (createdPool) {
const errors = await createdPool.dispose();
if (errors.length > 0) {
throw new AggregateError(errors, "[copilot] pool disposal errors");
}
}
})();
return disposePromise;
},
};
}

View File

@@ -0,0 +1,140 @@
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();
plugin.register(
createTestPluginApi({
id: "copilot",
name: "GitHub Copilot agent runtime",
source: "test",
config: {},
pluginConfig,
runtime: {} 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 };
}
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: {} 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, { poolOptions: { idleTtlMs: 2500 } });
expect(createHarness.mock.calls[1]?.[0]).toBeUndefined();
});
});

View File

@@ -0,0 +1,35 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createCopilotAgentHarness } from "./harness.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
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) {
const poolOptions = readPoolOptions(api.pluginConfig);
api.registerAgentHarness(createCopilotAgentHarness(poolOptions ? { poolOptions } : undefined));
},
});

View File

@@ -0,0 +1,39 @@
{
"id": "copilot",
"name": "GitHub Copilot agent runtime",
"description": "Registers the GitHub Copilot agent runtime.",
"version": "2026.5.28",
"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
}
}
}

View File

@@ -0,0 +1,43 @@
{
"name": "@openclaw/copilot",
"version": "2026.5.28",
"description": "OpenClaw GitHub Copilot agent runtime plugin (registers a `github-copilot` AgentHarness backed by @github/copilot-sdk over JSON-RPC to the bundled GitHub Copilot CLI)",
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"devDependencies": {
"@github/copilot": "1.0.48",
"@github/copilot-sdk": "1.0.0-beta.4",
"@openclaw/plugin-sdk": "workspace:*"
},
"peerDependencies": {
"@github/copilot-sdk": "1.0.0-beta.4"
},
"peerDependenciesMeta": {
"@github/copilot-sdk": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"npmSpec": "@openclaw/copilot",
"defaultChoice": "npm",
"minHostVersion": ">=2026.5.1-beta.1"
},
"compat": {
"pluginApi": ">=2026.5.28"
},
"build": {
"openclawVersion": "2026.5.28"
},
"release": {
"publishToClawHub": false,
"publishToNpm": false
}
}
}

View File

@@ -0,0 +1,213 @@
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);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,321 @@
import { createHash } from "node:crypto";
import { homedir as osHomedir } from "node:os";
import { join, normalize, resolve, sep } 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";
/** Present only when authMode is "gitHubToken". */
gitHubToken?: string;
/** Present only when authMode is "gitHubToken". */
authProfileId?: string;
/** Present only when authMode is "gitHubToken". */
authProfileVersion?: string;
/** Absolute, normalized path. */
copilotHome: string;
/** Validated agent id used for path defaults and pool keying. */
agentId: string;
}
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;
}
/**
* Normalize a copilotHome path for cross-platform pool keying.
* Re-exported so attempt.ts / runtime.ts can share the same
* normalization without re-implementing.
*/
export function normalizeCopilotHomePath(value: string): string {
return normalize(resolve(value)).replace(new RegExp(`${escapeForRegex(sep)}+$`), "");
}
function escapeForRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,242 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createInfiniteSessionConfig, writeOpenClawCompactionMarker } 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");
});
});
describe("writeOpenClawCompactionMarker", () => {
it("writes a JSON marker with expected shape under <workspaceDir>/files", async () => {
const workspaceDir = await mkdtemp(join(tmpdir(), "copilot-compaction-"));
try {
const written = await writeOpenClawCompactionMarker(
{
sessionId: "openclaw-sess-123",
workspaceDir,
trigger: "manual",
currentTokenCount: 42,
sdkSessionId: "sdk-sess-abc",
reason: "deferred-to-sdk-infinite-sessions",
},
{ now: () => 1_700_000_000_000 },
);
expect(written.path).toBe(
join(workspaceDir, "files", "openclaw-compaction-1700000000000-openclaw-sess-123.json"),
);
expect(written.marker).toEqual({
version: 1,
source: "copilot-harness",
sessionId: "openclaw-sess-123",
ts: 1_700_000_000_000,
compacted: false,
trigger: "manual",
sdkSessionId: "sdk-sess-abc",
currentTokenCount: 42,
reason: "deferred-to-sdk-infinite-sessions",
});
const contents = await readFile(written.path, "utf8");
expect(contents.endsWith("\n")).toBe(true);
expect(JSON.parse(contents)).toEqual(written.marker);
} finally {
await rm(workspaceDir, { recursive: true, force: true });
}
});
it("records force:true in the marker without acting on it", async () => {
const writes: Array<{ path: string; contents: string }> = [];
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async (path: string, contents: string) => {
writes.push({ path, contents });
}),
};
const written = await writeOpenClawCompactionMarker(
{
sessionId: "s1",
workspaceDir: "/ws",
force: true,
reason: "force-requested-but-sdk-has-no-synchronous-compact-api",
},
{ now: () => 1, fs: fs as never },
);
expect(written.marker.force).toBe(true);
expect(written.marker.compacted).toBe(false);
expect(writes).toHaveLength(1);
expect(JSON.parse(writes[0].contents)).toMatchObject({ force: true });
});
it("omits force / trigger / sdkSessionId / currentTokenCount when undefined", async () => {
const writes: Array<{ path: string; contents: string }> = [];
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async (path: string, contents: string) => {
writes.push({ path, contents });
}),
};
const written = await writeOpenClawCompactionMarker(
{ sessionId: "s1", workspaceDir: "/ws" },
{ now: () => 7, fs: fs as never },
);
expect(written.marker).toEqual({
version: 1,
source: "copilot-harness",
sessionId: "s1",
ts: 7,
compacted: false,
});
const parsed = JSON.parse(writes[0].contents);
expect(parsed).not.toHaveProperty("force");
expect(parsed).not.toHaveProperty("trigger");
expect(parsed).not.toHaveProperty("sdkSessionId");
expect(parsed).not.toHaveProperty("currentTokenCount");
expect(parsed).not.toHaveProperty("reason");
});
it("sanitizes sessionId chars in the filename", async () => {
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
};
const written = await writeOpenClawCompactionMarker(
{ sessionId: "abc:/?\\@!def", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never },
);
expect(written.path).toContain("openclaw-compaction-1-abc______def.json");
// sessionId in the marker body stays the original unsanitized value.
expect(written.marker.sessionId).toBe("abc:/?\\@!def");
});
it("creates the subdir recursively before writing", async () => {
const calls: Array<{ kind: "mkdir" | "write"; path: string; opts?: unknown }> = [];
const fs = {
mkdir: vi.fn(async (path: string, opts: unknown) => {
calls.push({ kind: "mkdir", path, opts });
}),
writeFile: vi.fn(async (path: string) => {
calls.push({ kind: "write", path });
}),
};
await writeOpenClawCompactionMarker(
{ sessionId: "s", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never },
);
expect(calls[0]).toEqual({ kind: "mkdir", path: "/ws/files", opts: { recursive: true } });
expect(calls[1]?.kind).toBe("write");
});
it("honours a custom subdir option", async () => {
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
};
const written = await writeOpenClawCompactionMarker(
{ sessionId: "s", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never, subdir: "compaction" },
);
expect(written.path).toBe("/ws/compaction/openclaw-compaction-1-s.json");
});
it("surfaces mkdir failures", async () => {
const fs = {
mkdir: vi.fn(async () => {
throw new Error("EACCES");
}),
writeFile: vi.fn(async () => undefined),
};
await expect(
writeOpenClawCompactionMarker(
{ sessionId: "s", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never },
),
).rejects.toThrow("EACCES");
expect(fs.writeFile).not.toHaveBeenCalled();
});
it("surfaces writeFile failures", async () => {
const fs = {
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async () => {
throw new Error("ENOSPC");
}),
};
await expect(
writeOpenClawCompactionMarker(
{ sessionId: "s", workspaceDir: "/ws" },
{ now: () => 1, fs: fs as never },
),
).rejects.toThrow("ENOSPC");
});
it("throws on missing sessionId", async () => {
await expect(
writeOpenClawCompactionMarker({ sessionId: "", workspaceDir: "/ws" }),
).rejects.toThrow(/sessionId is required/);
});
it("throws on missing workspaceDir", async () => {
await expect(
writeOpenClawCompactionMarker({ sessionId: "s", workspaceDir: "" }),
).rejects.toThrow(/workspaceDir is required/);
});
});

View File

@@ -0,0 +1,183 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { SessionConfig } from "@github/copilot-sdk";
// Compaction bridge for the GitHub Copilot agent runtime.
//
// Two responsibilities:
//
// 1. Shape `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 (background at
// `backgroundCompactionThreshold`, blocking at
// `bufferExhaustionThreshold`).
//
// 2. Write an OpenClaw-shaped JSON marker file at
// `<workspaceDir>/files/openclaw-compaction-<sessionId>-<ts>.json`
// whenever the host calls `harness.compact(params)`. Existing
// OpenClaw transcript readers look in `workspacePath/files/` for
// compaction artifacts; the marker keeps them informed even
// though the SDK now owns the actual context-window mechanics
// under infiniteSessions.
//
// 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;
}
export interface OpenClawCompactionMarkerInput {
/** OpenClaw session id (CompactEmbeddedPiSessionParams.sessionId). */
readonly sessionId: string;
/** Workspace dir (CompactEmbeddedPiSessionParams.workspaceDir). */
readonly workspaceDir: string;
/** Compaction trigger from CompactEmbeddedPiSessionParams.trigger. */
readonly trigger?: "budget" | "overflow" | "manual";
/** Optional caller-observed token count at compaction time. */
readonly currentTokenCount?: number;
/** Optional active SDK session id when the marker is written. */
readonly sdkSessionId?: string;
/** Optional reason string for the marker. */
readonly reason?: string;
/**
* Whether the host passed `force: true` in CompactEmbeddedPiSessionParams.
* Recorded for diagnostics — the harness cannot synchronously force
* compaction since the SDK has no on-demand compact RPC.
*/
readonly force?: boolean;
}
export interface OpenClawCompactionMarkerOptions {
/** Override `Date.now`. Default: `Date.now`. */
readonly now?: () => number;
/** Override `node:fs/promises` writers. Useful in tests. */
readonly fs?: Pick<typeof import("node:fs/promises"), "mkdir" | "writeFile">;
/**
* Subdirectory under workspaceDir that holds the markers. Default
* `files` to match the proposal-defined location.
*/
readonly subdir?: string;
}
export interface OpenClawCompactionMarker {
readonly version: 1;
readonly source: "copilot-harness";
readonly sessionId: string;
readonly ts: number;
/**
* Whether actual compaction occurred. Always false from the harness
* path: SDK auto-compaction runs asynchronously in the background
* and the harness does not synchronously force it.
*/
readonly compacted: false;
readonly trigger?: "budget" | "overflow" | "manual";
readonly force?: boolean;
readonly sdkSessionId?: string;
readonly currentTokenCount?: number;
readonly reason?: string;
}
export interface WrittenOpenClawCompactionMarker {
readonly path: string;
readonly marker: OpenClawCompactionMarker;
}
function compactJsonValue<T extends Record<string, unknown>>(input: T): T {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) {
out[key] = value;
}
}
return out as T;
}
/**
* Write an OpenClaw-shaped compaction marker JSON file under
* `<workspaceDir>/<subdir>/openclaw-compaction-<sessionId>-<ts>.json`.
*
* Returns the resolved file path and the marker payload that was
* written. Throws if the workspaceDir or sessionId is missing/empty
* (the caller should not invoke this without those — the harness
* `compact()` must validate first).
*/
export async function writeOpenClawCompactionMarker(
input: OpenClawCompactionMarkerInput,
options: OpenClawCompactionMarkerOptions = {},
): Promise<WrittenOpenClawCompactionMarker> {
if (!input.workspaceDir || typeof input.workspaceDir !== "string") {
throw new Error("[copilot:compaction-bridge] workspaceDir is required to write a marker");
}
if (!input.sessionId || typeof input.sessionId !== "string") {
throw new Error("[copilot:compaction-bridge] sessionId is required to write a marker");
}
const now = options.now ?? Date.now;
const fs = options.fs ?? { mkdir, writeFile };
const subdir = options.subdir ?? "files";
const ts = now();
const safeSessionId = input.sessionId.replace(/[^a-zA-Z0-9._-]/g, "_");
// Filename pattern: ts-first so listings sort chronologically. Suffix
// sessionId for collision safety when multiple sessions share a
// workspace. Matches the proposal's `openclaw-compaction-<ts>` prefix.
const filename = `openclaw-compaction-${ts}-${safeSessionId}.json`;
const dirPath = join(input.workspaceDir, subdir);
const filePath = join(dirPath, filename);
const marker: OpenClawCompactionMarker = compactJsonValue({
version: 1 as const,
source: "copilot-harness" as const,
sessionId: input.sessionId,
ts,
compacted: false as const,
trigger: input.trigger,
force: input.force,
sdkSessionId: input.sdkSessionId,
currentTokenCount: input.currentTokenCount,
reason: input.reason,
});
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(filePath, `${JSON.stringify(marker, null, 2)}\n`, "utf8");
return { path: filePath, marker };
}

View File

@@ -0,0 +1,283 @@
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
probeCopilotAuthShape,
probeCopilotCliVersion,
probeCopilotHomeWritable,
} from "./doctor-probes.js";
type FakeChildOptions = {
exitCode?: number | null;
signal?: NodeJS.Signals | null;
stdout?: string;
stderr?: string;
emitErrorMessage?: string;
/** When true, never emits close; useful for timeout tests. */
hang?: boolean;
};
function makeFakeChild(opts: FakeChildOptions = {}) {
const emitter = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: () => void;
};
emitter.stdout = new EventEmitter();
emitter.stderr = new EventEmitter();
emitter.kill = vi.fn();
queueMicrotask(() => {
if (opts.stdout) {
emitter.stdout.emit("data", Buffer.from(opts.stdout, "utf8"));
}
if (opts.stderr) {
emitter.stderr.emit("data", Buffer.from(opts.stderr, "utf8"));
}
if (opts.emitErrorMessage) {
emitter.emit("error", new Error(opts.emitErrorMessage));
return;
}
if (!opts.hang) {
emitter.emit("close", opts.exitCode ?? 0, opts.signal ?? null);
}
});
return emitter;
}
const tempDirs: string[] = [];
afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
});
async function makeTempHome(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-copilot-doctor-"));
tempDirs.push(dir);
return dir;
}
describe("probeCopilotCliVersion", () => {
it("reports ok with trimmed version on exit 0 with stdout", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ stdout: " 1.2.3 \n" }) as never,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.version).toBe("1.2.3");
expect(result.command).toBe("copilot");
}
});
it("uses custom command and args when provided", async () => {
const calls: Array<{ cmd: string; args: string[] }> = [];
const result = await probeCopilotCliVersion({
command: "my-copilot",
args: ["-V"],
spawnFn: ((cmd: string, args: readonly string[]) => {
calls.push({ cmd, args: [...args] });
return makeFakeChild({ stdout: "9.9.9" }) as never;
}) as never,
});
expect(calls).toEqual([{ cmd: "my-copilot", args: ["-V"] }]);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.command).toBe("my-copilot");
}
});
it("reports non-zero-exit with stderr details", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ exitCode: 2, stderr: "boom: not installed" }) as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("non-zero-exit");
expect(result.details?.exitCode).toBe(2);
expect(result.details?.stderr).toBe("boom: not installed");
}
});
it("reports empty-version when exit 0 produces no stdout", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ stdout: " \n" }) as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("empty-version");
}
});
it("reports spawn-failed when spawnFn throws synchronously (e.g. ENOENT)", async () => {
const result = await probeCopilotCliVersion({
spawnFn: (() => {
throw new Error("ENOENT: copilot not found");
}) as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("spawn-failed");
expect(result.details?.rawError).toContain("ENOENT");
}
});
it("reports spawn-error when child emits 'error'", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ emitErrorMessage: "spawn ENOEXEC" }) as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("spawn-error");
expect(result.details?.rawError).toBe("spawn ENOEXEC");
}
});
it("reports probe-timeout when child hangs past timeoutMs and kills the child", async () => {
const fakeChild = makeFakeChild({ hang: true });
const result = await probeCopilotCliVersion({
timeoutMs: 10,
spawnFn: () => fakeChild as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("probe-timeout");
expect(result.details?.timeoutMs).toBe(10);
}
expect(fakeChild.kill).toHaveBeenCalled();
});
it("returns just the first non-empty line as version when stdout has a banner / update hint", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () =>
makeFakeChild({
stdout: "GitHub Copilot CLI 1.0.48.\nRun 'copilot update' to check for updates.\n",
}) as never,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.version).toBe("GitHub Copilot CLI 1.0.48.");
expect(result.rawStdout).toBe(
"GitHub Copilot CLI 1.0.48.\nRun 'copilot update' to check for updates.",
);
}
});
it("does not surface rawStdout when stdout is already single-line", async () => {
const result = await probeCopilotCliVersion({
spawnFn: () => makeFakeChild({ stdout: "1.2.3\n" }) as never,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.version).toBe("1.2.3");
expect(result.rawStdout).toBeUndefined();
}
});
});
describe("probeCopilotHomeWritable", () => {
it("reports ok when the directory exists and is writable, cleaning up after itself", async () => {
const home = await makeTempHome();
const result = await probeCopilotHomeWritable(home);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.copilotHome).toBe(home);
expect(result.probedPath.startsWith(home)).toBe(true);
}
const entries = await fs.readdir(home);
expect(entries).toEqual([]);
});
it("creates copilotHome if missing", async () => {
const root = await makeTempHome();
const home = path.join(root, "nested", "copilot-cfg");
const result = await probeCopilotHomeWritable(home);
expect(result.ok).toBe(true);
const stat = await fs.stat(home);
expect(stat.isDirectory()).toBe(true);
});
it("reports copilothome-not-writable when fs throws on mkdir", async () => {
const result = await probeCopilotHomeWritable("/some/path", {
fsApi: {
mkdir: vi.fn().mockRejectedValueOnce(new Error("EPERM: not permitted")),
writeFile: vi.fn(),
rm: vi.fn(),
} as never,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("copilothome-not-writable");
expect(result.details?.rawError).toContain("EPERM");
}
});
it("falls back to the platform default copilotHome when argument is empty or whitespace", async () => {
const writeFile = vi.fn().mockResolvedValue(undefined);
const result = await probeCopilotHomeWritable(" ", {
fsApi: {
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile,
rm: vi.fn().mockResolvedValue(undefined),
} as never,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.copilotHome.length).toBeGreaterThan(0);
expect(result.copilotHome.toLowerCase()).toContain("copilot");
}
});
});
describe("probeCopilotAuthShape", () => {
it("resolves to useLoggedInUser when the flag is true", () => {
const result = probeCopilotAuthShape({ useLoggedInUser: true });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.resolvedMode).toBe("useLoggedInUser");
}
});
it("resolves to gitHubToken when a non-empty token is supplied", () => {
const result = probeCopilotAuthShape({ gitHubToken: "ghp_xxx" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.resolvedMode).toBe("gitHubToken");
}
});
it("resolves to profile when both profileId and profileVersion are supplied", () => {
const result = probeCopilotAuthShape({ profileId: "p1", profileVersion: "v1" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.resolvedMode).toBe("profile");
}
});
it("rejects when no auth source is provided", () => {
const result = probeCopilotAuthShape({});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("no-auth-source");
}
});
it("rejects when only one of profileId / profileVersion is provided", () => {
expect(probeCopilotAuthShape({ profileId: "p1" }).ok).toBe(false);
expect(probeCopilotAuthShape({ profileVersion: "v1" }).ok).toBe(false);
});
it("rejects useLoggedInUser:false on its own", () => {
const result = probeCopilotAuthShape({ useLoggedInUser: false });
expect(result.ok).toBe(false);
});
it("rejects an empty gitHubToken string", () => {
const result = probeCopilotAuthShape({ gitHubToken: "" });
expect(result.ok).toBe(false);
});
});

View File

@@ -0,0 +1,260 @@
/**
* Runtime doctor probes for the copilot extension.
*
* Imperative side-effecting checks used to diagnose a copilot
* deployment from within `openclaw doctor` (or any equivalent
* harness-side health check). Kept out of doctor-contract-api.ts
* because that contract is declarative and auto-loaded by the
* plugin registry, whereas these probes spawn subprocesses or
* touch the filesystem and must be invoked imperatively.
*
* All probes are pure (no module-level state) and dependency-
* injectable for tests. They never throw on a probe-negative
* result — failure is surfaced via the `ok: false` shape so the
* caller can render a structured doctor report.
*/
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export type ProbeResult<TPayload extends object = Record<string, never>> =
| ({ ok: true } & TPayload)
| { ok: false; reason: string; details?: Record<string, unknown> };
export interface ProbeCopilotCliVersionOptions {
/** Command to invoke; defaults to "copilot". */
command?: string;
/** Argv used to ask for version; defaults to ["--version"]. */
args?: readonly string[];
/** Timeout in milliseconds; defaults to 5_000. */
timeoutMs?: number;
/** Injection seam for testing. Defaults to node:child_process spawn. */
spawnFn?: typeof spawn;
}
export interface ProbeCopilotHomeOptions {
/** Injection seam for testing. */
fsApi?: Pick<typeof fs, "mkdir" | "writeFile" | "rm">;
/** Filename used for the writability probe. */
probeFileName?: string;
}
const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
const DEFAULT_PROBE_FILENAME = ".copilot-doctor-probe";
/**
* Probe that the Copilot CLI is installed and prints a version.
* Treats non-zero exit, missing stdout, and timeout all as failures.
*/
export async function probeCopilotCliVersion(
options: ProbeCopilotCliVersionOptions = {},
): Promise<ProbeResult<{ version: string; command: string; rawStdout?: string }>> {
const command = options.command ?? "copilot";
const args = options.args ?? ["--version"];
const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
const spawnImpl = options.spawnFn ?? spawn;
return new Promise<ProbeResult<{ version: string; command: string; rawStdout?: string }>>(
(resolve) => {
let child: ReturnType<typeof spawn> | undefined;
let settled = false;
const settle = (
result: ProbeResult<{ version: string; command: string; rawStdout?: string }>,
): void => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
try {
child?.kill();
} catch {
// ignore double-kill / already-dead errors
}
resolve(result);
};
const timer = setTimeout(() => {
settle({
ok: false,
reason: "probe-timeout",
details: { command, args: [...args], timeoutMs },
});
}, timeoutMs);
try {
child = spawnImpl(command, [...args], { stdio: ["ignore", "pipe", "pipe"] });
} catch (error) {
settle({
ok: false,
reason: "spawn-failed",
details: { command, args: [...args], rawError: formatProbeError(error) },
});
return;
}
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
child.on("error", (error: Error) => {
settle({
ok: false,
reason: "spawn-error",
details: { command, args: [...args], rawError: error.message },
});
});
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (code !== 0) {
settle({
ok: false,
reason: "non-zero-exit",
details: {
command,
args: [...args],
exitCode: code,
signal,
stderr: stderr.trim() || undefined,
},
});
return;
}
const rawStdout = stdout.trim();
if (!rawStdout) {
settle({
ok: false,
reason: "empty-version",
details: { command, args: [...args] },
});
return;
}
// Many version commands (notably the bundled `copilot --version`)
// print a banner plus an "update available" hint on subsequent
// lines. Surface only the first non-empty line as `version` so the
// doctor UI gets a clean string; keep the full stdout in
// `rawStdout` for debugging.
const version = firstNonEmptyLine(rawStdout) ?? rawStdout;
const payload: { version: string; command: string; rawStdout?: string } = {
version,
command,
};
if (rawStdout !== version) {
payload.rawStdout = rawStdout;
}
settle({ ok: true, ...payload });
});
},
);
}
function firstNonEmptyLine(value: string): string | undefined {
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return undefined;
}
/**
* Probe that copilotHome (or default ~/.config/copilot) is writable
* by the running user. Mirrors the existing auth-bridge's expectation
* that the SDK can persist credentials under copilotHome.
*/
export async function probeCopilotHomeWritable(
copilotHome: string | undefined,
options: ProbeCopilotHomeOptions = {},
): Promise<ProbeResult<{ copilotHome: string; probedPath: string }>> {
const fsApi = options.fsApi ?? fs;
const probeFileName = options.probeFileName ?? DEFAULT_PROBE_FILENAME;
const resolvedHome =
typeof copilotHome === "string" && copilotHome.trim().length > 0
? copilotHome.trim()
: defaultCopilotHome();
const probedPath = path.join(resolvedHome, probeFileName);
try {
await fsApi.mkdir(resolvedHome, { recursive: true });
await fsApi.writeFile(probedPath, "copilot-doctor-probe", "utf8");
await fsApi.rm(probedPath, { force: true });
return { ok: true, copilotHome: resolvedHome, probedPath };
} catch (error) {
return {
ok: false,
reason: "copilothome-not-writable",
details: {
copilotHome: resolvedHome,
probedPath,
rawError: formatProbeError(error),
},
};
}
}
/**
* Probe GitHub Copilot agent runtime auth resolution given a useLoggedInUser hint.
* Validates that at least one of {useLoggedInUser, gitHubToken,
* profileId+profileVersion} is set. This is intentionally a
* shape-only probe: actually performing an SDK auth handshake
* would require a pool and is out of scope for `openclaw doctor`.
*/
export function probeCopilotAuthShape(input: {
useLoggedInUser?: boolean;
gitHubToken?: string;
profileId?: string;
profileVersion?: string;
}): ProbeResult<{ resolvedMode: "useLoggedInUser" | "gitHubToken" | "profile" }> {
if (input.useLoggedInUser === true) {
return { ok: true, resolvedMode: "useLoggedInUser" };
}
if (typeof input.gitHubToken === "string" && input.gitHubToken.length > 0) {
return { ok: true, resolvedMode: "gitHubToken" };
}
if (
typeof input.profileId === "string" &&
input.profileId.length > 0 &&
typeof input.profileVersion === "string" &&
input.profileVersion.length > 0
) {
return { ok: true, resolvedMode: "profile" };
}
return {
ok: false,
reason: "no-auth-source",
details: {
hint: "Set useLoggedInUser:true, or gitHubToken, or both profileId+profileVersion",
},
};
}
function defaultCopilotHome(): string {
// Mirrors the SDK convention; auth-bridge uses the same default.
if (process.platform === "win32") {
return path.join(process.env.APPDATA ?? os.homedir(), "copilot");
}
const xdg = process.env.XDG_CONFIG_HOME;
if (xdg && xdg.length > 0) {
return path.join(xdg, "copilot");
}
return path.join(os.homedir(), ".config", "copilot");
}
function formatProbeError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}

View File

@@ -0,0 +1,376 @@
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,
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,
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,
sessionKey: "session-1",
messages: [...messages],
idempotencyScope: "copilot:session-1",
});
await mirrorCopilotTranscript({
sessionFile,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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 () => {
// Pointing sessionFile at a path under a non-existent root with an
// empty-string segment can fail differently on different platforms;
// instead force failure by passing an invalid type and asserting
// that the wrapper itself does not reject. Use any-cast for the
// bad input shape since we are testing the wrapper's catch.
await expect(
dualWriteCopilotTranscriptBestEffort({
sessionFile: "" as unknown as string,
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "should-not-throw" }],
timestamp: Date.now(),
}),
],
idempotencyScope: "scope",
}),
).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,220 @@
/**
* 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 fs from "node:fs/promises";
import {
acquireSessionWriteLock,
appendSessionTranscriptMessage,
emitSessionTranscriptUpdate,
resolveSessionWriteLockAcquireTimeoutMs,
runAgentHarnessBeforeMessageWriteHook,
type AgentMessage,
type SessionWriteLockAcquireTimeoutConfig,
} from "openclaw/plugin-sdk/agent-harness-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;
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?: SessionWriteLockAcquireTimeoutConfig;
}
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 lock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
timeoutMs: resolveSessionWriteLockAcquireTimeoutMs(params.config),
});
try {
const existingIdempotencyKeys = await readTranscriptIdempotencyKeys(params.sessionFile);
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;
await appendSessionTranscriptMessage({
transcriptPath: params.sessionFile,
message: messageToAppend,
config: params.config,
});
if (idempotencyKey) {
existingIdempotencyKeys.add(idempotencyKey);
}
}
} finally {
await lock.release();
}
if (params.sessionKey) {
emitSessionTranscriptUpdate({ sessionFile: params.sessionFile, sessionKey: params.sessionKey });
} else {
emitSessionTranscriptUpdate(params.sessionFile);
}
}
async function readTranscriptIdempotencyKeys(sessionFile: string): Promise<Set<string>> {
const keys = new Set<string>();
let raw: string;
try {
raw = await fs.readFile(sessionFile, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
return keys;
}
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
try {
const parsed = JSON.parse(line) as { message?: { idempotencyKey?: unknown } };
if (typeof parsed.message?.idempotencyKey === "string") {
keys.add(parsed.message.idempotencyKey);
}
} catch {
continue;
}
}
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);
}
}

View File

@@ -0,0 +1,828 @@
import type { SessionEvent } from "@github/copilot-sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
import { attachEventBridge, type SessionLike } from "./event-bridge.js";
const MODEL_REF = {
api: "openai-responses",
id: "gpt-5",
provider: "github-copilot",
} as const;
const REGISTERED_EVENT_TYPES = [
"assistant.message_delta",
"assistant.reasoning_delta",
"assistant.message",
"assistant.usage",
"tool.execution_start",
"tool.execution_complete",
"session.error",
"abort",
] as const;
type FakeSession = SessionLike & {
emit: (eventType: string, event: SessionEvent) => void;
listenerCount: (eventType: string) => number;
};
function createDeferred<T>() {
let rejectPromise: ((reason?: unknown) => void) | undefined;
let resolvePromise: ((value: T | PromiseLike<T>) => void) | undefined;
const promise = new Promise<T>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
return {
promise,
reject(reason?: unknown) {
rejectPromise?.(reason);
},
resolve(value: T) {
resolvePromise?.(value);
},
};
}
function flushAsync() {
// oxlint-disable-next-line unicorn/no-useless-promise-resolve-reject -- the inner Promise.resolve() forces an additional microtask tick so delta-chain ordering can be observed deterministically in tests.
return Promise.resolve().then(() => Promise.resolve());
}
function makeEvent(type: string, data: Record<string, unknown>): SessionEvent {
return {
data,
id: `${type}-id`,
parentId: null,
timestamp: "2024-01-01T00:00:00.000Z",
type,
} as SessionEvent;
}
function makeAssistantMessageEvent(
content = "assistant text",
overrides: Record<string, unknown> = {},
): SessionEvent {
return makeEvent("assistant.message", {
content,
messageId: "msg-1",
model: "gpt-5",
...overrides,
});
}
function createFakeSession(
options: {
onOff?: (eventType: string) => void;
onReturnedUnsubscribe?: (eventType: string) => void;
returnUnsubscribe?: boolean;
} = {},
): FakeSession {
const listeners = new Map<string, Array<(event: SessionEvent) => void>>();
const returnUnsubscribe = options.returnUnsubscribe !== false;
const off = vi.fn((eventType: string, handler: (event: SessionEvent) => void) => {
options.onOff?.(eventType);
listeners.set(
eventType,
(listeners.get(eventType) ?? []).filter((existing) => existing !== handler),
);
});
const on = vi.fn((eventType: string, handler: (event: SessionEvent) => void) => {
listeners.set(eventType, [...(listeners.get(eventType) ?? []), handler]);
if (!returnUnsubscribe) {
return undefined;
}
return () => {
options.onReturnedUnsubscribe?.(eventType);
off(eventType, handler);
};
});
return {
abort: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
emit(eventType: string, event: SessionEvent) {
for (const handler of listeners.get(eventType) ?? []) {
handler(event);
}
},
id: "session-id",
listenerCount(eventType: string) {
return listeners.get(eventType)?.length ?? 0;
},
off,
on,
sendAndWait: vi.fn().mockResolvedValue(undefined),
sessionId: "sdk-session-id",
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("attachEventBridge", () => {
it("assistant.message_delta accumulates text per messageId in arrival order", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "he", messageId: "msg-1" }),
);
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "llo", messageId: "msg-1" }),
);
expect(bridge.snapshot().assistantTexts).toEqual(["hello"]);
});
it("interleaved messageIds produce two ordered assistantTexts entries", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "a", messageId: "msg-1" }),
);
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "x", messageId: "msg-2" }),
);
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "b", messageId: "msg-1" }),
);
expect(bridge.snapshot().assistantTexts).toEqual(["ab", "x"]);
});
it("onAssistantDelta receives appended text, live sessionId, and current usage", async () => {
const session = createFakeSession();
let sdkSessionId = "sdk-session-1";
const onAssistantDelta = vi.fn().mockResolvedValue(undefined);
const bridge = attachEventBridge(session, {
getSdkSessionId: () => sdkSessionId,
isAborted: () => false,
onAssistantDelta,
});
session.emit(
"assistant.usage",
makeEvent("assistant.usage", {
cacheReadTokens: 1,
cacheWriteTokens: 2,
inputTokens: 3,
outputTokens: 4,
}),
);
sdkSessionId = "sdk-session-2";
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "hi", messageId: "msg-1" }),
);
await bridge.awaitDeltaChain();
expect(onAssistantDelta).toHaveBeenCalledTimes(1);
expect(onAssistantDelta).toHaveBeenCalledWith({
delta: "hi",
sessionId: "sdk-session-2",
text: "hi",
usage: {
cacheRead: 1,
cacheWrite: 2,
input: 3,
output: 4,
total: 10,
},
});
});
it("onAssistantDelta callbacks are serialized and awaitDeltaChain resolves after both", async () => {
const session = createFakeSession();
const order: string[] = [];
const releases: Array<() => void> = [];
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onAssistantDelta: vi.fn(async (payload: { delta: string }) => {
order.push(`start:${payload.delta}`);
await new Promise<void>((resolve) => {
releases.push(() => {
order.push(`end:${payload.delta}`);
resolve();
});
});
}),
});
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "a", messageId: "msg-1" }),
);
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "b", messageId: "msg-1" }),
);
await flushAsync();
expect(order).toEqual(["start:a"]);
releases[0]?.();
await flushAsync();
expect(order).toEqual(["start:a", "end:a", "start:b"]);
releases[1]?.();
await expect(bridge.awaitDeltaChain()).resolves.toBeUndefined();
expect(order).toEqual(["start:a", "end:a", "start:b", "end:b"]);
});
it("onAssistantDelta rejection propagates through awaitDeltaChain while later deltas still serialize", async () => {
const session = createFakeSession();
const order: string[] = [];
const firstError = new Error("delta failed");
const secondDeferred = createDeferred<void>();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onAssistantDelta: vi.fn((payload: { delta: string }) => {
order.push(`start:${payload.delta}`);
if (payload.delta === "a") {
return Promise.reject(firstError);
}
return secondDeferred.promise.then(() => {
order.push(`end:${payload.delta}`);
});
}),
});
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "a", messageId: "msg-1" }),
);
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "b", messageId: "msg-1" }),
);
await flushAsync();
await flushAsync();
expect(order).toEqual(["start:a", "start:b"]);
secondDeferred.resolve(undefined);
await expect(bridge.awaitDeltaChain()).rejects.toBe(firstError);
expect(order).toEqual(["start:a", "start:b", "end:b"]);
});
it("assistant.reasoning_delta accumulates reasoning in arrival order for buildAssistantMessage", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"assistant.reasoning_delta",
makeEvent("assistant.reasoning_delta", { deltaContent: "thin", reasoningId: "reason-1" }),
);
session.emit(
"assistant.reasoning_delta",
makeEvent("assistant.reasoning_delta", { deltaContent: "king", reasoningId: "reason-1" }),
);
bridge.recordSendResult(makeAssistantMessageEvent("done"));
expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 7 })?.content).toEqual([
{ thinking: "thinking", type: "thinking" },
{ text: "done", type: "text" },
]);
});
it("buildAssistantMessage prefers terminal reasoningText over reasoning deltas", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"assistant.reasoning_delta",
makeEvent("assistant.reasoning_delta", { deltaContent: "older", reasoningId: "reason-1" }),
);
bridge.recordSendResult(
makeAssistantMessageEvent("done", {
reasoningText: "terminal reasoning",
}),
);
expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 8 })?.content).toEqual([
{ thinking: "terminal reasoning", type: "thinking" },
{ text: "done", type: "text" },
]);
});
it("assistant.message only overwrites accumulated text when content is at least as long", () => {
const shorterSession = createFakeSession();
const shorterBridge = attachEventBridge(shorterSession, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
shorterSession.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "longer", messageId: "msg-1" }),
);
shorterSession.emit(
"assistant.message",
makeAssistantMessageEvent("short", { messageId: "msg-1" }),
);
const longerSession = createFakeSession();
const longerBridge = attachEventBridge(longerSession, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
longerSession.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "tiny", messageId: "msg-1" }),
);
longerSession.emit(
"assistant.message",
makeAssistantMessageEvent("longer text", { messageId: "msg-1" }),
);
expect(shorterBridge.finalizeAssistantTexts()).toEqual(["longer"]);
expect(longerBridge.finalizeAssistantTexts()).toEqual(["longer text"]);
});
it("assistant.message with toolRequests produces toolCall content and toolUse stopReason", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
bridge.recordSendResult(
makeAssistantMessageEvent("call tool", {
outputTokens: 7,
toolRequests: [
{
arguments: { path: "README.md" },
name: "read_file",
toolCallId: "call-1",
},
],
}),
);
expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 9 })).toEqual({
api: "openai-responses",
content: [
{ text: "call tool", type: "text" },
{
arguments: { path: "README.md" },
id: "call-1",
name: "read_file",
type: "toolCall",
},
],
model: "gpt-5",
provider: "github-copilot",
role: "assistant",
stopReason: "toolUse",
timestamp: 9,
usage: {
cacheRead: 0,
cacheWrite: 0,
cost: {
cacheRead: 0,
cacheWrite: 0,
input: 0,
output: 0,
total: 0,
},
input: 0,
output: 7,
totalTokens: 7,
},
});
});
it("assistant.usage updates internal usage and the next onAssistantDelta payload reads it", async () => {
const session = createFakeSession();
const onAssistantDelta = vi.fn().mockResolvedValue(undefined);
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onAssistantDelta,
});
session.emit(
"assistant.usage",
makeEvent("assistant.usage", {
cacheReadTokens: -2,
cacheWriteTokens: Number.NaN,
inputTokens: 4.9,
outputTokens: 5.1,
}),
);
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "x", messageId: "msg-1" }),
);
await bridge.awaitDeltaChain();
expect(onAssistantDelta).toHaveBeenCalledWith({
delta: "x",
sessionId: "sdk-session-id",
text: "x",
usage: {
cacheRead: 0,
cacheWrite: undefined,
input: 4,
output: 5,
total: 9,
},
});
});
it("preserves all-zero usage snapshot after an invalid assistant.usage event", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
bridge.recordSendResult(makeAssistantMessageEvent("done", { outputTokens: 7 }));
session.emit(
"assistant.usage",
makeEvent("assistant.usage", {
cacheReadTokens: "bad",
cacheWriteTokens: Number.POSITIVE_INFINITY,
inputTokens: undefined,
outputTokens: Number.NaN,
}),
);
expect(bridge.snapshot().usage).toEqual({
cacheRead: undefined,
cacheWrite: undefined,
input: undefined,
output: undefined,
total: 0,
});
expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 9.5 })?.usage).toEqual({
cacheRead: 0,
cacheWrite: 0,
cost: {
cacheRead: 0,
cacheWrite: 0,
input: 0,
output: 0,
total: 0,
},
input: 0,
output: 0,
totalTokens: 0,
});
});
it("overwrites prior usage with an all-zero snapshot when a later invalid usage event arrives", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"assistant.usage",
makeEvent("assistant.usage", {
inputTokens: 5,
}),
);
session.emit(
"assistant.usage",
makeEvent("assistant.usage", {
inputTokens: "bad",
}),
);
expect(bridge.snapshot().usage).toEqual({
cacheRead: undefined,
cacheWrite: undefined,
input: undefined,
output: undefined,
total: 0,
});
});
it("tool.execution_start increments startedCount and pushes toolMetas without meta", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"tool.execution_start",
makeEvent("tool.execution_start", { toolCallId: "call-1", toolName: "bash" }),
);
expect(bridge.snapshot()).toEqual({
assistantTexts: [],
completedCount: 0,
lastAssistantEvent: undefined,
startedCount: 1,
streamError: undefined,
toolMetas: [{ toolName: "bash" }],
usage: undefined,
});
});
it("tool.execution_complete uses detailedContent or content on success and error.message on failure", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"tool.execution_start",
makeEvent("tool.execution_start", { toolCallId: "call-1", toolName: "bash" }),
);
session.emit(
"tool.execution_complete",
makeEvent("tool.execution_complete", {
result: { content: "content", detailedContent: "details" },
success: true,
toolCallId: "call-1",
}),
);
session.emit(
"tool.execution_start",
makeEvent("tool.execution_start", { toolCallId: "call-2", toolName: "read" }),
);
session.emit(
"tool.execution_complete",
makeEvent("tool.execution_complete", {
error: { message: "failed" },
success: false,
toolCallId: "call-2",
}),
);
expect(bridge.snapshot().toolMetas).toEqual([
{ toolName: "bash" },
{ meta: "details", toolName: "bash" },
{ toolName: "read" },
{ meta: "failed", toolName: "read" },
]);
});
it("tool.execution_complete without a matching start increments completedCount without pushing meta", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"tool.execution_complete",
makeEvent("tool.execution_complete", {
result: { content: "done" },
success: true,
toolCallId: "missing",
}),
);
expect(bridge.snapshot().completedCount).toBe(1);
expect(bridge.snapshot().toolMetas).toEqual([]);
});
it("session.error populates streamError with errorCode or errorType only when not aborted", () => {
const activeSession = createFakeSession();
const activeBridge = attachEventBridge(activeSession, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
activeSession.emit(
"session.error",
makeEvent("session.error", {
errorCode: "boom_code",
errorType: "boom_type",
message: "boom",
}),
);
const abortedSession = createFakeSession();
const abortedBridge = attachEventBridge(abortedSession, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => true,
});
abortedSession.emit(
"session.error",
makeEvent("session.error", {
errorType: "ignored",
message: "ignored",
}),
);
expect((activeBridge.snapshot().streamError as Error & { code?: string })?.code).toBe(
"boom_code",
);
expect(activeBridge.snapshot().streamError?.message).toBe("boom");
expect(abortedBridge.snapshot().streamError).toBeUndefined();
});
it("abort populates streamError with session_aborted only when not aborted", () => {
const activeSession = createFakeSession();
const activeBridge = attachEventBridge(activeSession, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
activeSession.emit("abort", makeEvent("abort", { reason: "because" }));
const abortedSession = createFakeSession();
const abortedBridge = attachEventBridge(abortedSession, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => true,
});
abortedSession.emit("abort", makeEvent("abort", { reason: "ignored" }));
expect((activeBridge.snapshot().streamError as Error & { code?: string })?.code).toBe(
"session_aborted",
);
expect(activeBridge.snapshot().streamError?.message).toBe(
"[copilot-attempt] session aborted: because",
);
expect(abortedBridge.snapshot().streamError).toBeUndefined();
});
it("recordSendResult returns false for undefined and true for assistant.message while updating lastAssistantEvent", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
expect(bridge.recordSendResult(undefined)).toBe(false);
const event = makeAssistantMessageEvent("done", { outputTokens: 2 });
expect(bridge.recordSendResult(event)).toBe(true);
expect(bridge.snapshot().lastAssistantEvent).toEqual(event);
expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 11 })?.content).toEqual([
{ text: "done", type: "text" },
]);
});
it("recordSendResult falls back to terminal content when no deltas arrived", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
bridge.recordSendResult(makeAssistantMessageEvent("done"));
expect(bridge.finalizeAssistantTexts()).toEqual(["done"]);
});
it("ignores empty assistant and reasoning deltas", () => {
const onAssistantDelta = vi.fn();
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onAssistantDelta,
});
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "", messageId: "msg-1" }),
);
session.emit(
"assistant.reasoning_delta",
makeEvent("assistant.reasoning_delta", { deltaContent: "", reasoningId: "reason-1" }),
);
session.emit("assistant.message", makeAssistantMessageEvent("", { messageId: "msg-1" }));
expect(onAssistantDelta).not.toHaveBeenCalled();
expect(bridge.finalizeAssistantTexts()).toEqual([]);
expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 13 })).toBeUndefined();
});
it("detach is idempotent after the first unsubscribe pass", () => {
const order: string[] = [];
const session = createFakeSession({
onReturnedUnsubscribe: (eventType) => {
order.push(eventType);
},
});
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
bridge.detach();
bridge.detach();
expect(order).toEqual([...REGISTERED_EVENT_TYPES].toReversed());
expect(session.off).toHaveBeenCalledTimes(REGISTERED_EVENT_TYPES.length);
});
it("detach unsubscribes in reverse order when session.on returns unsubscribe functions", () => {
const order: string[] = [];
const session = createFakeSession({
onReturnedUnsubscribe: (eventType) => {
order.push(eventType);
},
});
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
bridge.detach();
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "ignored", messageId: "msg-1" }),
);
expect(order).toEqual([...REGISTERED_EVENT_TYPES].toReversed());
expect(session.listenerCount("assistant.message_delta")).toBe(0);
});
it("detach unsubscribes in reverse order via off() fallback", () => {
const order: string[] = [];
const session = createFakeSession({
onOff: (eventType) => {
order.push(eventType);
},
returnUnsubscribe: false,
});
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
bridge.detach();
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "ignored", messageId: "msg-1" }),
);
expect(order).toEqual([...REGISTERED_EVENT_TYPES].toReversed());
expect(session.listenerCount("assistant.message_delta")).toBe(0);
});
it("buildAssistantMessage returns undefined with no event, text, reasoning, or toolRequests", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 12 })).toBeUndefined();
});
it("snapshot returns defensive copies for arrays and usage objects", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
});
session.emit(
"assistant.message_delta",
makeEvent("assistant.message_delta", { deltaContent: "hello", messageId: "msg-1" }),
);
session.emit(
"assistant.usage",
makeEvent("assistant.usage", { inputTokens: 1, outputTokens: 2 }),
);
session.emit(
"tool.execution_start",
makeEvent("tool.execution_start", { toolCallId: "call-1", toolName: "bash" }),
);
const first = bridge.snapshot();
(first.assistantTexts as string[]).push("mutated");
(first.toolMetas as Array<{ meta?: string; toolName: string }>)[0].toolName = "mutated";
(first.usage as { input?: number }).input = 999;
const second = bridge.snapshot();
expect(second.assistantTexts).toEqual(["hello"]);
expect(second.toolMetas).toEqual([{ toolName: "bash" }]);
expect(second.usage).toEqual({
cacheRead: undefined,
cacheWrite: undefined,
input: 1,
output: 2,
total: 3,
});
});
});

View File

@@ -0,0 +1,356 @@
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;
};
sendAndWait(options: MessageOptions, timeout?: number): Promise<SessionEvent | undefined>;
sessionId?: string;
}
export interface EventBridgeOptions {
onAssistantDelta?: (payload: OnAssistantDeltaPayload) => 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;
awaitDeltaChain(): Promise<void>;
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 deltaQueue = Promise.resolve();
let deltaChain = Promise.resolve();
let firstDeltaError: unknown;
let detached = false;
const unsubscribeFns: Array<() => void> = [];
registerListener(session, unsubscribeFns, "assistant.message_delta", (event) => {
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 firstDeltaError;
}
});
void deltaChain.catch(() => undefined);
});
registerListener(session, unsubscribeFns, "assistant.reasoning_delta", (event) => {
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) => {
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) => {
usage = normalizeCopilotUsage(event.data);
});
registerListener(session, unsubscribeFns, "tool.execution_start", (event) => {
startedCount += 1;
toolNamesByCallId.set(event.data.toolCallId, event.data.toolName);
toolMetas.push({ toolName: event.data.toolName });
});
registerListener(session, unsubscribeFns, "tool.execution_complete", (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.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;
},
awaitDeltaChain() {
return deltaChain;
},
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 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 joinReasoning(order: string[], reasoningById: Map<string, string>): string {
return order.map((reasoningId) => reasoningById.get(reasoningId) ?? "").join("");
}
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);
});
}

View File

@@ -0,0 +1,149 @@
import { describe, expect, it, vi } from "vitest";
import { createHooksBridge, type CopilotHooksConfig } from "./hooks-bridge.js";
describe("createHooksBridge", () => {
it("returns undefined when no config is provided", () => {
expect(createHooksBridge()).toBeUndefined();
});
it("returns undefined when config has no handlers", () => {
expect(createHooksBridge({})).toBeUndefined();
});
it("returns undefined when only onHookError is supplied (no real handlers)", () => {
expect(createHooksBridge({ onHookError: () => undefined })).toBeUndefined();
});
it("includes only the handlers that were configured", () => {
const onPreToolUse = vi.fn();
const onSessionStart = vi.fn();
const hooks = createHooksBridge({ onPreToolUse, onSessionStart })!;
expect(hooks).toBeDefined();
expect(typeof hooks.onPreToolUse).toBe("function");
expect(typeof hooks.onSessionStart).toBe("function");
expect(hooks.onPostToolUse).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 = { timestamp: 1, cwd: "/tmp", toolName: "bash", toolArgs: { cmd: "ls" } };
const result = await hooks.onPreToolUse!(input, { sessionId: "sess-1" });
expect(result).toEqual({ permissionDecision: "allow", additionalContext: "ok" });
expect(onPreToolUse).toHaveBeenCalledTimes(1);
expect(onPreToolUse).toHaveBeenCalledWith(input, { sessionId: "sess-1" });
});
it("isolates synchronous throws: returns undefined and notifies onHookError", async () => {
const onHookError = vi.fn();
const hooks = createHooksBridge({
onPostToolUse: () => {
throw new Error("post boom");
},
onHookError,
})!;
const result = await hooks.onPostToolUse!(
{ timestamp: 1, cwd: "/", toolName: "x", toolArgs: {}, toolResult: {} as never },
{ sessionId: "s" },
);
expect(result).toBeUndefined();
expect(onHookError).toHaveBeenCalledTimes(1);
expect(onHookError.mock.calls[0]?.[0]).toEqual({
hookName: "onPostToolUse",
error: expect.any(Error),
});
expect((onHookError.mock.calls[0]?.[0]?.error as Error).message).toBe("post boom");
});
it("isolates async rejections: returns undefined and notifies onHookError", async () => {
const onHookError = vi.fn();
const hooks = createHooksBridge({
onUserPromptSubmitted: async () => {
throw new Error("async boom");
},
onHookError,
})!;
const result = await hooks.onUserPromptSubmitted!(
{ timestamp: 1, cwd: "/", prompt: "hi" },
{ sessionId: "s" },
);
expect(result).toBeUndefined();
expect(onHookError).toHaveBeenCalledTimes(1);
expect(onHookError.mock.calls[0]?.[0]?.hookName).toBe("onUserPromptSubmitted");
});
it("uses console.warn as the default onHookError", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
const hooks = createHooksBridge({
onErrorOccurred: () => {
throw new Error("default-error-handler");
},
})!;
const result = await hooks.onErrorOccurred!(
{ timestamp: 1, cwd: "/", error: "x", errorContext: "system", recoverable: true },
{ sessionId: "s" },
);
expect(result).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("onErrorOccurred");
} finally {
warnSpy.mockRestore();
}
});
it("never throws when onHookError itself throws", async () => {
const hooks = createHooksBridge({
onSessionEnd: () => {
throw new Error("hook boom");
},
onHookError: () => {
throw new Error("notifier boom");
},
})!;
await expect(
hooks.onSessionEnd!({ timestamp: 1, cwd: "/", reason: "complete" }, { sessionId: "s" }),
).resolves.toBeUndefined();
});
it("preserves all six SDK hook handlers when supplied", async () => {
const config: CopilotHooksConfig = {
onPreToolUse: vi.fn().mockResolvedValue({ suppressOutput: true }),
onPostToolUse: vi.fn().mockResolvedValue({ suppressOutput: false }),
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.onPostToolUse).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");
});
it("forwards void returns transparently", async () => {
const hooks = createHooksBridge({
onSessionStart: () => undefined,
})!;
const result = await hooks.onSessionStart!(
{ timestamp: 1, cwd: "/", source: "new" },
{ sessionId: "s" },
);
expect(result).toBeUndefined();
});
it("does not invoke unconfigured handlers' isolators", () => {
const hooks = createHooksBridge({ onPreToolUse: () => undefined })!;
// ensure the missing handlers are literally absent, not just nullable
expect("onPostToolUse" in hooks).toBe(false);
expect("onUserPromptSubmitted" in hooks).toBe(false);
});
});

View File

@@ -0,0 +1,134 @@
/**
* Hooks bridge for the copilot agent runtime.
*
* BACK-POINTER: The host-side hook runner lives outside this package
* boundary in `src/agents/harness/lifecycle-hook-helpers.ts` (uses the
* plugin hook runner via `src/plugins/hook-runner-global.ts`). Per
* proposal §266 (todo `hooks-bridge`), this module provides a small
* contract surface that mirrors the SDK's `SessionHooks` shape; the
* core wiring layer constructs handlers that call into
* `runAgentHarnessLlmInputHook`, `runAgentHarnessLlmOutputHook`,
* `runAgentHarnessAgentEndHook`, etc., and threads them through
* `AttemptParamsLike.hooks`.
*
* Cross-package boundary note: the heavy host lifecycle helpers
* cannot be imported here (`tsconfig.package-boundary.base.json`). The
* bridge keeps the SDK hook contracts intact, wraps each provided
* handler in an error-isolating envelope so a thrown host hook cannot
* crash the SDK session, and returns a `SessionHooks` object that
* `createSessionConfig` can plug into `SessionConfig.hooks`.
*
* Note on default omission: if no handlers are supplied, the bridge
* returns `undefined` so that `SessionConfig.hooks` stays absent and
* the SDK skips the entire hook subsystem (matches the "no hooks
* installed" runtime behaviour the harness had pre-bridge).
*/
import type { SessionConfig } from "@github/copilot-sdk";
// All hook handler types are derived from SessionHooks so this bridge
// stays pinned to the same SDK source the rest of the harness uses,
// without depending on the SDK re-exporting individual handler aliases
// (which it does not, as of @github/copilot-sdk@1.0.0-beta.4).
type SdkSessionHooks = NonNullable<SessionConfig["hooks"]>;
type PreToolUseHandler = NonNullable<SdkSessionHooks["onPreToolUse"]>;
type PostToolUseHandler = NonNullable<SdkSessionHooks["onPostToolUse"]>;
type UserPromptSubmittedHandler = NonNullable<SdkSessionHooks["onUserPromptSubmitted"]>;
type SessionStartHandler = NonNullable<SdkSessionHooks["onSessionStart"]>;
type SessionEndHandler = NonNullable<SdkSessionHooks["onSessionEnd"]>;
type ErrorOccurredHandler = NonNullable<SdkSessionHooks["onErrorOccurred"]>;
export interface CopilotHooksConfig {
onPreToolUse?: PreToolUseHandler;
onPostToolUse?: PostToolUseHandler;
onUserPromptSubmitted?: UserPromptSubmittedHandler;
onSessionStart?: SessionStartHandler;
onSessionEnd?: SessionEndHandler;
onErrorOccurred?: ErrorOccurredHandler;
/**
* Optional hook-error notifier. Called whenever any wrapped handler
* throws (synchronously or as a Promise rejection). Defaults to
* `console.warn` so the failure is visible to operators without
* crashing the SDK session. Receives the SDK hook name and the
* raised error.
*/
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 host handler in an error-isolating envelope so it cannot
* throw out into the SDK. Returns `undefined` (no opinion) when the
* host handler throws, so the SDK falls back to its default behaviour
* for that hook.
*/
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 out
}
return undefined;
}
};
}
/**
* Build an SDK-shaped `SessionHooks` object from a host-supplied
* `CopilotHooksConfig`. Returns `undefined` when no handlers were
* supplied so the SDK skips the hook subsystem entirely.
*/
export function createHooksBridge(config?: CopilotHooksConfig): 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 post = isolate("onPostToolUse", config.onPostToolUse, 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 (post) {
hooks.onPostToolUse = post as PostToolUseHandler;
}
if (userPrompt) {
hooks.onUserPromptSubmitted = userPrompt as UserPromptSubmittedHandler;
}
if (sessionStart) {
hooks.onSessionStart = sessionStart as SessionStartHandler;
}
if (sessionEnd) {
hooks.onSessionEnd = sessionEnd as SessionEndHandler;
}
if (errorOccurred) {
hooks.onErrorOccurred = errorOccurred as ErrorOccurredHandler;
}
if (Object.keys(hooks).length === 0) {
return undefined;
}
return hooks;
}

View File

@@ -0,0 +1,255 @@
import type {
PermissionRequest as SdkPermissionRequest,
PermissionRequestResult as SdkPermissionRequestResult,
} from "@github/copilot-sdk";
import { describe, expect, it, vi } from "vitest";
import {
allowListPolicy,
allowOncePolicy,
composePolicies,
createPermissionBridge,
delegatingPolicy,
rejectAllPolicy,
REJECT_ALL_FEEDBACK,
type CopilotPermissionContext,
type CopilotPermissionPolicy,
} from "./permission-bridge.js";
function makeRequest(overrides: Partial<SdkPermissionRequest> = {}): SdkPermissionRequest {
return {
kind: "shell",
toolCallId: "call-1",
...overrides,
};
}
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("allowOncePolicy", () => {
it("returns approve-once for every request kind", async () => {
for (const kind of [
"shell",
"write",
"mcp",
"read",
"url",
"custom-tool",
"memory",
"hook",
] as const) {
const result = await allowOncePolicy(makeCtx({ request: makeRequest({ kind }) }));
expect(result).toEqual({ kind: "approve-once" });
}
});
});
describe("allowListPolicy", () => {
it("approves listed kinds and rejects others with default feedback", async () => {
const policy = allowListPolicy({ kinds: ["read"] });
const approved = await policy(makeCtx({ request: makeRequest({ kind: "read" }) }));
expect(approved).toEqual({ kind: "approve-once" });
const rejected = await policy(makeCtx({ request: makeRequest({ kind: "shell" }) }));
expect(rejected).toEqual({ kind: "reject", feedback: REJECT_ALL_FEEDBACK });
});
it("uses custom rejectFeedback when provided", async () => {
const policy = allowListPolicy({
kinds: ["read"],
rejectFeedback: "only reads allowed",
});
const result = await policy(makeCtx({ request: makeRequest({ kind: "write" }) }));
expect(result).toEqual({ kind: "reject", feedback: "only reads allowed" });
});
it("supports multiple kinds in the allow-list", async () => {
const policy = allowListPolicy({ kinds: ["read", "write"] });
expect(await policy(makeCtx({ request: makeRequest({ kind: "read" }) }))).toEqual({
kind: "approve-once",
});
expect(await policy(makeCtx({ request: makeRequest({ kind: "write" }) }))).toEqual({
kind: "approve-once",
});
expect((await policy(makeCtx({ request: makeRequest({ kind: "mcp" }) })))?.kind).toBe("reject");
});
it("rejects all when given an empty allow-list", async () => {
const policy = allowListPolicy({ kinds: [] });
for (const kind of ["shell", "read", "write"] as const) {
const result = await policy(makeCtx({ request: makeRequest({ kind }) }));
expect(result?.kind).toBe("reject");
}
});
});
describe("delegatingPolicy", () => {
it("forwards the request to the host callback and returns its decision", async () => {
const onRequest = vi.fn<CopilotPermissionPolicy>().mockResolvedValue({
kind: "approve-for-session",
} satisfies SdkPermissionRequestResult);
const policy = delegatingPolicy({ onRequest });
const ctx = makeCtx({ sessionId: "sess-xyz", request: makeRequest({ kind: "write" }) });
const result = await policy(ctx);
expect(result).toEqual({ kind: "approve-for-session" });
expect(onRequest).toHaveBeenCalledTimes(1);
expect(onRequest).toHaveBeenCalledWith(ctx);
});
it("returns the rejectAll default when host callback returns undefined", async () => {
const onRequest = vi.fn<CopilotPermissionPolicy>().mockResolvedValue(undefined);
const policy = delegatingPolicy({ onRequest });
const result = await policy(makeCtx());
expect(result).toEqual({ kind: "reject", feedback: REJECT_ALL_FEEDBACK });
});
it("rejects with the error message when host callback throws", async () => {
const onRequest = vi
.fn<CopilotPermissionPolicy>()
.mockRejectedValue(new Error("host policy boom"));
const policy = delegatingPolicy({ onRequest });
const result = await policy(makeCtx());
expect(result?.kind).toBe("reject");
expect((result as { feedback?: string }).feedback).toContain("host policy boom");
});
it("falls back to onError policy when host callback throws", async () => {
const onError = vi.fn<CopilotPermissionPolicy>().mockResolvedValue({ kind: "approve-once" });
const policy = delegatingPolicy({
onRequest: () => {
throw new Error("host policy boom");
},
onError,
});
const result = await policy(makeCtx());
expect(result).toEqual({ kind: "approve-once" });
expect(onError).toHaveBeenCalledTimes(1);
});
it("falls through to a hard-coded reject if onError also throws", async () => {
const policy = delegatingPolicy({
onRequest: () => {
throw new Error("host boom");
},
onError: () => {
throw new Error("fallback boom");
},
});
const result = await policy(makeCtx());
expect(result?.kind).toBe("reject");
expect((result as { feedback?: string }).feedback).toContain("host boom");
});
it("formats non-Error throws via JSON.stringify", async () => {
const policy = delegatingPolicy({
onRequest: () => {
throw { code: 42, msg: "weird" } as unknown as Error;
},
});
const result = await policy(makeCtx());
expect((result as { feedback?: string }).feedback).toContain('"code":42');
});
});
describe("composePolicies", () => {
it("returns the first non-undefined result and skips subsequent policies", async () => {
const a: CopilotPermissionPolicy = () => undefined;
const b: CopilotPermissionPolicy = () => ({ kind: "approve-once" });
const c = vi.fn<CopilotPermissionPolicy>(() => ({
kind: "reject",
feedback: "should never run",
}));
const policy = composePolicies(a, b, c);
const result = await policy(makeCtx());
expect(result).toEqual({ kind: "approve-once" });
expect(c).not.toHaveBeenCalled();
});
it("falls through to fail-closed reject when all policies return undefined", async () => {
const policy = composePolicies(
() => undefined,
() => undefined,
);
const result = await policy(makeCtx());
expect(result).toEqual({ kind: "reject", feedback: REJECT_ALL_FEEDBACK });
});
it("short-circuits to reject if any policy throws (does not consult later policies)", async () => {
const later = vi.fn<CopilotPermissionPolicy>(() => ({ kind: "approve-once" }));
const policy = composePolicies(() => {
throw new Error("nope");
}, later);
const result = await policy(makeCtx());
expect(result?.kind).toBe("reject");
expect((result as { feedback?: string }).feedback).toContain("nope");
expect(later).not.toHaveBeenCalled();
});
});
describe("createPermissionBridge", () => {
it("adapts a policy to the SDK PermissionHandler shape", async () => {
const handler = createPermissionBridge(allowOncePolicy);
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(allowOncePolicy);
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" });
}
});
});

View File

@@ -0,0 +1,219 @@
/**
* 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. Provides built-in policies for common defaults (fail-closed,
* approve-all-for-test, allow-list-by-kind).
* 3. Provides a `delegatingPolicy({ onRequest })` so the core layer
* can plug in a host-side callback that calls into
* `runBeforeToolCallHook` / `effective-tool-policy` and returns
* the SDK-shaped decision.
* 4. 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
* `delegatingPolicy` 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,
});
/**
* Approve every request as "approve-once". Use only in tests / live
* smoke runs where the operator has accepted the risk. This is the
* SDK-bundled `approveAll` behavior re-exported as an explicit named
* policy so test sites can opt in without `@github/copilot-sdk`
* imports leaking into call sites.
*/
export const allowOncePolicy: CopilotPermissionPolicy = () => ({
kind: "approve-once",
});
export interface AllowListPolicyOptions {
/** Permission kinds that should be approved once. */
kinds: ReadonlyArray<SdkPermissionRequest["kind"]>;
/** Optional feedback text attached to rejections. */
rejectFeedback?: string;
}
/**
* Approve requests whose `kind` is in the allow-list; reject everything
* else with `rejectFeedback` (defaulting to `REJECT_ALL_FEEDBACK`).
*/
export function allowListPolicy(options: AllowListPolicyOptions): CopilotPermissionPolicy {
const allowed = new Set<SdkPermissionRequest["kind"]>(options.kinds);
const feedback = options.rejectFeedback ?? REJECT_ALL_FEEDBACK;
return ({ request }) => {
if (allowed.has(request.kind)) {
return { kind: "approve-once" };
}
return { kind: "reject", feedback };
};
}
export interface DelegatingPolicyOptions {
/**
* Host-supplied callback. Returning `undefined` falls through to the
* fail-closed default. Throwing falls back to the configured
* `onError` policy if provided; otherwise the throw is converted to a
* reject with the error message embedded in `feedback` (so the model
* sees the diagnostic instead of a generic RPC failure).
*/
onRequest: CopilotPermissionPolicy;
/**
* Optional fallback when `onRequest` throws. If omitted, throws are
* reflected back as `reject` with the error message in `feedback`.
* If supplied and `onError` also throws, fall through to the
* error-message reject.
*/
onError?: CopilotPermissionPolicy;
}
/**
* Wrap a host callback into a policy, catching synchronous throws and
* async rejections so the SDK never sees an exception (which would
* surface as a generic RPC failure to the model).
*/
export function delegatingPolicy(options: DelegatingPolicyOptions): CopilotPermissionPolicy {
const { onRequest, onError } = options;
return async (ctx) => {
try {
const result = await onRequest(ctx);
if (result !== undefined) {
return result;
}
return { kind: "reject", feedback: REJECT_ALL_FEEDBACK };
} catch (error) {
if (onError) {
try {
const fallback = await onError(ctx);
if (fallback !== undefined) {
return fallback;
}
} catch {
// fall through to error-message reject
}
}
return {
kind: "reject",
feedback: `copilot permission policy threw: ${formatError(error)}`,
};
}
};
}
/**
* Compose policies in order. The first policy to return a non-undefined
* result wins. If all return undefined, a fail-closed `reject` is
* produced. Throws inside any policy short-circuit to `reject` with the
* error message; downstream policies are not consulted after a throw
* (so a misbehaving host policy cannot mask itself by being followed by
* an allow-policy).
*/
export function composePolicies(...policies: CopilotPermissionPolicy[]): CopilotPermissionPolicy {
return async (ctx) => {
for (const policy of policies) {
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 };
};
}
/**
* 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);
}
}

View File

@@ -0,0 +1,301 @@
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: "memory_search" }])).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("detects async-started tools even without a mutating name", () => {
expect(
copilotToolMetasHavePotentialSideEffects([{ asyncStarted: true, toolName: "read" }]),
).toBe(true);
});
});

View File

@@ -0,0 +1,252 @@
// 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",
"memory_search",
"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);
}

View File

@@ -0,0 +1,488 @@
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,
cwd: overrides.cwd,
};
}
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.copilotHome ?? "",
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.copilotHome)}`);
}
return {
id: attempt,
copilotHome: clientOptions.copilotHome,
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 "stop-string";
},
});
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]?.copilotHome)).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]?.copilotHome)).toBe(normalizedHome);
});
});

View File

@@ -0,0 +1,387 @@
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";
readonly authProfileId?: string;
readonly authProfileVersion?: string;
}
export interface ClientCreateOptions extends Omit<
CopilotClientOptions,
"copilotHome" | "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 {
return {
...options,
copilotHome: 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));
}

View File

@@ -0,0 +1,232 @@
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_FALLBACK_DIR,
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 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(COPILOT_SDK_SPEC),
});
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("pnpm add");
});
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("default fallback dir points at ~/.openclaw/npm-runtime/copilot", () => {
expect(COPILOT_SDK_FALLBACK_DIR).toMatch(/\.openclaw[\\/]+npm-runtime[\\/]+copilot$/);
});
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("contract with core copilot-sdk-install", () => {
// We assert literal values rather than importing core's exports because
// extension test files must stay on public plugin-sdk surfaces. The
// symmetric test in src/commands/copilot-sdk-install.test.ts asserts the
// same literals against core's exports, so any drift on either side fails
// one of the two tests.
it("COPILOT_SDK_FALLBACK_DIR matches the canonical core install fallback path", () => {
expect(COPILOT_SDK_FALLBACK_DIR).toMatch(/\.openclaw[\\/]+npm-runtime[\\/]+copilot$/);
});
it("COPILOT_SDK_SPEC pins the canonical SDK spec", () => {
expect(COPILOT_SDK_SPEC).toBe("@github/copilot-sdk@1.0.0-beta.4");
});
});

View File

@@ -0,0 +1,123 @@
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_FALLBACK_DIR = resolveCopilotSdkFallbackDir();
export const COPILOT_SDK_SPEC = "@github/copilot-sdk@1.0.0-beta.4";
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) => {
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 Copilot agent runtime requires @github/copilot-sdk (~260 MB",
"after pulling its platform-specific @github/copilot CLI binary).",
"Install it once with:",
"",
` pnpm add ${COPILOT_SDK_SPEC}`,
` # or: npm install ${COPILOT_SDK_SPEC}`,
"",
`Alternatively, install into the on-demand fallback location 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);
}
}

View File

@@ -0,0 +1,238 @@
import { describe, expect, it, vi } from "vitest";
import {
createTelemetryConfig,
createTraceContextProvider,
type CopilotTraceContextErrorInfo,
} from "./telemetry-bridge.js";
describe("createTelemetryConfig", () => {
it("returns undefined for undefined input", () => {
expect(createTelemetryConfig()).toBeUndefined();
});
it("returns undefined when every field is undefined", () => {
expect(createTelemetryConfig({})).toBeUndefined();
expect(
createTelemetryConfig({
otlpEndpoint: undefined,
filePath: undefined,
}),
).toBeUndefined();
});
it("includes only the fields that were explicitly set", () => {
expect(createTelemetryConfig({ otlpEndpoint: "https://otel.example/v1/traces" })).toEqual({
otlpEndpoint: "https://otel.example/v1/traces",
});
expect(createTelemetryConfig({ sourceName: "openclaw" })).toEqual({
sourceName: "openclaw",
});
});
it("round-trips a fully populated config", () => {
const result = createTelemetryConfig({
otlpEndpoint: "https://otel.example/v1/traces",
filePath: "/tmp/openclaw-traces.jsonl",
exporterType: "otlp-http",
sourceName: "openclaw",
captureContent: true,
});
expect(result).toEqual({
otlpEndpoint: "https://otel.example/v1/traces",
filePath: "/tmp/openclaw-traces.jsonl",
exporterType: "otlp-http",
sourceName: "openclaw",
captureContent: true,
});
});
it("preserves captureContent: false (explicit disable, not undefined)", () => {
expect(createTelemetryConfig({ captureContent: false })).toEqual({
captureContent: false,
});
});
it("preserves empty-string values (caller chose to set them)", () => {
expect(createTelemetryConfig({ otlpEndpoint: "" })).toEqual({ otlpEndpoint: "" });
});
});
describe("createTraceContextProvider", () => {
it("returns an empty context when no sources are configured", async () => {
const provider = createTraceContextProvider();
await expect(provider()).resolves.toEqual({});
});
it("prefers getTraceContext over the convenience sources", async () => {
const getTraceContext = vi.fn().mockResolvedValue({
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
tracestate: "vendor=value",
});
const getTraceparent = vi.fn().mockResolvedValue("00-ffff-ffff-01");
const provider = createTraceContextProvider({ getTraceContext, getTraceparent });
const ctx = await provider();
expect(ctx).toEqual({
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
tracestate: "vendor=value",
});
expect(getTraceparent).not.toHaveBeenCalled();
});
it("falls back to getTraceparent when getTraceContext returns undefined", async () => {
const getTraceContext = vi.fn().mockResolvedValue(undefined);
const getTraceparent = vi
.fn()
.mockResolvedValue("00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01");
const provider = createTraceContextProvider({ getTraceContext, getTraceparent });
await expect(provider()).resolves.toEqual({
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
});
expect(getTraceContext).toHaveBeenCalledTimes(1);
expect(getTraceparent).toHaveBeenCalledTimes(1);
});
it("includes tracestate when both convenience sources return non-empty values", async () => {
const provider = createTraceContextProvider({
getTraceparent: () => "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
getTracestate: () => "vendor=value",
});
await expect(provider()).resolves.toEqual({
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
tracestate: "vendor=value",
});
});
it("omits empty/undefined tracestate even when traceparent is present", async () => {
const providerUndef = createTraceContextProvider({
getTraceparent: () => "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
getTracestate: () => undefined,
});
await expect(providerUndef()).resolves.toEqual({
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
});
const providerEmpty = createTraceContextProvider({
getTraceparent: () => "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
getTracestate: () => "",
});
await expect(providerEmpty()).resolves.toEqual({
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
});
});
it("does not propagate tracestate without traceparent (W3C requirement)", async () => {
const getTracestate = vi.fn().mockResolvedValue("vendor=value");
const provider = createTraceContextProvider({
getTraceparent: () => undefined,
getTracestate,
});
await expect(provider()).resolves.toEqual({});
expect(getTracestate).not.toHaveBeenCalled();
});
it("re-reads sources on every invocation (so caching the provider is safe)", async () => {
let parent = "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01";
const provider = createTraceContextProvider({ getTraceparent: () => parent });
await expect(provider()).resolves.toEqual({ traceparent: parent });
parent = "00-cccccccccccccccccccccccccccccccc-dddddddddddddddd-01";
await expect(provider()).resolves.toEqual({ traceparent: parent });
});
it("getTraceContext failure → empty context + notifier called with the original error", async () => {
const onError = vi.fn();
const provider = createTraceContextProvider({
getTraceContext: () => {
throw new Error("ctx-boom");
},
getTraceparent: () => "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
onError,
});
await expect(provider()).resolves.toEqual({});
expect(onError).toHaveBeenCalledTimes(1);
const info = onError.mock.calls[0]?.[0] as CopilotTraceContextErrorInfo;
expect(info.part).toBe("traceContext");
expect(info.error.message).toBe("ctx-boom");
});
it("getTraceparent failure → empty context + notifier called", async () => {
const onError = vi.fn();
const provider = createTraceContextProvider({
getTraceparent: async () => {
throw new Error("parent-boom");
},
getTracestate: () => "vendor=value",
onError,
});
await expect(provider()).resolves.toEqual({});
expect(onError).toHaveBeenCalledTimes(1);
expect((onError.mock.calls[0]?.[0] as CopilotTraceContextErrorInfo).part).toBe("traceparent");
});
it("getTracestate failure → partial success (traceparent kept) + notifier called", async () => {
const onError = vi.fn();
const provider = createTraceContextProvider({
getTraceparent: () => "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
getTracestate: () => {
throw new Error("state-boom");
},
onError,
});
await expect(provider()).resolves.toEqual({
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
});
expect(onError).toHaveBeenCalledTimes(1);
expect((onError.mock.calls[0]?.[0] as CopilotTraceContextErrorInfo).part).toBe("tracestate");
});
it("default notifier uses console.warn", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
const provider = createTraceContextProvider({
getTraceparent: () => {
throw new Error("default-warn-path");
},
});
await expect(provider()).resolves.toEqual({});
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("traceparent");
expect(String(warnSpy.mock.calls[0]?.[0])).toContain("default-warn-path");
} finally {
warnSpy.mockRestore();
}
});
it("normalizes non-Error throws into Error before notifying", async () => {
const onError = vi.fn();
const provider = createTraceContextProvider({
getTraceparent: () => {
throw "string-boom";
},
onError,
});
await expect(provider()).resolves.toEqual({});
const info = onError.mock.calls[0]?.[0] as CopilotTraceContextErrorInfo;
expect(info.error).toBeInstanceOf(Error);
expect(info.error.message).toBe("string-boom");
});
it("notifier throws are swallowed (provider always resolves)", async () => {
const provider = createTraceContextProvider({
getTraceparent: () => {
throw new Error("boom");
},
onError: () => {
throw new Error("notifier-boom");
},
});
await expect(provider()).resolves.toEqual({});
});
it("treats only-traceContext source returning empty object as a valid context (no fallback)", async () => {
const getTraceparent = vi.fn();
const provider = createTraceContextProvider({
getTraceContext: () => ({}),
getTraceparent,
});
await expect(provider()).resolves.toEqual({});
expect(getTraceparent).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,218 @@
import type { CopilotClientOptions } from "@github/copilot-sdk";
// Telemetry bridge for the GitHub Copilot agent runtime.
//
// SDK surface:
// - `CopilotClientOptions.telemetry?: TelemetryConfig` — OpenTelemetry
// configuration applied to the spawned CLI process via env vars.
// - `CopilotClientOptions.onGetTraceContext?: TraceContextProvider` —
// async callback returning a W3C `{traceparent?, tracestate?}` that the
// SDK injects into `session.create`, `session.resume`, and
// `session.send` RPCs for distributed trace propagation.
//
// Host-side back-pointers (NOT imported here to keep the package boundary
// clean — the wiring layer injects these via callbacks):
// - `src/infra/diagnostic-trace-context.ts` — `getActiveDiagnosticTraceContext`,
// `formatDiagnosticTraceparent`, `DiagnosticTraceContext`.
// - `src/infra/diagnostic-events.ts` — `formatDiagnosticTraceparentForPropagation`
// for trusted-only propagation.
//
// IMPORTANT — pool reuse caveat:
// `CopilotClientPool` keys on `{agentId, copilotHome, authMode,
// authProfileId, authProfileVersion}`. Client-level telemetry and
// `onGetTraceContext` are NOT part of the pool key. Two callers that
// share a pool key but supply different telemetry options will get the
// first-acquire's options ("first wins"). Mitigation:
// - The trace-context provider returned by `createTraceContextProvider`
// reads the active context **on every invocation**, so even when the
// provider function is cached the propagated `traceparent` reflects
// the current scope at RPC time. Per-call accuracy is preserved.
// - `TelemetryConfig` (OTel env vars) is genuinely first-wins because
// the CLI subprocess is spawned once per pool entry. Wire telemetry
// as a process-wide / per-agent setting, not per-attempt.
type SdkTraceContext = NonNullable<
Awaited<ReturnType<NonNullable<CopilotClientOptions["onGetTraceContext"]>>>
>;
type SdkTraceContextProvider = NonNullable<CopilotClientOptions["onGetTraceContext"]>;
type SdkTelemetryConfig = NonNullable<CopilotClientOptions["telemetry"]>;
export type { SdkTraceContext as CopilotTraceContext };
export type { SdkTelemetryConfig as CopilotTelemetryConfig };
export type CopilotTraceContextSource = () =>
| SdkTraceContext
| undefined
| Promise<SdkTraceContext | undefined>;
export type CopilotTraceparentSource = () => string | undefined | Promise<string | undefined>;
export type CopilotTracestateSource = () => string | undefined | Promise<string | undefined>;
export interface CopilotTraceContextErrorInfo {
readonly part: "traceContext" | "traceparent" | "tracestate";
readonly error: Error;
}
export interface CopilotTraceContextOptions {
/**
* Primary source: a single callback returning the full SDK trace context
* (`{traceparent?, tracestate?}`). Use this when the host has one
* authoritative source of trace context so that traceparent and tracestate
* always reflect the same logical scope.
*/
getTraceContext?: CopilotTraceContextSource;
/**
* Convenience source: returns just the W3C `traceparent` header. Used
* when {@link getTraceContext} is not supplied OR returns undefined.
*/
getTraceparent?: CopilotTraceparentSource;
/**
* Convenience source: returns the W3C `tracestate` header. Only used
* when {@link getTraceContext} is not supplied AND a non-empty
* `traceparent` was obtained via {@link getTraceparent}. (Per W3C,
* `tracestate` is meaningless without an accompanying `traceparent`.)
*/
getTracestate?: CopilotTracestateSource;
/**
* Notifier for errors thrown by any source. Defaults to `console.warn`.
* Notifier failures are themselves swallowed.
*/
onError?: (info: CopilotTraceContextErrorInfo) => void;
}
const EMPTY_TRACE_CONTEXT: SdkTraceContext = Object.freeze({}) as SdkTraceContext;
function toError(error: unknown): Error {
if (error instanceof Error) {
return error;
}
return new Error(String(error));
}
function defaultOnTraceContextError(info: CopilotTraceContextErrorInfo): void {
console.warn(`[copilot:telemetry-bridge] ${info.part} source failed: ${info.error.message}`);
}
function safeNotify(
notifier: (info: CopilotTraceContextErrorInfo) => void,
info: CopilotTraceContextErrorInfo,
): void {
try {
notifier(info);
} catch {
// Notifier failures are swallowed: telemetry is best-effort.
}
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
/**
* Build a TraceContextProvider suitable for `CopilotClientOptions.onGetTraceContext`.
*
* Resolution order on each invocation:
* 1. If `getTraceContext` is supplied and returns a non-undefined value,
* return it as-is. Errors from this source → return `{}` and notify.
* 2. Otherwise call `getTraceparent` (if supplied). On error → return
* `{}` and notify (no traceparent = no propagation).
* 3. If traceparent is non-empty, call `getTracestate` (if supplied)
* and attach the result. Errors on tracestate are partial-success:
* notify and return `{traceparent}` (do not lose the parent).
* 4. If no source provided OR all return undefined, return `{}` so the
* SDK behaves as if no provider were configured.
*/
export function createTraceContextProvider(
options?: CopilotTraceContextOptions,
): SdkTraceContextProvider {
const onError = options?.onError ?? defaultOnTraceContextError;
const getTraceContext = options?.getTraceContext;
const getTraceparent = options?.getTraceparent;
const getTracestate = options?.getTracestate;
return async () => {
if (getTraceContext) {
try {
const ctx = await getTraceContext();
if (ctx !== undefined) {
return ctx;
}
} catch (error) {
safeNotify(onError, { part: "traceContext", error: toError(error) });
return EMPTY_TRACE_CONTEXT;
}
}
if (!getTraceparent) {
return EMPTY_TRACE_CONTEXT;
}
let traceparent: string | undefined;
try {
traceparent = await getTraceparent();
} catch (error) {
safeNotify(onError, { part: "traceparent", error: toError(error) });
return EMPTY_TRACE_CONTEXT;
}
if (!isNonEmptyString(traceparent)) {
return EMPTY_TRACE_CONTEXT;
}
if (!getTracestate) {
return { traceparent } as SdkTraceContext;
}
let tracestate: string | undefined;
try {
tracestate = await getTracestate();
} catch (error) {
safeNotify(onError, { part: "tracestate", error: toError(error) });
return { traceparent } as SdkTraceContext;
}
return isNonEmptyString(tracestate)
? ({ traceparent, tracestate } as SdkTraceContext)
: ({ traceparent } as SdkTraceContext);
};
}
export interface CopilotTelemetryOptions {
otlpEndpoint?: string;
filePath?: string;
exporterType?: string;
sourceName?: string;
captureContent?: boolean;
}
/**
* Shape a `TelemetryConfig` for `CopilotClientOptions.telemetry`. Returns
* `undefined` when no fields are supplied so callers can spread
* conditionally without producing an empty telemetry object that would
* still partially configure the CLI's OTel env layout.
*
* Any explicitly-set value (including `false` for `captureContent`) is
* preserved — only `undefined` is treated as "no opinion".
*/
export function createTelemetryConfig(
options?: CopilotTelemetryOptions,
): SdkTelemetryConfig | undefined {
if (!options) {
return undefined;
}
const result: SdkTelemetryConfig = {};
if (options.otlpEndpoint !== undefined) {
result.otlpEndpoint = options.otlpEndpoint;
}
if (options.filePath !== undefined) {
result.filePath = options.filePath;
}
if (options.exporterType !== undefined) {
result.exporterType = options.exporterType;
}
if (options.sourceName !== undefined) {
result.sourceName = options.sourceName;
}
if (options.captureContent !== undefined) {
result.captureContent = options.captureContent;
}
return Object.keys(result).length > 0 ? result : undefined;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,680 @@
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,
getPluginToolMeta,
isSubagentSessionKey,
resolveAttemptSpawnWorkspaceDir,
resolveEmbeddedAttemptToolConstructionPlan,
resolveModelAuthMode,
} from "openclaw/plugin-sdk/agent-harness-runtime";
type CreateOpenClawCodingTools =
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
type OpenClawCodingToolsOptions = NonNullable<Parameters<CreateOpenClawCodingTools>[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 interface CopilotToolBridgeInput {
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;
createOpenClawCodingTools?: (opts: unknown) => AnyAgentTool[] | Promise<AnyAgentTool[]>;
beforeExecute?: (ctx: {
toolName: string;
toolCallId: string;
args: unknown;
sourceTool: AnyAgentTool;
invocation: ToolInvocation;
}) => void | Promise<void>;
}
export interface CopilotToolBridge {
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 (!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 toolOptions = buildOpenClawCodingToolsOptions(input, effectiveToolPlan);
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 plannedTools = filterCopilotToolsForConstructionPlan(
sourceTools as AnyAgentTool[],
effectiveToolPlan.codingToolConstructionPlan,
);
const filteredTools = filterCopilotToolsForAllowlist(
plannedTools,
effectiveToolPlan.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 {
sdkTools: filteredTools.map((sourceTool) =>
convertOpenClawToolToSdkTool(sourceTool, {
abortSignal: input.abortSignal,
beforeExecute: input.beforeExecute,
}),
),
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>,
): 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: a.config,
abortSignal: input.abortSignal,
modelProvider: input.modelProvider,
modelId: input.modelId,
includeCoreTools: toolPlan.includeCoreTools,
runtimeToolAllowlist: toolPlan.runtimeToolAllowlist,
toolConstructionPlan: toolPlan.codingToolConstructionPlan,
modelCompat,
modelApi: model?.api,
modelContextWindowTokens: model?.contextWindow,
modelAuthMode: resolveModelAuthMode(input.modelProvider, a.config, undefined, {
workspaceDir,
}),
currentChannelId: a.currentChannelId,
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"];
},
): 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 executeOnce = async (
args: unknown,
invocation: ToolInvocation,
): Promise<ToolResultObject> => {
if (ctx.abortSignal?.aborted) {
const error = new Error("[copilot-tool-bridge] aborted before execution");
return createFailureResult(error.message, error);
}
try {
await ctx.beforeExecute?.({
args,
invocation,
sourceTool,
toolCallId: invocation.toolCallId,
toolName: sourceTool.name,
});
} catch (error: unknown) {
return createFailureResult(
`[copilot-tool-bridge] beforeExecute failed for tool '${sourceTool.name}': ${toError(error).message}`,
error,
);
}
let preparedArgs = args;
try {
preparedArgs = sourceTool.prepareArguments ? sourceTool.prepareArguments(args) : args;
} catch (error: unknown) {
return createFailureResult(
`[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 createFailureResult(
`[copilot-tool-bridge] tool '${sourceTool.name}' failed: ${toError(error).message}`,
error,
);
}
return agentToolResultToSdk(result);
};
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,
};
}
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"],
): T[] {
if (plan.includeBaseCodingTools && plan.includeShellTools) {
return tools;
}
return tools.filter((tool) => {
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));
}

View File

@@ -0,0 +1,270 @@
import type { NormalizedUsage } from "openclaw/plugin-sdk/agent-harness-runtime";
import { describe, expect, it } from "vitest";
import {
buildCopilotAssistantUsage,
deriveCopilotUsageTotal,
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,
});
});
});
describe("deriveCopilotUsageTotal", () => {
it("returns undefined when usage is undefined", () => {
expect(deriveCopilotUsageTotal(undefined)).toBeUndefined();
});
it("sums input/output/cacheRead/cacheWrite for total", () => {
const usage: NormalizedUsage = {
cacheRead: 3,
cacheWrite: 4,
input: 1,
output: 2,
total: 999,
};
expect(deriveCopilotUsageTotal(usage)).toBe(10);
});
});
});

View File

@@ -0,0 +1,83 @@
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,
};
}
export function deriveCopilotUsageTotal(usage?: NormalizedUsage): number | undefined {
if (!usage) {
return undefined;
}
return (
(usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)
);
}

View File

@@ -0,0 +1,230 @@
import type { SessionConfig } from "@github/copilot-sdk";
import { describe, expect, it, vi } from "vitest";
type UserInputHandler = NonNullable<SessionConfig["onUserInputRequest"]>;
type SdkUserInputRequest = Parameters<UserInputHandler>[0];
type SdkUserInputResponse = Awaited<ReturnType<UserInputHandler>>;
import {
composeUserInputPolicies,
createUserInputBridge,
delegatingUserInputPolicy,
denyAllUserInputPolicy,
firstChoicePolicy,
staticAnswerPolicy,
DENY_ALL_ANSWER,
type CopilotUserInputContext,
type CopilotUserInputPolicy,
} from "./user-input-bridge.js";
function makeRequest(overrides: Partial<SdkUserInputRequest> = {}): SdkUserInputRequest {
return {
question: "what is your name?",
...overrides,
};
}
function makeCtx(overrides: Partial<CopilotUserInputContext> = {}): CopilotUserInputContext {
return {
request: makeRequest(),
sessionId: "sess-1",
...overrides,
};
}
describe("denyAllUserInputPolicy", () => {
it("returns the fail-closed DENY_ALL_ANSWER as a freeform answer", async () => {
const result = await denyAllUserInputPolicy(makeCtx());
expect(result).toEqual({ answer: DENY_ALL_ANSWER, wasFreeform: true });
});
});
describe("firstChoicePolicy", () => {
it("returns the first choice (wasFreeform: false) when choices are present", async () => {
const result = await firstChoicePolicy(
makeCtx({ request: makeRequest({ choices: ["yes", "no"] }) }),
);
expect(result).toEqual({ answer: "yes", wasFreeform: false });
});
it("falls back to DENY_ALL_ANSWER when choices are empty", async () => {
const result = await firstChoicePolicy(makeCtx({ request: makeRequest({ choices: [] }) }));
expect(result).toEqual({ answer: DENY_ALL_ANSWER, wasFreeform: true });
});
it("falls back to DENY_ALL_ANSWER when choices are absent", async () => {
const result = await firstChoicePolicy(makeCtx());
expect(result).toEqual({ answer: DENY_ALL_ANSWER, wasFreeform: true });
});
});
describe("staticAnswerPolicy", () => {
it("returns the configured answer for every request", async () => {
const policy = staticAnswerPolicy({ answer: "Alice" });
for (const question of ["a?", "b?", "c?"]) {
const result = await policy(makeCtx({ request: makeRequest({ question }) }));
expect(result).toEqual({ answer: "Alice", wasFreeform: true });
}
});
it("respects wasFreeform=false override", async () => {
const policy = staticAnswerPolicy({ answer: "yes", wasFreeform: false });
const result = await policy(makeCtx());
expect(result).toEqual({ answer: "yes", wasFreeform: false });
});
});
describe("delegatingUserInputPolicy", () => {
it("forwards the request and returns the host response", async () => {
const onRequest = vi
.fn<CopilotUserInputPolicy>()
.mockResolvedValue({ answer: "Bob", wasFreeform: true } satisfies SdkUserInputResponse);
const policy = delegatingUserInputPolicy({ onRequest });
const ctx = makeCtx({ sessionId: "sess-xyz" });
const result = await policy(ctx);
expect(result).toEqual({ answer: "Bob", wasFreeform: true });
expect(onRequest).toHaveBeenCalledTimes(1);
expect(onRequest).toHaveBeenCalledWith(ctx);
});
it("returns DENY_ALL_ANSWER when host callback returns undefined", async () => {
const onRequest = vi.fn<CopilotUserInputPolicy>().mockResolvedValue(undefined);
const policy = delegatingUserInputPolicy({ onRequest });
const result = await policy(makeCtx());
expect(result).toEqual({ answer: DENY_ALL_ANSWER, wasFreeform: true });
});
it("converts thrown errors into a DENY_ALL_ANSWER with the error message appended", async () => {
const policy = delegatingUserInputPolicy({
onRequest: () => {
throw new Error("prompt timeout");
},
});
const result = await policy(makeCtx());
expect(result).toBeDefined();
expect(result!.wasFreeform).toBe(true);
expect(result!.answer).toContain(DENY_ALL_ANSWER);
expect(result!.answer).toContain("prompt timeout");
});
it("falls back to onError policy when onRequest throws", async () => {
const onError = vi
.fn<CopilotUserInputPolicy>()
.mockResolvedValue({ answer: "fallback", wasFreeform: true });
const policy = delegatingUserInputPolicy({
onRequest: () => {
throw new Error("host boom");
},
onError,
});
const result = await policy(makeCtx());
expect(result).toEqual({ answer: "fallback", wasFreeform: true });
expect(onError).toHaveBeenCalledTimes(1);
});
it("falls through to error-message response when onError also throws", async () => {
const policy = delegatingUserInputPolicy({
onRequest: () => {
throw new Error("host boom");
},
onError: () => {
throw new Error("fallback boom");
},
});
const result = await policy(makeCtx());
expect(result).toBeDefined();
expect(result!.answer).toContain("host boom");
});
it("formats non-Error throws via JSON.stringify", async () => {
const policy = delegatingUserInputPolicy({
onRequest: () => {
throw { code: 7, msg: "weird" } as unknown as Error;
},
});
const result = await policy(makeCtx());
expect(result).toBeDefined();
expect(result!.answer).toContain('"code":7');
});
});
describe("composeUserInputPolicies", () => {
it("returns the first non-undefined result and skips subsequent policies", async () => {
const a: CopilotUserInputPolicy = () => undefined;
const b: CopilotUserInputPolicy = () => ({ answer: "from-b", wasFreeform: true });
const c = vi.fn<CopilotUserInputPolicy>(() => ({ answer: "from-c", wasFreeform: true }));
const policy = composeUserInputPolicies(a, b, c);
const result = await policy(makeCtx());
expect(result).toEqual({ answer: "from-b", wasFreeform: true });
expect(c).not.toHaveBeenCalled();
});
it("falls through to DENY_ALL_ANSWER when all policies return undefined", async () => {
const policy = composeUserInputPolicies(
() => undefined,
() => undefined,
);
const result = await policy(makeCtx());
expect(result).toEqual({ answer: DENY_ALL_ANSWER, wasFreeform: true });
});
it("short-circuits to error-message response when any policy throws", async () => {
const later = vi.fn<CopilotUserInputPolicy>(() => ({ answer: "later", wasFreeform: true }));
const policy = composeUserInputPolicies(() => {
throw new Error("compose boom");
}, later);
const result = await policy(makeCtx());
expect(result).toBeDefined();
expect(result!.answer).toContain("compose boom");
expect(later).not.toHaveBeenCalled();
});
});
describe("createUserInputBridge", () => {
it("adapts a policy to the SDK UserInputHandler shape", async () => {
const handler = createUserInputBridge(staticAnswerPolicy({ answer: "Alice" }));
const result = await handler(makeRequest(), { sessionId: "sess-1" });
expect(result).toEqual({ answer: "Alice", wasFreeform: true });
});
it("defaults to denyAllUserInputPolicy when no policy is passed", async () => {
const handler = createUserInputBridge();
const result = await handler(makeRequest(), { sessionId: "sess-1" });
expect(result).toEqual({ answer: DENY_ALL_ANSWER, wasFreeform: true });
});
it("forwards the SDK sessionId into the policy context", async () => {
const policy = vi.fn<CopilotUserInputPolicy>(() => ({ answer: "x", wasFreeform: true }));
const handler = createUserInputBridge(policy);
await handler(makeRequest({ question: "q?", choices: ["a"] }), { sessionId: "sess-xyz" });
expect(policy).toHaveBeenCalledTimes(1);
expect(policy.mock.calls[0]?.[0]).toEqual({
sessionId: "sess-xyz",
request: { question: "q?", choices: ["a"] },
});
});
it("never throws when policy throws; returns DENY_ALL_ANSWER with the error message", async () => {
const handler = createUserInputBridge(() => {
throw new Error("policy boom");
});
const result = await handler(makeRequest(), { sessionId: "sess-1" });
expect(result.answer).toContain(DENY_ALL_ANSWER);
expect(result.answer).toContain("policy boom");
expect(result.wasFreeform).toBe(true);
});
it("never returns undefined: a policy returning undefined yields fail-closed answer", async () => {
const handler = createUserInputBridge(() => undefined);
const result = await handler(makeRequest(), { sessionId: "sess-1" });
expect(result).toEqual({ answer: DENY_ALL_ANSWER, wasFreeform: true });
});
it("preserves wasFreeform=false from a policy that picked from choices", async () => {
const handler = createUserInputBridge(firstChoicePolicy);
const result = await handler(makeRequest({ choices: ["one", "two"], allowFreeform: false }), {
sessionId: "sess-1",
});
expect(result).toEqual({ answer: "one", wasFreeform: false });
});
});

View File

@@ -0,0 +1,244 @@
/**
* User-input bridge for the copilot agent runtime.
*
* STATUS — MVP DORMANT: This module is intentionally NOT registered with
* the SDK in the current harness (see `attempt.ts` / `side-question.ts`).
* The SDK contract is "When `onUserInputRequest` is provided, enables the
* `ask_user` tool allowing the agent to ask questions" (see
* `node_modules/@github/copilot-sdk/dist/types.d.ts` `SessionConfig`);
* by omitting the handler we hide `ask_user` from the model entirely.
* Agents under the MVP must make best-judgment decisions from the
* initial prompt rather than asking clarifying questions mid-turn.
*
* FOLLOW-UP: The scaffolding below stays in tree so the follow-up that
* ports the codex user-input-bridge pattern
* (`extensions/codex/src/app-server/user-input-bridge.ts`) has a stable
* surface to wire — that change will route SDK `UserInputRequest`s
* through `params.onBlockReply` / `onPartialReply` and resolve the
* pending promise from the next inbound channel message, then register
* `createUserInputBridge(delegatingUserInputPolicy(...))` from
* `createSessionConfig`.
*
* BACK-POINTER: The host-side channel/TUI prompt flow lives outside
* this package boundary in `commitments/` and the channel plugins
* (slack/discord/cli/tui). Per proposal §50, this bridge does NOT
* import that flow directly (the package boundary
* `tsconfig.package-boundary.base.json` only allows
* `openclaw/plugin-sdk/*` and `@github/copilot-sdk`). Instead, this
* module:
*
* 1. Defines a small `CopilotUserInputPolicy` contract that the
* core wiring layer implements to forward `UserInputRequest`s to
* the host's channel/TUI prompt path.
* 2. Provides built-in policies for common defaults (deny-all with a
* synthetic answer, auto-first-choice, static-answer).
* 3. Provides a `delegatingUserInputPolicy({ onRequest })` so the
* core wiring layer can plug in a host-side callback that calls
* into `commitments/` and returns the SDK-shaped response.
* 4. Adapts the resulting policy into the SDK's `UserInputHandler`
* shape via `createUserInputBridge(policy)`.
*
* SDK contract note: unlike `PermissionHandler` (which has a
* `no-result` escape hatch), `UserInputHandler` MUST resolve with a
* `UserInputResponse`. The bridge therefore never returns `undefined`
* to the SDK; if a policy returns `undefined` or throws, the default
* fail-closed answer is used so the model sees a real string rather
* than a generic RPC failure.
*
* If the host's prompt contract changes materially, the contract here
* must be revisited in lockstep. The unit tests in
* `user-input-bridge.test.ts` exercise the SDK-shaped response envelope
* so any silent drift in the SDK type is caught at typecheck.
*/
import type { SessionConfig } from "@github/copilot-sdk";
type UserInputHandler = NonNullable<SessionConfig["onUserInputRequest"]>;
type SdkUserInputRequest = Parameters<UserInputHandler>[0];
type SdkUserInputResponse = Awaited<ReturnType<UserInputHandler>>;
/** Request shape forwarded to host-implemented user-input policies. */
export interface CopilotUserInputContext {
/** SDK session id that originated the request. */
sessionId: string;
/** Original SDK request payload. */
request: SdkUserInputRequest;
}
/**
* Policy contract. Implementors return an SDK-shaped response (or a
* Promise of one).
*
* Returning `undefined` is treated as "no opinion" and falls through
* to the default fail-closed response (`DENY_ALL_ANSWER`). This keeps
* composition trivial without requiring explicit responses from every
* code path.
*/
export type CopilotUserInputPolicy = (
ctx: CopilotUserInputContext,
) => SdkUserInputResponse | undefined | Promise<SdkUserInputResponse | undefined>;
/**
* Default answer used when no host policy provides one. The string is
* intentionally explicit so the model can detect the missing-prompt
* condition rather than treating it as a real user answer.
*/
export const DENY_ALL_ANSWER =
"[copilot agent runtime: no user-input policy installed; request declined]";
export const denyAllUserInputPolicy: CopilotUserInputPolicy = () => ({
answer: DENY_ALL_ANSWER,
wasFreeform: true,
});
/**
* Auto-pick the first choice if the request offers choices; otherwise
* fall back to `DENY_ALL_ANSWER` as a freeform answer. Useful for
* non-interactive test runs.
*/
export const firstChoicePolicy: CopilotUserInputPolicy = ({ request }) => {
if (request.choices && request.choices.length > 0) {
return { answer: request.choices[0], wasFreeform: false };
}
return { answer: DENY_ALL_ANSWER, wasFreeform: true };
};
export interface StaticAnswerPolicyOptions {
/** Answer returned for every request. */
answer: string;
/**
* Whether the answer should be flagged as a freeform response.
* Defaults to `true` (caller did not pick from `choices`).
*/
wasFreeform?: boolean;
}
/** Always return a fixed answer. Useful for deterministic tests. */
export function staticAnswerPolicy(options: StaticAnswerPolicyOptions): CopilotUserInputPolicy {
const wasFreeform = options.wasFreeform ?? true;
return () => ({ answer: options.answer, wasFreeform });
}
export interface DelegatingUserInputPolicyOptions {
/**
* Host-supplied callback. Returning `undefined` falls through to the
* fail-closed default. Throwing falls back to the configured
* `onError` policy if provided; otherwise the throw is converted to
* a `DENY_ALL_ANSWER` response so the SDK never sees an exception
* (which would surface as a generic RPC failure to the model).
*/
onRequest: CopilotUserInputPolicy;
/**
* Optional fallback when `onRequest` throws. If omitted, throws are
* converted to a `DENY_ALL_ANSWER` response with the error message
* appended. If supplied and `onError` also throws, fall through to
* the error-message response.
*/
onError?: CopilotUserInputPolicy;
}
/**
* Wrap a host callback into a policy, catching synchronous throws and
* async rejections so the SDK never sees an exception.
*/
export function delegatingUserInputPolicy(
options: DelegatingUserInputPolicyOptions,
): CopilotUserInputPolicy {
const { onRequest, onError } = options;
return async (ctx) => {
try {
const result = await onRequest(ctx);
if (result !== undefined) {
return result;
}
return { answer: DENY_ALL_ANSWER, wasFreeform: true };
} catch (error) {
if (onError) {
try {
const fallback = await onError(ctx);
if (fallback !== undefined) {
return fallback;
}
} catch {
// fall through to error-message response
}
}
return {
answer: `${DENY_ALL_ANSWER} (host policy threw: ${formatError(error)})`,
wasFreeform: true,
};
}
};
}
/**
* Compose policies in order. The first policy to return a non-undefined
* result wins. If all return undefined, a fail-closed `DENY_ALL_ANSWER`
* response is produced. Throws inside any policy short-circuit to the
* error-message response; downstream policies are not consulted after a
* throw.
*/
export function composeUserInputPolicies(
...policies: CopilotUserInputPolicy[]
): CopilotUserInputPolicy {
return async (ctx) => {
for (const policy of policies) {
try {
const result = await policy(ctx);
if (result !== undefined) {
return result;
}
} catch (error) {
return {
answer: `${DENY_ALL_ANSWER} (host policy threw: ${formatError(error)})`,
wasFreeform: true,
};
}
}
return { answer: DENY_ALL_ANSWER, wasFreeform: true };
};
}
/**
* Adapt a `CopilotUserInputPolicy` to the SDK's `UserInputHandler`
* shape. The returned handler always resolves with a valid
* `UserInputResponse` (never throws, never returns undefined),
* defaulting to `DENY_ALL_ANSWER` when the policy returns undefined or
* throws.
*/
export function createUserInputBridge(
policy: CopilotUserInputPolicy = denyAllUserInputPolicy,
): UserInputHandler {
return async (
request: SdkUserInputRequest,
invocation: { sessionId: string },
): Promise<SdkUserInputResponse> => {
const ctx: CopilotUserInputContext = {
request,
sessionId: invocation.sessionId,
};
try {
const result = await policy(ctx);
if (result !== undefined) {
return result;
}
} catch (error) {
return {
answer: `${DENY_ALL_ANSWER} (host policy threw: ${formatError(error)})`,
wasFreeform: true,
};
}
return { answer: DENY_ALL_ANSWER, wasFreeform: true };
};
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}

View File

@@ -0,0 +1,268 @@
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);
});
});

View File

@@ -0,0 +1,255 @@
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,
};

View 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"
]
}

View File

@@ -1397,10 +1397,10 @@
"audit:seams": "node scripts/audit-seams.mjs",
"build": "node scripts/build-all.mjs",
"build:ci-artifacts": "node scripts/build-all.mjs ciArtifacts",
"build:docker": "node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm plugins:assets:build && pnpm plugins:assets:copy && node --experimental-strip-types scripts/copy-hook-metadata.ts && node --experimental-strip-types scripts/copy-export-html-templates.ts && node --experimental-strip-types scripts/write-build-info.ts && node --experimental-strip-types scripts/write-cli-startup-metadata.ts && node --experimental-strip-types scripts/write-cli-compat.ts",
"build:docker": "node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm plugins:assets:build && pnpm plugins:assets:copy && node --experimental-strip-types scripts/copy-hook-metadata.ts && node --experimental-strip-types scripts/copy-copilot-sdk-manifest.ts && node --experimental-strip-types scripts/copy-export-html-templates.ts && node --experimental-strip-types scripts/write-build-info.ts && node --experimental-strip-types scripts/write-cli-startup-metadata.ts && node --experimental-strip-types scripts/write-cli-compat.ts",
"build:plugin-sdk:dts": "node scripts/run-tsgo.mjs -p tsconfig.plugin-sdk.dts.json --declaration true",
"build:plugin-sdk:strict-smoke": "pnpm build:plugin-sdk:dts && node --experimental-strip-types scripts/write-plugin-sdk-entry-dts.ts",
"build:strict-smoke": "pnpm plugins:assets:build && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm build:plugin-sdk:dts && node --experimental-strip-types scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs",
"build:strict-smoke": "pnpm plugins:assets:build && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && node --experimental-strip-types scripts/copy-copilot-sdk-manifest.ts && pnpm build:plugin-sdk:dts && node --experimental-strip-types scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs",
"canvas:a2ui:bundle": "node scripts/bundle-a2ui.mjs",
"changed:lanes": "node scripts/changed-lanes.mjs",
"check": "node scripts/check.mjs",

95
pnpm-lock.yaml generated
View File

@@ -570,6 +570,18 @@ importers:
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/copilot:
devDependencies:
'@github/copilot':
specifier: 1.0.48
version: 1.0.48
'@github/copilot-sdk':
specifier: 1.0.0-beta.4
version: 1.0.0-beta.4
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/copilot-proxy:
devDependencies:
'@openclaw/plugin-sdk':
@@ -2564,6 +2576,50 @@ packages:
'@noble/hashes':
optional: true
'@github/copilot-darwin-arm64@1.0.48':
resolution: {integrity: sha512-82MLoMQwPVVFM8EYssihFxSEPUYtZADE8rMzQ3jG9HgRg2qjQSfnHQS1mKe64dlXswZUK/onw6/8kjnW5I4pPg==}
cpu: [arm64]
os: [darwin]
hasBin: true
'@github/copilot-darwin-x64@1.0.48':
resolution: {integrity: sha512-1VQ5r5F0h8GwboXmZTcutqcJT+iCpPXAF27QqodmpKEvW9aYfG8g9X2kFJOzDZoX+SA3Uaka9qXdYKF2xT6Uog==}
cpu: [x64]
os: [darwin]
hasBin: true
'@github/copilot-linux-arm64@1.0.48':
resolution: {integrity: sha512-PmsGnb0DZlI+Bf53l9HM1PAHHkUcMyB4y8v/7tnC/jDOV5dGF124n0HnDNfJLOLiJGiQGodthIif6QtPaAxpeA==}
cpu: [arm64]
os: [linux]
hasBin: true
'@github/copilot-linux-x64@1.0.48':
resolution: {integrity: sha512-b2cc4euSlke9fYHXXsS2EL9UYbctN0h4lZvtAcKUDY+RCnpYAQOVBZK+c1R9dQrtsT6Z/yUv7PuFPSs8qdtc2Q==}
cpu: [x64]
os: [linux]
hasBin: true
'@github/copilot-sdk@1.0.0-beta.4':
resolution: {integrity: sha512-DcVMN2FWODxamFS9nTls8AW3QsyMnj6JDVBNRVBXaTY9kEhGHCjt8lp7sJp95/vyl52hvEb4/68Oh6SdFU9O/Q==}
engines: {node: '>=20.0.0'}
'@github/copilot-win32-arm64@1.0.48':
resolution: {integrity: sha512-VEEOwddtpJ3DTbXGhnK6K8im4ofl9m08q1m/K++sNvWV8wkkOSOQBTiPdyUsuU/TXAoFhb8tZMIJv+6NnMBtMw==}
cpu: [arm64]
os: [win32]
hasBin: true
'@github/copilot-win32-x64@1.0.48':
resolution: {integrity: sha512-93BzvXLPHTyy1gWBXQY/IWIHor4IAwZuuo7/obG80/Qa6U0WeaN9slz/FBJvrsgVNrrRfEID5Xm3At+S6Kj67Q==}
cpu: [x64]
os: [win32]
hasBin: true
'@github/copilot@1.0.48':
resolution: {integrity: sha512-U5SzyTEq376UU9A4Sd3TEKz+Y2nRUd90cLO4Hc1otaB8yFSy9Ur2UVGcI2/wCoodL3a39k6WbdgNzFxr0gWFRQ==}
hasBin: true
'@google/genai@2.6.0':
resolution: {integrity: sha512-HjoW3mPuEn7pnuKABJl9VbDoWDSF4nbwYKYvYYor7YjPeDxrrBxHzu2d1Prcd+BAuC4w+85UP6y7ZdcrQAoO7g==}
engines: {node: '>=20.0.0'}
@@ -7008,6 +7064,10 @@ packages:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
vscode-jsonrpc@8.2.1:
resolution: {integrity: sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==}
engines: {node: '>=14.0.0'}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@@ -8144,6 +8204,39 @@ snapshots:
optionalDependencies:
'@noble/hashes': 2.0.1
'@github/copilot-darwin-arm64@1.0.48':
optional: true
'@github/copilot-darwin-x64@1.0.48':
optional: true
'@github/copilot-linux-arm64@1.0.48':
optional: true
'@github/copilot-linux-x64@1.0.48':
optional: true
'@github/copilot-sdk@1.0.0-beta.4':
dependencies:
'@github/copilot': 1.0.48
vscode-jsonrpc: 8.2.1
zod: 4.4.3
'@github/copilot-win32-arm64@1.0.48':
optional: true
'@github/copilot-win32-x64@1.0.48':
optional: true
'@github/copilot@1.0.48':
optionalDependencies:
'@github/copilot-darwin-arm64': 1.0.48
'@github/copilot-darwin-x64': 1.0.48
'@github/copilot-linux-arm64': 1.0.48
'@github/copilot-linux-x64': 1.0.48
'@github/copilot-win32-arm64': 1.0.48
'@github/copilot-win32-x64': 1.0.48
'@google/genai@2.6.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))':
dependencies:
google-auth-library: 10.6.2
@@ -12915,6 +13008,8 @@ snapshots:
void-elements@3.1.0: {}
vscode-jsonrpc@8.2.1: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0

390
qa/copilot-capabilities.md Normal file
View File

@@ -0,0 +1,390 @@
# Copilot SDK capability inventory (`@github/copilot-sdk@1.0.0-beta.4`)
> Public preview audit for the `1.0.0-beta.4` pin. Per task contract, treat this as the current `latest` dist-tag snapshot and re-generate this document whenever the pinned SDK version changes.
This inventory documents the shipped TypeScript surface that the bundled `copilot` plugin pins against, instead of guessing. Every claim below is tied to the installed SDK's `.d.ts` files and bundled docs; where the inventory is silent, this document says so explicitly.
## 1. Package metadata
- Package name: `@github/copilot-sdk`.
- Version: `1.0.0-beta.4`.
- Export map:
- `.` -> ESM `./dist/index.js`, CJS `./dist/cjs/index.js`, types `./dist/index.d.ts`.
- `./extension` -> ESM `./dist/extension.js`, CJS `./dist/cjs/extension.js`, types `./dist/extension.d.ts`.
- Primary type barrel `dist/index.d.ts` re-exports `CopilotClient`, `CopilotSession`, `AssistantMessageEvent`, helpers like `defineTool`/`approveAll`, and the full public type surface from `dist/types.d.ts`.
- Declared runtime deps:
- `@github/copilot` `^1.0.46` (bundled CLI/runtime dependency)
- `vscode-jsonrpc` `^8.2.1`
- `zod` `^4.3.6`
Sources: `package.json` (on-disk install): 2-32, 58-62; `dist/index.d.ts` (sdk-inventory.txt:1033-1042).
## 2. Lifecycle methods on `CopilotClient`
Public methods/getters visible in `dist/client.d.ts`:
| Member | Signature | Return shape | What it does |
| ------------------------ | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `rpc` | `get rpc(): ReturnType<typeof createServerRpc>` | typed server RPC facade | Low-level server-scoped RPC surface; throws if not connected. |
| `start` | `start(): Promise<void>` | `void` | Starts/spawns the CLI server and connects. |
| `stop` | `stop(): Promise<Error[]>` | cleanup errors array | Graceful shutdown: closes sessions, JSON-RPC connection, then spawned CLI; preserves on-disk session state. |
| `forceStop` | `forceStop(): Promise<void>` | `void` | Force-kills client state/process without graceful cleanup. |
| `createSession` | `createSession(config: SessionConfig): Promise<CopilotSession>` | `CopilotSession` | Creates a new conversation session; auto-starts when enabled. |
| `resumeSession` | `resumeSession(sessionId: string, config: ResumeSessionConfig): Promise<CopilotSession>` | `CopilotSession` | Re-attaches to a persisted session; returns `workspacePath` when infinite sessions were enabled. |
| `getState` | `getState(): ConnectionState` | `"disconnected" \| "connecting" \| "connected" \| "error"` | Returns client connection state. |
| `ping` | `ping(message?: string): Promise<{ message: string; timestamp: number; protocolVersion?: number; }>` | echo payload | Connectivity/protocol sanity check. |
| `getStatus` | `getStatus(): Promise<GetStatusResponse>` | `{ version: string; protocolVersion: number }` | Returns CLI package version and negotiated protocol version. |
| `getAuthStatus` | `getAuthStatus(): Promise<GetAuthStatusResponse>` | `{ isAuthenticated, authType?, host?, login?, statusMessage? }` | Returns current auth mode/status. |
| `listModels` | `listModels(): Promise<ModelInfo[]>` | model metadata array | Lists models; caches first successful result unless overridden by `onListModels`. |
| `getLastSessionId` | `getLastSessionId(): Promise<string | undefined>` | optional session id | Returns most recently updated session id. |
| `deleteSession` | `deleteSession(sessionId: string): Promise<void>` | `void` | Irreversibly deletes persisted session data from disk. |
| `listSessions` | `listSessions(filter?: SessionListFilter): Promise<SessionMetadata[]>` | session metadata array | Lists persisted sessions, optionally filtered by cwd/git context. |
| `getSessionMetadata` | `getSessionMetadata(sessionId: string): Promise<SessionMetadata | undefined>` | optional metadata | O(1)-style lookup for one session's metadata. |
| `getForegroundSessionId` | `getForegroundSessionId(): Promise<string | undefined>` | optional session id | TUI+server-only: returns current foreground session. |
| `setForegroundSessionId` | `setForegroundSessionId(sessionId: string): Promise<void>` | `void` | TUI+server-only: asks the TUI to foreground a session. |
| `on` (typed) | `on<K extends SessionLifecycleEventType>(eventType: K, handler: TypedSessionLifecycleHandler<K>): () => void` | unsubscribe fn | Subscribes to one lifecycle event type. |
| `on` (catch-all) | `on(handler: SessionLifecycleHandler): () => void` | unsubscribe fn | Subscribes to all lifecycle events. |
Lifecycle event types for `client.on(...)`: `session.created`, `session.deleted`, `session.updated`, `session.foreground`, `session.background`.
Sources: `dist/client.d.ts` (sdk-inventory.txt:1081-1518), especially 1112-1477; `dist/types.d.ts` (sdk-inventory.txt:3421-3528); README API docs (sdk-inventory.txt:96-199).
## 3. Lifecycle methods on `CopilotSession`
Public properties/getters/methods visible in `dist/session.d.ts`:
| Member | Signature | Return shape | Notes |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `rpc` | `get rpc(): ReturnType<typeof createSessionRpc>` | typed session RPC facade | Low-level session RPC surface. |
| `workspacePath` | `get workspacePath(): string | undefined` | optional path | Present only when infinite sessions are enabled; workspace contains `checkpoints/`, `plan.md`, `files/`. |
| `capabilities` | `get capabilities(): SessionCapabilities` | `{ ui?: { elicitation?: boolean } }` | Host capability snapshot; auto-updated on capability change events. |
| `ui` | `get ui(): SessionUiApi` | convenience UI API | Exposes `elicitation`, `confirm`, `select`, `input`; requires `capabilities.ui?.elicitation`. |
| `send` | `send(options: MessageOptions): Promise<string>` | message id | Queues a user prompt and returns immediately. |
| `sendAndWait` | `sendAndWait(options: MessageOptions, timeout?: number): Promise<AssistantMessageEvent | undefined>` | final assistant message or `undefined` | Waits for `session.idle`; timeout defaults to 60000ms and does **not** abort in-flight work. |
| `on` (typed) | `on<K extends SessionEventType>(eventType: K, handler: TypedSessionEventHandler<K>): () => void` | unsubscribe fn | Subscribes to one event type. |
| `on` (catch-all) | `on(handler: SessionEventHandler): () => void` | unsubscribe fn | Subscribes to all session events. |
| `getMessages` | `getMessages(): Promise<SessionEvent[]>` | complete event history | Returns the full persisted conversation/event stream. |
| `disconnect` | `disconnect(): Promise<void>` | `void` | Releases in-memory resources but preserves on-disk session state for resume. |
| `destroy` | `destroy(): Promise<void>` | `void` | Deprecated alias for `disconnect()`. |
| `[Symbol.asyncDispose]` | `[Symbol.asyncDispose](): Promise<void>` | `void` | Enables `await using`. |
| `abort` | `abort(): Promise<void>` | `void` | Cancels the currently processing message without invalidating the session. |
| `setModel` | `setModel(model: string, options?: { reasoningEffort?: ReasoningEffort; modelCapabilities?: ModelCapabilitiesOverride; }): Promise<void>` | `void` | Switches model for future turns while preserving history. |
| `log` | `log(message: string, options?: { level?: "info" \| "warning" \| "error"; ephemeral?: boolean; }): Promise<void>` | `void` | Writes timeline messages; docs explicitly say to use this instead of `console.log()`. |
`MessageOptions` supports `prompt`, `attachments`, optional `mode` (`enqueue` or `immediate`), and per-turn `requestHeaders`.
Sources: `dist/session.d.ts` (sdk-inventory.txt:1520-2003); `dist/types.d.ts` (sdk-inventory.txt:3292-3339); docs/examples.md (sdk-inventory.txt:3829-3894).
## 4. Event types
### 4.1 Harness-relevant event types with inspected payloads
#### Streaming deltas / assistant turn
| Event | Payload shape | Sources |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------- |
| `assistant.turn_start` | `{ interactionId?, turnId }` | `dist/generated/session-events.d.ts`: 1633-1668 |
| `assistant.intent` | `{ intent: string }` | `dist/generated/session-events.d.ts`: 1670-1699 |
| `assistant.reasoning` | `{ content: string, reasoningId: string }` | `dist/generated/session-events.d.ts`: 1700-1735 |
| `assistant.reasoning_delta` | `{ deltaContent: string, reasoningId: string }` | `dist/generated/session-events.d.ts`: 1737-1770 |
| `assistant.streaming_delta` | `{ totalResponseSizeBytes: number }` | `dist/generated/session-events.d.ts`: 1771-1800 |
| `assistant.message_start` | `{ messageId: string, phase?: string }` | `dist/generated/session-events.d.ts`: 1927-1960 |
| `assistant.message_delta` | `{ deltaContent: string, messageId: string, parentToolCallId? }` | `dist/generated/session-events.d.ts`: 1961-1999 |
| `assistant.message` | `{ content, messageId, model?, outputTokens?, toolRequests?, reasoningText?, reasoningOpaque?, encryptedContent?, interactionId?, requestId?, phase?, turnId?, anthropicAdvisorBlocks?, anthropicAdvisorModel?, parentToolCallId? }` | `dist/generated/session-events.d.ts`: 1801-1926 |
| `assistant.turn_end` | `{ turnId: string }` | `dist/generated/session-events.d.ts`: 2000-2032 |
| `assistant.usage` | usage metrics including `{ model, inputTokens?, outputTokens?, reasoningTokens?, reasoningEffort?, duration?, cost?, cacheReadTokens?, cacheWriteTokens?, ttftMs?, interTokenLatencyMs?, quotaSnapshots?, copilotUsage? }` | `dist/generated/session-events.d.ts`: 2033-2215 |
#### Tool execution
| Event | Payload shape | Sources |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------- |
| `tool.execution_start` | `{ toolCallId, toolName, arguments?, mcpServerName?, mcpToolName?, parentToolCallId?, turnId? }` | `dist/generated/session-events.d.ts`: 2323-2382 |
| `tool.execution_partial_result` | `{ partialOutput: string, toolCallId: string }` | `dist/generated/session-events.d.ts`: 2383-2416 |
| `tool.execution_progress` | `{ progressMessage: string, toolCallId: string }` | `dist/generated/session-events.d.ts`: 2417-2450 |
| `tool.execution_complete` | `{ success: boolean, toolCallId: string, result?, error?, model?, interactionId?, isUserRequested?, toolTelemetry?, turnId?, parentToolCallId? }`; `result` is `{ content, contents?, detailedContent? }`; `error` is `{ code?, message }` | `dist/generated/session-events.d.ts`: 2451-2665 |
#### Interactivity / permissions / user prompts
| Event | Payload shape | Sources |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `permission.requested` | `{ requestId: string, permissionRequest, promptRequest?, resolvedByHook? }`; `permissionRequest` is a rich union, not just a bare kind | `dist/generated/session-events.d.ts`: 3293-3628 |
| `permission.completed` | `{ requestId: string, result: PermissionResult, toolCallId? }` where result kinds include `approved`, `approved-for-session`, `approved-for-location`, `cancelled`, `denied-by-rules`, `denied-no-approval-rule-and-could-not-request-from-user`, `denied-interactively-by-user`, `denied-by-content-exclusion-policy`, `denied-by-permission-request-hook` | `dist/generated/session-events.d.ts`: 3909-4120 |
| `user_input.requested` | `{ question: string, choices?, allowFreeform?, requestId: string, toolCallId? }` | `dist/generated/session-events.d.ts`: 4121-4166 |
| `user_input.completed` | `{ answer?, requestId: string, wasFreeform? }` | `dist/generated/session-events.d.ts`: 4167-4204 |
| `elicitation.requested` | `{ message: string, requestId: string, elicitationSource?, mode?, requestedSchema?, toolCallId?, url? }` | `dist/generated/session-events.d.ts`: 4205-4257 |
| `elicitation.completed` | `{ requestId: string, action?, content? }` | `dist/generated/session-events.d.ts`: 4273-4308 |
| `command.execute` | `{ commandName, command, args, requestId }` | `dist/generated/session-events.d.ts`: 4588-4629 |
| `commands.changed` | `{ commands: Array<{ name: string, description?: string }> }` | `dist/generated/session-events.d.ts`: 4732-4765 |
| `capabilities.changed` | `{ ui?: { elicitation?: boolean } }` | `dist/generated/session-events.d.ts`: 4766-4801 |
#### Lifecycle / error / compaction
| Event | Payload shape | Sources |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
| `session.start` | session bootstrap metadata including `{ sessionId, startTime, copilotVersion, producer, selectedModel?, reasoningEffort?, remoteSteerable?, context? }` | `dist/generated/session-events.d.ts`: 135-238 |
| `session.resume` | `{ eventCount, resumeTime, selectedModel?, reasoningEffort?, continuePendingWork?, sessionWasActive?, context? }` | `dist/generated/session-events.d.ts`: 239-300 |
| `session.error` | `{ errorType: string, message: string, errorCode?, eligibleForAutoSwitch?, providerCallId?, stack?, statusCode?, url? }` | `dist/generated/session-events.d.ts`: 334-394 |
| `session.idle` | `{ aborted?: boolean }` | `dist/generated/session-events.d.ts`: 395-424 |
| `session.usage_info` | `{ currentTokens, tokenLimit, messagesLength, conversationTokens?, systemTokens?, toolDefinitionsTokens?, isInitial? }` | `dist/generated/session-events.d.ts`: 1116-1169 |
| `session.compaction_start` | `{ conversationTokens?, systemTokens?, toolDefinitionsTokens? }` | `dist/generated/session-events.d.ts`: 1170-1210 |
| `session.compaction_complete` | `{ success, checkpointNumber?, checkpointPath?, summaryContent?, messagesRemoved?, preCompactionTokens?, postCompactionTokens?, tokensRemoved?, compactionTokensUsed?, error?, requestId? }` | `dist/generated/session-events.d.ts`: 1211-1308 |
| `model.call_failure` | `{ source, model?, statusCode?, durationMs?, apiCallId?, providerCallId?, errorMessage?, initiator? }` | `dist/generated/session-events.d.ts`: 2195-2249 |
| `abort` | `{ reason: "user_initiated" \| "remote_command" \| "user_abort" }` | `dist/generated/session-events.d.ts`: 2250-2279 |
### 4.2 Full `SessionEvent` union members
The generated `SessionEvent` union is authoritative and currently includes all of these members:
- `StartEvent`, `ResumeEvent`, `RemoteSteerableChangedEvent`, `ErrorEvent`, `IdleEvent`, `TitleChangedEvent`, `ScheduleCreatedEvent`, `ScheduleCancelledEvent`, `InfoEvent`, `WarningEvent`, `ModelChangeEvent`, `ModeChangedEvent`, `PlanChangedEvent`, `WorkspaceFileChangedEvent`, `HandoffEvent`, `TruncationEvent`, `SnapshotRewindEvent`, `ShutdownEvent`, `ContextChangedEvent`, `UsageInfoEvent`, `CompactionStartEvent`, `CompactionCompleteEvent`, `TaskCompleteEvent`, `UserMessageEvent`, `PendingMessagesModifiedEvent`, `AssistantTurnStartEvent`, `AssistantIntentEvent`, `AssistantReasoningEvent`, `AssistantReasoningDeltaEvent`, `AssistantStreamingDeltaEvent`, `AssistantMessageEvent`, `AssistantMessageStartEvent`, `AssistantMessageDeltaEvent`, `AssistantTurnEndEvent`, `AssistantUsageEvent`, `ModelCallFailureEvent`, `AbortEvent`, `ToolUserRequestedEvent`, `ToolExecutionStartEvent`, `ToolExecutionPartialResultEvent`, `ToolExecutionProgressEvent`, `ToolExecutionCompleteEvent`, `SkillInvokedEvent`, `SubagentStartedEvent`, `SubagentCompletedEvent`, `SubagentFailedEvent`, `SubagentSelectedEvent`, `SubagentDeselectedEvent`, `HookStartEvent`, `HookEndEvent`, `SystemMessageEvent`, `SystemNotificationEvent`, `PermissionRequestedEvent`, `PermissionCompletedEvent`, `UserInputRequestedEvent`, `UserInputCompletedEvent`, `ElicitationRequestedEvent`, `ElicitationCompletedEvent`, `SamplingRequestedEvent`, `SamplingCompletedEvent`, `McpOauthRequiredEvent`, `McpOauthCompletedEvent`, `ExternalToolRequestedEvent`, `ExternalToolCompletedEvent`, `CommandQueuedEvent`, `CommandExecuteEvent`, `CommandCompletedEvent`, `AutoModeSwitchRequestedEvent`, `AutoModeSwitchCompletedEvent`, `CommandsChangedEvent`, `CapabilitiesChangedEvent`, `ExitPlanModeRequestedEvent`, `ExitPlanModeCompletedEvent`, `ToolsUpdatedEvent`, `BackgroundTasksChangedEvent`, `SkillsLoadedEvent`, `CustomAgentsUpdatedEvent`, `McpServersLoadedEvent`, `McpServerStatusChangedEvent`, `ExtensionsLoadedEvent`.
For OpenClaw harness work, the inspected payloads above are the important ones; the remaining union members exist in the shipped schema but are not otherwise documented in the README.
Source: `dist/generated/session-events.d.ts`: 5.
## 5. Tool contract
- Public tool shape:
- `name: string`
- `description?: string`
- `parameters?: ZodSchema<TArgs> | Record<string, unknown>`
- `handler: ToolHandler<TArgs>`
- `overridesBuiltInTool?: boolean`
- `skipPermission?: boolean`
- `ToolHandler<TArgs>` signature: `(args: TArgs, invocation: ToolInvocation) => Promise<unknown> | unknown`.
- `ToolInvocation` carries `{ sessionId, toolCallId, toolName, arguments, traceparent?, tracestate? }`.
- Return values:
- A plain `string`
- A `ToolResultObject` with `{ textResultForLlm, binaryResultsForLlm?, resultType, error?, sessionLog?, toolTelemetry? }`
- README/examples also state any JSON-serializable handler return is accepted and auto-wrapped; extension docs add that `undefined` becomes an empty success and throwing becomes a failure/error message.
- `ToolResultType` is `"success" | "failure" | "rejected" | "denied" | "timeout"`.
- Built-in tool override semantics: using a built-in tool name without `overridesBuiltInTool: true` throws.
- Permission bypass semantics: `skipPermission: true` suppresses permission prompts for that custom tool.
- Helper: `defineTool(name, config)` exists purely to preserve type inference from Zod schemas.
Sources: `dist/types.d.ts` (sdk-inventory.txt:2203-2304); README tools section (sdk-inventory.txt:430-485); docs/agent-author.md (sdk-inventory.txt:3708-3745, 3905).
## 6. Permission contract (`onPermissionRequest`)
- Session config requires `onPermissionRequest: PermissionHandler` for both `createSession` and `resumeSession`.
- Declared handler type in `dist/types.d.ts`:
- `type PermissionHandler = (request: PermissionRequest, invocation: { sessionId: string }) => Promise<PermissionRequestResult> | PermissionRequestResult`
- `PermissionRequest` is typed only as `{ kind: "shell" | "write" | "mcp" | "read" | "url" | "custom-tool" | "memory" | "hook"; toolCallId?: string }`
- `PermissionRequestResult` is `PermissionDecisionRequest["result"] | { kind: "no-result" }`
- README claims the runtime supplies richer fields such as `toolName`, `fileName`, and `fullCommandText` to custom handlers; the generated `permission.requested` event schema confirms a richer union exists with per-kind payloads:
- `shell`: `fullCommandText`, `commands[]`, `possiblePaths[]`, `possibleUrls[]`, `hasWriteFileRedirection`, `intention`, `warning`, `canOfferSessionApproval`
- `write`: `fileName`, `diff`, `newFileContents?`, `intention`, `canOfferSessionApproval`
- `read`: `path`, `intention`
- `mcp`: `serverName`, `toolName`, `toolTitle`, `args?`, `readOnly`
- `url`: `url`, `intention`
- `memory`: `action?`, `fact`, `subject?`, `citations?`, `direction?`, `reason?`
- `custom-tool`: `toolName`, `toolDescription`, `args?`
- `hook`: `toolName`, `toolArgs?`, `hookMessage?`
- plus extension-specific `extension-management` and `extension-permission-access` variants in the event schema.
- Result kinds explicitly documented in README: `approved`, `denied-interactively-by-user`, `denied-no-approval-rule-and-could-not-request-from-user`, `denied-by-rules`, `denied-by-content-exclusion-policy`, `no-result`.
- Protocol-v2 caveat: `NO_RESULT_PERMISSION_V2_ERROR = "Permission handlers cannot return 'no-result' when connected to a protocol v2 server."`
- Timeout behavior: not documented in the public types/docs inspected.
Sources: `dist/types.d.ts` (sdk-inventory.txt:2608-2619); README permission handling (sdk-inventory.txt:804-879); `dist/session.d.ts` (sdk-inventory.txt:1529, 1813-1822, 1866-1873); `dist/generated/session-events.d.ts`: 3293-3628, 3909-4120.
## 7. User-input contract (`onUserInputRequest`)
- Session config field: `onUserInputRequest?: UserInputHandler`.
- Declared handler type: `(request: UserInputRequest, invocation: { sessionId: string }) => Promise<UserInputResponse> | UserInputResponse`.
- `UserInputRequest` fields:
- `question: string`
- `choices?: string[]`
- `allowFreeform?: boolean` (default `true`)
- `UserInputResponse` fields:
- `answer: string`
- `wasFreeform: boolean`
- README says providing the handler enables the `ask_user` tool.
- The event stream adds request/response correlation fields not present in the handler type:
- `user_input.requested` includes `requestId` and optional `toolCallId`
- `user_input.completed` includes `requestId`, optional `answer`, optional `wasFreeform`
- Timeout behavior is not documented in the inspected public surface.
Sources: `dist/types.d.ts` (sdk-inventory.txt:2624-2657, 3091-3095); README user-input section (sdk-inventory.txt:881-905); `dist/generated/session-events.d.ts`: 4121-4204.
## 8. Infinite sessions
- `SessionConfig.infiniteSessions?: InfiniteSessionConfig` controls the feature.
- `InfiniteSessionConfig` fields:
- `enabled?: boolean` (default `true`)
- `backgroundCompactionThreshold?: number` (default `0.80`)
- `bufferExhaustionThreshold?: number` (default `0.95`)
- README says infinite sessions are the default, automatically manage context limits, and persist state to a workspace directory.
- `CopilotSession.workspacePath` is populated only when infinite sessions are enabled.
- The workspace is explicitly documented as containing `checkpoints/`, `plan.md`, and `files/`.
- README example shows the default location as `~/.copilot/session-state/{sessionId}/`.
- Auto-compaction trigger semantics:
- background compaction starts at the configured `backgroundCompactionThreshold`
- the session blocks at `bufferExhaustionThreshold` until compaction finishes
- events emitted: `session.compaction_start` and `session.compaction_complete`
- Compaction result payload includes checkpoint metadata (`checkpointNumber`, `checkpointPath`), summary text (`summaryContent`), before/after token counts, messages removed, tokens removed, and nested `compactionTokensUsed` usage breakdown.
Sources: README infinite sessions section (sdk-inventory.txt:627-660); `dist/session.d.ts` (sdk-inventory.txt:1594-1598); `dist/types.d.ts` (sdk-inventory.txt:2980-3006, 3168-3172); docs/examples.md (sdk-inventory.txt:4330-4346); `dist/generated/session-events.d.ts`: 1170-1308.
## 9. Reasoning effort
- Declared enum/type: `type ReasoningEffort = "low" | "medium" | "high" | "xhigh"`.
- Session config field: `reasoningEffort?: ReasoningEffort`.
- It is only valid when `ModelCapabilities.supports.reasoningEffort` is `true`.
- Discovery/model metadata surface:
- `ModelInfo.supportedReasoningEfforts?: ReasoningEffort[]`
- `ModelInfo.defaultReasoningEffort?: ReasoningEffort`
- The README repeatedly points callers to `listModels()` to discover support/defaults rather than assuming a global SDK default.
- Runtime/event reflection:
- `session.start` / `session.resume` metadata may include `reasoningEffort?: string`
- `assistant.usage` may also include `reasoningEffort?: string` plus `reasoningTokens?`
Sources: README API docs (sdk-inventory.txt:116-123, 118); `dist/types.d.ts` (sdk-inventory.txt:3003-3006, 3023-3027, 3445-3498); `dist/generated/session-events.d.ts`: 181-183, 281-283, 2115-2121.
## 10. Telemetry
- `TelemetryConfig` shape:
- `otlpEndpoint?: string`
- `filePath?: string`
- `exporterType?: string` (`"otlp-http"` or `"file"` in README)
- `sourceName?: string`
- `captureContent?: boolean`
- `CopilotClientOptions.telemetry?: TelemetryConfig` configures CLI-process telemetry by setting environment variables on the spawned CLI.
- `TraceContextProvider` signature: `() => TraceContext | Promise<TraceContext>`.
- `TraceContext` shape: `{ traceparent?: string; tracestate?: string }`.
- `CopilotClientOptions.onGetTraceContext?: TraceContextProvider` is called before `session.create`, `session.resume`, and `session.send` RPCs to inject distributed trace headers.
- Tool handlers receive inbound trace context on `ToolInvocation.traceparent` and `ToolInvocation.tracestate`.
- `dist/telemetry.d.ts` exports `getTraceContext(provider?)` as a helper that returns `{}` when no provider is configured.
Sources: README telemetry section (sdk-inventory.txt:759-803); `dist/types.d.ts` (sdk-inventory.txt:2020-2049, 2137-2167, 2253-2262); `dist/telemetry.d.ts` (sdk-inventory.txt:3560-3574).
## 11. Auth modes
### Client-level auth/config
- `gitHubToken?: string`: explicit GitHub token; takes priority over other auth methods.
- `useLoggedInUser?: boolean`: default `true`, but defaults to `false` when `gitHubToken` is provided.
- `copilotHome?: string`: base directory for Copilot data; only used when the SDK spawns the CLI process.
- `cliUrl?: string`: connect to an existing server instead of spawning the CLI.
- `useLoggedInUser` cannot be used with `cliUrl`; `copilotHome` is ignored with `cliUrl`.
- `getAuthStatus()` returns `{ isAuthenticated, authType?, host?, login?, statusMessage? }`, where `authType` can be `user`, `env`, `gh-cli`, `hmac`, `api-key`, or `token`.
### Session-level auth/BYOK
- `SessionConfig.gitHubToken?: string` is separate from client auth. The docs say it is resolved into a full GitHub identity used for content exclusion, model routing, and quota checks, enabling multitenant sessions.
- `SessionConfig.provider?: ProviderConfig` switches the session to a custom API provider (`openai`, `azure`, or `anthropic`) with `baseUrl`, optional `apiKey`, optional `bearerToken` (takes precedence over `apiKey`), optional `wireApi`, optional `azure.apiVersion`, optional `headers`, `modelId`, `wireModel`, `maxInputTokens`, `maxOutputTokens`.
- README explicitly says `model` is required when using `provider`.
- `enableSessionTelemetry` is always disabled when a custom `provider` is configured.
### Legality / unresolved combinations
- Explicitly documented illegal/mutually exclusive combos:
- `cliUrl` with `useLoggedInUser`
- constructor rejects mutually exclusive options such as `cliUrl` with `useStdio` or `cliPath`
- The inspected inventory does **not** explicitly document whether `provider` may be combined with client-level/session-level GitHub auth, so treat that as an open probe.
Sources: README options/custom-provider docs (sdk-inventory.txt:83-94, 116-123, 696-757); `dist/client.d.ts` (sdk-inventory.txt:1121-1123, 1304-1308); `dist/types.d.ts` (sdk-inventory.txt:2051-2167, 3077-3085, 3174-3183, 3223-3288, 3430-3441).
## 12. `copilotHome`
What is explicit in the inventory:
- `copilotHome` is the base directory for Copilot data: "session state, config, etc."; it sets `COPILOT_HOME` on the spawned CLI process.
- If omitted, the CLI defaults to `~/.copilot`.
- `workspacePath` examples place per-session state under `~/.copilot/session-state/{sessionId}/`, with `checkpoints/`, `plan.md`, and `files/` inside that session directory.
What is **not** explicit in the inventory:
- Exact full directory tree under `copilotHome`
- File/lock semantics for multiple `CopilotClient` instances sharing the same `copilotHome`
- Whether same-process sharing is safe under concurrent session creation/resume/delete
OpenClaw implication: the docs are not strong enough to justify shared `copilotHome` pools. Q5's per-agent-pool decision should therefore keep isolated `copilotHome` directories until `spike-app` proves concurrency safety.
Sources: README options/infinite-session docs (sdk-inventory.txt:90-94, 627-660); `dist/types.d.ts` (sdk-inventory.txt:2067-2073); `dist/session.d.ts` (sdk-inventory.txt:1594-1598).
## 13. Replay / resume
- `resumeSession(sessionId, config)` re-attaches to a previous session and keeps conversation history.
- `disconnect()` preserves on-disk session state; `stop()` also preserves it; `deleteSession()` is the destructive operation.
- `getMessages()` returns the complete session event history (`SessionEvent[]`).
- `listSessions(filter?)` returns persisted session metadata including `sessionId`, `startTime`, `modifiedTime`, `summary?`, `isRemote`, `context?`.
- `getSessionMetadata(sessionId)` is a targeted metadata lookup.
- `getLastSessionId()` returns the most recently updated session id.
- Resume-specific semantics in `ResumeSessionConfig`:
- `disableResume?: boolean` skips emitting `session.resume`
- `continuePendingWork?: boolean` resumes in-flight permissions/tool work; otherwise pending work is treated as interrupted and permissions are re-emitted as `permission.requested`
- Resume event metadata distinguishes hot vs cold attach:
- `sessionWasActive?: boolean` means the runtime already had the session in memory
- `false`/missing means a cold resume reconstructed from persisted event log
Sources: README API docs (sdk-inventory.txt:128-170, 281-287, 867-875); `dist/client.d.ts` (sdk-inventory.txt:1246-1395); `dist/session.d.ts` (sdk-inventory.txt:1892-1944); `dist/types.d.ts` (sdk-inventory.txt:3200-3221, 3409-3417); `dist/generated/session-events.d.ts`: 266-299.
## 14. Models advertised
Explicit model ids mentioned in the inspected inventory:
- `gpt-5`
- `gpt-4`
- `gpt-4.1`
- `claude-sonnet-4.5`
- `claude-sonnet-4.6`
- example BYOK/Ollama model: `deepseek-coder-v2:16b`
Discovery API:
- `client.listModels(): Promise<ModelInfo[]>` is the authoritative discovery path.
- `ModelInfo` carries `id`, `name`, `capabilities`, optional `policy`, optional `billing`, optional `supportedReasoningEfforts`, optional `defaultReasoningEffort`.
- `CopilotClientOptions.onListModels` can override discovery entirely (useful for BYOK mode).
What is **not** in the inspected inventory:
- A static canonical built-in model catalog beyond the handful of examples above.
Sources: README/examples (sdk-inventory.txt:38, 65, 117-118, 633-665, 713-749); `dist/client.d.ts` (sdk-inventory.txt:1310-1320); `dist/types.d.ts` (sdk-inventory.txt:2130-2135, 3483-3498); `dist/session.d.ts` (sdk-inventory.txt:1975-1982).
## 15. Error surface
### Public methods
- Public methods generally document `@throws Error`; the SDK does **not** expose a rich public exception-class hierarchy in the inspected `.d.ts` files.
- `stop()` is unusual: instead of throwing cleanup failures, it resolves to `Error[]`.
- Constructor may throw on mutually exclusive options.
- `createSession()` can throw if auto-start is disabled and the client is disconnected.
- `resumeSession()` can throw if the session does not exist or the client is not connected.
- `sendAndWait()` throws on timeout or connection/disconnect failure.
- `Tool` registration can throw for built-in name collisions unless `overridesBuiltInTool: true` is set.
- README says missing `model` with custom `provider` throws.
- Protocol-v2 permission adapter throws the exported `NO_RESULT_PERMISSION_V2_ERROR` if a handler returns `no-result`.
### Event / telemetry error reporting
- `session.error` carries `{ errorType, message, errorCode?, statusCode?, providerCallId?, stack?, url?, eligibleForAutoSwitch? }`.
- `model.call_failure` carries failed model-call telemetry (`source`, `model?`, `statusCode?`, `durationMs?`, `providerCallId?`, `errorMessage?`).
- `tool.execution_complete.error` carries `{ code?, message }`.
- Hook APIs expose explicit recovery output: `onErrorOccurred` may return `errorHandling: "retry" | "skip" | "abort"` plus `retryCount?`.
### Retryability
- Explicitly retry-like signals in the inspected surface:
- `session.error.eligibleForAutoSwitch` for rate-limit flows
- `auto_mode_switch.requested` / `auto_mode_switch.completed` events
- `onErrorOccurred` hook output `errorHandling: "retry"`
- The SDK does **not** publish a general retryable/non-retryable error enum for all thrown errors. Anything beyond the rate-limit/auto-switch path needs probing.
Sources: README/tool/provider/error docs (sdk-inventory.txt:459-480, 753-757, 1013-1021); `dist/client.d.ts` (sdk-inventory.txt:1119-1123, 1147-1214, 1222-1225, 1252-1255, 1284-1288, 1346-1355); `dist/session.d.ts` (sdk-inventory.txt:1529, 1645-1650, 1813-1822, 1866-1889); `dist/generated/session-events.d.ts`: 361-393, 2195-2279, 2478-2529; `dist/types.d.ts` (sdk-inventory.txt:2822-2871).
## 16. Open SDK questions
Concrete gaps to answer in `spike-app` before landing a real harness:
1. **Permission handler typing mismatch:** README says `onPermissionRequest` receives rich per-kind fields (`toolName`, `fileName`, `fullCommandText`), but `dist/types.d.ts` types `PermissionRequest` as just `{ kind, toolCallId? }`. What object shape does runtime actually deliver to JS/TS handlers?
2. **Permission timeouts:** what happens if `onPermissionRequest` never resolves? Is there a default timeout, cancellation, or session hang?
3. **User-input timeouts/cancellation:** same question for `onUserInputRequest`.
4. **`copilotHome` concurrency:** can multiple `CopilotClient` instances in one process safely share one `copilotHome`, or are there lock/race hazards around `session-state/` and config files?
5. **Exact `copilotHome` layout:** beyond `session-state/<id>/{checkpoints,plan.md,files}`, what other top-level files/directories are created, and which are session-global versus client-global?
6. **Provider/auth combination matrix:** what combinations of client-level `gitHubToken`, session-level `gitHubToken`, `useLoggedInUser`, and `provider` are accepted or rejected in practice?
7. **Resume behavior for encrypted reasoning fields:** `assistant.message` notes `encryptedContent`/`reasoningOpaque` are session-bound and stripped on resume. What survives after process restart versus live reconnect?
8. **Event coverage needed by OpenClaw:** do we need additional exact-string handling for non-core events like `ToolsUpdatedEvent`, `SkillsLoadedEvent`, `McpServersLoadedEvent`, `ExtensionsLoadedEvent`, or is the harness safe to ignore them?
9. **Cold-resume pending work:** with `continuePendingWork: true`, what concrete low-level RPCs are required to finish previously pending external tool calls in an SDK-only consumer?
10. **Model discovery under BYOK:** when `provider` is set without `onListModels`, what does `listModels()` return, if anything?
Sources: `dist/types.d.ts` (sdk-inventory.txt:2608-2619, 2624-2657, 3203-3221, 3174-3183, 3226-3288); README permission/user-input/provider docs (sdk-inventory.txt:823-845, 883-905, 696-757); `dist/generated/session-events.d.ts`: 266-299, 1828-1889, 3293-3628.

View File

@@ -73,6 +73,19 @@ export const BUILD_ALL_STEPS = [
kind: "node",
args: ["--experimental-strip-types", "scripts/copy-hook-metadata.ts"],
},
{
label: "copy-copilot-sdk-manifest",
kind: "node",
args: ["--experimental-strip-types", "scripts/copy-copilot-sdk-manifest.ts"],
cache: {
inputs: [
"scripts/copy-copilot-sdk-manifest.ts",
"scripts/lib/copy-assets.ts",
"src/commands/copilot-sdk-install-manifest",
],
outputs: ["dist/commands/copilot-sdk-install-manifest"],
},
},
{
label: "copy-export-html-templates",
kind: "node",
@@ -127,6 +140,7 @@ export const BUILD_ALL_PROFILES = {
"check-plugin-sdk-exports",
"plugins:assets:copy",
"copy-hook-metadata",
"copy-copilot-sdk-manifest",
"copy-export-html-templates",
"ui:build",
"write-build-info",

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env tsx
/**
* Copy the Copilot SDK install manifest (package.json + package-lock.json)
* from src/commands/copilot-sdk-install-manifest/ to dist/commands/copilot-sdk-install-manifest/.
*
* The Copilot agent runtime's on-demand SDK installer
* (src/commands/copilot-sdk-install.ts) resolves the manifest dir
* relative to its compiled location via `import.meta.url`. tsdown does
* not copy non-source files alongside compiled output, so we mirror the
* manifest here as part of the build chain. Mirrors the precedent set
* by scripts/copy-hook-metadata.ts.
*/
import fs from "node:fs";
import path from "node:path";
import { ensureDirectory, logVerboseCopy, resolveBuildCopyContext } from "./lib/copy-assets.ts";
const context = resolveBuildCopyContext(import.meta.url);
const SRC_MANIFEST_DIR = path.join(
context.projectRoot,
"src",
"commands",
"copilot-sdk-install-manifest",
);
const DIST_MANIFEST_DIR = path.join(
context.projectRoot,
"dist",
"commands",
"copilot-sdk-install-manifest",
);
const MANIFEST_FILES = ["package.json", "package-lock.json"];
function copyCopilotSdkManifest(): void {
if (!fs.existsSync(SRC_MANIFEST_DIR)) {
throw new Error(
`${context.prefix} Source manifest dir missing: ${SRC_MANIFEST_DIR}. This directory is part of the Copilot agent runtime pinned install graph and must exist in the repo.`,
);
}
ensureDirectory(DIST_MANIFEST_DIR);
for (const fileName of MANIFEST_FILES) {
const sourcePath = path.join(SRC_MANIFEST_DIR, fileName);
const destPath = path.join(DIST_MANIFEST_DIR, fileName);
if (!fs.existsSync(sourcePath)) {
throw new Error(
`${context.prefix} Missing manifest file ${sourcePath}. Re-generate with \`npm install --package-lock-only\` in src/commands/copilot-sdk-install-manifest/.`,
);
}
fs.copyFileSync(sourcePath, destPath);
logVerboseCopy(context, `Copied copilot-sdk-install-manifest/${fileName}`);
}
console.log(
`${context.prefix} Copied Copilot SDK install manifest (${MANIFEST_FILES.length} files).`,
);
}
copyCopilotSdkManifest();

View File

@@ -13,6 +13,9 @@ export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = [
"extensions/acpx/src/runtime-internals/mcp-proxy.mjs",
"extensions/canvas/src/host/a2ui-app/bootstrap.js",
"extensions/canvas/src/host/a2ui-app/rolldown.config.mjs",
"extensions/copilot/src/doctor-probes.ts",
"extensions/copilot/src/telemetry-bridge.ts",
"extensions/copilot/src/user-input-bridge.ts",
"extensions/diffs/src/viewer-client.ts",
"extensions/diffs/src/viewer-payload.ts",
"extensions/matrix/src/plugin-entry.runtime.js",

View File

@@ -52,6 +52,17 @@ function shouldBuildBundledDistEntry(packageJson) {
return packageJson?.openclaw?.build?.bundledDist !== false;
}
function isExcludedTopLevelPublicSurfaceFile(fileName) {
const normalizedName = fileName.toLowerCase();
return (
normalizedName.endsWith(".d.ts") ||
/^config-api\.(?:[cm]?[jt]s)$/u.test(normalizedName) ||
TOP_LEVEL_PRIVATE_TEST_SURFACE_RE.test(normalizedName) ||
normalizedName.includes(".fixture.") ||
normalizedName.includes(".snap")
);
}
export function collectPluginSourceEntries(packageJson) {
let packageEntries = Array.isArray(packageJson?.openclaw?.extensions)
? packageJson.openclaw.extensions.filter(
@@ -86,14 +97,7 @@ export function collectTopLevelPublicSurfaceEntries(pluginDir) {
return [];
}
const normalizedName = dirent.name.toLowerCase();
if (
normalizedName.endsWith(".d.ts") ||
/^config-api\.(?:[cm]?[jt]s)$/u.test(normalizedName) ||
TOP_LEVEL_PRIVATE_TEST_SURFACE_RE.test(normalizedName) ||
normalizedName.includes(".fixture.") ||
normalizedName.includes(".snap")
) {
if (isExcludedTopLevelPublicSurfaceFile(dirent.name)) {
return [];
}
@@ -114,14 +118,7 @@ function collectTopLevelPublicSurfaceEntriesFromFiles(relativeFiles) {
return [];
}
const normalizedName = relativeFile.toLowerCase();
if (
normalizedName.endsWith(".d.ts") ||
/^config-api\.(?:[cm]?[jt]s)$/u.test(normalizedName) ||
TOP_LEVEL_PRIVATE_TEST_SURFACE_RE.test(normalizedName) ||
normalizedName.includes(".fixture.") ||
normalizedName.includes(".snap")
) {
if (isExcludedTopLevelPublicSurfaceFile(relativeFile)) {
return [];
}

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { modelSelectionShouldEnsureCopilotSdk } from "./copilot-routing.js";
const emptyCfg = {} as OpenClawConfig;
function cfgWithProviderRuntime(id: string): OpenClawConfig {
return {
models: {
providers: {
"github-copilot": { agentRuntime: { id } },
},
},
} as unknown as OpenClawConfig;
}
function cfgWithModelRuntime(modelId: string, id: string): OpenClawConfig {
return {
models: {
providers: {
"github-copilot": {
models: [{ id: modelId, agentRuntime: { id } }],
},
},
},
} as unknown as OpenClawConfig;
}
describe("modelSelectionShouldEnsureCopilotSdk", () => {
it("returns false for github-copilot/* without explicit agentRuntime opt-in", () => {
// Built-in GitHub Copilot provider already supports these models;
// we must not nag users with a 260 MB SDK install prompt.
expect(
modelSelectionShouldEnsureCopilotSdk({
model: "github-copilot/gpt-4o",
config: emptyCfg,
}),
).toBe(false);
});
it("returns true when the provider config sets agentRuntime.id = copilot", () => {
expect(
modelSelectionShouldEnsureCopilotSdk({
model: "github-copilot/gpt-4o",
config: cfgWithProviderRuntime("copilot"),
}),
).toBe(true);
});
it("returns true when a model override sets agentRuntime.id = copilot", () => {
expect(
modelSelectionShouldEnsureCopilotSdk({
model: "github-copilot/claude-sonnet-4",
config: cfgWithModelRuntime("claude-sonnet-4", "copilot"),
}),
).toBe(true);
});
it("normalizes id casing/whitespace before matching", () => {
expect(
modelSelectionShouldEnsureCopilotSdk({
model: "github-copilot/gpt-4o",
config: cfgWithProviderRuntime(" Copilot "),
}),
).toBe(true);
});
it("returns false when the runtime id is anything other than copilot", () => {
expect(
modelSelectionShouldEnsureCopilotSdk({
model: "github-copilot/gpt-4o",
config: cfgWithProviderRuntime("pi"),
}),
).toBe(false);
expect(
modelSelectionShouldEnsureCopilotSdk({
model: "github-copilot/gpt-4o",
config: cfgWithProviderRuntime("codex"),
}),
).toBe(false);
});
it("model-scope override takes precedence over provider scope", () => {
const cfg = {
models: {
providers: {
"github-copilot": {
agentRuntime: { id: "copilot" },
models: [{ id: "gpt-4o", agentRuntime: { id: "pi" } }],
},
},
},
} as unknown as OpenClawConfig;
expect(
modelSelectionShouldEnsureCopilotSdk({
model: "github-copilot/gpt-4o",
config: cfg,
}),
).toBe(false);
// A different model that has no override still inherits the provider-level opt-in.
expect(
modelSelectionShouldEnsureCopilotSdk({
model: "github-copilot/claude-sonnet-4",
config: cfg,
}),
).toBe(true);
});
it("returns false for other providers regardless of agentRuntime config", () => {
const cfg = {
models: {
providers: {
openai: { agentRuntime: { id: "copilot" } },
},
},
} as unknown as OpenClawConfig;
expect(modelSelectionShouldEnsureCopilotSdk({ model: "openai/gpt-4o", config: cfg })).toBe(
false,
);
expect(
modelSelectionShouldEnsureCopilotSdk({
model: "anthropic/claude-3",
config: emptyCfg,
}),
).toBe(false);
expect(
modelSelectionShouldEnsureCopilotSdk({
model: "openai-codex/gpt-4o",
config: emptyCfg,
}),
).toBe(false);
});
it("returns false for undefined, empty, or unprefixed model refs", () => {
expect(modelSelectionShouldEnsureCopilotSdk({ config: emptyCfg })).toBe(false);
expect(modelSelectionShouldEnsureCopilotSdk({ model: "", config: emptyCfg })).toBe(false);
expect(modelSelectionShouldEnsureCopilotSdk({ model: "gpt-4o", config: emptyCfg })).toBe(false);
expect(
modelSelectionShouldEnsureCopilotSdk({ model: "github-copilot/", config: emptyCfg }),
).toBe(false);
});
});

55
src/agents/copilot-routing.ts Executable file
View File

@@ -0,0 +1,55 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveModelRuntimePolicy } from "./model-runtime-policy.js";
import { parseModelRefProvider } from "./openai-codex-routing.js";
export const GITHUB_COPILOT_PROVIDER_ID = "github-copilot";
/**
* Canonical id of the Copilot agent runtime plugin
* (see `extensions/copilot/index.ts`, which registers as `id: "copilot"`).
*/
export const COPILOT_RUNTIME_ID = "copilot";
function parseModelRefId(model: string | undefined): string | undefined {
if (typeof model !== "string") {
return undefined;
}
const trimmed = model.trim();
const slash = trimmed.indexOf("/");
if (slash <= 0 || slash === trimmed.length - 1) {
return undefined;
}
return trimmed.slice(slash + 1);
}
/**
* Returns true when the selected model should trigger the on-demand
* install of `@github/copilot-sdk` for the Copilot agent runtime.
*
* Gating contract (review #2, P1):
* - Model ref must use the `github-copilot/*` provider prefix.
* - The user's config must explicitly opt in by setting
* `agentRuntime.id: "copilot"` at the provider, model, or agent scope
* (resolved via `resolveModelRuntimePolicy`).
*
* Without the explicit opt-in we fall through to the built-in GitHub
* Copilot provider, which has shipped support for `github-copilot/*`
* models for a long time and must not surface a 260 MB SDK install
* prompt to users who never asked for the runtime.
*/
export function modelSelectionShouldEnsureCopilotSdk(params: {
model?: string;
config?: OpenClawConfig;
}): boolean {
if (parseModelRefProvider(params.model) !== GITHUB_COPILOT_PROVIDER_ID) {
return false;
}
const modelId = parseModelRefId(params.model);
const resolved = resolveModelRuntimePolicy({
config: params.config,
provider: GITHUB_COPILOT_PROVIDER_ID,
modelId,
});
const runtimeId = resolved.policy?.id?.trim().toLowerCase();
return runtimeId === COPILOT_RUNTIME_ID;
}

View File

@@ -680,6 +680,89 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
).toBeUndefined();
});
it("forwards unscoped tool auth profiles to Copilot plugin harnesses", async () => {
const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js");
const pluginRunAttempt = vi.fn<AgentHarness["runAttempt"]>(async () =>
makeAttemptResult({ assistantTexts: ["ok"] }),
);
const runtimePlan = makeForwardedRuntimePlan({
resolvedRef: {
provider: "github-copilot",
modelId: "gpt-4o",
harnessId: "copilot",
},
auth: {
harnessAuthProvider: "github-copilot",
forwardedAuthProfileId: "github-copilot:work",
},
});
clearAgentHarnesses();
registerAgentHarness({
id: "copilot",
label: "Copilot",
supports: (ctx) =>
ctx.provider === "github-copilot"
? { supported: true, priority: 100 }
: { supported: false },
runAttempt: pluginRunAttempt,
});
mockedBuildAgentRuntimePlan.mockReturnValueOnce(runtimePlan);
mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped"));
const copilotAuthStore = {
version: 1,
profiles: {
"github-copilot:work": {
type: "oauth" as const,
provider: "github-copilot",
access: "access",
refresh: "refresh",
expires: Date.now() + 60_000,
},
"anthropic:work": {
type: "api_key" as const,
provider: "anthropic",
key: "sk-ant",
},
},
};
mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce(copilotAuthStore);
try {
await runEmbeddedAgent({
...overflowBaseRunParams,
provider: "github-copilot",
model: "gpt-4o",
config: {
models: {
providers: {
"github-copilot": {
agentRuntime: { id: "copilot" },
baseUrl: "https://api.githubcopilot.com",
models: [],
},
},
},
},
authProfileId: "github-copilot:work",
authProfileIdSource: "user",
runId: "copilot-plugin-harness-forwards-tool-auth-store",
});
} finally {
clearAgentHarnesses();
}
expect(mockedGetApiKeyForModel).not.toHaveBeenCalled();
expect(pluginRunAttempt).toHaveBeenCalledTimes(1);
const harnessParams = mockCallArg(pluginRunAttempt) as {
authProfileStore?: { profiles?: Record<string, unknown> };
toolAuthProfileStore?: unknown;
};
const forwardedAuthStore = expectRecordFields(harnessParams.authProfileStore, {});
const authProfiles = expectRecordFields(forwardedAuthStore.profiles, {});
expect(Object.keys(authProfiles)).toEqual(["github-copilot:work"]);
expect(harnessParams.toolAuthProfileStore).toBe(copilotAuthStore);
});
it("forwards optional attempt params and the runtime plan into one attempt call", async () => {
const internalEvents: AgentInternalEvent[] = [];
const forwardingCase = makeForwardingCase(internalEvents);

View File

@@ -1119,6 +1119,8 @@ export async function runEmbeddedAgent(
: lastProfileId,
)
: attemptAuthProfileStore;
const harnessBuildsOpenClawTools =
agentHarness.id === "codex" || agentHarness.id === "copilot";
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
@@ -1605,9 +1607,9 @@ export async function runEmbeddedAgent(
initialReplayState: accumulatedReplayState,
authStorage,
authProfileStore: runAttemptAuthProfileStore,
// Codex builds OpenClaw tools inside its harness. Keep transport
// auth scoped while letting tool construction see plugin creds.
toolAuthProfileStore: agentHarness.id === "codex" ? attemptAuthProfileStore : undefined,
// These harnesses build OpenClaw tools internally. Keep transport auth
// scoped while letting tool construction see plugin/provider creds.
toolAuthProfileStore: harnessBuildsOpenClawTools ? attemptAuthProfileStore : undefined,
modelRegistry,
agentId: workspaceResolution.agentId,
beforeAgentStartResult,

View File

@@ -448,6 +448,7 @@ export async function loadImageFromRef(
options?: {
maxBytes?: number;
workspaceOnly?: boolean;
localRoots?: readonly string[];
sandbox?: { root: string; bridge: SandboxFsBridge };
},
): Promise<ImageContent | null> {
@@ -491,7 +492,7 @@ export async function loadImageFromRef(
: await loadWebMedia(
targetPath,
options?.workspaceOnly
? { maxBytes: options.maxBytes, localRoots: [workspaceDir] }
? { maxBytes: options.maxBytes, localRoots: options.localRoots ?? [workspaceDir] }
: options?.maxBytes,
);
@@ -542,6 +543,7 @@ export async function detectAndLoadPromptImages(params: {
maxBytes?: number;
maxDimensionPx?: number;
workspaceOnly?: boolean;
localRoots?: readonly string[];
sandbox?: { root: string; bridge: SandboxFsBridge };
}): Promise<{
/** Images for the current prompt (existingImages + detected in current prompt) */
@@ -594,6 +596,7 @@ export async function detectAndLoadPromptImages(params: {
const image = await loadImageFromRef(ref, params.workspaceDir, {
maxBytes: params.maxBytes,
workspaceOnly: params.workspaceOnly,
localRoots: params.localRoots,
sandbox: params.sandbox,
});
if (image) {
@@ -609,6 +612,7 @@ export async function detectAndLoadPromptImages(params: {
const image = await loadImageFromRef(ref, params.workspaceDir, {
maxBytes: params.maxBytes,
workspaceOnly: params.workspaceOnly,
localRoots: params.localRoots,
sandbox: params.sandbox,
});
offloadedImages.push(image);

View File

@@ -93,6 +93,83 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
);
});
it("loads a configured Copilot harness plugin before selection", async () => {
await ensureSelectedAgentHarnessPlugin({
provider: "github-copilot",
modelId: "gpt-4o",
config: {
models: {
providers: {
"github-copilot": {
agentRuntime: { id: "copilot" },
baseUrl: "https://api.githubcopilot.com",
models: [],
},
},
},
} as OpenClawConfig,
workspaceDir: "/tmp/workspace",
});
expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled();
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
expect.objectContaining({
scope: "all",
workspaceDir: "/tmp/workspace",
onlyPluginIds: ["copilot"],
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["copilot"],
entries: expect.objectContaining({
copilot: expect.objectContaining({ enabled: true }),
}),
}),
}),
}),
);
});
it("does not bypass a restrictive allowlist that omits a configured Copilot harness", async () => {
await ensureSelectedAgentHarnessPlugin({
provider: "github-copilot",
modelId: "gpt-4o",
config: {
plugins: {
allow: ["telegram"],
entries: {
telegram: { enabled: true },
},
},
models: {
providers: {
"github-copilot": {
agentRuntime: { id: "copilot" },
baseUrl: "https://api.githubcopilot.com",
models: [],
},
},
},
} as OpenClawConfig,
workspaceDir: "/tmp/workspace",
});
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith(
expect.objectContaining({
scope: "all",
workspaceDir: "/tmp/workspace",
onlyPluginIds: ["copilot"],
config: expect.objectContaining({
plugins: expect.objectContaining({
allow: ["telegram"],
entries: expect.not.objectContaining({
copilot: expect.anything(),
}),
}),
}),
}),
);
});
it("widens a scoped harness allowlist with the provider owner for openai-codex models", async () => {
await ensureSelectedAgentHarnessPlugin({
provider: "openai-codex",
@@ -241,4 +318,26 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled();
});
it("does not treat CLI backend runtime aliases as plugin ids", async () => {
await ensureSelectedAgentHarnessPlugin({
provider: "anthropic",
modelId: "claude-opus-4-7",
config: {
models: {
providers: {
anthropic: {
agentRuntime: { id: "claude-cli" },
baseUrl: "https://api.anthropic.com",
models: [],
},
},
},
} as OpenClawConfig,
workspaceDir: "/tmp/workspace",
});
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled();
});
});

View File

@@ -5,10 +5,12 @@ import {
resolveBundledProviderCompatPluginIds,
resolveOwningPluginIdsForProviderRef,
} from "../../plugins/providers.js";
import { isDefaultAgentRuntimeId } from "../agent-runtime-id.js";
import { isDefaultAgentRuntimeId, OPENCLAW_AGENT_RUNTIME_ID } from "../agent-runtime-id.js";
import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js";
import { resolveAgentHarnessPolicy } from "./policy.js";
const COLD_LOADABLE_HARNESS_PLUGIN_IDS = new Set(["codex", "copilot"]);
function dedupePluginIds(values: readonly string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
@@ -28,11 +30,15 @@ function restrictiveAllowlistOmitsPlugin(config: OpenClawConfig | undefined, plu
return allow.length > 0 && !allow.includes(pluginId);
}
function resolveCodexHarnessPluginIds(params: {
function resolveHarnessPluginIds(params: {
runtime: string;
provider: string;
config?: OpenClawConfig;
workspaceDir: string;
}): string[] {
if (params.runtime !== "codex") {
return [params.runtime];
}
if (restrictiveAllowlistOmitsPlugin(params.config, "codex")) {
return ["codex"];
}
@@ -106,20 +112,25 @@ export async function ensureSelectedAgentHarnessPlugin(params: {
});
const runtime =
runtimeOverride && !isDefaultAgentRuntimeId(runtimeOverride) ? runtimeOverride : policy.runtime;
if (runtime !== "codex") {
if (
isDefaultAgentRuntimeId(runtime) ||
runtime === OPENCLAW_AGENT_RUNTIME_ID ||
!COLD_LOADABLE_HARNESS_PLUGIN_IDS.has(runtime)
) {
return;
}
const { ensurePluginRegistryLoaded } =
await import("../../plugins/runtime/runtime-registry-loader.js");
const pluginIds = resolveCodexHarnessPluginIds({
const pluginIds = resolveHarnessPluginIds({
runtime,
provider: params.provider,
config: params.config,
workspaceDir: params.workspaceDir,
});
const configWithAllowedRuntimePlugins = withRuntimePluginIdsAllowed({
config: params.config,
requiredPluginId: "codex",
requiredPluginId: runtime,
pluginIds,
});
const activatedConfig =

View File

@@ -43,6 +43,63 @@ describe("createSandboxBridgeReadFile", () => {
expect(stat).not.toHaveBeenCalled();
});
it("keeps workspace-only container paths under the sandbox workspace mount", async () => {
const resolvePath = vi.fn(({ filePath }: { filePath: string }) => {
if (filePath === "/tmp/sandbox-root") {
return {
relativePath: "",
containerPath: "/remote/workspace",
};
}
return {
relativePath: filePath,
containerPath: `/remote/workspace/${filePath}`,
};
});
const resolved = await resolveSandboxedBridgeMediaPath({
sandbox: {
root: "/tmp/sandbox-root",
workspaceOnly: true,
bridge: {
resolvePath,
} as unknown as SandboxFsBridge,
},
mediaPath: "image.png",
});
expect(resolved).toEqual({ resolved: "/remote/workspace/image.png" });
expect(resolvePath).toHaveBeenCalledWith({
filePath: "/tmp/sandbox-root",
cwd: "/tmp/sandbox-root",
});
});
it("rejects workspace-only container paths outside the sandbox workspace mount", async () => {
await expect(
resolveSandboxedBridgeMediaPath({
sandbox: {
root: "/tmp/sandbox-root",
workspaceOnly: true,
bridge: {
resolvePath: vi.fn(({ filePath }: { filePath: string }) =>
filePath === "/tmp/sandbox-root"
? {
relativePath: "",
containerPath: "/remote/workspace",
}
: {
relativePath: filePath,
containerPath: "/remote/agent/secret.png",
},
),
} as unknown as SandboxFsBridge,
},
mediaPath: "/remote/agent/secret.png",
}),
).rejects.toThrow("Sandbox path escapes workspace root: /remote/agent/secret.png");
});
it("rewrites inbound media URIs before direct sandbox resolution", async () => {
const resolvePath = vi.fn(({ filePath }: { filePath: string }) => ({
hostPath: `/tmp/sandbox-root/${filePath}`,

View File

@@ -1,7 +1,8 @@
import path from "node:path";
import { resolveMediaReferenceSandboxPath } from "../media/media-reference.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import type { SandboxFsBridge, SandboxResolvedPath } from "./sandbox/fs-bridge.js";
import { isPathInsideContainerRoot, normalizeContainerPath } from "./sandbox/path-utils.js";
export type SandboxedBridgeMediaPathConfig = {
root: string;
@@ -40,15 +41,30 @@ export async function resolveSandboxedBridgeMediaPath(params: {
throw new Error(`Sandbox media reference is not staged: ${rewrittenFrom}`);
}
}
const enforceWorkspaceBoundary = async (hostPath: string) => {
const enforceWorkspaceBoundary = async (resolved: SandboxResolvedPath) => {
if (!params.sandbox.workspaceOnly) {
return;
}
await assertSandboxPath({
filePath: hostPath,
if (resolved.hostPath) {
await assertSandboxPath({
filePath: resolved.hostPath,
cwd: params.sandbox.root,
root: params.sandbox.root,
});
return;
}
const workspaceRoot = params.sandbox.bridge.resolvePath({
filePath: params.sandbox.root,
cwd: params.sandbox.root,
root: params.sandbox.root,
});
if (
!isPathInsideContainerRoot(
normalizeContainerPath(workspaceRoot.containerPath),
normalizeContainerPath(resolved.containerPath),
)
) {
throw new Error(`Sandbox path escapes workspace root: ${resolved.containerPath}`);
}
};
const resolveDirect = () =>
@@ -58,9 +74,7 @@ export async function resolveSandboxedBridgeMediaPath(params: {
});
try {
const resolved = resolveDirect();
if (resolved.hostPath) {
await enforceWorkspaceBoundary(resolved.hostPath);
}
await enforceWorkspaceBoundary(resolved);
return {
resolved: resolved.hostPath ?? resolved.containerPath,
...(rewrittenFrom ? { rewrittenFrom } : {}),
@@ -86,9 +100,7 @@ export async function resolveSandboxedBridgeMediaPath(params: {
filePath: fallbackPath,
cwd: params.sandbox.root,
});
if (resolvedFallback.hostPath) {
await enforceWorkspaceBoundary(resolvedFallback.hostPath);
}
await enforceWorkspaceBoundary(resolvedFallback);
return {
resolved: resolvedFallback.hostPath ?? resolvedFallback.containerPath,
rewrittenFrom: filePath,

View File

@@ -84,6 +84,13 @@ vi.mock("../agents/agent-scope.js", () => ({
`${process.env.OPENCLAW_STATE_DIR ?? "/tmp/openclaw-state"}/agents/${agentId}/agent`,
resolveAgentWorkspaceDir: (configForTest: unknown, agentId: string) =>
`/tmp/openclaw-workspaces/${agentId}`,
// Required by src/agents/model-runtime-policy.ts, which is transitively
// imported through provider-auth-choice -> copilot-sdk-install ->
// copilot-routing -> model-runtime-policy. Without these stubs the mock
// surface is incomplete and the dynamic import of copilot-sdk-install
// explodes inside applyAuthChoice.
resolveSessionAgentIds: () => ({ defaultAgentId: "main", sessionAgentId: "main" }),
listAgentEntries: () => [],
}));
vi.mock("../agents/workspace.js", () => ({

View File

@@ -0,0 +1,160 @@
{
"name": "openclaw-copilot-sdk-bootstrap",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openclaw-copilot-sdk-bootstrap",
"version": "1.0.0",
"dependencies": {
"@github/copilot-sdk": "1.0.0-beta.4"
}
},
"node_modules/@github/copilot": {
"version": "1.0.48",
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.48.tgz",
"integrity": "sha512-U5SzyTEq376UU9A4Sd3TEKz+Y2nRUd90cLO4Hc1otaB8yFSy9Ur2UVGcI2/wCoodL3a39k6WbdgNzFxr0gWFRQ==",
"license": "SEE LICENSE IN LICENSE.md",
"bin": {
"copilot": "npm-loader.js"
},
"optionalDependencies": {
"@github/copilot-darwin-arm64": "1.0.48",
"@github/copilot-darwin-x64": "1.0.48",
"@github/copilot-linux-arm64": "1.0.48",
"@github/copilot-linux-x64": "1.0.48",
"@github/copilot-win32-arm64": "1.0.48",
"@github/copilot-win32-x64": "1.0.48"
}
},
"node_modules/@github/copilot-darwin-arm64": {
"version": "1.0.48",
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.48.tgz",
"integrity": "sha512-82MLoMQwPVVFM8EYssihFxSEPUYtZADE8rMzQ3jG9HgRg2qjQSfnHQS1mKe64dlXswZUK/onw6/8kjnW5I4pPg==",
"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.48",
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.48.tgz",
"integrity": "sha512-1VQ5r5F0h8GwboXmZTcutqcJT+iCpPXAF27QqodmpKEvW9aYfG8g9X2kFJOzDZoX+SA3Uaka9qXdYKF2xT6Uog==",
"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.48",
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.48.tgz",
"integrity": "sha512-PmsGnb0DZlI+Bf53l9HM1PAHHkUcMyB4y8v/7tnC/jDOV5dGF124n0HnDNfJLOLiJGiQGodthIif6QtPaAxpeA==",
"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.48",
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.48.tgz",
"integrity": "sha512-b2cc4euSlke9fYHXXsS2EL9UYbctN0h4lZvtAcKUDY+RCnpYAQOVBZK+c1R9dQrtsT6Z/yUv7PuFPSs8qdtc2Q==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linux-x64": "copilot"
}
},
"node_modules/@github/copilot-sdk": {
"version": "1.0.0-beta.4",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.0-beta.4.tgz",
"integrity": "sha512-DcVMN2FWODxamFS9nTls8AW3QsyMnj6JDVBNRVBXaTY9kEhGHCjt8lp7sJp95/vyl52hvEb4/68Oh6SdFU9O/Q==",
"license": "MIT",
"dependencies": {
"@github/copilot": "^1.0.46",
"vscode-jsonrpc": "^8.2.1",
"zod": "^4.3.6"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@github/copilot-win32-arm64": {
"version": "1.0.48",
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.48.tgz",
"integrity": "sha512-VEEOwddtpJ3DTbXGhnK6K8im4ofl9m08q1m/K++sNvWV8wkkOSOQBTiPdyUsuU/TXAoFhb8tZMIJv+6NnMBtMw==",
"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.48",
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.48.tgz",
"integrity": "sha512-93BzvXLPHTyy1gWBXQY/IWIHor4IAwZuuo7/obG80/Qa6U0WeaN9slz/FBJvrsgVNrrRfEID5Xm3At+S6Kj67Q==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
],
"bin": {
"copilot-win32-x64": "copilot.exe"
}
},
"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"
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "openclaw-copilot-sdk-bootstrap",
"version": "1.0.0",
"private": true,
"description": "Pinned dependency graph for @github/copilot-sdk used by the Copilot agent runtime installer.",
"dependencies": {
"@github/copilot-sdk": "1.0.0-beta.4"
},
"overrides": {
"@github/copilot": "1.0.48"
}
}

View File

@@ -0,0 +1,721 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
COPILOT_SDK_FALLBACK_DIR,
COPILOT_SDK_INSTALL_MANIFEST_DIR,
COPILOT_SDK_SPEC,
ensureCopilotSdkForModelSelection,
installCopilotSdk,
isCopilotSdkInstalled,
resolveCopilotSdkFallbackDir,
selectedModelShouldEnsureCopilotSdk,
verifyCopilotSdkInstall,
} from "./copilot-sdk-install.js";
function fakeRuntime(): RuntimeEnv {
return {
log: () => undefined,
error: () => undefined,
exit: () => undefined,
};
}
function fakePrompter(overrides: Partial<WizardPrompter> = {}): WizardPrompter {
const noop = async () => undefined as never;
return {
intro: async () => undefined,
outro: async () => undefined,
note: async () => undefined,
plain: async () => undefined,
select: noop,
multiselect: noop,
text: async () => "",
confirm: async () => true,
progress: () => ({ update: () => undefined, stop: () => undefined }),
...overrides,
} as WizardPrompter;
}
const emptyCfg = {} as OpenClawConfig;
function cfgWithCopilotRuntime(): OpenClawConfig {
return {
models: {
providers: {
"github-copilot": { agentRuntime: { id: "copilot" } },
},
},
} as unknown as OpenClawConfig;
}
describe("selectedModelShouldEnsureCopilotSdk", () => {
it("returns false for github-copilot/* without explicit agentRuntime opt-in", () => {
// Built-in GitHub Copilot provider already supports github-copilot/*;
// we must not nag users with the SDK install prompt by default.
expect(
selectedModelShouldEnsureCopilotSdk({
cfg: emptyCfg,
model: "github-copilot/gpt-4o",
}),
).toBe(false);
});
it("returns true for github-copilot/* when agentRuntime.id = copilot is set", () => {
expect(
selectedModelShouldEnsureCopilotSdk({
cfg: cfgWithCopilotRuntime(),
model: "github-copilot/gpt-4o",
}),
).toBe(true);
});
it("returns false for other providers", () => {
expect(
selectedModelShouldEnsureCopilotSdk({ cfg: emptyCfg, model: "anthropic/claude-3" }),
).toBe(false);
expect(selectedModelShouldEnsureCopilotSdk({ cfg: emptyCfg, model: "openai/gpt-4o" })).toBe(
false,
);
});
it("returns false when model is undefined", () => {
expect(selectedModelShouldEnsureCopilotSdk({ cfg: emptyCfg })).toBe(false);
});
});
describe("ensureCopilotSdkForModelSelection", () => {
it("returns required=false and no-ops when model is not github-copilot", async () => {
const confirm = vi.fn();
const result = await ensureCopilotSdkForModelSelection({
cfg: emptyCfg,
model: "anthropic/claude-3",
prompter: fakePrompter({ confirm }),
runtime: fakeRuntime(),
isInstalled: () => false,
});
expect(result.required).toBe(false);
expect(result.installed).toBe(false);
expect(confirm).not.toHaveBeenCalled();
});
it("returns required=false for github-copilot when config does not opt into the SDK runtime", async () => {
// Same model, same env, but no agentRuntime.id=copilot anywhere in the
// config -> the built-in GitHub Copilot provider stays in charge and the
// SDK installer is not invoked. This is the entire point of P1 gating.
const confirm = vi.fn();
const install = vi.fn();
const result = await ensureCopilotSdkForModelSelection({
cfg: emptyCfg,
model: "github-copilot/gpt-4o",
prompter: fakePrompter({ confirm }),
runtime: fakeRuntime(),
isInstalled: () => false,
install,
});
expect(result.required).toBe(false);
expect(confirm).not.toHaveBeenCalled();
expect(install).not.toHaveBeenCalled();
});
it("returns already-installed without prompting when SDK is present", async () => {
const confirm = vi.fn();
const install = vi.fn();
const result = await ensureCopilotSdkForModelSelection({
cfg: cfgWithCopilotRuntime(),
model: "github-copilot/gpt-4o",
prompter: fakePrompter({ confirm }),
runtime: fakeRuntime(),
isInstalled: () => true,
install,
});
expect(result.required).toBe(true);
expect(result.installed).toBe(false);
expect(result.status).toBe("already-installed");
expect(confirm).not.toHaveBeenCalled();
expect(install).not.toHaveBeenCalled();
});
it("does not prompt or auto-install in Nix mode", async () => {
const previousNixMode = process.env.OPENCLAW_NIX_MODE;
process.env.OPENCLAW_NIX_MODE = "1";
try {
const confirm = vi.fn();
const install = vi.fn();
const note = vi.fn();
const result = await ensureCopilotSdkForModelSelection({
cfg: cfgWithCopilotRuntime(),
model: "github-copilot/gpt-4o",
prompter: fakePrompter({ confirm, note }),
runtime: fakeRuntime(),
isInstalled: () => false,
install,
});
expect(result).toMatchObject({
required: true,
installed: false,
status: "nix-mode",
});
expect(confirm).not.toHaveBeenCalled();
expect(install).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledOnce();
expect(String(note.mock.calls[0]?.[0])).toContain("OPENCLAW_NIX_MODE=1");
} finally {
if (previousNixMode === undefined) {
delete process.env.OPENCLAW_NIX_MODE;
} else {
process.env.OPENCLAW_NIX_MODE = previousNixMode;
}
}
});
it("prompts and installs when SDK is missing and user confirms", async () => {
const confirm = vi.fn(async () => true);
const install = vi.fn(async () => ({
installed: true,
fallbackDir: COPILOT_SDK_FALLBACK_DIR,
spec: COPILOT_SDK_SPEC,
}));
const result = await ensureCopilotSdkForModelSelection({
cfg: cfgWithCopilotRuntime(),
model: "github-copilot/gpt-4o",
prompter: fakePrompter({ confirm }),
runtime: fakeRuntime(),
isInstalled: () => false,
install,
});
expect(confirm).toHaveBeenCalledOnce();
expect(install).toHaveBeenCalledOnce();
expect(result.required).toBe(true);
expect(result.installed).toBe(true);
expect(result.status).toBe("installed");
});
it("respects user decline and reports status=declined", async () => {
const confirm = vi.fn(async () => false);
const install = vi.fn();
const note = vi.fn();
const result = await ensureCopilotSdkForModelSelection({
cfg: cfgWithCopilotRuntime(),
model: "github-copilot/gpt-4o",
prompter: fakePrompter({ confirm, note }),
runtime: fakeRuntime(),
isInstalled: () => false,
install,
});
expect(confirm).toHaveBeenCalledOnce();
expect(install).not.toHaveBeenCalled();
expect(note).toHaveBeenCalledOnce();
expect(result.required).toBe(true);
expect(result.installed).toBe(false);
expect(result.status).toBe("declined");
});
it("reports status=failed and surfaces error via note when install throws", async () => {
const confirm = vi.fn(async () => true);
const install = vi.fn(async () => {
throw new Error("network down");
});
const note = vi.fn();
const result = await ensureCopilotSdkForModelSelection({
cfg: cfgWithCopilotRuntime(),
model: "github-copilot/gpt-4o",
prompter: fakePrompter({ confirm, note }),
runtime: fakeRuntime(),
isInstalled: () => false,
install,
});
expect(result.required).toBe(true);
expect(result.installed).toBe(false);
expect(result.status).toBe("failed");
expect(note).toHaveBeenCalledOnce();
const noteMessage = (note as unknown as { mock: { calls: string[][] } }).mock.calls[0][0];
expect(noteMessage).toContain("network down");
expect(noteMessage).toContain("copilot-sdk-install-manifest");
});
});
function writeFakePinnedManifest(manifestDir: string): void {
const fs = require("node:fs") as typeof import("node:fs");
const path = require("node:path") as typeof import("node:path");
fs.writeFileSync(
path.join(manifestDir, "package.json"),
JSON.stringify({ dependencies: { "@github/copilot-sdk": "1.0.0-beta.4" } }),
);
fs.writeFileSync(
path.join(manifestDir, "package-lock.json"),
JSON.stringify({
lockfileVersion: 3,
packages: {
"node_modules/@github/copilot-sdk": { version: "1.0.0-beta.4" },
"node_modules/@github/copilot": { version: "1.0.48" },
},
}),
);
}
function installFakeFallbackGraph(dir: string, sdkVersion: string, cliVersion: string): void {
const fs = require("node:fs") as typeof import("node:fs");
const path = require("node:path") as typeof import("node:path");
const sdkDir = path.join(dir, "node_modules", "@github", "copilot-sdk");
const cliDir = path.join(dir, "node_modules", "@github", "copilot");
fs.mkdirSync(sdkDir, { recursive: true });
fs.mkdirSync(cliDir, { recursive: true });
fs.writeFileSync(
path.join(sdkDir, "package.json"),
JSON.stringify({ name: "@github/copilot-sdk", version: sdkVersion }),
);
fs.writeFileSync(
path.join(cliDir, "package.json"),
JSON.stringify({ name: "@github/copilot", version: cliVersion }),
);
}
describe("installCopilotSdk", () => {
it("stages the pinned manifest and runs the install command when SDK is missing", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-install-"));
const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-manifest-"));
fs.writeFileSync(
path.join(manifestDir, "package.json"),
JSON.stringify({ dependencies: { "@github/copilot-sdk": "1.0.0-beta.4" } }),
);
fs.writeFileSync(
path.join(manifestDir, "package-lock.json"),
JSON.stringify({
lockfileVersion: 3,
packages: {
"node_modules/@github/copilot-sdk": { version: "1.0.0-beta.4" },
"node_modules/@github/copilot": { version: "1.0.48" },
},
}),
);
try {
const runInstall = vi.fn(
async ({ dir }: { dir: string; spec: string; manifestDir: string }) => {
const sdkDir = path.join(dir, "node_modules", "@github", "copilot-sdk");
const cliDir = path.join(dir, "node_modules", "@github", "copilot");
fs.mkdirSync(sdkDir, { recursive: true });
fs.mkdirSync(cliDir, { recursive: true });
fs.writeFileSync(
path.join(sdkDir, "package.json"),
JSON.stringify({ name: "@github/copilot-sdk", version: "1.0.0-beta.4" }),
);
fs.writeFileSync(
path.join(cliDir, "package.json"),
JSON.stringify({ name: "@github/copilot", version: "1.0.48" }),
);
},
);
const result = await installCopilotSdk({
fallbackDir: tmp,
manifestDir,
runInstall,
});
expect(runInstall).toHaveBeenCalledOnce();
// Staged manifest must land in fallbackDir for `npm ci` to use.
expect(fs.existsSync(path.join(tmp, "package.json"))).toBe(true);
expect(fs.existsSync(path.join(tmp, "package-lock.json"))).toBe(true);
// And the staged manifest must be byte-identical to the pinned source.
expect(fs.readFileSync(path.join(tmp, "package-lock.json"), "utf8")).toBe(
fs.readFileSync(path.join(manifestDir, "package-lock.json"), "utf8"),
);
// runInstall receives the manifestDir argument so it can rely on it.
const call = runInstall.mock.calls[0][0];
expect(call.manifestDir).toBe(manifestDir);
expect(call.dir).toBe(tmp);
expect(result.installed).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
}
});
it("returns installed=false when fallback graph matches the pinned manifest (skip install)", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-install-"));
const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-manifest-"));
try {
writeFakePinnedManifest(manifestDir);
installFakeFallbackGraph(tmp, "1.0.0-beta.4", "1.0.48");
// Copy the manifest lock into the fallback dir to simulate a prior
// successful install having staged it (npm ci does this).
fs.copyFileSync(
path.join(manifestDir, "package-lock.json"),
path.join(tmp, "package-lock.json"),
);
const runInstall = vi.fn();
const result = await installCopilotSdk({ fallbackDir: tmp, manifestDir, runInstall });
expect(runInstall).not.toHaveBeenCalled();
expect(result.installed).toBe(false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
}
});
it("reinstalls when the fallback dir has the SDK but no pinned lock (stale tree)", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-install-"));
const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-manifest-"));
try {
writeFakePinnedManifest(manifestDir);
// Stale install: SDK dir exists but no package-lock.json at the
// fallback root, so the verifier must reject.
installFakeFallbackGraph(tmp, "1.0.0-beta.4", "1.0.48");
const runInstall = vi.fn(async ({ dir }: { dir: string }) => {
installFakeFallbackGraph(dir, "1.0.0-beta.4", "1.0.48");
});
const result = await installCopilotSdk({ fallbackDir: tmp, manifestDir, runInstall });
expect(runInstall).toHaveBeenCalledOnce();
expect(result.installed).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
}
});
it("reinstalls when the installed SDK version differs from the pinned manifest", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-install-"));
const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-manifest-"));
try {
writeFakePinnedManifest(manifestDir);
// Stage a fallback graph whose @github/copilot-sdk version drifts.
installFakeFallbackGraph(tmp, "1.0.0-beta.3", "1.0.48");
fs.copyFileSync(
path.join(manifestDir, "package-lock.json"),
path.join(tmp, "package-lock.json"),
);
const runInstall = vi.fn(async ({ dir }: { dir: string }) => {
installFakeFallbackGraph(dir, "1.0.0-beta.4", "1.0.48");
});
const result = await installCopilotSdk({ fallbackDir: tmp, manifestDir, runInstall });
expect(runInstall).toHaveBeenCalledOnce();
expect(result.installed).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
}
});
it("reinstalls when the installed Copilot CLI version drifts from the pinned manifest", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-install-"));
const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-manifest-"));
try {
writeFakePinnedManifest(manifestDir);
// CLI version drift only; SDK matches.
installFakeFallbackGraph(tmp, "1.0.0-beta.4", "1.0.54");
fs.copyFileSync(
path.join(manifestDir, "package-lock.json"),
path.join(tmp, "package-lock.json"),
);
const runInstall = vi.fn(async ({ dir }: { dir: string }) => {
installFakeFallbackGraph(dir, "1.0.0-beta.4", "1.0.48");
});
const result = await installCopilotSdk({ fallbackDir: tmp, manifestDir, runInstall });
expect(runInstall).toHaveBeenCalledOnce();
expect(result.installed).toBe(true);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
}
});
it("throws when runInstall succeeds but SDK still missing", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-install-"));
const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-manifest-"));
writeFakePinnedManifest(manifestDir);
try {
const runInstall = vi.fn(async () => undefined);
await expect(
installCopilotSdk({ fallbackDir: tmp, manifestDir, runInstall }),
).rejects.toThrow(/does not match the pinned manifest/);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
}
});
it("throws a useful error when the manifest dir is missing the pinned files", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-install-"));
const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-manifest-"));
try {
const runInstall = vi.fn();
await expect(
installCopilotSdk({ fallbackDir: tmp, manifestDir, runInstall }),
).rejects.toThrow(/cannot read pinned SDK manifest/);
expect(runInstall).not.toHaveBeenCalled();
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
}
});
});
describe("constants", () => {
it("exports fallback dir under ~/.openclaw/npm-runtime/copilot", () => {
expect(COPILOT_SDK_FALLBACK_DIR).toMatch(/\.openclaw[\\/]+npm-runtime[\\/]+copilot$/);
});
it("resolves fallback dir from OPENCLAW_STATE_DIR when the profile is relocated", () => {
expect(
resolveCopilotSdkFallbackDir({
...process.env,
OPENCLAW_STATE_DIR: "/tmp/openclaw-state",
}),
).toBe(path.join("/tmp/openclaw-state", "npm-runtime", "copilot"));
});
it("pins SDK spec to @github/copilot-sdk@1.0.0-beta.4", () => {
expect(COPILOT_SDK_SPEC).toBe("@github/copilot-sdk@1.0.0-beta.4");
});
it("isCopilotSdkInstalled returns false for nonexistent dirs", () => {
expect(isCopilotSdkInstalled("/tmp/definitely-does-not-exist-openclaw")).toBe(false);
});
});
describe("verifyCopilotSdkInstall", () => {
it("returns ok when fallback lock and installed package.json match the pinned manifest", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-verify-"));
const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-manifest-"));
try {
writeFakePinnedManifest(manifestDir);
installFakeFallbackGraph(tmp, "1.0.0-beta.4", "1.0.48");
fs.copyFileSync(
path.join(manifestDir, "package-lock.json"),
path.join(tmp, "package-lock.json"),
);
expect(verifyCopilotSdkInstall(tmp, manifestDir)).toEqual({ ok: true });
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
}
});
it("reports the missing fallback lock with the full path so logs are actionable", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-verify-"));
const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-manifest-"));
try {
writeFakePinnedManifest(manifestDir);
installFakeFallbackGraph(tmp, "1.0.0-beta.4", "1.0.48");
const result = verifyCopilotSdkInstall(tmp, manifestDir);
expect(result.ok).toBe(false);
expect(result.reason).toContain(path.join(tmp, "package-lock.json"));
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
}
});
it("reports drift when the installed package.json version differs from the manifest", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-verify-"));
const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-manifest-"));
try {
writeFakePinnedManifest(manifestDir);
// Lock looks correct; on-disk @github/copilot/package.json drifts.
installFakeFallbackGraph(tmp, "1.0.0-beta.4", "1.0.48");
fs.copyFileSync(
path.join(manifestDir, "package-lock.json"),
path.join(tmp, "package-lock.json"),
);
fs.writeFileSync(
path.join(tmp, "node_modules", "@github", "copilot", "package.json"),
JSON.stringify({ name: "@github/copilot", version: "1.0.54" }),
);
const result = verifyCopilotSdkInstall(tmp, manifestDir);
expect(result.ok).toBe(false);
expect(result.reason).toContain("version drift");
expect(result.reason).toContain("1.0.54");
expect(result.reason).toContain("1.0.48");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
}
});
it("reports drift when the fallback lock differs outside the entry package versions", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-verify-"));
const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-manifest-"));
try {
writeFakePinnedManifest(manifestDir);
installFakeFallbackGraph(tmp, "1.0.0-beta.4", "1.0.48");
const fallbackLockPath = path.join(tmp, "package-lock.json");
fs.copyFileSync(path.join(manifestDir, "package-lock.json"), fallbackLockPath);
const fallbackLock = JSON.parse(fs.readFileSync(fallbackLockPath, "utf8")) as {
packages?: Record<string, { version?: string }>;
};
fallbackLock.packages = {
...fallbackLock.packages,
"node_modules/drifted-transitive": { version: "9.9.9" },
};
fs.writeFileSync(fallbackLockPath, JSON.stringify(fallbackLock));
const result = verifyCopilotSdkInstall(tmp, manifestDir);
expect(result.ok).toBe(false);
expect(result.reason).toContain("package-lock drift");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
}
});
it("reports missing installed package dir even when the lock is present", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-verify-"));
const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-manifest-"));
try {
writeFakePinnedManifest(manifestDir);
fs.copyFileSync(
path.join(manifestDir, "package-lock.json"),
path.join(tmp, "package-lock.json"),
);
// node_modules/@github/copilot-sdk was never created.
const result = verifyCopilotSdkInstall(tmp, manifestDir);
expect(result.ok).toBe(false);
expect(result.reason).toContain("missing installed package");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
}
});
it("throws when the shipped manifest is missing a pinned version (build broke contract)", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-verify-"));
const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-manifest-"));
try {
// Manifest lock declares no packages at all -> fatal misconfiguration.
fs.writeFileSync(
path.join(manifestDir, "package-lock.json"),
JSON.stringify({ lockfileVersion: 3, packages: {} }),
);
expect(() => verifyCopilotSdkInstall(tmp, manifestDir)).toThrow(
/missing a version for node_modules\/@github\/copilot-sdk/,
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
}
});
it("throws when the shipped manifest package-lock.json cannot be read", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-verify-"));
const manifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-manifest-"));
try {
// No package-lock.json in manifestDir -> readFileSync throws -> fatal.
expect(() => verifyCopilotSdkInstall(tmp, manifestDir)).toThrow(
/cannot read pinned SDK manifest/,
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(manifestDir, { recursive: true, force: true });
}
});
it("contract: the shipped manifest at COPILOT_SDK_INSTALL_MANIFEST_DIR pins both packages", () => {
// Reading from the real shipped manifest dir must not throw, which means
// the build pipeline keeps the pinned versions for both keys present.
// The verifier returns ok=false here because the fallback dir is empty,
// but it must not throw.
const fs = require("node:fs") as typeof import("node:fs");
const os = require("node:os") as typeof import("node:os");
const path = require("node:path") as typeof import("node:path");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-copilot-sdk-verify-real-"));
try {
const result = verifyCopilotSdkInstall(tmp, COPILOT_SDK_INSTALL_MANIFEST_DIR);
expect(result.ok).toBe(false);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});
describe("copilot-sdk install manifest (contract)", () => {
it("pins the manifest package.json to the exact spec advertised by COPILOT_SDK_SPEC", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const raw = fs.readFileSync(
path.join(COPILOT_SDK_INSTALL_MANIFEST_DIR, "package.json"),
"utf8",
);
const parsed = JSON.parse(raw) as {
dependencies?: Record<string, string>;
};
const expectedVersion = COPILOT_SDK_SPEC.split("@").pop()!;
expect(parsed.dependencies?.["@github/copilot-sdk"]).toBe(expectedVersion);
});
it("ships a lockfile that includes the SDK and a Copilot CLI binary", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const raw = fs.readFileSync(
path.join(COPILOT_SDK_INSTALL_MANIFEST_DIR, "package-lock.json"),
"utf8",
);
const parsed = JSON.parse(raw) as {
lockfileVersion?: number;
packages?: Record<string, { version?: string; integrity?: string }>;
};
// Reject older lockfile formats so the install graph stays npm v7+ compatible.
expect(parsed.lockfileVersion).toBeGreaterThanOrEqual(2);
const sdkEntry = parsed.packages?.["node_modules/@github/copilot-sdk"];
expect(sdkEntry).toBeDefined();
expect(sdkEntry?.version).toBe(COPILOT_SDK_SPEC.split("@").pop()!);
expect(sdkEntry?.integrity).toMatch(/^sha512-/);
// The Copilot CLI is what gives the runtime its native shell/write tools;
// its presence here proves the lockfile resolves the transitive graph.
const cliEntry = parsed.packages?.["node_modules/@github/copilot"];
expect(cliEntry).toBeDefined();
// Pin to the exact @github/copilot version that the repository pnpm-lock
// also resolves (and that CI tests exercise). Drift here means users would
// install a different Copilot CLI graph than the one reviewed/tested.
expect(cliEntry?.version).toBe("1.0.48");
// Every platform-specific @github/copilot-* optional dependency must
// resolve to the same version as the parent CLI package.
for (const [key, entry] of Object.entries(parsed.packages ?? {})) {
if (/^node_modules\/@github\/copilot-(?:darwin|linux|linuxmusl|win32)-/.test(key)) {
expect(entry?.version).toBe("1.0.48");
}
}
});
});

View File

@@ -0,0 +1,402 @@
import { spawn } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { modelSelectionShouldEnsureCopilotSdk as routingShouldEnsure } from "../agents/copilot-routing.js";
import { resolveIsNixMode, resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
/**
* On-demand install for `@github/copilot-sdk`, the runtime dependency of
* the bundled `copilot` agent runtime extension.
*
* The extension itself is shipped inside the openclaw tarball, but the
* SDK and its platform-specific CLI binary add ~260 MB of download to a
* baseline openclaw install. Most openclaw users do not use the Copilot
* runtime, so we install the SDK lazily: the wizard offers to install
* it the first time the user selects a `github-copilot/*` model.
*
* Mirrors the codex on-demand install pattern in
* `./codex-runtime-plugin-install.ts`, but installs a single npm
* package (the SDK) rather than a full openclaw plugin, so the install
* machinery here is much smaller than `ensureCodexRuntimePluginForModelSelection`.
*
* The fallback-dir resolver and `COPILOT_SDK_SPEC` are mirrored in the
* copilot extension's sdk-loader module; contract tests keep them aligned.
*/
export function resolveCopilotSdkFallbackDir(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveStateDir(env), "npm-runtime", "copilot");
}
export const COPILOT_SDK_FALLBACK_DIR = resolveCopilotSdkFallbackDir();
export const COPILOT_SDK_SPEC = "@github/copilot-sdk@1.0.0-beta.4";
export const COPILOT_SDK_PACKAGE_LABEL = "GitHub Copilot SDK (@github/copilot-sdk)";
/**
* Directory containing the checked-in {@link COPILOT_SDK_SPEC} install graph
* (`package.json` + `package-lock.json`). Both files are generated via
* `npm install --package-lock-only` and committed under
* `src/commands/copilot-sdk-install-manifest/`. The build step in
* `scripts/copy-copilot-sdk-manifest.ts` copies them alongside the
* compiled output so `import.meta.url`-based resolution works in
* published tarballs.
*
* Using `npm ci` against this graph means user installs cannot pull a
* newer Copilot CLI or transitive dependency set than the one this PR
* was reviewed against (review #2, P1).
*/
export const COPILOT_SDK_INSTALL_MANIFEST_DIR = fileURLToPath(
new URL("./copilot-sdk-install-manifest/", import.meta.url),
);
export type CopilotSdkInstallStatus =
| "already-installed"
| "installed"
| "declined"
| "failed"
| "nix-mode";
export type CopilotSdkInstallResult = {
cfg: OpenClawConfig;
required: boolean;
installed: boolean;
status?: CopilotSdkInstallStatus;
};
export function selectedModelShouldEnsureCopilotSdk(params: {
cfg: OpenClawConfig;
model?: string;
}): boolean {
return routingShouldEnsure({ config: params.cfg, model: params.model });
}
export function isCopilotSdkInstalled(
fallbackDir: string = resolveCopilotSdkFallbackDir(),
): boolean {
const sdkPath = path.join(fallbackDir, "node_modules", "@github", "copilot-sdk");
return existsSync(sdkPath);
}
export interface InstallCopilotSdkOptions {
readonly fallbackDir?: string;
readonly spec?: string;
readonly manifestDir?: string;
readonly logger?: (message: string) => void;
readonly runInstall?: (cmd: { dir: string; spec: string; manifestDir: string }) => Promise<void>;
}
export interface InstallCopilotSdkResult {
readonly installed: boolean;
readonly fallbackDir: string;
readonly spec: string;
}
/**
* Result of {@link verifyCopilotSdkInstall}. `ok: true` means the install
* at `fallbackDir` matches the pinned manifest in `manifestDir` exactly,
* and the caller can skip running `npm ci` again. Any `ok: false` carries a
* `reason` suitable for surfacing in setup logs and triggering a reinstall.
*/
export interface CopilotSdkVerifyResult {
readonly ok: boolean;
readonly reason?: string;
}
const COPILOT_SDK_PINNED_PACKAGE_KEYS = [
"node_modules/@github/copilot-sdk",
"node_modules/@github/copilot",
] as const;
function stableStringifyJson(value: unknown): string {
return JSON.stringify(sortJsonValue(value));
}
function sortJsonValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(sortJsonValue);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => [key, sortJsonValue(entry)]),
);
}
return value;
}
/**
* Confirms that the on-demand install at `fallbackDir` matches the
* pinned lock graph declared in the shipped manifest at `manifestDir`.
* The directory check used to be the only gate (`isCopilotSdkInstalled`),
* but that lets stale, partial, or manually placed trees bypass the
* reviewed dependency graph. This verifier closes that hole by comparing
* the shipped `package-lock.json` as a whole against the install's lock
* AND the installed package.json files for the runtime entry packages.
*
* Manifest-side errors (missing file, malformed JSON, missing pinned
* version entry) are treated as fatal because a packaged openclaw install
* cannot recover from a broken shipped manifest. Install-side errors
* (missing lock, unreadable package.json) are returned as reinstall
* signals so npm ci can wipe and restage.
*/
export function verifyCopilotSdkInstall(
fallbackDir: string,
manifestDir: string,
): CopilotSdkVerifyResult {
let manifestLock: { packages?: Record<string, { version?: string }> };
const manifestLockPath = path.join(manifestDir, "package-lock.json");
try {
manifestLock = JSON.parse(readFileSync(manifestLockPath, "utf8")) as {
packages?: Record<string, { version?: string }>;
};
} catch (err) {
throw new Error(
`[copilot] cannot read pinned SDK manifest at ${manifestLockPath}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err },
);
}
// Validate the shipped manifest contract upfront before touching the install
// tree. A broken manifest is a fatal build/packaging error and must surface
// regardless of whether the fallback dir is empty, partial, or already
// installed.
const expectedVersions: Record<string, string> = {};
for (const key of COPILOT_SDK_PINNED_PACKAGE_KEYS) {
const expected = manifestLock.packages?.[key]?.version;
if (!expected) {
throw new Error(
`[copilot] pinned SDK manifest at ${manifestLockPath} is missing a version for ${key}; refusing to verify install`,
);
}
expectedVersions[key] = expected;
}
const installedLockPath = path.join(fallbackDir, "package-lock.json");
if (!existsSync(installedLockPath)) {
return { ok: false, reason: `no pinned package-lock.json at ${installedLockPath}` };
}
let installedLock: { packages?: Record<string, { version?: string }> };
try {
installedLock = JSON.parse(readFileSync(installedLockPath, "utf8")) as {
packages?: Record<string, { version?: string }>;
};
} catch (err) {
return {
ok: false,
reason: `unreadable fallback package-lock.json: ${
err instanceof Error ? err.message : String(err)
}`,
};
}
for (const key of COPILOT_SDK_PINNED_PACKAGE_KEYS) {
const expected = expectedVersions[key];
const actualInLock = installedLock.packages?.[key]?.version;
if (actualInLock !== expected) {
return {
ok: false,
reason: `${key} lock drift: installed=${actualInLock ?? "(missing)"}, pinned=${expected}`,
};
}
const pkgJsonPath = path.join(fallbackDir, key, "package.json");
if (!existsSync(pkgJsonPath)) {
return { ok: false, reason: `missing installed package ${key}` };
}
try {
const actualVersion = (JSON.parse(readFileSync(pkgJsonPath, "utf8")) as { version?: string })
.version;
if (actualVersion !== expected) {
return {
ok: false,
reason: `${key} version drift: installed=${actualVersion ?? "(missing)"}, pinned=${expected}`,
};
}
} catch (err) {
return {
ok: false,
reason: `unreadable ${key}/package.json: ${
err instanceof Error ? err.message : String(err)
}`,
};
}
}
if (stableStringifyJson(installedLock) !== stableStringifyJson(manifestLock)) {
return {
ok: false,
reason: "fallback package-lock drift: installed lock does not match pinned manifest",
};
}
return { ok: true };
}
export async function installCopilotSdk(
options: InstallCopilotSdkOptions = {},
): Promise<InstallCopilotSdkResult> {
const fallbackDir = options.fallbackDir ?? resolveCopilotSdkFallbackDir();
const spec = options.spec ?? COPILOT_SDK_SPEC;
const logger = options.logger ?? (() => undefined);
const manifestDir = options.manifestDir ?? COPILOT_SDK_INSTALL_MANIFEST_DIR;
const verify = verifyCopilotSdkInstall(fallbackDir, manifestDir);
if (verify.ok) {
logger(
`[copilot] @github/copilot-sdk already installed at ${fallbackDir} (pinned graph matches)`,
);
return { installed: false, fallbackDir, spec };
}
if (isCopilotSdkInstalled(fallbackDir)) {
// Stale, partial, or manually-placed tree. Log the drift before letting
// `npm ci` wipe node_modules and reinstall from the pinned lock.
logger(
`[copilot] reinstalling Copilot SDK: ${verify.reason ?? "fallback install does not match pinned manifest"}`,
);
}
mkdirSync(fallbackDir, { recursive: true });
// Stage the pinned package.json + package-lock.json into the fallback dir
// so the subsequent `npm ci` resolves the same dependency graph that this
// PR was reviewed against. We intentionally overwrite any prior copies so a
// bumped manifest in a later openclaw release re-pins user installs cleanly.
for (const file of ["package.json", "package-lock.json"]) {
const source = path.join(manifestDir, file);
if (!existsSync(source)) {
throw new Error(
`[copilot] missing Copilot SDK install manifest at ${source}; expected the openclaw build to copy src/commands/copilot-sdk-install-manifest/`,
);
}
copyFileSync(source, path.join(fallbackDir, file));
}
const runInstall = options.runInstall ?? defaultRunInstall;
logger(`[copilot] installing ${spec} into ${fallbackDir} (npm ci against pinned manifest) ...`);
await runInstall({ dir: fallbackDir, spec, manifestDir });
const postVerify = verifyCopilotSdkInstall(fallbackDir, manifestDir);
if (!postVerify.ok) {
throw new Error(
`[copilot] install of ${spec} reported success but the resulting fallback graph does not match the pinned manifest at ${manifestDir}: ${
postVerify.reason ?? "unknown"
}`,
);
}
logger(`[copilot] installed ${spec}`);
return { installed: true, fallbackDir, spec };
}
async function defaultRunInstall(cmd: {
dir: string;
spec: string;
manifestDir: string;
}): Promise<void> {
await new Promise<void>((resolve, reject) => {
// `npm ci` requires the lockfile we just staged into cmd.dir and refuses
// to resolve anything outside it; this is what gives us a deterministic
// graph across user machines. We deliberately keep install scripts
// enabled because the @github/copilot CLI has a postinstall that pulls
// the platform-specific binary, which is the whole reason we run npm
// here instead of a single tarball fetch.
const child = spawn("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], {
cwd: cmd.dir,
stdio: ["ignore", "inherit", "inherit"],
shell: process.platform === "win32",
});
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`[copilot] npm ci ${cmd.spec} exited with code ${code ?? "null"}`));
});
});
}
/**
* Wizard hook called from `src/plugins/provider-auth-choice.ts` after
* the user selects a model. If the selected model needs the Copilot
* SDK and it is not installed, prompts the user to install it now.
*
* Returns `{ required: false }` and a no-op if the selection does not
* need the SDK; this is the hot path for most model selections.
*/
export async function ensureCopilotSdkForModelSelection(params: {
cfg: OpenClawConfig;
model?: string;
prompter: WizardPrompter;
runtime: RuntimeEnv;
isInstalled?: () => boolean;
install?: (options: InstallCopilotSdkOptions) => Promise<InstallCopilotSdkResult>;
}): Promise<CopilotSdkInstallResult> {
if (!selectedModelShouldEnsureCopilotSdk({ cfg: params.cfg, model: params.model })) {
return { cfg: params.cfg, required: false, installed: false };
}
const isInstalled =
params.isInstalled ??
(() =>
verifyCopilotSdkInstall(resolveCopilotSdkFallbackDir(), COPILOT_SDK_INSTALL_MANIFEST_DIR).ok);
if (isInstalled()) {
return {
cfg: params.cfg,
required: true,
installed: false,
status: "already-installed",
};
}
if (resolveIsNixMode()) {
await params.prompter.note(
"Nix mode detected (OPENCLAW_NIX_MODE=1). The Copilot agent runtime SDK cannot be auto-installed; add the pinned @github/copilot-sdk manifest dependency to the Nix-managed OpenClaw package set, then rebuild.",
COPILOT_SDK_PACKAGE_LABEL,
);
return { cfg: params.cfg, required: true, installed: false, status: "nix-mode" };
}
const proceed = await params.prompter.confirm({
message:
"The Copilot agent runtime needs @github/copilot-sdk (~260 MB on first install, downloads the @github/copilot CLI binary for your platform). Install now?",
initialValue: true,
});
if (!proceed) {
await params.prompter.note(
"Skipped. The Copilot agent runtime will fail at first invocation with an install message. Re-run setup to retry; the pinned dependency graph ships with openclaw under src/commands/copilot-sdk-install-manifest/.",
COPILOT_SDK_PACKAGE_LABEL,
);
return { cfg: params.cfg, required: true, installed: false, status: "declined" };
}
const progress = params.prompter.progress(`Installing ${COPILOT_SDK_PACKAGE_LABEL}`);
try {
const installer = params.install ?? installCopilotSdk;
const result = await installer({
logger: (message) => {
progress.update(message);
params.runtime.log(message);
},
});
progress.stop(result.installed ? "Installed." : "Already installed.");
return {
cfg: params.cfg,
required: true,
installed: result.installed,
status: "installed",
};
} catch (err) {
progress.stop("Install failed.");
const message = err instanceof Error ? err.message : String(err);
await params.prompter.note(
`Install failed: ${message}\n\nRe-run setup to retry the install (the pinned dependency graph ships with openclaw under src/commands/copilot-sdk-install-manifest/).`,
COPILOT_SDK_PACKAGE_LABEL,
);
return { cfg: params.cfg, required: true, installed: false, status: "failed" };
}
}

View File

@@ -15,8 +15,11 @@ import {
setActiveEmbeddedRun,
type EmbeddedAgentQueueMessageOptions,
} from "../agents/embedded-agent-runner/runs.js";
import type { SandboxFsBridge } from "../agents/sandbox/fs-bridge.js";
import { formatToolDetail, resolveToolDisplay } from "../agents/tool-display.js";
import type { ImageContent } from "../llm/types.js";
import { redactToolDetail } from "../logging/redact.js";
import type { PromptImageOrderEntry } from "../media/prompt-image-order.js";
import { truncateUtf16Safe } from "../utils.js";
export const TOOL_PROGRESS_OUTPUT_MAX_CHARS = 8_000;
@@ -130,8 +133,14 @@ export {
} from "../agents/agent-scope.js";
export { resolveModelAuthMode } from "../agents/model-auth.js";
export { supportsModelTools } from "../agents/model-tool-support.js";
export { resolveAttemptFsWorkspaceOnly } from "../agents/embedded-agent-runner/run/attempt.prompt-helpers.js";
export { resolveAttemptSpawnWorkspaceDir } from "../agents/embedded-agent-runner/run/attempt.thread-helpers.js";
export { buildEmbeddedAttemptToolRunContext } from "../agents/embedded-agent-runner/run/attempt.tool-run-context.js";
export {
applyEmbeddedAttemptToolsAllow,
resolveEmbeddedAttemptToolConstructionPlan,
} from "../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js";
export { getPluginToolMeta } from "../plugins/tools.js";
export {
abortEmbeddedAgentRun as abortAgentHarnessRun,
clearActiveEmbeddedRun,
@@ -170,6 +179,43 @@ export type {
} from "../agents/codex-mcp-config.types.js";
export { normalizeProviderToolSchemas } from "../agents/embedded-agent-runner/tool-schema-runtime.js";
export async function detectAndLoadAgentHarnessPromptImages(params: {
prompt: string;
workspaceDir: string;
model: { input?: string[] };
existingImages?: ImageContent[];
imageOrder?: PromptImageOrderEntry[];
config?: import("../config/types.openclaw.js").OpenClawConfig;
workspaceOnly?: boolean;
localRoots?: readonly string[];
sandbox?: { root: string; bridge: SandboxFsBridge };
}): Promise<{
images: ImageContent[];
detectedRefs: Array<{ raw: string; resolved: string; type: "path" | "media-uri" }>;
loadedCount: number;
skippedCount: number;
}> {
const [{ resolveImageSanitizationLimits }, { detectAndLoadPromptImages }, { MAX_IMAGE_BYTES }] =
await Promise.all([
import("../agents/image-sanitization.js"),
import("../agents/embedded-agent-runner/run/images.js"),
import("../media/constants.js"),
]);
return detectAndLoadPromptImages({
prompt: params.prompt,
workspaceDir: params.workspaceDir,
model: params.model,
existingImages: params.existingImages,
imageOrder: params.imageOrder,
maxBytes: MAX_IMAGE_BYTES,
maxDimensionPx: resolveImageSanitizationLimits(params.config).maxDimensionPx,
workspaceOnly: params.workspaceOnly,
localRoots: params.localRoots,
sandbox: params.sandbox,
});
}
export async function loadCodexBundleMcpThreadConfig(
params: LoadCodexBundleMcpThreadConfigParams,
): Promise<CodexBundleMcpThreadConfig> {
@@ -177,6 +223,7 @@ export async function loadCodexBundleMcpThreadConfig(
return load(params);
}
export { resolveSandboxContext } from "../agents/sandbox.js";
export type { SandboxContext, SandboxWorkspaceAccess } from "../agents/sandbox.js";
export {
hasSandboxBindContainerPathAliases,
hasSandboxBindReadonlyHostShadows,

View File

@@ -189,6 +189,15 @@ async function applyDefaultModelFromAuthChoice(params: {
});
nextConfig = migrationResult.config;
}
const { ensureCopilotSdkForModelSelection } =
await import("../commands/copilot-sdk-install.js");
const copilotInstall = await ensureCopilotSdkForModelSelection({
cfg: nextConfig,
model: params.selectedModel,
prompter: params.prompter,
runtime: params.runtime,
});
nextConfig = copilotInstall.cfg;
}
await noteDefaultModelResult({
previousPrimary,

View File

@@ -186,6 +186,7 @@ describe("resolveBuildAllSteps", () => {
"check-plugin-sdk-exports",
"plugins:assets:copy",
"copy-hook-metadata",
"copy-copilot-sdk-manifest",
"copy-export-html-templates",
"ui:build",
"write-build-info",

View File

@@ -105,6 +105,8 @@ describe("bundled plugin build entries", () => {
const entries = listBundledPluginBuildEntries();
expect(entries["extensions/browser/test-support"]).toBeUndefined();
expect(entries["extensions/comfy/test-helpers"]).toBeUndefined();
expect(entries["extensions/minimax/provider-http.test-helpers"]).toBeUndefined();
});
it("discovers repo plugin build entries without directory scans", () => {

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:45.085Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:31.545Z",
"locale": "ar",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:40.369Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:21.334Z",
"locale": "de",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:41.157Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:23.802Z",
"locale": "es",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:52.569Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:52.280Z",
"locale": "fa",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:44.275Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:29.028Z",
"locale": "fr",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:48.486Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:42.075Z",
"locale": "id",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:45.949Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:33.241Z",
"locale": "it",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:41.993Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:25.623Z",
"locale": "ja-JP",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:43.457Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:27.331Z",
"locale": "ko",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:51.760Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:49.875Z",
"locale": "nl",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:49.306Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:43.834Z",
"locale": "pl",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:39.541Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:18.990Z",
"locale": "pt-BR",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:50.121Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:45.871Z",
"locale": "th",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:46.798Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:35.897Z",
"locale": "tr",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:47.638Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:39.972Z",
"locale": "uk",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:50.934Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:47.804Z",
"locale": "vi",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:37.925Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:15.047Z",
"locale": "zh-CN",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1,15 +1,11 @@
{
"fallbackKeys": [
"chat.queue.retry",
"chat.queue.retryQueuedMessage",
"chat.queue.retrySend"
],
"generatedAt": "2026-05-28T16:05:38.739Z",
"fallbackKeys": [],
"generatedAt": "2026-05-28T18:48:17.187Z",
"locale": "zh-TW",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "7c867296be27a09ab0e35f76d2518f479e24ab667179c5b3fabf83d6c57f3ef9",
"totalKeys": 1158,
"translatedKeys": 1155,
"translatedKeys": 1158,
"workflow": 1
}

View File

@@ -1111,9 +1111,9 @@ export const ar: TranslationMap = {
sendMessage: "Send message",
},
queue: {
retry: "Retry",
retrySend: "Retry send",
retryQueuedMessage: "Retry queued message",
retry: "إعادة المحاولة",
retrySend: "إعادة الإرسال",
retryQueuedMessage: "إعادة محاولة الرسالة في قائمة الانتظار",
},
composer: {
placeholder: "Message {name} (Enter to send)",

View File

@@ -1135,9 +1135,9 @@ export const de: TranslationMap = {
sendMessage: "Send message",
},
queue: {
retry: "Retry",
retrySend: "Retry send",
retryQueuedMessage: "Retry queued message",
retry: "Erneut versuchen",
retrySend: "Senden erneut versuchen",
retryQueuedMessage: "Nachricht in der Warteschlange erneut versuchen",
},
composer: {
placeholder: "Message {name} (Enter to send)",

View File

@@ -1132,9 +1132,9 @@ export const es: TranslationMap = {
sendMessage: "Send message",
},
queue: {
retry: "Retry",
retrySend: "Retry send",
retryQueuedMessage: "Retry queued message",
retry: "Reintentar",
retrySend: "Reintentar envío",
retryQueuedMessage: "Reintentar mensaje en cola",
},
composer: {
placeholder: "Message {name} (Enter to send)",

View File

@@ -1128,9 +1128,9 @@ export const fa: TranslationMap = {
sendMessage: "Send message",
},
queue: {
retry: "Retry",
retrySend: "Retry send",
retryQueuedMessage: "Retry queued message",
retry: "تلاش مجدد",
retrySend: "ارسال مجدد",
retryQueuedMessage: "تلاش مجدد برای پیام در صف",
},
composer: {
placeholder: "Message {name} (Enter to send)",

Some files were not shown because too many files have changed in this diff Show More