From 67c9dc0d727417cc7d5b95e2efe23bc25a98303a Mon Sep 17 00:00:00 2001 From: Martin Krastev Date: Wed, 10 Jun 2026 21:17:37 +0200 Subject: [PATCH 1/8] fix(core): use Copilot SDK 0.3.0+ permission response vocabulary Copilot SDK 0.3.0 renamed the PermissionResponse "kind" literals, and the bundled GitHub Copilot CLI 1.0.x only accepts the new vocabulary: "approved" -> "approve-once" "denied-no-approval-rule-and-could-not-request-from-user" -> "user-not-available" "denied-interactively-by-user" -> "reject" agentrc is pinned to @github/copilot-sdk ^0.2.0 (resolves to 0.2.2) and still sends the old kinds from two places: - READ_ONLY_PERMISSION_HANDLER in services/instructions.ts - attachDefaultPermissionHandler's approveAll in services/copilotSdk.ts The CLI rejects every permission response with "unexpected user permission response", every glob/view/grep/shell call fails, and the model generalizes the systematic tool failure into "the environment is broken" and emits a short error message via the custom emit_file_content tool. agentrc writes that string verbatim to .github/copilot-instructions.md and exits 0. This commit updates both handlers (and their tests) to emit the new literals. The 0.2.x type declarations still declare the old strings, so we cast through "unknown" until the SDK dependency is bumped; that bump is intentionally out of scope here because SDK 1.x removes cliUrl, useStdio, and session.destroy() and is not a drop-in upgrade. Refs: https://github.com/github/copilot-sdk/releases/tag/v0.3.0 Related: #153 --- packages/core/src/services/copilotSdk.ts | 4 +++- packages/core/src/services/instructions.ts | 9 ++++++--- src/services/__tests__/copilotSdk.test.ts | 13 +++++++------ src/services/__tests__/instructions.test.ts | 11 +++++++---- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/core/src/services/copilotSdk.ts b/packages/core/src/services/copilotSdk.ts index 20711fd0..02204ae0 100644 --- a/packages/core/src/services/copilotSdk.ts +++ b/packages/core/src/services/copilotSdk.ts @@ -156,7 +156,9 @@ export type PatchedCopilotClient = Omit< export function attachDefaultPermissionHandler( client: InstanceType ): void { - const approveAll: CopilotSdk.PermissionHandler = () => ({ kind: "approved" as const }); + // SDK 0.3.0+ / CLI 1.0.x renamed "approved" -> "approve-once". The 0.2.x + // type declarations still say "approved", so we cast through unknown. + const approveAll = (() => ({ kind: "approve-once" })) as unknown as CopilotSdk.PermissionHandler; const originalCreateSession = client.createSession.bind(client); // Override createSession so onPermissionRequest is optional at call sites. // The cast targets our PatchedCopilotClient createSession signature which diff --git a/packages/core/src/services/instructions.ts b/packages/core/src/services/instructions.ts index f9f107a3..1e45fb1b 100644 --- a/packages/core/src/services/instructions.ts +++ b/packages/core/src/services/instructions.ts @@ -226,12 +226,15 @@ const INSTRUCTION_GENERATION_EXCLUDED_TOOLS = [ const READ_ONLY_PERMISSION_HANDLER: PermissionHandler = (request) => { if (request.kind === "read" || request.kind === "custom-tool") { - return { kind: "approved" }; + // SDK 0.3.0+ / CLI 1.0.x renamed "approved" -> "approve-once". + // Cast through unknown so the 0.2.x typings (which still declare "approved") + // continue to compile while we emit the wire shape the new CLI requires. + return { kind: "approve-once" } as unknown as ReturnType; } return { - kind: "denied-no-approval-rule-and-could-not-request-from-user" - }; + kind: "user-not-available" + } as unknown as ReturnType; }; function getSessionError(errorMsg: string): Error { diff --git a/src/services/__tests__/copilotSdk.test.ts b/src/services/__tests__/copilotSdk.test.ts index b83efcf5..f9046aea 100644 --- a/src/services/__tests__/copilotSdk.test.ts +++ b/src/services/__tests__/copilotSdk.test.ts @@ -36,13 +36,14 @@ describe("attachDefaultPermissionHandler", () => { expect(passedConfig.config).toHaveProperty("onPermissionRequest"); expect(typeof passedConfig.config.onPermissionRequest).toBe("function"); - // The injected handler should approve all request kinds + // The injected handler should approve all request kinds. + // SDK 0.3.0+ / CLI 1.0.x renamed "approved" -> "approve-once". const handler = passedConfig.config.onPermissionRequest as (req: unknown) => { kind: string }; - expect(handler({ kind: "shell" })).toEqual({ kind: "approved" }); - expect(handler({ kind: "write" })).toEqual({ kind: "approved" }); - expect(handler({ kind: "read" })).toEqual({ kind: "approved" }); - expect(handler({ kind: "url" })).toEqual({ kind: "approved" }); - expect(handler({ kind: "mcp" })).toEqual({ kind: "approved" }); + expect(handler({ kind: "shell" })).toEqual({ kind: "approve-once" }); + expect(handler({ kind: "write" })).toEqual({ kind: "approve-once" }); + expect(handler({ kind: "read" })).toEqual({ kind: "approve-once" }); + expect(handler({ kind: "url" })).toEqual({ kind: "approve-once" }); + expect(handler({ kind: "mcp" })).toEqual({ kind: "approve-once" }); }); it("preserves a caller-supplied onPermissionRequest", async () => { diff --git a/src/services/__tests__/instructions.test.ts b/src/services/__tests__/instructions.test.ts index cda36232..45d1bcb5 100644 --- a/src/services/__tests__/instructions.test.ts +++ b/src/services/__tests__/instructions.test.ts @@ -816,17 +816,20 @@ describe("instruction generation sessions", () => { const expectReadOnlyPermissions = async ( onPermissionRequest: (request: { kind: string }) => Promise<{ kind: string }> | { kind: string } ) => { + // SDK 0.3.0+ / CLI 1.0.x renamed the permission response vocabulary: + // "approved" -> "approve-once" + // "denied-no-approval-rule-and-could-not-request-from-user" -> "user-not-available" await expect(Promise.resolve(onPermissionRequest({ kind: "read" }))).resolves.toEqual({ - kind: "approved" + kind: "approve-once" }); await expect(Promise.resolve(onPermissionRequest({ kind: "custom-tool" }))).resolves.toEqual({ - kind: "approved" + kind: "approve-once" }); await expect(Promise.resolve(onPermissionRequest({ kind: "shell" }))).resolves.toEqual({ - kind: "denied-no-approval-rule-and-could-not-request-from-user" + kind: "user-not-available" }); await expect(Promise.resolve(onPermissionRequest({ kind: "write" }))).resolves.toEqual({ - kind: "denied-no-approval-rule-and-could-not-request-from-user" + kind: "user-not-available" }); }; From 9c83a01365efb2798cf44f10c80f17c5fe830e17 Mon Sep 17 00:00:00 2001 From: Martin Krastev Date: Wed, 10 Jun 2026 21:18:15 +0200 Subject: [PATCH 2/8] fix(core): bump headless probe timeout to 20s for Windows cold start isHeadlessCompatible (and the equivalent assertCopilotCliReady probe) runs the candidate CLI with --headless --version under a 5-second timeout for non-npx invocations. On Windows the discovered binary is a .bat shim that spawns Node; the first probe of a session intermittently exceeds 5 seconds even on a warm machine, the candidate is marked incompatible, and the user sees a spurious "Found Copilot CLI candidate ... but it does not support --headless" failure that retries moments later succeed. Bump the non-npx budget to 20s. npx already gets 30s because of package-download cost; 20s is comfortably above observed cold-start spawn time without making the failure case noticeably slower. This matches the intermittent failure mode reported in #153, which the permission vocabulary fix in the previous commit otherwise masked behind a successful-then-broken generation. Refs: #153 --- packages/core/src/services/copilot.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/copilot.ts b/packages/core/src/services/copilot.ts index 939901ca..adf727bc 100644 --- a/packages/core/src/services/copilot.ts +++ b/packages/core/src/services/copilot.ts @@ -41,7 +41,7 @@ export async function assertCopilotCliReady(): Promise { try { const isNpx = config.cliArgs?.includes("@github/copilot") ?? false; - const timeout = isNpx ? 30000 : 5000; + const timeout = isNpx ? 30000 : 20000; const [cmd, args] = buildExecArgs(config, ["--headless", "--version"]); await execFileAsync(cmd, args, { timeout }); } catch { @@ -287,7 +287,7 @@ async function findFirstCompatibleCandidate( async function isHeadlessCompatible(config: CopilotCliConfig): Promise { // npx may need to download the package on first run, so allow a longer timeout const isNpx = config.cliArgs?.includes("@github/copilot") ?? false; - const timeout = isNpx ? 30000 : 5000; + const timeout = isNpx ? 30000 : 20000; try { const [cmd, args] = buildExecArgs(config, ["--headless", "--version"]); await execFileAsync(cmd, args, { timeout }); From 99d59e8256dbf325cb90479baaa428d890660b13 Mon Sep 17 00:00:00 2001 From: Martin Krastev Date: Wed, 10 Jun 2026 21:18:59 +0200 Subject: [PATCH 3/8] fix(core): extend session.sendAndWait budget to 600s for instruction generation Instruction generation drives the Copilot SDK through a multi-step agent loop: read README/package manifests, glob the tree, sample representative files, then emit the final markdown via the custom emit_file_content tool. End-to-end this routinely runs longer than the current 180s budget on a non-trivial repo (especially with reasoning models on slower endpoints), and a timeout aborts the session midway through, surfacing as "Timeout after 180000ms waiting for session.idle" with no usable output. Bump all four sendAndWait call sites to 600s (10 min). This is an upper bound, not an idle wait: fast runs complete well under a minute, slow runs no longer get cut off. The user-visible progress indicator already runs throughout so the longer ceiling is observable. Symptom only becomes visible after the permission vocabulary fix in the first commit lets the agent actually run through to the end - before that fix, every tool call failed instantly and the session never reached the timeout. --- packages/core/src/services/instructions.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/services/instructions.ts b/packages/core/src/services/instructions.ts index 1e45fb1b..7b275383 100644 --- a/packages/core/src/services/instructions.ts +++ b/packages/core/src/services/instructions.ts @@ -371,7 +371,7 @@ ${existingSection}`; progress("Analyzing codebase..."); let sendError: unknown; try { - await session.sendAndWait({ prompt }, 180000); + await session.sendAndWait({ prompt }, 600000); } catch (err) { sendError = err; } finally { @@ -474,7 +474,7 @@ ${existingSection ? `\nDo NOT duplicate content already covered by existing inst progress(`Analyzing area "${area.name}"...`); let sendError: unknown; try { - await session.sendAndWait({ prompt }, 180000); + await session.sendAndWait({ prompt }, 600000); } catch (err) { sendError = err; } finally { @@ -775,7 +775,7 @@ ${existingSection ? `\nDo NOT duplicate content from existing instruction files\ let sendError: unknown; try { - await session.sendAndWait({ prompt }, 180000); + await session.sendAndWait({ prompt }, 600000); } catch (err) { sendError = err; } finally { @@ -859,7 +859,7 @@ Description: ${options.topic.description}`; let sendError: unknown; try { - await session.sendAndWait({ prompt }, 180000); + await session.sendAndWait({ prompt }, 600000); } catch (err) { sendError = err; } finally { From c68eb4c0fb741ffafc051672f6918ed4ec636d3e Mon Sep 17 00:00:00 2001 From: Martin Krastev Date: Wed, 10 Jun 2026 22:19:48 +0200 Subject: [PATCH 4/8] refactor(core): address review feedback on SDK compatibility fixes Follow-up to the three fix commits on this branch. Centralizes the shape-cast for the new Copilot SDK 0.3.0+ wire vocabulary, names the two large timeout literals, and adds env-var overrides so operators can tune them without rebuilding. Changes: 1. copilotSdk.ts - extract WirePermissionResponseKind + wirePermissionResponse helper. attachDefaultPermissionHandler now calls the helper instead of inlining the s unknown as cast at every call site. Type name uses "Response" suffix to disambiguate from request kinds. 2. copilot.ts - add parsePositiveIntEnv() helper that reads + validates a positive-integer env var and emits a logCopilotDebug() trace on invalid input (instead of silently falling back). getHeadlessProbeTimeoutMs() uses it to honor AGENTRC_COPILOT_PROBE_TIMEOUT_MS, defaulting to 20_000. 3. instructions.ts - replace the inline 600_000 ms literal at every sendAndWait() call site with getInstructionGenerationTimeoutMs(), which reads AGENTRC_INSTRUCTION_TIMEOUT_MS via parsePositiveIntEnv() and defaults to 600_000. Function (not IIFE) so tests can override process.env without re-importing the module. Verified locally: lint, typecheck, build, and the impacted unit suites all pass (67/68 - the single failure is the pre-existing Windows symlink EPERM unrelated to this change). End-to-end `agentrc instructions --force` against a sample MCP repo produced a real 4.1 KB instructions file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/core/src/services/copilot.ts | 36 +++++++++++++++++++--- packages/core/src/services/copilotSdk.ts | 34 ++++++++++++++++++-- packages/core/src/services/instructions.ts | 36 ++++++++++++++-------- 3 files changed, 85 insertions(+), 21 deletions(-) diff --git a/packages/core/src/services/copilot.ts b/packages/core/src/services/copilot.ts index adf727bc..86b481fd 100644 --- a/packages/core/src/services/copilot.ts +++ b/packages/core/src/services/copilot.ts @@ -14,6 +14,22 @@ export function logCopilotDebug(message: string): void { process.stderr.write(`[agentrc:copilot] ${message}\n`); } +/** + * Parse a positive-integer environment variable. Returns the parsed value + * when the env var is set to a finite number > 0; otherwise returns + * `undefined` so callers can apply their own default. Emits a debug log + * when the variable is set but unparseable, so users running with + * `AGENTRC_DEBUG_COPILOT=1` can see when their override was rejected. + */ +export function parsePositiveIntEnv(name: string): number | undefined { + const raw = process.env[name]; + if (raw === undefined || raw === "") return undefined; + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + logCopilotDebug(`ignoring invalid ${name}=${raw}`); + return undefined; +} + export type CopilotCliConfig = { cliPath: string; cliArgs?: string[]; @@ -28,6 +44,19 @@ let cachedCliConfig: CopilotCliConfig | null = null; let cachedCliConfigTimestamp = 0; const CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +/** + * Timeout for the `--headless --version` readiness probe. npx may need to + * download `@github/copilot` on first run, so it gets a longer budget; the + * `.bat`-shim / Node cold start on Windows still routinely exceeds 5 s, so + * the non-npx path uses 20 s. Overridable via `AGENTRC_COPILOT_PROBE_TIMEOUT_MS`. + */ +function getHeadlessProbeTimeoutMs(config: CopilotCliConfig): number { + const override = parsePositiveIntEnv("AGENTRC_COPILOT_PROBE_TIMEOUT_MS"); + if (override !== undefined) return override; + const isNpx = config.cliArgs?.includes("@github/copilot") ?? false; + return isNpx ? 30000 : 20000; +} + function cacheConfig(config: CopilotCliConfig): CopilotCliConfig { cachedCliConfig = config; cachedCliConfigTimestamp = Date.now(); @@ -40,8 +69,7 @@ export async function assertCopilotCliReady(): Promise { logCopilotDebug(`validating CLI compatibility with ${desc}`); try { - const isNpx = config.cliArgs?.includes("@github/copilot") ?? false; - const timeout = isNpx ? 30000 : 20000; + const timeout = getHeadlessProbeTimeoutMs(config); const [cmd, args] = buildExecArgs(config, ["--headless", "--version"]); await execFileAsync(cmd, args, { timeout }); } catch { @@ -285,9 +313,7 @@ async function findFirstCompatibleCandidate( } async function isHeadlessCompatible(config: CopilotCliConfig): Promise { - // npx may need to download the package on first run, so allow a longer timeout - const isNpx = config.cliArgs?.includes("@github/copilot") ?? false; - const timeout = isNpx ? 30000 : 20000; + const timeout = getHeadlessProbeTimeoutMs(config); try { const [cmd, args] = buildExecArgs(config, ["--headless", "--version"]); await execFileAsync(cmd, args, { timeout }); diff --git a/packages/core/src/services/copilotSdk.ts b/packages/core/src/services/copilotSdk.ts index 02204ae0..f1502c3b 100644 --- a/packages/core/src/services/copilotSdk.ts +++ b/packages/core/src/services/copilotSdk.ts @@ -6,6 +6,36 @@ import { buildExecArgs, logCopilotDebug, type CopilotCliConfig } from "./copilot export type CopilotSdkModule = typeof CopilotSdk; +/** + * Permission response kinds the bundled Copilot CLI 1.0.x accepts. + * Mirrors the SDK 0.3.0 vocabulary (https://github.com/github/copilot-sdk/releases/tag/v0.3.0). + * + * Note: this is the *response* kind sent back from a permission handler, + * not the *request* kind (`"read"`, `"shell"`, ...) the SDK passes in. + */ +export type WirePermissionResponseKind = + | "approve-once" + | "approve-for-session" + | "approve-for-location" + | "reject" + | "user-not-available"; + +/** + * Build the wire-shape permission response the Copilot CLI expects. + * + * The bundled CLI 1.0.x speaks the SDK 0.3.0+ vocabulary, but agentrc pins + * `@github/copilot-sdk` to ^0.2.0 whose type declarations still describe the + * older `"approved"` / `"denied-..."` kinds. This helper isolates the + * necessary unchecked cast in one place so the rest of the codebase can stay + * strict; remove it and use `{ kind } as const` once the SDK dependency is + * bumped. + */ +export function wirePermissionResponse( + kind: WirePermissionResponseKind +): ReturnType { + return { kind } as unknown as ReturnType; +} + let cachedSdkModule: Promise | null = null; function normalizeSdkLoadError(error: unknown): Error { @@ -156,9 +186,7 @@ export type PatchedCopilotClient = Omit< export function attachDefaultPermissionHandler( client: InstanceType ): void { - // SDK 0.3.0+ / CLI 1.0.x renamed "approved" -> "approve-once". The 0.2.x - // type declarations still say "approved", so we cast through unknown. - const approveAll = (() => ({ kind: "approve-once" })) as unknown as CopilotSdk.PermissionHandler; + const approveAll: CopilotSdk.PermissionHandler = () => wirePermissionResponse("approve-once"); const originalCreateSession = client.createSession.bind(client); // Override createSession so onPermissionRequest is optional at call sites. // The cast targets our PatchedCopilotClient createSession signature which diff --git a/packages/core/src/services/instructions.ts b/packages/core/src/services/instructions.ts index 7b275383..f022a699 100644 --- a/packages/core/src/services/instructions.ts +++ b/packages/core/src/services/instructions.ts @@ -8,8 +8,8 @@ import { ensureDir, safeWriteFile } from "../utils/fs"; import type { Area, InstructionStrategy } from "./analyzer"; import { sanitizeAreaName } from "./analyzer"; -import { assertCopilotCliReady } from "./copilot"; -import { createCopilotClient, loadCopilotSdk } from "./copilotSdk"; +import { assertCopilotCliReady, parsePositiveIntEnv } from "./copilot"; +import { createCopilotClient, loadCopilotSdk, wirePermissionResponse } from "./copilotSdk"; import type { FileAction } from "./generator"; import { getSkillDirectory } from "./skills"; @@ -224,17 +224,27 @@ const INSTRUCTION_GENERATION_EXCLUDED_TOOLS = [ "str_replace_editor" ]; +/** + * Upper bound for a single `session.sendAndWait` call during instruction + * generation. Generation drives an agent loop with many tool calls and on + * slower endpoints or larger repos the total run can exceed the SDK's default + * 3-minute budget. This is a ceiling, not an idle wait — fast runs complete + * well under a minute. Overridable via `AGENTRC_INSTRUCTION_TIMEOUT_MS` for + * environments with slow networks or very large repositories. + * + * Implemented as a function (rather than a module-load constant) so test code + * can stub the env var via `vi.stubEnv` after the module has been imported. + */ +function getInstructionGenerationTimeoutMs(): number { + return parsePositiveIntEnv("AGENTRC_INSTRUCTION_TIMEOUT_MS") ?? 600_000; +} + const READ_ONLY_PERMISSION_HANDLER: PermissionHandler = (request) => { if (request.kind === "read" || request.kind === "custom-tool") { - // SDK 0.3.0+ / CLI 1.0.x renamed "approved" -> "approve-once". - // Cast through unknown so the 0.2.x typings (which still declare "approved") - // continue to compile while we emit the wire shape the new CLI requires. - return { kind: "approve-once" } as unknown as ReturnType; + return wirePermissionResponse("approve-once"); } - return { - kind: "user-not-available" - } as unknown as ReturnType; + return wirePermissionResponse("user-not-available"); }; function getSessionError(errorMsg: string): Error { @@ -371,7 +381,7 @@ ${existingSection}`; progress("Analyzing codebase..."); let sendError: unknown; try { - await session.sendAndWait({ prompt }, 600000); + await session.sendAndWait({ prompt }, getInstructionGenerationTimeoutMs()); } catch (err) { sendError = err; } finally { @@ -474,7 +484,7 @@ ${existingSection ? `\nDo NOT duplicate content already covered by existing inst progress(`Analyzing area "${area.name}"...`); let sendError: unknown; try { - await session.sendAndWait({ prompt }, 600000); + await session.sendAndWait({ prompt }, getInstructionGenerationTimeoutMs()); } catch (err) { sendError = err; } finally { @@ -775,7 +785,7 @@ ${existingSection ? `\nDo NOT duplicate content from existing instruction files\ let sendError: unknown; try { - await session.sendAndWait({ prompt }, 600000); + await session.sendAndWait({ prompt }, getInstructionGenerationTimeoutMs()); } catch (err) { sendError = err; } finally { @@ -859,7 +869,7 @@ Description: ${options.topic.description}`; let sendError: unknown; try { - await session.sendAndWait({ prompt }, 600000); + await session.sendAndWait({ prompt }, getInstructionGenerationTimeoutMs()); } catch (err) { sendError = err; } finally { From bed7b8e45b579b6c7774b1ea636de5af88b509d0 Mon Sep 17 00:00:00 2001 From: Martin Krastev Date: Wed, 10 Jun 2026 22:54:46 +0200 Subject: [PATCH 5/8] fix(core): enforce integer-only contract in parsePositiveIntEnv Review follow-up. The helper was named/documented as parsing a positive integer but used Number(), which silently accepted non-integers ("1.5"), scientific notation ("1e3"), and hex ("0x10") - surprising for timeout config. Now requires a trimmed base-10 integer literal (/^\d+\$/) that is a safe integer > 0; everything else is rejected (and debug-logged) so callers fall back to their default. Adds unit coverage for the accepted/rejected cases, including leading zeros and out-of-safe-range values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/core/src/services/copilot.ts | 22 +++++--- src/services/__tests__/copilot.test.ts | 76 +++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/packages/core/src/services/copilot.ts b/packages/core/src/services/copilot.ts index 86b481fd..89963f8e 100644 --- a/packages/core/src/services/copilot.ts +++ b/packages/core/src/services/copilot.ts @@ -15,17 +15,23 @@ export function logCopilotDebug(message: string): void { } /** - * Parse a positive-integer environment variable. Returns the parsed value - * when the env var is set to a finite number > 0; otherwise returns - * `undefined` so callers can apply their own default. Emits a debug log - * when the variable is set but unparseable, so users running with - * `AGENTRC_DEBUG_COPILOT=1` can see when their override was rejected. + * Parse a positive-integer environment variable. Accepts only a base-10 + * integer literal (after trimming surrounding whitespace) so surprising + * inputs such as `"1.5"`, `"1e3"`, or `"0x10"` are rejected rather than + * silently coerced. Returns the parsed value when it is an integer > 0; + * otherwise returns `undefined` so callers can apply their own default. + * Emits a debug log when the variable is set but rejected, so users running + * with `AGENTRC_DEBUG_COPILOT=1` can see when their override was ignored. */ export function parsePositiveIntEnv(name: string): number | undefined { const raw = process.env[name]; - if (raw === undefined || raw === "") return undefined; - const parsed = Number(raw); - if (Number.isFinite(parsed) && parsed > 0) return parsed; + if (raw === undefined) return undefined; + const trimmed = raw.trim(); + if (trimmed === "") return undefined; + if (/^\d+$/u.test(trimmed)) { + const parsed = Number(trimmed); + if (Number.isSafeInteger(parsed) && parsed > 0) return parsed; + } logCopilotDebug(`ignoring invalid ${name}=${raw}`); return undefined; } diff --git a/src/services/__tests__/copilot.test.ts b/src/services/__tests__/copilot.test.ts index 16f1a3c6..3b57749b 100644 --- a/src/services/__tests__/copilot.test.ts +++ b/src/services/__tests__/copilot.test.ts @@ -1,5 +1,5 @@ -import { extractModelChoices } from "@agentrc/core/services/copilot"; -import { describe, expect, it } from "vitest"; +import { extractModelChoices, parsePositiveIntEnv } from "@agentrc/core/services/copilot"; +import { afterEach, describe, expect, it } from "vitest"; describe("extractModelChoices", () => { it("extracts model names from a single-line --help output", () => { @@ -35,3 +35,75 @@ describe("extractModelChoices", () => { expect(extractModelChoices(stderr)).toEqual(["gpt-5", "gpt-4.1", "claude-sonnet-4.5"]); }); }); + +describe("parsePositiveIntEnv", () => { + const NAME = "AGENTRC_TEST_TIMEOUT_MS"; + + afterEach(() => { + delete process.env[NAME]; + }); + + it("returns undefined when the variable is unset", () => { + delete process.env[NAME]; + expect(parsePositiveIntEnv(NAME)).toBeUndefined(); + }); + + it("parses a plain positive integer", () => { + process.env[NAME] = "5000"; + expect(parsePositiveIntEnv(NAME)).toBe(5000); + }); + + it("trims surrounding whitespace", () => { + process.env[NAME] = " 42 "; + expect(parsePositiveIntEnv(NAME)).toBe(42); + }); + + it("accepts leading zeros as base-10 (no octal)", () => { + process.env[NAME] = "007"; + expect(parsePositiveIntEnv(NAME)).toBe(7); + }); + + it("rejects non-integer decimals like 1.5", () => { + process.env[NAME] = "1.5"; + expect(parsePositiveIntEnv(NAME)).toBeUndefined(); + }); + + it("rejects scientific notation like 1e3", () => { + process.env[NAME] = "1e3"; + expect(parsePositiveIntEnv(NAME)).toBeUndefined(); + }); + + it("rejects hex literals like 0x10", () => { + process.env[NAME] = "0x10"; + expect(parsePositiveIntEnv(NAME)).toBeUndefined(); + }); + + it("rejects signed values", () => { + process.env[NAME] = "+5"; + expect(parsePositiveIntEnv(NAME)).toBeUndefined(); + }); + + it("rejects zero and negatives", () => { + process.env[NAME] = "0"; + expect(parsePositiveIntEnv(NAME)).toBeUndefined(); + process.env[NAME] = "-5"; + expect(parsePositiveIntEnv(NAME)).toBeUndefined(); + }); + + it("rejects empty and whitespace-only values", () => { + process.env[NAME] = ""; + expect(parsePositiveIntEnv(NAME)).toBeUndefined(); + process.env[NAME] = " "; + expect(parsePositiveIntEnv(NAME)).toBeUndefined(); + }); + + it("rejects non-numeric junk", () => { + process.env[NAME] = "5px"; + expect(parsePositiveIntEnv(NAME)).toBeUndefined(); + }); + + it("rejects values beyond the safe-integer range", () => { + process.env[NAME] = "99999999999999999999"; + expect(parsePositiveIntEnv(NAME)).toBeUndefined(); + }); +}); From 08d660d61ab69dd17be5728b779e6f0b1ade54f6 Mon Sep 17 00:00:00 2001 From: Martin Krastev Date: Wed, 10 Jun 2026 23:38:13 +0200 Subject: [PATCH 6/8] test(core): cover probe-timeout selection; clarify env-parse logging Addresses two review follow-ups: 1. parsePositiveIntEnv docstring claimed it logs whenever a variable is "set but rejected", but unset/empty/whitespace values intentionally return undefined without logging (blank == not configured, avoids log noise). Clarified the docstring to document the distinction: blank is silent; present-but-invalid is logged. 2. The probe-timeout selection (npx 30s vs non-npx 20s vs env override) had no test coverage. Exported getHeadlessProbeTimeoutMs (same testability pattern as extractModelChoices) and added unit tests for all three branches plus invalid-override fallback. The new env-touching suites snapshot the original value, clear it before each test, and restore it after, so they are robust against an ambient AGENTRC_COPILOT_PROBE_TIMEOUT_MS being exported and never leak into the surrounding process. Verified passing with both a clean env and the var preset. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/core/src/services/copilot.ts | 14 ++++-- src/services/__tests__/copilot.test.ts | 66 ++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/packages/core/src/services/copilot.ts b/packages/core/src/services/copilot.ts index 89963f8e..b2c90dc6 100644 --- a/packages/core/src/services/copilot.ts +++ b/packages/core/src/services/copilot.ts @@ -18,10 +18,14 @@ export function logCopilotDebug(message: string): void { * Parse a positive-integer environment variable. Accepts only a base-10 * integer literal (after trimming surrounding whitespace) so surprising * inputs such as `"1.5"`, `"1e3"`, or `"0x10"` are rejected rather than - * silently coerced. Returns the parsed value when it is an integer > 0; + * silently coerced. Returns the parsed value when it is a safe integer > 0; * otherwise returns `undefined` so callers can apply their own default. - * Emits a debug log when the variable is set but rejected, so users running - * with `AGENTRC_DEBUG_COPILOT=1` can see when their override was ignored. + * + * An unset, empty, or whitespace-only value is treated as "not configured" + * and returns `undefined` silently. A value that is present and non-blank + * but not a valid positive integer is rejected and logged via + * `logCopilotDebug`, so users running with `AGENTRC_DEBUG_COPILOT=1` can see + * when their override was ignored. */ export function parsePositiveIntEnv(name: string): number | undefined { const raw = process.env[name]; @@ -55,8 +59,10 @@ const CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes * download `@github/copilot` on first run, so it gets a longer budget; the * `.bat`-shim / Node cold start on Windows still routinely exceeds 5 s, so * the non-npx path uses 20 s. Overridable via `AGENTRC_COPILOT_PROBE_TIMEOUT_MS`. + * + * Exported for unit testing of the selection logic. */ -function getHeadlessProbeTimeoutMs(config: CopilotCliConfig): number { +export function getHeadlessProbeTimeoutMs(config: CopilotCliConfig): number { const override = parsePositiveIntEnv("AGENTRC_COPILOT_PROBE_TIMEOUT_MS"); if (override !== undefined) return override; const isNpx = config.cliArgs?.includes("@github/copilot") ?? false; diff --git a/src/services/__tests__/copilot.test.ts b/src/services/__tests__/copilot.test.ts index 3b57749b..9042c64d 100644 --- a/src/services/__tests__/copilot.test.ts +++ b/src/services/__tests__/copilot.test.ts @@ -1,5 +1,9 @@ -import { extractModelChoices, parsePositiveIntEnv } from "@agentrc/core/services/copilot"; -import { afterEach, describe, expect, it } from "vitest"; +import { + extractModelChoices, + getHeadlessProbeTimeoutMs, + parsePositiveIntEnv +} from "@agentrc/core/services/copilot"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; describe("extractModelChoices", () => { it("extracts model names from a single-line --help output", () => { @@ -38,13 +42,21 @@ describe("extractModelChoices", () => { describe("parsePositiveIntEnv", () => { const NAME = "AGENTRC_TEST_TIMEOUT_MS"; + const original = process.env[NAME]; - afterEach(() => { + beforeEach(() => { delete process.env[NAME]; }); + afterEach(() => { + if (original === undefined) { + delete process.env[NAME]; + } else { + process.env[NAME] = original; + } + }); + it("returns undefined when the variable is unset", () => { - delete process.env[NAME]; expect(parsePositiveIntEnv(NAME)).toBeUndefined(); }); @@ -107,3 +119,49 @@ describe("parsePositiveIntEnv", () => { expect(parsePositiveIntEnv(NAME)).toBeUndefined(); }); }); + +describe("getHeadlessProbeTimeoutMs", () => { + const ENV = "AGENTRC_COPILOT_PROBE_TIMEOUT_MS"; + const original = process.env[ENV]; + + beforeEach(() => { + delete process.env[ENV]; + }); + + afterEach(() => { + if (original === undefined) { + delete process.env[ENV]; + } else { + process.env[ENV] = original; + } + }); + + it("uses 30s for the npx candidate", () => { + expect( + getHeadlessProbeTimeoutMs({ cliPath: "npx", cliArgs: ["--yes", "@github/copilot"] }) + ).toBe(30000); + }); + + it("uses 20s for a bare-path (non-npx) candidate", () => { + expect(getHeadlessProbeTimeoutMs({ cliPath: "/usr/bin/copilot" })).toBe(20000); + }); + + it("uses 20s when cliArgs do not include @github/copilot", () => { + expect( + getHeadlessProbeTimeoutMs({ cliPath: process.execPath, cliArgs: ["/path/to/npm-loader.js"] }) + ).toBe(20000); + }); + + it("honors a valid override for both npx and non-npx candidates", () => { + process.env[ENV] = "12345"; + expect(getHeadlessProbeTimeoutMs({ cliPath: "/usr/bin/copilot" })).toBe(12345); + expect( + getHeadlessProbeTimeoutMs({ cliPath: "npx", cliArgs: ["--yes", "@github/copilot"] }) + ).toBe(12345); + }); + + it("ignores an invalid override and falls back to the default", () => { + process.env[ENV] = "not-a-number"; + expect(getHeadlessProbeTimeoutMs({ cliPath: "/usr/bin/copilot" })).toBe(20000); + }); +}); From 0aeb6f43686d641972908dbbb3a45feb8344c1e4 Mon Sep 17 00:00:00 2001 From: Martin Krastev Date: Wed, 10 Jun 2026 23:47:37 +0200 Subject: [PATCH 7/8] fix(core): robust npx detection, sanitized env logging, isolated env tests Addresses three review follow-ups: 1. getHeadlessProbeTimeoutMs detected the npx candidate with cliArgs.includes("@github/copilot"), which misses versioned specs like "@github/copilot@latest" and would wrongly apply the shorter non-npx 20s timeout (risking false probe failures on first-run downloads). Now matches "@github/copilot" exactly or any "@github/copilot@" via some()/startsWith, while still rejecting substring lookalikes such as "@github/copilot-foo". 2. parsePositiveIntEnv logged the raw rejected env value, allowing log injection via embedded newlines/control chars. Now logs JSON.stringify(raw) so the value stays single-line and unambiguous. 3. The env-var test suites now use vi.stubEnv/vi.unstubAllEnvs for per-test isolation (avoids cross-file interference). beforeEach force-unsets via vi.stubEnv(name, undefined) so the default-timeout assertions remain correct even when the var is exported in the ambient environment; afterEach restores via vi.unstubAllEnvs. Added tests for the versioned npx spec and the substring-lookalike case. Verified 25/25 passing with both a clean env and the probe-timeout var preset; lint, typecheck, and build are green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/core/src/services/copilot.ts | 7 ++- src/services/__tests__/copilot.test.ts | 65 ++++++++++++++------------ 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/packages/core/src/services/copilot.ts b/packages/core/src/services/copilot.ts index b2c90dc6..1bc1932a 100644 --- a/packages/core/src/services/copilot.ts +++ b/packages/core/src/services/copilot.ts @@ -36,7 +36,7 @@ export function parsePositiveIntEnv(name: string): number | undefined { const parsed = Number(trimmed); if (Number.isSafeInteger(parsed) && parsed > 0) return parsed; } - logCopilotDebug(`ignoring invalid ${name}=${raw}`); + logCopilotDebug(`ignoring invalid ${name}=${JSON.stringify(raw)}`); return undefined; } @@ -65,7 +65,10 @@ const CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes export function getHeadlessProbeTimeoutMs(config: CopilotCliConfig): number { const override = parsePositiveIntEnv("AGENTRC_COPILOT_PROBE_TIMEOUT_MS"); if (override !== undefined) return override; - const isNpx = config.cliArgs?.includes("@github/copilot") ?? false; + const isNpx = + config.cliArgs?.some( + (arg) => arg === "@github/copilot" || arg.startsWith("@github/copilot@") + ) ?? false; return isNpx ? 30000 : 20000; } diff --git a/src/services/__tests__/copilot.test.ts b/src/services/__tests__/copilot.test.ts index 9042c64d..4749ecc7 100644 --- a/src/services/__tests__/copilot.test.ts +++ b/src/services/__tests__/copilot.test.ts @@ -3,7 +3,7 @@ import { getHeadlessProbeTimeoutMs, parsePositiveIntEnv } from "@agentrc/core/services/copilot"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; describe("extractModelChoices", () => { it("extracts model names from a single-line --help output", () => { @@ -42,18 +42,13 @@ describe("extractModelChoices", () => { describe("parsePositiveIntEnv", () => { const NAME = "AGENTRC_TEST_TIMEOUT_MS"; - const original = process.env[NAME]; beforeEach(() => { - delete process.env[NAME]; + vi.stubEnv(NAME, undefined); }); afterEach(() => { - if (original === undefined) { - delete process.env[NAME]; - } else { - process.env[NAME] = original; - } + vi.unstubAllEnvs(); }); it("returns undefined when the variable is unset", () => { @@ -61,79 +56,74 @@ describe("parsePositiveIntEnv", () => { }); it("parses a plain positive integer", () => { - process.env[NAME] = "5000"; + vi.stubEnv(NAME, "5000"); expect(parsePositiveIntEnv(NAME)).toBe(5000); }); it("trims surrounding whitespace", () => { - process.env[NAME] = " 42 "; + vi.stubEnv(NAME, " 42 "); expect(parsePositiveIntEnv(NAME)).toBe(42); }); it("accepts leading zeros as base-10 (no octal)", () => { - process.env[NAME] = "007"; + vi.stubEnv(NAME, "007"); expect(parsePositiveIntEnv(NAME)).toBe(7); }); it("rejects non-integer decimals like 1.5", () => { - process.env[NAME] = "1.5"; + vi.stubEnv(NAME, "1.5"); expect(parsePositiveIntEnv(NAME)).toBeUndefined(); }); it("rejects scientific notation like 1e3", () => { - process.env[NAME] = "1e3"; + vi.stubEnv(NAME, "1e3"); expect(parsePositiveIntEnv(NAME)).toBeUndefined(); }); it("rejects hex literals like 0x10", () => { - process.env[NAME] = "0x10"; + vi.stubEnv(NAME, "0x10"); expect(parsePositiveIntEnv(NAME)).toBeUndefined(); }); it("rejects signed values", () => { - process.env[NAME] = "+5"; + vi.stubEnv(NAME, "+5"); expect(parsePositiveIntEnv(NAME)).toBeUndefined(); }); it("rejects zero and negatives", () => { - process.env[NAME] = "0"; + vi.stubEnv(NAME, "0"); expect(parsePositiveIntEnv(NAME)).toBeUndefined(); - process.env[NAME] = "-5"; + vi.stubEnv(NAME, "-5"); expect(parsePositiveIntEnv(NAME)).toBeUndefined(); }); it("rejects empty and whitespace-only values", () => { - process.env[NAME] = ""; + vi.stubEnv(NAME, ""); expect(parsePositiveIntEnv(NAME)).toBeUndefined(); - process.env[NAME] = " "; + vi.stubEnv(NAME, " "); expect(parsePositiveIntEnv(NAME)).toBeUndefined(); }); it("rejects non-numeric junk", () => { - process.env[NAME] = "5px"; + vi.stubEnv(NAME, "5px"); expect(parsePositiveIntEnv(NAME)).toBeUndefined(); }); it("rejects values beyond the safe-integer range", () => { - process.env[NAME] = "99999999999999999999"; + vi.stubEnv(NAME, "99999999999999999999"); expect(parsePositiveIntEnv(NAME)).toBeUndefined(); }); }); describe("getHeadlessProbeTimeoutMs", () => { const ENV = "AGENTRC_COPILOT_PROBE_TIMEOUT_MS"; - const original = process.env[ENV]; beforeEach(() => { - delete process.env[ENV]; + vi.stubEnv(ENV, undefined); }); afterEach(() => { - if (original === undefined) { - delete process.env[ENV]; - } else { - process.env[ENV] = original; - } + vi.unstubAllEnvs(); }); it("uses 30s for the npx candidate", () => { @@ -142,6 +132,15 @@ describe("getHeadlessProbeTimeoutMs", () => { ).toBe(30000); }); + it("uses 30s for a versioned npx spec (e.g. @github/copilot@latest)", () => { + expect( + getHeadlessProbeTimeoutMs({ cliPath: "npx", cliArgs: ["--yes", "@github/copilot@latest"] }) + ).toBe(30000); + expect( + getHeadlessProbeTimeoutMs({ cliPath: "npx", cliArgs: ["-y", "@github/copilot@0.3.0"] }) + ).toBe(30000); + }); + it("uses 20s for a bare-path (non-npx) candidate", () => { expect(getHeadlessProbeTimeoutMs({ cliPath: "/usr/bin/copilot" })).toBe(20000); }); @@ -152,8 +151,14 @@ describe("getHeadlessProbeTimeoutMs", () => { ).toBe(20000); }); + it("does not treat a substring match (e.g. @github/copilot-foo) as npx", () => { + expect( + getHeadlessProbeTimeoutMs({ cliPath: "npx", cliArgs: ["--yes", "@github/copilot-foo"] }) + ).toBe(20000); + }); + it("honors a valid override for both npx and non-npx candidates", () => { - process.env[ENV] = "12345"; + vi.stubEnv(ENV, "12345"); expect(getHeadlessProbeTimeoutMs({ cliPath: "/usr/bin/copilot" })).toBe(12345); expect( getHeadlessProbeTimeoutMs({ cliPath: "npx", cliArgs: ["--yes", "@github/copilot"] }) @@ -161,7 +166,7 @@ describe("getHeadlessProbeTimeoutMs", () => { }); it("ignores an invalid override and falls back to the default", () => { - process.env[ENV] = "not-a-number"; + vi.stubEnv(ENV, "not-a-number"); expect(getHeadlessProbeTimeoutMs({ cliPath: "/usr/bin/copilot" })).toBe(20000); }); }); From d92c976f7d964609dd4288e93e1b12db36352bb1 Mon Sep 17 00:00:00 2001 From: Martin Krastev Date: Thu, 11 Jun 2026 00:03:25 +0200 Subject: [PATCH 8/8] refactor(core): mark probe-timeout helper @internal; compute gen timeout once per run Addresses two review follow-ups: 1. getHeadlessProbeTimeoutMs was exported solely for unit testing, which the reviewer noted widens the @agentrc/core/services/copilot subpath surface. Tagged it /** @internal */ to document that it is not public API. It is already absent from the curated barrel (index.ts), and the core package is private + bundled (not published), so this is purely a documentation/intent signal; the existing direct unit tests are kept. 2. getInstructionGenerationTimeoutMs() was re-invoked (re-parsing the env var) at every session.sendAndWait call. The timeout is now computed once per instruction-generation run and reused: - generateCopilotInstructions / generateAreaInstructions: one local const timeoutMs per run. - generateNestedInstructions: computes timeoutMs once and threads it into generateNestedHub and each generateNestedDetail via a new timeoutMs option, so the hub and all detail files share one consistent value even if process.env changes mid-run. No behavior change (same value reaches sendAndWait); only the number of computations changes. Verified: lint, typecheck, build green; copilot + instructions suites pass (89/90 - the lone failure is the pre-existing Windows symlink EPERM, unrelated). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/core/src/services/copilot.ts | 3 ++- packages/core/src/services/instructions.ts | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/core/src/services/copilot.ts b/packages/core/src/services/copilot.ts index 1bc1932a..132c8cbd 100644 --- a/packages/core/src/services/copilot.ts +++ b/packages/core/src/services/copilot.ts @@ -60,7 +60,8 @@ const CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes * `.bat`-shim / Node cold start on Windows still routinely exceeds 5 s, so * the non-npx path uses 20 s. Overridable via `AGENTRC_COPILOT_PROBE_TIMEOUT_MS`. * - * Exported for unit testing of the selection logic. + * @internal Exported only for unit testing of the selection logic; not part of + * the public API and intentionally excluded from the `@agentrc/core` barrel. */ export function getHeadlessProbeTimeoutMs(config: CopilotCliConfig): number { const override = parsePositiveIntEnv("AGENTRC_COPILOT_PROBE_TIMEOUT_MS"); diff --git a/packages/core/src/services/instructions.ts b/packages/core/src/services/instructions.ts index f022a699..bdc4cdd6 100644 --- a/packages/core/src/services/instructions.ts +++ b/packages/core/src/services/instructions.ts @@ -351,6 +351,8 @@ export async function generateCopilotInstructions( skillDirectories: [rootSkillDir] }); + const timeoutMs = getInstructionGenerationTimeoutMs(); + await trySetAutopilot(session); let content = ""; @@ -381,7 +383,7 @@ ${existingSection}`; progress("Analyzing codebase..."); let sendError: unknown; try { - await session.sendAndWait({ prompt }, getInstructionGenerationTimeoutMs()); + await session.sendAndWait({ prompt }, timeoutMs); } catch (err) { sendError = err; } finally { @@ -452,6 +454,8 @@ export async function generateAreaInstructions( skillDirectories: [areaSkillDir] }); + const timeoutMs = getInstructionGenerationTimeoutMs(); + await trySetAutopilot(session); let content = ""; @@ -484,7 +488,7 @@ ${existingSection ? `\nDo NOT duplicate content already covered by existing inst progress(`Analyzing area "${area.name}"...`); let sendError: unknown; try { - await session.sendAndWait({ prompt }, getInstructionGenerationTimeoutMs()); + await session.sendAndWait({ prompt }, timeoutMs); } catch (err) { sendError = err; } finally { @@ -712,6 +716,7 @@ async function generateNestedHub( area?: Area; childAreas?: Area[]; model?: string; + timeoutMs: number; onProgress?: (message: string) => void; } ): Promise { @@ -785,7 +790,7 @@ ${existingSection ? `\nDo NOT duplicate content from existing instruction files\ let sendError: unknown; try { - await session.sendAndWait({ prompt }, getInstructionGenerationTimeoutMs()); + await session.sendAndWait({ prompt }, options.timeoutMs); } catch (err) { sendError = err; } finally { @@ -812,6 +817,7 @@ async function generateNestedDetail( topic: NestedTopic; area?: Area; model?: string; + timeoutMs: number; onProgress?: (message: string) => void; } ): Promise { @@ -869,7 +875,7 @@ Description: ${options.topic.description}`; let sendError: unknown; try { - await session.sendAndWait({ prompt }, getInstructionGenerationTimeoutMs()); + await session.sendAndWait({ prompt }, options.timeoutMs); } catch (err) { sendError = err; } finally { @@ -908,6 +914,8 @@ export async function generateNestedInstructions( progress("Starting Copilot SDK..."); const client = await createCopilotClient(cliConfig); + const timeoutMs = getInstructionGenerationTimeoutMs(); + try { // Step 1: Generate hub const { hubContent, topics } = await generateNestedHub(client, { @@ -916,6 +924,7 @@ export async function generateNestedInstructions( area: options.area, childAreas: options.childAreas, model: options.model, + timeoutMs, onProgress: options.onProgress }); @@ -944,6 +953,7 @@ export async function generateNestedInstructions( topic, area: options.area, model: options.model, + timeoutMs, onProgress: options.onProgress }); if (detailContent) {