Skip to content

feat(llm): add GitHub Copilot provider via Copilot CLI ACP mode - #10529

Draft
eliandoran wants to merge 5 commits into
mainfrom
worktree-copilot-agent-provider
Draft

feat(llm): add GitHub Copilot provider via Copilot CLI ACP mode#10529
eliandoran wants to merge 5 commits into
mainfrom
worktree-copilot-agent-provider

Conversation

@eliandoran

Copy link
Copy Markdown
Contributor

Adds a copilot-agent LLM provider that lets users with a GitHub Copilot subscription use Trilium's built-in chat without an API key — mirroring the existing Claude Agent design, but driving the user's installed GitHub Copilot CLI in ACP mode (copilot --acp) as a subprocess. Authentication is owned entirely by the CLI (copilot login, or credentials shared with another editor integration on the machine).

What's here

  • acp_client.ts — dependency-free Agent Client Protocol client (newline-delimited JSON-RPC over stdio): framing, request/response correlation, agent→client requests, subprocess lifecycle.
  • copilot_agent.ts — the provider. Implements chatChunks(): maps chat notes to ACP sessions (resume when the transcript matches, reseed via history replay when it diverged), streams session/update notifications into LlmStreamChunks, selects models via session/set_model, and exposes the CLI's live model catalog with premium-request multipliers mapped onto costMultiplier.
  • copilot_binary.ts — bring-your-own-binary resolver (TRILIUM_COPILOT_PATHcopilot on PATH), Windows .cmd-shim aware.
  • copilot_mcp_endpoint.ts — private loopback HTTP MCP endpoint (random port + 128-bit secret path) exposing note tools to the agent. ACP takes MCP servers by URL, not in-process, and this is deliberately independent of the user-facing mcpEnabled toggle.
  • transcript.ts — hash/replay helpers extracted from claude_agent.ts and shared between both agent providers.
  • Registration server-side (llm/index.ts) and client-side (AddProviderModal card, icon, translations).

Security

Note tools are pre-approved via --allow-tool=trilium and built-in file/shell/network tools are denied via --deny-tool. The permission callback is fail-closed: it denies anything that reaches it, so the worst case is a note tool failing to run — never a shell command on the server host. Verified live against CLI 1.0.71 (--deny-tool=shell blocks shell before any permission request reaches the callback).

Validation

  • Full suite 4031 passing (80+ new unit tests across acp_client, copilot_agent, copilot_binary), typecheck clean, lint clean.
  • Live end-to-end against the real CLI: initialize → session/new → set_model → streaming reply; real permission-request payload confirmed rejected.

Known constraints (draft — needs decision before merge)

  1. Note tools require an org policy on Copilot Business/Enterprise. The CLI skips third-party MCP servers unless the org's "MCP servers in Copilot" policy is enabled (off by default). Chat works regardless; the note-tools path is gated by this and is currently covered by unit tests only — it could not be live-tested on a Business account.
  2. The permission model is fail-closed by design (see above) because the CLI presents tool calls with opaque IDs and human-friendly titles, making name-based allow-listing unreliable.

🤖 Generated with Claude Code

Adds a `copilot-agent` LLM provider mirroring the Claude Agent design:
it drives the user's installed GitHub Copilot CLI in ACP mode
(`copilot --acp`) as a subprocess, so users with a Copilot subscription
can use the in-app chat without an API key. Authentication is owned by
the CLI (`copilot login`, or credentials shared with another editor).

- acp_client.ts: dependency-free ACP (ndjson JSON-RPC over stdio) client
- copilot_agent.ts: chatChunks() provider — session mapping/resume,
  streaming, live model catalog, fail-closed tool permission policy
- copilot_binary.ts: bring-your-own-binary resolver (TRILIUM_COPILOT_PATH
  or `copilot` on PATH), Windows .cmd-shim aware
- copilot_mcp_endpoint.ts: private loopback HTTP MCP endpoint exposing
  note tools to the agent (ACP takes MCP servers by URL, not in-process)
- transcript.ts: hash/replay helpers extracted from claude_agent.ts and
  shared between both agent providers
- register the provider server-side (llm/index.ts) and client-side
  (AddProviderModal card, icon, translations)

Note tools are pre-approved via `--allow-tool=trilium` and built-in
file/shell tools denied via `--deny-tool`; the permission callback denies
anything reaching it, so the worst case is a note tool failing to run,
never a shell command on the server host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🖥️ App preview is ready!

🔗 Preview URL: https://pr-10529.trilium-app.pages.dev
📖 Production URL: https://app.triliumnotes.org

✅ All checks passed

This preview will be updated automatically with new commits.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new GitHub Copilot Agent provider (copilot-agent) that drives the GitHub Copilot CLI in Agent Client Protocol (ACP) mode, allowing users to use the in-app chat with their Copilot subscription. It includes a transport-only ACP client, a loopback MCP endpoint to expose note tools, and shared transcript helpers extracted from the Claude Agent provider. The review feedback highlights several robustness and security improvements, including enforcing a size limit on the request body in the private MCP endpoint to prevent Denial of Service (DoS) crashes, preventing a potential crash in the ACP client's line handler if a line parses to null, handling abort signals immediately in the generator loop to avoid hanging, registering a generic error handler on the MCP HTTP server, wrapping synchronous send calls in a try-catch block to prevent resource leaks, supporting both direct and wrapped content blocks in tool outputs, and adding defensive checks for falsy or non-array content in buildPromptBlocks to prevent TypeErrors.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +116 to +123
async function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk as Buffer);
}
const text = Buffer.concat(chunks).toString("utf8");
return text ? JSON.parse(text) : undefined;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Reading the entire request body into memory without a size limit poses a Denial of Service (DoS) risk via Out-Of-Memory (OOM) crashes if a client sends an excessively large payload. Enforce a reasonable maximum size limit (e.g., 10MB) while buffering the request chunks.

Suggested change
async function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk as Buffer);
}
const text = Buffer.concat(chunks).toString("utf8");
return text ? JSON.parse(text) : undefined;
}
async function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let totalLength = 0;
const MAX_SIZE = 10 * 1024 * 1024; // 10MB limit
for await (const chunk of req) {
totalLength += chunk.length;
if (totalLength > MAX_SIZE) {
throw new Error("Request body too large");
}
chunks.push(chunk as Buffer);
}
const text = Buffer.concat(chunks).toString("utf8");
return text ? JSON.parse(text) : undefined;
}

Comment on lines +152 to +158
let message: JsonRpcMessage;
try {
message = JSON.parse(line) as JsonRpcMessage;
} catch {
// Not part of the protocol stream (e.g. a stray banner) — ignore.
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the subprocess outputs a line containing null (which is valid JSON), JSON.parse(line) will return null. Accessing message.id on the next line will then throw a TypeError: Cannot read properties of null and crash the line handler. Ensure the parsed message is a non-null object before proceeding.

        let message: JsonRpcMessage;
        try {
            const parsed = JSON.parse(line);
            if (!parsed || typeof parsed !== "object") {
                return;
            }
            message = parsed as JsonRpcMessage;
        } catch {
            // Not part of the protocol stream (e.g. a stray banner) — ignore.
            return;
        }

Comment on lines +250 to +280
const onAbort = () => {
if (sessionId) {
client?.notify("session/cancel", { sessionId });
}
};
signal?.addEventListener("abort", onAbort, { once: true });

try {
const promptPromise = client.request<{ stopReason?: string }>(
"session/prompt",
{ sessionId, prompt: buildPromptBlocks(lastMessage.content, prefix) },
PROMPT_TIMEOUT_MS
);

// Drain updates as they arrive until the prompt resolves (and
// then whatever is still queued).
let result: { stopReason?: string } | undefined;
let promptError: unknown;
const done = promptPromise
.then(r => { result = r; })
.catch(err => { promptError = err; })
.finally(() => wakeup?.());

let finished = false;
void done.then(() => { finished = true; wakeup?.(); });
while (!finished || chunkQueue.length > 0) {
if (chunkQueue.length === 0) {
await new Promise<void>(resolve => { wakeup = resolve; });
wakeup = undefined;
continue;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the agent subprocess hangs or takes too long to respond to session/cancel upon abort, the generator loop will remain suspended on wakeup for up to 15 minutes (PROMPT_TIMEOUT_MS). Wake up the generator immediately on abort and break the loop so that the finally block can run and dispose of the client/subprocess immediately.

            const onAbort = () => {
                if (sessionId) {
                    client?.notify("session/cancel", { sessionId });
                }
                wakeup?.();
            };
            signal?.addEventListener("abort", onAbort, { once: true });

            try {
                const promptPromise = client.request<{ stopReason?: string }>( 
                    "session/prompt",
                    { sessionId, prompt: buildPromptBlocks(lastMessage.content, prefix) },
                    PROMPT_TIMEOUT_MS
                );

                // Drain updates as they arrive until the prompt resolves (and
                // then whatever is still queued).
                let result: { stopReason?: string } | undefined;
                let promptError: unknown;
                const done = promptPromise
                    .then(r => { result = r; })
                    .catch(err => { promptError = err; })
                    .finally(() => wakeup?.());

                let finished = false;
                void done.then(() => { finished = true; wakeup?.(); });
                while ((!finished && !signal?.aborted) || chunkQueue.length > 0) {
                    if (chunkQueue.length === 0) {
                        if (signal?.aborted) {
                            break;
                        }
                        await new Promise<void>(resolve => { wakeup = resolve; });
                        wakeup = undefined;
                        continue;
                    }

Comment on lines +67 to +70
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the HTTP server encounters an error after successfully starting to listen, the lack of an active error listener will cause the error to propagate as an unhandled exception, crashing the entire Node.js process. Register a generic error handler on the server once it starts listening.

    await new Promise<void>((resolve, reject) => {
        const onError = (err: Error) => reject(err);
        server.once("error", onError);
        server.listen(0, "127.0.0.1", () => {
            server.removeListener("error", onError);
            server.on("error", err => getLog().error(`Copilot MCP HTTP server error: ${err}`));
            resolve();
        });
    });

reject(err);
}
});
this.send({ jsonrpc: "2.0", id, method, params });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If this.send throws an error synchronously (e.g., if the stdin stream is destroyed/closed), the promise will reject but the timeout timer will remain active and the pending request ID will leak in this.pending. Wrap this.send in a try-catch block to clean up the timer and pending entry immediately.

            try {
                this.send({ jsonrpc: "2.0", id, method, params });
            } catch (err) {
                clearTimeout(timer);
                this.pending.delete(id);
                reject(err instanceof Error ? err : new Error(String(err)));
            }

Comment on lines +571 to +577
const texts = content
.map(item => {
if (item && typeof item === "object" && "content" in item) {
return extractText((item as { content?: AcpContentBlock }).content);
}
return "";
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In ACP/MCP, tool output content can be an array of content blocks directly (e.g., [{ type: "text", text: "..." }]) rather than wrapped in an object with a content property. Support both direct and wrapped content blocks to prevent empty tool results.

        const texts = content
            .map(item => {
                if (item && typeof item === "object") {
                    if ("content" in item) {
                        return extractText((item as { content?: unknown }).content);
                    }
                    return extractText(item);
                }
                return "";
            })

Comment on lines +594 to +598
function buildPromptBlocks(content: string | LlmMessagePart[], prefix: string): AcpContentBlock[] {
if (typeof content === "string") {
const text = prefix ? `${prefix}\n\n${content}` : content;
return [{ type: "text", text }];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If content is falsy or not an array/string (e.g., if the message content is missing or malformed), buildPromptBlocks will throw a TypeError when trying to iterate over it. Add defensive checks to handle falsy or non-array content gracefully.

function buildPromptBlocks(content: string | LlmMessagePart[] | undefined | null, prefix: string): AcpContentBlock[] {
    if (!content) {
        return prefix ? [{ type: "text", text: prefix }] : [];
    }
    if (typeof content === "string") {
        const text = prefix ? `${prefix}\n\n${content}` : content;
        return [{ type: "text", text }];
    }
    if (!Array.isArray(content)) {
        return prefix ? [{ type: "text", text: prefix }] : [];
    }

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 6.81kB (0.01%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
standalone-esm 53.26MB 4.5kB (0.01%) ⬆️
client-esm 49.53MB 2.32kB (0.0%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: client-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
src/dist-*.js -19 bytes 63 bytes -23.17%
src/dist-*.js -7 bytes 56 bytes -11.11%
src/dist-*.js 26 bytes 82 bytes 46.43% ⚠️
src/AddProviderModal-*.js 2.32kB 9.51kB 32.23% ⚠️

Files in src/AddProviderModal-*.js:

  • ./src/widgets/type_widgets/options/llm/AddProviderModal.tsx → Total Size: 4.95kB
view changes for bundle: standalone-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/abstract_provider-BJFf7ed_.js (New) 2.08MB 2.08MB 100.0% 🚀
assets/src-CDQJUSn7.js (New) 194.86kB 194.86kB 100.0% 🚀
assets/crypto_provider-ahEfRzMM.js (New) 97.66kB 97.66kB 100.0% 🚀
assets/in_app_help_provider-Dk7ITs7W.js (New) 78.44kB 78.44kB 100.0% 🚀
assets/zip-IouagaX1.js (New) 56.09kB 56.09kB 100.0% 🚀
src/setup.js 1.19kB 14.4kB 9.02% ⚠️
src/AddProviderModal.js 2.32kB 9.4kB 32.71% ⚠️
src/SetupPage.css 207 bytes 9.37kB 2.26%
assets/browser_routes-WbDT87cW.js (New) 8.21kB 8.21kB 100.0% 🚀
assets/becca_loader-D8pIOejg.js (New) 5.71kB 5.71kB 100.0% 🚀
assets/local-server-worker-CNcBcMkT.js (New) 4.65kB 4.65kB 100.0% 🚀
assets/html-BSujMm5X.js (New) 2.93kB 2.93kB 100.0% 🚀
assets/ru-ePW9W42M.js (New) 2.39kB 2.39kB 100.0% 🚀
assets/backup_provider-MI5JT8B4.js (New) 2.32kB 2.32kB 100.0% 🚀
assets/uk-DWWM8_0p.js (New) 2.31kB 2.31kB 100.0% 🚀
assets/log_provider-CuZbf21x.js (New) 1.96kB 1.96kB 100.0% 🚀
assets/0216__move_content_into_blobs-CxAs76G-.js (New) 1.9kB 1.9kB 100.0% 🚀
assets/zip_export_provider_factory-D_HF6iiT.js (New) 1.83kB 1.83kB 100.0% 🚀
assets/ar-D5scsU34.js (New) 1.81kB 1.81kB 100.0% 🚀
assets/cs-Db8fouKJ.js (New) 1.78kB 1.78kB 100.0% 🚀
assets/pl-BSqKO-zS.js (New) 1.74kB 1.74kB 100.0% 🚀
assets/hi-DTfUNF7v.js (New) 1.74kB 1.74kB 100.0% 🚀
assets/zh-cn-D_30hKuy.js (New) 1.54kB 1.54kB 100.0% 🚀
assets/de-DW8xQH_Y.js (New) 1.52kB 1.52kB 100.0% 🚀
assets/zh-tw-UxkIdXU5.js (New) 1.51kB 1.51kB 100.0% 🚀
assets/ja-C3pS0cwC.js (New) 1.35kB 1.35kB 100.0% 🚀
assets/pt-IULiPjE3.js (New) 1.3kB 1.3kB 100.0% 🚀
assets/ga-ici-ihdW.js (New) 1.29kB 1.29kB 100.0% 🚀
assets/en-gb-BkkscO0W.js (New) 1.29kB 1.29kB 100.0% 🚀
assets/pt-br-DeAdvqwx.js (New) 1.28kB 1.28kB 100.0% 🚀
assets/fr-B3xzk4mE.js (New) 1.27kB 1.27kB 100.0% 🚀
assets/es-C3gU7dfK.js (New) 1.25kB 1.25kB 100.0% 🚀
assets/it-C2IJgU2z.js (New) 1.23kB 1.23kB 100.0% 🚀
assets/ro-CMxxvBxR.js (New) 1.22kB 1.22kB 100.0% 🚀
assets/id-q8WQC6zP.js (New) 1.22kB 1.22kB 100.0% 🚀
assets/0233__migrate_geo_map_to_collection-DN9iQvDD.js (New) 777 bytes 777 bytes 100.0% 🚀
assets/0220__migrate_images_to_attachments-DWMe_d-h.js (New) 672 bytes 672 bytes 100.0% 🚀
assets/0234__migrate_ai_chat_to_code-BZnj_yzA.js (New) 443 bytes 443 bytes 100.0% 🚀
assets/markdown-l2pLSI3w.js (New) 323 bytes 323 bytes 100.0% 🚀
assets/abstract_provider-cBsaAvM4.js (Deleted) -2.07MB 0 bytes -100.0% 🗑️
assets/src-Q6Irhe5Q.js (Deleted) -194.51kB 0 bytes -100.0% 🗑️
assets/crypto_provider-DwvpK433.js (Deleted) -97.66kB 0 bytes -100.0% 🗑️
assets/in_app_help_provider-BjVGwdRP.js (Deleted) -78.44kB 0 bytes -100.0% 🗑️
assets/zip-BhODR_2d.js (Deleted) -56.09kB 0 bytes -100.0% 🗑️
assets/browser_routes-3ILoHprM.js (Deleted) -8.21kB 0 bytes -100.0% 🗑️
assets/becca_loader-B7lv6eMl.js (Deleted) -5.71kB 0 bytes -100.0% 🗑️
assets/local-server-worker-F2QfuEJ3.js (Deleted) -4.65kB 0 bytes -100.0% 🗑️
assets/html-C9Y1lGhx.js (Deleted) -2.93kB 0 bytes -100.0% 🗑️
assets/ru-DGDs_mIF.js (Deleted) -2.39kB 0 bytes -100.0% 🗑️
assets/backup_provider-_Okjywa_.js (Deleted) -2.32kB 0 bytes -100.0% 🗑️
assets/uk-DtR8KVPT.js (Deleted) -2.31kB 0 bytes -100.0% 🗑️
assets/log_provider-3Ors-XNF.js (Deleted) -1.96kB 0 bytes -100.0% 🗑️
assets/0216__move_content_into_blobs-BDA6qXv2.js (Deleted) -1.9kB 0 bytes -100.0% 🗑️
assets/zip_export_provider_factory-W1sKp8dU.js (Deleted) -1.83kB 0 bytes -100.0% 🗑️
assets/ar-C1iHg3Gr.js (Deleted) -1.81kB 0 bytes -100.0% 🗑️
assets/cs-BYs7xSSw.js (Deleted) -1.78kB 0 bytes -100.0% 🗑️
assets/pl-M9MAg5bR.js (Deleted) -1.74kB 0 bytes -100.0% 🗑️
assets/hi-DZiZbgB9.js (Deleted) -1.74kB 0 bytes -100.0% 🗑️
assets/zh-cn-ChKC53kA.js (Deleted) -1.54kB 0 bytes -100.0% 🗑️
assets/de-C8qeMcYv.js (Deleted) -1.52kB 0 bytes -100.0% 🗑️
assets/zh-tw-B4opsDFW.js (Deleted) -1.51kB 0 bytes -100.0% 🗑️
assets/ja-BTQQ9EGI.js (Deleted) -1.35kB 0 bytes -100.0% 🗑️
assets/pt-Br_rDQ3M.js (Deleted) -1.3kB 0 bytes -100.0% 🗑️
assets/ga-iNZS6XGM.js (Deleted) -1.29kB 0 bytes -100.0% 🗑️
assets/en-gb-CNZ00ZV-.js (Deleted) -1.29kB 0 bytes -100.0% 🗑️
assets/pt-br-BAzbLk8m.js (Deleted) -1.28kB 0 bytes -100.0% 🗑️
assets/fr-CWjF_aBz.js (Deleted) -1.27kB 0 bytes -100.0% 🗑️
assets/es-D9O_jMf2.js (Deleted) -1.25kB 0 bytes -100.0% 🗑️
assets/it-B504MKpW.js (Deleted) -1.23kB 0 bytes -100.0% 🗑️
assets/ro-DY_NDoH6.js (Deleted) -1.22kB 0 bytes -100.0% 🗑️
assets/id-CW_Kp17F.js (Deleted) -1.22kB 0 bytes -100.0% 🗑️
assets/0233__migrate_geo_map_to_collection-54uyNQfh.js (Deleted) -777 bytes 0 bytes -100.0% 🗑️
assets/0220__migrate_images_to_attachments-B0CrUBES.js (Deleted) -672 bytes 0 bytes -100.0% 🗑️
assets/0234__migrate_ai_chat_to_code-CuEPi8oB.js (Deleted) -443 bytes 0 bytes -100.0% 🗑️
assets/markdown-BsiXXzun.js (Deleted) -323 bytes 0 bytes -100.0% 🗑️

@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.50495% with 2 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...server/src/services/llm/providers/copilot_agent.ts 99.08% 0 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a copilot-agent LLM provider that drives the GitHub Copilot CLI in ACP (Agent Client Protocol) mode as a subprocess, allowing users with a Copilot subscription to use Trilium's built-in chat without an API key — mirroring the existing claude-agent design.

  • acp_client.ts implements a dependency-free newline-delimited JSON-RPC client over stdio; copilot_agent.ts handles session lifecycle (resume-by-hash, reseed-on-diverge), permission denial, and streaming; copilot_binary.ts resolves the user's installed CLI with a one-shot async probe; and copilot_mcp_endpoint.ts exposes note tools on an ephemeral loopback HTTP server with a 128-bit secret path.
  • Shared transcript.ts helpers (hash, replay) are extracted from claude_agent.ts and reused by both agent providers; the existing stdin-error handling gap and body-size limit raised in earlier review threads are now addressed.

Confidence Score: 5/5

The change is safe to merge; both previously flagged concerns (stdin EPIPE crash, missing body-size cap) are addressed, and the new code handles subprocess lifecycle, permission denial, and secret-path isolation correctly.

The implementation is thorough with 80+ new unit tests covering session resume/reseed, abort, permission denial, and stop-reason mapping. The two flagged observations are non-blocking edge cases that do not affect the happy path or the security boundary.

No files require special attention before merging. The two minor behavioural edge cases are in copilot_agent.ts (silent cancel) and copilot_mcp_endpoint.ts (JSON parse error status code).

Important Files Changed

Filename Overview
apps/server/src/services/llm/providers/acp_client.ts Well-implemented ACP client: EPIPE handled via the stdin error listener (line 81), request correlation with per-request timeouts, and clean subprocess lifecycle via dispose(). failAll() correctly drains the pending map before clearing.
apps/server/src/services/llm/providers/copilot_agent.ts Core provider logic is sound; session resume/reseed, abort handling, and permission fail-close all work correctly. Minor concern: a CLI-initiated "cancelled" stop reason silently yields only done with no user-visible explanation.
apps/server/src/services/llm/providers/copilot_mcp_endpoint.ts Security is solid: 128-bit secret path, loopback-only bind, DNS rebinding protection, and 4 MB body cap. Minor: invalid JSON body propagates as a 500 rather than a client-appropriate 400.
apps/server/src/services/llm/providers/copilot_binary.ts Binary resolution is robust: async probe so the first chat turn does not block the event loop, Windows .cmd/.bat shim handling, promise-level caching with failure-clearing, and clear user-facing error messages.
apps/server/src/services/llm/providers/transcript.ts Clean extraction of hash/replay helpers from claude_agent.ts; SHA-256 transcript hashing, history replay block, and attachment placeholder are all straightforward and well-tested.
apps/server/src/services/llm/index.ts Registration of copilot-agent factory mirrors the claude-agent pattern cleanly; no API key required and the factory instantiates a fresh CopilotAgentProvider.
apps/client/src/widgets/type_widgets/options/llm/AddProviderModal.tsx GitHub Copilot card added alongside Claude Code with matching usesApiKey: false and connectionDescription setup; UI logic is unchanged.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant UI as Client UI
    participant Provider as CopilotAgentProvider
    participant Binary as copilot_binary.ts
    participant ACP as AcpClient (subprocess)
    participant MCP as copilot_mcp_endpoint.ts
    participant Notes as Note Tools

    UI->>Provider: chatChunks(messages, config, signal)
    Provider->>Binary: resolveCopilotBinaryPath()
    Binary-->>Provider: /path/to/copilot (cached)
    Provider->>ACP: spawn copilot --acp + initialize
    Provider->>MCP: getCopilotMcpEndpointUrl() (lazy start)
    MCP-->>Provider: http://127.0.0.1:PORT/mcp-SECRET

    alt session hash matches stored
        Provider->>ACP: session/load (muted)
        ACP-->>Provider: replay suppressed
    else fresh session
        Provider->>ACP: session/new with mcpServers
        ACP-->>Provider: sessionId
        Provider->>ACP: session/set_model (optional)
    end

    Provider->>ACP: session/prompt

    loop streaming
        ACP-->>Provider: session/update notifications
        Provider-->>UI: LlmStreamChunk
        opt note tool call
            ACP->>MCP: HTTP POST /mcp-SECRET
            MCP->>Notes: tool handler
            Notes-->>ACP: result
        end
    end

    opt abort
        UI->>Provider: AbortSignal
        Provider->>ACP: session/cancel
    end

    ACP-->>Provider: stopReason
    Provider-->>UI: done
    Provider->>ACP: dispose
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant UI as Client UI
    participant Provider as CopilotAgentProvider
    participant Binary as copilot_binary.ts
    participant ACP as AcpClient (subprocess)
    participant MCP as copilot_mcp_endpoint.ts
    participant Notes as Note Tools

    UI->>Provider: chatChunks(messages, config, signal)
    Provider->>Binary: resolveCopilotBinaryPath()
    Binary-->>Provider: /path/to/copilot (cached)
    Provider->>ACP: spawn copilot --acp + initialize
    Provider->>MCP: getCopilotMcpEndpointUrl() (lazy start)
    MCP-->>Provider: http://127.0.0.1:PORT/mcp-SECRET

    alt session hash matches stored
        Provider->>ACP: session/load (muted)
        ACP-->>Provider: replay suppressed
    else fresh session
        Provider->>ACP: session/new with mcpServers
        ACP-->>Provider: sessionId
        Provider->>ACP: session/set_model (optional)
    end

    Provider->>ACP: session/prompt

    loop streaming
        ACP-->>Provider: session/update notifications
        Provider-->>UI: LlmStreamChunk
        opt note tool call
            ACP->>MCP: HTTP POST /mcp-SECRET
            MCP->>Notes: tool handler
            Notes-->>ACP: result
        end
    end

    opt abort
        UI->>Provider: AbortSignal
        Provider->>ACP: session/cancel
    end

    ACP-->>Provider: stopReason
    Provider-->>UI: done
    Provider->>ACP: dispose
Loading

Reviews (5): Last reviewed commit: "fix(llm): resolve the Windows .cmd shim ..." | Re-trigger Greptile

Comment thread apps/server/src/services/llm/providers/acp_client.ts
Comment thread apps/server/src/services/llm/providers/copilot_mcp_endpoint.ts
Comment on lines +48 to +56
const AVAILABLE_MODELS: ModelInfo[] = [
{ id: "auto", name: "Auto", pricing: { input: 0, output: 0 }, isDefault: true, isSubscription: true },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5", pricing: { input: 0, output: 0 }, costMultiplier: 1, isSubscription: true },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", pricing: { input: 0, output: 0 }, costMultiplier: 0.3, isSubscription: true },
{ id: "gpt-5.4", name: "GPT-5.4", pricing: { input: 0, output: 0 }, costMultiplier: 1, isSubscription: true },
{ id: "gpt-5.3-codex", name: "GPT-5.3-Codex", pricing: { input: 0, output: 0 }, costMultiplier: 1, isSubscription: true },
{ id: "gpt-5.4-mini", name: "GPT-5.4 mini", pricing: { input: 0, output: 0 }, costMultiplier: 0.3, isSubscription: true },
{ id: "gpt-5-mini", name: "GPT-5 mini", pricing: { input: 0, output: 0 }, costMultiplier: 0, isSubscription: true }
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded model list will diverge from the CLI's live catalog. AVAILABLE_MODELS is a static snapshot; when GitHub adds, renames, or removes models in a CLI update the list silently goes stale — users won't see new models, or will try to select models that the CLI no longer accepts. The session/new response from the CLI already carries the authoritative model catalog (the CLI uses it for its own picker), so the data is available without an extra round-trip. Consider persisting the model list returned by the first session/new call (similar to how resolveCopilotBinaryPath caches the probe result) instead of maintaining a hand-rolled snapshot.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

eliandoran and others added 3 commits July 18, 2026 22:04
Addresses PR review feedback on the Copilot Agent provider. Each fix below
is covered by a test that fails against the unpatched source.

Crashes:
  - acp_client: stdin had no "error" listener, so an EPIPE from writing to
    an already-exited subprocess (a late session/cancel, or an agent-request
    reply resumed after dispose()) was re-thrown by EventEmitter and took
    the server down.
  - acp_client: a bare `null` line on stdout parses as valid JSON, then
    threw a TypeError on the property reads that follow. Only accept objects.

Hang:
  - copilot_agent: on abort the drain loop stayed suspended until the agent
    honoured session/cancel, holding the subprocess alive for up to
    PROMPT_TIMEOUT_MS (15 min). Wake the loop on abort and stop once the
    queue is drained. Bailing early leaves the session's real history
    unknown, so the session mapping is no longer recorded for an aborted
    turn -- a later turn reseeds instead of resuming a diverged session.

Robustness:
  - copilot_mcp_endpoint: cap the buffered request body. The public /mcp
    route is bounded by Express's body parser; this raw listener bypassed it.
  - copilot_mcp_endpoint: keep an error listener attached past startup, so
    a post-listen error is logged rather than silently dropped.
  - acp_client: a synchronous send() failure left the timeout armed and the
    id stranded in `pending` until it elapsed.
  - copilot_agent: flattenToolContent now accepts direct content blocks as
    well as ACP's wrapped form, which previously fell back to raw JSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Writing tests for the loopback MCP endpoint surfaced a security control
that never fired:

    allowedHosts: [req.headers.host ?? ""].filter(h => /^127\.0\.0\.1:\d+$/.test(h))

The allowlist was derived from the very header it validates. A rebound
Host filters down to an empty array, and the MCP SDK skips the check
entirely when the list is empty (webStandardStreamableHttp.js:113,
`if (this._allowedHosts && this._allowedHosts.length > 0)`), so
enableDnsRebindingProtection could never reject anything -- the 128-bit
secret path was the only real access control. The allowlist is now
pinned to the address we bind and hand to the agent, so a rebound Host
gets a 403. A request with no Host at all is refused earlier, with a 400
from the transport.

Coverage for the files this PR adds, all now at 100% lines/functions:
  - copilot_mcp_endpoint.ts    1.88% -> 100% (54/54 lines, 15/15 fns)
  - copilot_agent.ts          62.72% -> 100% (215/215, 38/38)
  - acp_client.ts             80.72% -> 100% (79/79, 19/19)
  - llm/index.ts                 50% -> 100% (50/50, 13/13)

Notable additions: the session/load resume-and-reseed paths, generateTitle,
the update collector's mute/filter/tool-call branches, LRU session
eviction, the ACP request timeout and synchronous-send failure, and the
endpoint's failed-start, body-cap and error-response paths.

wrapSystemInstructions' empty-prompt guard is marked /* v8 ignore */: it
is unreachable because buildSystemPrompt always appends the Markdown
hints and there is exactly one caller.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
npm installs a bare extensionless `copilot` (a POSIX sh script for Git
Bash) alongside `copilot.cmd`. findOnPath probed the empty extension
first, so on Windows it resolved the sh script, which Node cannot spawn
-- surfacing as the misleading "Found GitHub Copilot CLI at ... but it
failed to run (spawn ENOENT)".

Probe the Windows-native extensions and skip the bare name entirely,
matching claude_binary.ts. Skipping (rather than keeping "" as a
fallback) means a directory holding only the unusable sh script yields
the actionable "not found -- install it" message instead.

This was masked for anyone running the server from a VS Code terminal:
VS Code puts its own bundled CLI on PATH, and that directory ships only
copilot.bat/.ps1, so the bad candidate never existed there. Users who
follow the error message's own `npm install -g @github/copilot` advice
and start the server from a plain shell hit it every time.

The existing Windows test passed because it stubbed only copilot.cmd as
existing; the new one stubs the real npm layout (both files present).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant