From 034e5c1995f22ba1343357b361ec36a590c15ddd Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 20:41:37 +0900 Subject: [PATCH 1/7] feat(command-code): opt in to bounded project context envelope Carries #4228 by @yansigit with provider-scoped validation, total skill byte limits, and exact outbound context coverage. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 13 + scripts/test-layout/layout.json | 1 + src/adapters/command-code-project-context.ts | 392 +++++++++ src/adapters/command-code.ts | 6 +- src/config/provider-validation.ts | 9 + src/config/schema/leaf-validators.ts | 7 +- src/server/auth-cors.ts | 4 + src/types/provider.ts | 2 + structure/config.md | 4 +- structure/providers-and-adapters.md | 12 + tests/fixtures/test-layout-expected.json | 1 + .../command-code-project-context.test.ts | 741 ++++++++++++++++++ tests/providers/command-code-provider.test.ts | 35 +- .../provider-config-validation.test.ts | 12 + 14 files changed, 1233 insertions(+), 6 deletions(-) create mode 100644 src/adapters/command-code-project-context.ts create mode 100644 tests/providers/command-code-project-context.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index d41f9bfe95c..1ce583050fc 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -697,6 +697,19 @@ complete declared-tool call with no native counterpart is restored only after a interrupted or filtered turns leave the markup as text. Create Provider-API keys at [Command Code Studio](https://commandcode.ai/studio/). +For a provider using the native `command-code` adapter, `projectContext: "on"` opts into +sending local project files in the `/alpha/generate` `memory`, `taste`, and `skills` fields. +Unset or `"off"` leaves those fields empty and does not read project-context files. +The roots are resolved from the **opencodex proxy process working directory**, not from a +caller's remote workspace. The loader reads `AGENTS.md`, `.commandcode/taste/taste.md`, +and `SKILL.md` files in immediate child directories of `.commandcode/skills`, +`.agents/skills`, and `.pi/skills` under that working directory. Enabling the option sends +the collected contents upstream to the configured Command Code endpoint. It is rejected +for other adapters. Reads remain inside the resolved working directory, count every +visited skill-directory entry, select at most 16 skills, and limit file bytes, total +skill bytes, and skill-loading time; unreadable or missing files produce empty fields. +Results are cached for 30 seconds. + **OrcaRouter authentication and discovery.** Choose either `ocx login orcarouter-oauth` for one-click browser authorization or `ocx login orcarouter` to paste an existing API key. The PKCE flow starts a loopback listener first, sends a fresh S256 challenge and state to diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 15cfa718c64..07142b90e3a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -604,6 +604,7 @@ "combos.test.ts": "codex-integration", "command-code-error-finish.test.ts": "providers", "command-code-efforts.test.ts": "providers", + "command-code-project-context.test.ts": "providers", "command-code-provider.test.ts": "providers", "command-code-quota.test.ts": "providers", "command-code-tool-text.test.ts": "providers", diff --git a/src/adapters/command-code-project-context.ts b/src/adapters/command-code-project-context.ts new file mode 100644 index 00000000000..3e88ede5da0 --- /dev/null +++ b/src/adapters/command-code-project-context.ts @@ -0,0 +1,392 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { open, opendir } from "node:fs/promises"; +import { join, sep } from "node:path"; + +export type CommandCodeProjectContext = { + memory: string; + taste: string | null; + skills: string | null; +}; + +export const EMPTY_COMMAND_CODE_PROJECT_CONTEXT: CommandCodeProjectContext = { + memory: "", + taste: null, + skills: null, +}; + +const MEMORY_CAP_BYTES = 32_768; +const TASTE_CAP_BYTES = 8_192; +const SKILLS_XML_CAP_BYTES = 32_768; +const SKILL_FILE_CAP_BYTES = 8_192; +const SKILLS_READ_CAP_BYTES = 32_768; +const MAX_SKILLS = 16; +// Every visited entry, including hidden files and invalid directories, consumes this +// per-root scan budget. Selection remains separately capped by MAX_SKILLS. +const MAX_SKILL_DIRS_TO_SCAN = 256; +const COMMAND_CODE_FILE_OP_TIMEOUT_MS = 2_000; +let fileOpTimeoutForTests: number | undefined; + +export function setCommandCodeFileOpTimeoutForTests(timeoutMs: number | undefined): void { + fileOpTimeoutForTests = timeoutMs; +} +const PROJECT_CONTEXT_TTL_MS = 30_000; +const MAX_PROJECT_CONTEXT_CACHE_ENTRIES = 128; + +const TRUNCATION_MARKER = "\n"; + +const SKILL_ROOTS = [ + ".commandcode/skills", + ".agents/skills", + ".pi/skills", +] as const; + +export const projectContextCache = new Map(); + +/** + * Evict expired entries first, then the oldest live entry if at capacity. + * Called before inserting a new key so the cache never exceeds the cap. + */ +function pruneExpiredProjectContextCache(now: number): void { + for (const [key, entry] of projectContextCache) { + if (now - entry.collectedAt >= PROJECT_CONTEXT_TTL_MS) { + projectContextCache.delete(key); + } + } +} + +export function pruneProjectContextCache(now: number): void { + pruneExpiredProjectContextCache(now); + if (projectContextCache.size >= MAX_PROJECT_CONTEXT_CACHE_ENTRIES) { + let oldestKey: string | null = null; + let oldestAt = Infinity; + for (const [key, entry] of projectContextCache) { + if (entry.collectedAt < oldestAt) { + oldestAt = entry.collectedAt; + oldestKey = key; + } + } + if (oldestKey !== null) projectContextCache.delete(oldestKey); + } +} + +/** Fail-soft canonical path; returns null when the path does not exist or cannot be resolved. */ +function canonicalPath(candidate: string): string | null { + try { + lstatSync(candidate); + return realpathSync.native(candidate); + } catch { + return null; + } +} + +function normalizePathIdentity(path: string): string { + return process.platform === "win32" ? path.toLowerCase() : path; +} + +function confinedCanonicalPath(filePath: string, cwdCanonical: string): string | null { + const fileCanonical = canonicalPath(filePath); + if (!fileCanonical) return null; + const fileId = normalizePathIdentity(fileCanonical); + const cwdId = normalizePathIdentity(cwdCanonical); + if (fileId === cwdId || fileId.startsWith(cwdId + sep)) return fileCanonical; + return null; +} + +async function withTimeout(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("timeout")), ms); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +function truncateUtf8(text: string, capBytes: number): string { + const buf = Buffer.from(text, "utf8"); + if (buf.length <= capBytes) return text; + const markerBuf = Buffer.from(TRUNCATION_MARKER, "utf8"); + const prefixCap = capBytes - markerBuf.length; + if (prefixCap <= 0) return TRUNCATION_MARKER.slice(0, capBytes); + let end = prefixCap; + while (end > 0 && (buf[end]! & 0xc0) === 0x80) end--; + return buf.subarray(0, end).toString("utf8") + TRUNCATION_MARKER; +} + +async function readUtf8File(path: string, capBytes: number, timeoutMs = COMMAND_CODE_FILE_OP_TIMEOUT_MS): Promise { + type FileHandle = Awaited>; + let fileHandle: FileHandle | undefined; + const closedHandles = new WeakSet(); + const opened = open(path, "r"); + const closeBestEffort = (handle: FileHandle): Promise => { + if (closedHandles.has(handle)) return Promise.resolve(); + closedHandles.add(handle); + return Promise.resolve() + .then(() => handle.close()) + .catch(() => { + /* closing a timed-out read is best-effort */ + }); + }; + + const read = (async () => { + const handle = await opened; + fileHandle = handle; + try { + const data = Buffer.alloc(capBytes + 1); + const { bytesRead } = await handle.read(data, 0, data.length, 0); + return data.subarray(0, bytesRead).toString("utf8"); + } finally { + await closeBestEffort(handle); + if (fileHandle === handle) fileHandle = undefined; + } + })(); + + try { + return await withTimeout(read, timeoutMs); + } catch { + if (fileHandle) void closeBestEffort(fileHandle); + void opened.then(handle => closeBestEffort(handle), () => undefined); + return null; + } +} + +function xmlEscape(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function parseSkillFrontmatter(text: string): { name: string | null; body: string } { + const opening = text.startsWith("---\r\n") ? "---\r\n" : text.startsWith("---\n") ? "---\n" : null; + if (!opening) return { name: null, body: text }; + const closing = text.slice(opening.length).match(/^---(?:\r?\n|$)/m); + if (!closing || closing.index === undefined) return { name: null, body: text }; + const end = opening.length + closing.index; + const frontmatter = text.slice(opening.length, end).replace(/\r?\n$/, ""); + let name: string | null = null; + for (const line of frontmatter.split(/\r?\n/)) { + const match = line.match(/^name:\s*(.+)$/); + if (match) { + const parsed = match[1]!.trim(); + if (parsed.length > 0) name = parsed; + break; + } + } + const bodyStart = end + closing[0].length; + const body = text.slice(bodyStart); + return { name, body }; +} + +async function readMemory(cwd: string, cwdCanonical: string, timeoutMs: number): Promise { + const path = join(cwd, "AGENTS.md"); + const canonical = confinedCanonicalPath(path, cwdCanonical); + if (!canonical) return ""; + const text = await readUtf8File(canonical, MEMORY_CAP_BYTES, timeoutMs); + if (text === null) return ""; + return truncateUtf8(text, MEMORY_CAP_BYTES); +} + +async function readTaste(cwd: string, cwdCanonical: string, timeoutMs: number): Promise { + const path = join(cwd, ".commandcode", "taste", "taste.md"); + const canonical = confinedCanonicalPath(path, cwdCanonical); + if (!canonical) return null; + const text = await readUtf8File(canonical, TASTE_CAP_BYTES, timeoutMs); + if (text === null) return null; + return truncateUtf8(text, TASTE_CAP_BYTES); +} + +interface SkillEntry { + name: string; + body: string; + bytesRead: number; +} + +async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget: number, timeoutMs: number): Promise { + if (scanBudget <= 0) return []; + const skillRootCanonical = confinedCanonicalPath(skillRoot, cwdCanonical); + if (!skillRootCanonical) return []; + let dir: Awaited> | undefined; + try { + return await withTimeout( + (async () => { + const openedDir = await opendir(skillRootCanonical); + dir = openedDir; + const names: string[] = []; + let visitedEntries = 0; + try { + for await (const entry of openedDir) { + visitedEntries++; + const atLimit = visitedEntries >= scanBudget; + if (!entry.name.startsWith(".") && entry.isDirectory()) { + const skillMd = join(skillRoot, entry.name, "SKILL.md"); + const skillMdCanonical = confinedCanonicalPath(skillMd, cwdCanonical); + if (skillMdCanonical) { + names.push(entry.name); + } + } + if (atLimit) break; + } + } catch { + try { + await openedDir.close(); + } catch { + /* closing a failed iterator is best-effort */ + } + /* directory iteration is best-effort */ + } + names.sort(); + return names; + })(), + timeoutMs, + ); + } catch { + if (dir) { + try { + await dir.close(); + } catch { + /* closing a timed-out iterator is best-effort */ + } + } + return []; + } +} + +async function readSkill(skillRoot: string, dirName: string, cwdCanonical: string, capBytes: number, timeoutMs: number): Promise { + const path = join(skillRoot, dirName, "SKILL.md"); + const canonical = confinedCanonicalPath(path, cwdCanonical); + if (!canonical) return null; + const text = await readUtf8File(canonical, capBytes, timeoutMs); + if (text === null) return null; + const { name, body } = parseSkillFrontmatter(truncateUtf8(text, capBytes)); + return { name: name ?? dirName, body, bytesRead: Buffer.byteLength(text, "utf8") }; +} + +function buildSkillsXml(skills: SkillEntry[]): string | null { + if (skills.length === 0) return null; + const lines = [""]; + let usedBytes = Buffer.byteLength(lines[0]! + "\n", "utf8"); + + for (const skill of skills) { + const open = ` `; + const close = ""; + let body = skill.body; + let line = `${open}${xmlEscape(body)}${close}`; + let lineBytes = Buffer.byteLength(line + "\n", "utf8"); + + if (usedBytes + lineBytes > SKILLS_XML_CAP_BYTES) { + const overhead = Buffer.byteLength(open + close + "\n", "utf8"); + const bodyBudget = SKILLS_XML_CAP_BYTES - usedBytes - overhead; + if (bodyBudget <= 0) break; + const fittedBody = truncateUtf8BodyForXml(body, bodyBudget); + if (fittedBody === null) break; + const wasTruncated = fittedBody !== body; + body = fittedBody; + line = `${open}${xmlEscape(body)}${close}`; + lineBytes = Buffer.byteLength(line + "\n", "utf8"); + if (usedBytes + lineBytes > SKILLS_XML_CAP_BYTES) break; + lines.push(line); + usedBytes += lineBytes; + if (wasTruncated) break; + continue; + } + + lines.push(line); + usedBytes += lineBytes; + } + + lines.push(""); + if (lines.length === 2) return null; + return lines.join("\n"); +} + +function truncateUtf8BodyForXml(body: string, capBytes: number): string | null { + const rawBuf = Buffer.from(body, "utf8"); + if (Buffer.byteLength(xmlEscape(body), "utf8") <= capBytes) return body; + if (Buffer.byteLength(xmlEscape(TRUNCATION_MARKER), "utf8") > capBytes) return null; + + // XML entities can expand a raw body by several bytes per character. Binary-search the + // largest UTF-8 prefix whose escaped form, including the marker, fits the actual wire cap. + let low = 0; + let high = rawBuf.length; + let best = TRUNCATION_MARKER; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + let rawEnd = mid; + while (rawEnd > 0 && (rawBuf[rawEnd]! & 0xc0) === 0x80) rawEnd--; + const candidate = rawBuf.subarray(0, rawEnd).toString("utf8") + TRUNCATION_MARKER; + if (Buffer.byteLength(xmlEscape(candidate), "utf8") <= capBytes) { + best = candidate; + low = mid + 1; + } else { + high = mid - 1; + } + } + return best; +} + +async function readSkills(cwd: string, cwdCanonical: string, timeoutMs: number): Promise { + const seen = new Set(); + const collected: SkillEntry[] = []; + const deadline = Date.now() + timeoutMs; + let remainingBytes = SKILLS_READ_CAP_BYTES; + + for (const rootRel of SKILL_ROOTS) { + const remainingScanMs = deadline - Date.now(); + if (remainingScanMs <= 0) break; + const skillRoot = join(cwd, ...rootRel.split("/")); + const dirs = await listSkillDirs(skillRoot, cwdCanonical, MAX_SKILL_DIRS_TO_SCAN, remainingScanMs); + for (const dirName of dirs) { + if (collected.length >= MAX_SKILLS || remainingBytes <= 1) break; + const remainingReadMs = deadline - Date.now(); + if (remainingReadMs <= 0) break; + const skill = await readSkill(skillRoot, dirName, cwdCanonical, Math.min(SKILL_FILE_CAP_BYTES, remainingBytes - 1), remainingReadMs); + if (!skill) continue; + remainingBytes -= skill.bytesRead; + if (seen.has(skill.name)) continue; + seen.add(skill.name); + collected.push(skill); + } + if (collected.length >= MAX_SKILLS || remainingBytes <= 1) break; + } + + return buildSkillsXml(collected); +} + +async function collectProjectContext(cwd: string, timeoutMs: number): Promise { + const cwdCanonical = canonicalPath(cwd); + if (!cwdCanonical) return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT }; + + const [memory, taste, skills] = await Promise.all([ + readMemory(cwd, cwdCanonical, timeoutMs), + readTaste(cwd, cwdCanonical, timeoutMs), + readSkills(cwd, cwdCanonical, timeoutMs), + ]); + + return { memory, taste, skills }; +} + +export async function loadCommandCodeProjectContext(cwd: string | undefined): Promise { + if (!cwd) return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT }; + + const hadCachedEntry = projectContextCache.has(cwd); + const cached = projectContextCache.get(cwd); + if (cached && Date.now() - cached.collectedAt < PROJECT_CONTEXT_TTL_MS) { + return cached.value; + } + + const value = await collectProjectContext(cwd, fileOpTimeoutForTests ?? COMMAND_CODE_FILE_OP_TIMEOUT_MS); + const now = Date.now(); + if (hadCachedEntry) { + pruneExpiredProjectContextCache(now); + } else { + pruneProjectContextCache(now); + } + projectContextCache.set(cwd, { collectedAt: now, value }); + return value; +} diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 77d21501eff..87e233f6d43 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -16,6 +16,7 @@ import { parseDataUrl } from "./image"; import { createAdapterPhysicalSend } from "./physical-send"; import { SendBudgetExhaustedError } from "../lib/upstream-retry"; import { CommandCodeToolTextFilter, type CommandCodeDeclaredTools } from "./command-code-tool-text"; +import { EMPTY_COMMAND_CODE_PROJECT_CONTEXT, loadCommandCodeProjectContext } from "./command-code-project-context"; function declaredTools(tools: OcxTool[]): CommandCodeDeclaredTools { return new Map(tools.map(tool => [ @@ -610,8 +611,11 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA ...(choiceInstruction ? [choiceInstruction] : []), ].join("\n\n"), parsed.modelId); const reasoningEffort = supportedCommandCodeEffort(provider, parsed.modelId, parsed.options.reasoning); + const projectContext = provider.projectContext === "on" + ? await loadCommandCodeProjectContext(cwd) + : EMPTY_COMMAND_CODE_PROJECT_CONTEXT; const body = { - config: await commandCodeConfig(cwd), memory: "", taste: null, skills: null, + config: await commandCodeConfig(cwd), ...projectContext, permissionMode: "standard", mode: "agent", params: { model: canonicalCommandCodeModelId(parsed.modelId), diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index 5f703ff2465..d8ff2288509 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -142,6 +142,15 @@ export function apiKeyTransportConfigError( return null; } +/** Keep local project-file disclosure specific to the Command Code native adapter. */ +export function projectContextConfigError(provider: Pick): string | null { + if (provider.projectContext === undefined) return null; + if (provider.projectContext !== "off" && provider.projectContext !== "on") { + return 'projectContext must be "off" or "on"'; + } + return provider.adapter === "command-code" ? null : "projectContext is supported only by the command-code adapter"; +} + /** Shared strict boundary for the per-provider upstream HTTP-version pin. */ export function upstreamHttpVersionConfigError(value: unknown): string | null { if (value === undefined || value === null) return null; diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index c914448f903..16c8e0a23c7 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -382,6 +382,7 @@ export const providerConfigSchema = z.object({ // accepted, persisted, and then silently resolved to the `code_mode_only` default — the // operator asked for shell mode, got code mode, and was told nothing (#2106). codexToolMode: z.enum(["code_mode_only", "shell"]).optional(), + projectContext: z.enum(["off", "on"]).optional(), responsesItemIdRepair: z.object({ message: z.array(z.string().min(1)).optional(), reasoning: z.array(z.string().min(1)).optional(), @@ -397,7 +398,11 @@ export const providerConfigSchema = z.object({ xaiResponsesXSearch: z.boolean().optional(), xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined), zaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined), -}).passthrough(); +}).passthrough().superRefine((provider, ctx) => { + if (provider.projectContext !== undefined && provider.adapter !== "command-code") { + ctx.addIssue({ code: "custom", path: ["projectContext"], message: "projectContext is supported only by the command-code adapter" }); + } +}); export { providerRelativeSendPathConfigError } from "../provider-relative-send-path"; diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 048abd5f7a7..c05d14038b9 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -16,6 +16,7 @@ import { } from "../config"; import { apiKeyTransportConfigError, + projectContextConfigError, autoReviewModelOverridesConfigError, autoReviewModelTargetConfigError, booleanRecordConfigError, @@ -830,6 +831,8 @@ export function providerManagementConfigError( } const apiKeyTransportError = apiKeyTransportConfigError(typed); if (apiKeyTransportError) return `provider ${name} ${apiKeyTransportError}`; + const projectContextError = projectContextConfigError(typed); + if (projectContextError) return `provider ${JSON.stringify(redactSecretString(name))} ${projectContextError}`; const maxInputError = positiveIntegerRecordConfigError(raw.modelMaxInputTokens, "modelMaxInputTokens"); if (maxInputError) return `provider ${name} ${maxInputError}`; const autoCompactError = modelAutoCompactTokenLimitsConfigError( @@ -969,6 +972,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { defaultAliases: "editor", adapter: "editor", codexToolMode: "editor", + projectContext: "editor", requestPacing: "editor", mcpMaxTools: "editor", mcpMaxSchemaBytes: "editor", diff --git a/src/types/provider.ts b/src/types/provider.ts index d66cfbbd999..08930881b23 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -340,6 +340,8 @@ export interface OcxProviderConfig { * version here instead of waiting for a code change. Absent uses the adapter's current default. */ commandCodeVersion?: string; + /** Include bounded repository context in Command Code envelopes. Default omitted/off sends empty memory/taste/skills. */ + projectContext?: "off" | "on"; /** * Responses upstream that stores nothing server-side (DeepSeek documents "the API * is stateless"). Stateful request parameters are dropped, `store` is pinned false, diff --git a/structure/config.md b/structure/config.md index 0580950e81d..4c7561593e6 100644 --- a/structure/config.md +++ b/structure/config.md @@ -434,9 +434,9 @@ capture/route time and explicit false or empty declarations retain their field-s `src/config/provider-validation.ts` owns the pure provider payload checks shared by persisted config, CLI writes, and management DTO validation. `src/config.ts` imports those checks for Zod refinement and re-exports them as a compatibility facade; it must not grow a second copy. Validation error text, -ordering, and cross-field rules are part of the write/load contract because management requests and -hand-edited `config.json` must accept and reject the same provider shapes. +ordering, and cross-field rules are part of the write/load contract: management requests and hand-edited `config.json` accept and reject the same provider shapes. +Provider `projectContext` accepts `"off"` or `"on"` only for native `command-code`; `src/config/schema/leaf-validators.ts` rejects other adapters on load, while `src/config/provider-validation.ts` and `src/server/auth-cors.ts` reject them on management writes. This editor-owned outbound-file field follows [provider and adapter selection](providers-and-adapters.md). The Google tool-schema policy uses a closed enum at this boundary. Unknown values fail config load, management admission, and command-line creation rather than silently degrading to compatible mode. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 075a6334d9b..a0bad3f9452 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -249,6 +249,18 @@ An explicit `modelReasoningEffortsAuthoritative` model row overrides the shipped rows without that flag do not. `tests/providers/command-code-efforts.test.ts` covers the measured rows and wire values; `tests/providers/command-code-provider.test.ts` covers operator overrides. +The native Command Code adapter in `src/adapters/command-code.ts` gates its +`/alpha/generate` project envelope on the provider's literal `projectContext: "on"`. +Absent or `"off"` keeps empty `memory`, `taste`, and `skills` and never invokes +`src/adapters/command-code-project-context.ts`. The loader reads only the proxy process +working directory's `AGENTS.md`, `.commandcode/taste/taste.md`, and immediate child +`SKILL.md` files under `.commandcode/skills`, `.agents/skills`, and `.pi/skills`. +Canonical path containment rejects escapes; every visited directory entry consumes the +scan budget before filtering; at most 16 skills are selected. Individual files, aggregate +skill reads, serialized XML, and the full skill-loading interval are bounded. The +contents are sent to the configured Command Code endpoint when enabled, and missing or +failed reads degrade to empty fields. A 30-second, 128-entry cache bounds repeated reads. + ## TypeSafe JEV decision provider `src/providers/registry/entries-extended.ts` owns the canonical `jev` key preset at diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2c3cba9e823..1d05e0afd5c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -614,6 +614,7 @@ "combos.test.ts": "codex-integration", "command-code-error-finish.test.ts": "providers", "command-code-efforts.test.ts": "providers", + "command-code-project-context.test.ts": "providers", "command-code-provider.test.ts": "providers", "command-code-quota.test.ts": "providers", "command-code-tool-text.test.ts": "providers", diff --git a/tests/providers/command-code-project-context.test.ts b/tests/providers/command-code-project-context.test.ts new file mode 100644 index 00000000000..d4cbc29facc --- /dev/null +++ b/tests/providers/command-code-project-context.test.ts @@ -0,0 +1,741 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const fsPromises = await import("node:fs/promises"); +const realOpendir = fsPromises.opendir; +const realOpen = fsPromises.open; +const opendirMock = mock(realOpendir); +const openMock = mock(realOpen); +mock.module("node:fs/promises", () => ({ + ...fsPromises, + opendir: opendirMock, + open: openMock, +})); + +const { + EMPTY_COMMAND_CODE_PROJECT_CONTEXT, + loadCommandCodeProjectContext, + projectContextCache, + pruneProjectContextCache, + setCommandCodeFileOpTimeoutForTests, +} = await import("../../src/adapters/command-code-project-context"); + +const MAX_PROJECT_CONTEXT_CACHE_ENTRIES = 128; +const PROJECT_CONTEXT_TTL_MS = 30_000; + +function makeTempDir(prefix: string): string { + return realpathSync.native(mkdtempSync(join(tmpdir(), prefix))); +} + +function writeSkill( + root: string, + skillRoot: string, + dirName: string, + body: string, + frontmatter?: string, +): void { + const skillDir = join(root, skillRoot, dirName); + mkdirSync(skillDir, { recursive: true }); + const content = frontmatter ? `---\n${frontmatter}\n---\n${body}` : body; + writeFileSync(join(skillDir, "SKILL.md"), content, "utf8"); +} + +beforeEach(() => { + projectContextCache.clear(); + setCommandCodeFileOpTimeoutForTests(undefined); +}); + +afterEach(() => { + projectContextCache.clear(); + setCommandCodeFileOpTimeoutForTests(undefined); +}); + +describe("loadCommandCodeProjectContext", () => { + test("undefined cwd returns empty context", async () => { + const result = await loadCommandCodeProjectContext(undefined); + expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); + }); + + test("missing files return empty memory, null taste, null skills", async () => { + const root = makeTempDir("ocx-cc-ctx-empty-"); + try { + const result = await loadCommandCodeProjectContext(root); + expect(result).toEqual({ memory: "", taste: null, skills: null }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("loads memory, taste, and skills XML from fixture tree", async () => { + const root = makeTempDir("ocx-cc-ctx-fixture-"); + try { + writeFileSync(join(root, "AGENTS.md"), "project agents content", "utf8"); + mkdirSync(join(root, ".commandcode", "taste"), { recursive: true }); + writeFileSync(join(root, ".commandcode", "taste", "taste.md"), "taste prefs", "utf8"); + writeSkill(root, ".commandcode/skills", "yaml-skill", "yaml body", "name: YAML Named"); + writeSkill(root, ".commandcode/skills", "dir-fallback", "dir body"); + + const result = await loadCommandCodeProjectContext(root); + expect(result.memory).toBe("project agents content"); + expect(result.taste).toBe("taste prefs"); + expect(result.skills).toBe( + '\n' + + ' dir body\n' + + ' yaml body\n' + + "", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("first-wins across skill roots by resolved name", async () => { + const root = makeTempDir("ocx-cc-ctx-firstwins-"); + try { + writeSkill(root, ".commandcode/skills", "shared-cc", "from commandcode", "name: shared"); + writeSkill(root, ".agents/skills", "shared-agents", "from agents", "name: shared"); + writeSkill(root, ".pi/skills", "shared-pi", "from pi", "name: shared"); + writeSkill(root, ".agents/skills", "agents-only", "agents only body"); + writeSkill(root, ".pi/skills", "pi-only", "pi only body"); + + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).toContain('from commandcode'); + expect(result.skills).not.toContain("from agents"); + expect(result.skills).not.toContain("from pi"); + expect(result.skills).toContain('agents only body'); + expect(result.skills).toContain('pi only body'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("skips hidden skill dirs and entries without SKILL.md", async () => { + const root = makeTempDir("ocx-cc-ctx-skip-"); + try { + mkdirSync(join(root, ".commandcode", "skills", ".hidden"), { recursive: true }); + writeFileSync(join(root, ".commandcode", "skills", ".hidden", "SKILL.md"), "hidden", "utf8"); + mkdirSync(join(root, ".commandcode", "skills", "no-skill-md"), { recursive: true }); + writeFileSync(join(root, ".commandcode", "skills", "no-skill-md", "README.md"), "readme", "utf8"); + writeSkill(root, ".commandcode/skills", "visible", "visible body"); + + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).toBe('\n visible body\n'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("times out a hanging skill directory iteration", async () => { + const root = makeTempDir("ocx-cc-ctx-iteration-timeout-"); + const skillRoot = join(root, ".commandcode", "skills"); + mkdirSync(skillRoot, { recursive: true }); + let closeCalls = 0; + const hangingDir = { + close: async () => { + closeCalls++; + }, + [Symbol.asyncIterator]() { + return { + next: () => new Promise(() => {}), + }; + }, + }; + + opendirMock.mockImplementation(async path => { + if (String(path) === skillRoot) { + return hangingDir as Awaited>; + } + return realOpendir(path); + }); + + try { + setCommandCodeFileOpTimeoutForTests(20); + const result = await loadCommandCodeProjectContext(root); + expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); + expect(closeCalls).toBe(1); + } finally { + opendirMock.mockImplementation(realOpendir); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("caps skills at 16 entries", async () => { + const root = makeTempDir("ocx-cc-ctx-maxskills-"); + try { + for (let i = 0; i < 17; i++) { + writeSkill(root, ".commandcode/skills", `skill-${String(i).padStart(2, "0")}`, `body ${i}`); + } + + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).not.toBeNull(); + const matches = result.skills!.match(/ { + const root = makeTempDir("ocx-cc-ctx-skillbudget-"); + const skillRoot = join(root, ".commandcode", "skills"); + try { + // 300 valid skill dirs — above MAX_SKILL_DIRS_TO_SCAN (256). Enumeration must + // stop at the scan cap, not walk all 300 (each valid entry needs path checks). + for (let i = 0; i < 300; i++) { + writeSkill(root, ".commandcode/skills", `skill-${String(i).padStart(3, "0")}`, `body ${i}`); + } + + let entriesIterated = 0; + opendirMock.mockImplementation(async path => { + const dir = await realOpendir(path); + if (String(path) !== skillRoot) return dir; + const realIter = dir[Symbol.asyncIterator](); + return { + close: () => dir.close(), + [Symbol.asyncIterator]() { + return { + next: async () => { + const res = await realIter.next(); + if (!res.done) entriesIterated++; + return res; + }, + }; + }, + } as Awaited>; + }); + + const result = await loadCommandCodeProjectContext(root); + const matches = result.skills!.match(/ { + const root = makeTempDir("ocx-cc-ctx-mixed-budget-"); + const skillRoot = join(root, ".commandcode", "skills"); + try { + mkdirSync(skillRoot, { recursive: true }); + for (let i = 0; i < 100; i++) { + writeFileSync(join(skillRoot, `file-${String(i).padStart(3, "0")}.txt`), "data", "utf8"); + mkdirSync(join(skillRoot, `no-skill-${String(i).padStart(3, "0")}`)); + mkdirSync(join(skillRoot, `.hidden-${String(i).padStart(3, "0")}`)); + } + + let entriesIterated = 0; + opendirMock.mockImplementation(async path => { + const dir = await realOpendir(path); + const originalIterator = dir[Symbol.asyncIterator].bind(dir); + dir[Symbol.asyncIterator] = function () { + const iter = originalIterator(); + return { + async next() { + const res = await iter.next(); + if (!res.done) entriesIterated++; + return res; + }, + async return() { + return typeof iter.return === "function" ? iter.return() : { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + }; + return dir; + }); + + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).toBeNull(); + expect(entriesIterated).toBeLessThan(300); + expect(entriesIterated).toBeLessThanOrEqual(256); + } finally { + opendirMock.mockImplementation(realOpendir); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("truncates oversize AGENTS.md with marker", async () => { + const root = makeTempDir("ocx-cc-ctx-trunc-mem-"); + try { + const payload = "x".repeat(32768 + 100); + writeFileSync(join(root, "AGENTS.md"), payload, "utf8"); + + const result = await loadCommandCodeProjectContext(root); + expect(result.memory.endsWith("\n")).toBe(true); + expect(Buffer.byteLength(result.memory, "utf8")).toBeLessThanOrEqual(32768); + expect(result.memory.startsWith("x".repeat(100))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("bounds the file read to the memory cap plus one byte", async () => { + const root = makeTempDir("ocx-cc-ctx-bounded-read-"); + const agentsPath = join(root, "AGENTS.md"); + let requestedLength = 0; + try { + writeFileSync(agentsPath, "x".repeat(4 * 1024 * 1024), "utf8"); + openMock.mockImplementation(async path => { + const handle = await realOpen(path); + if (String(path) !== agentsPath) return handle; + const originalRead = handle.read.bind(handle); + return { + ...handle, + read: async (buffer: Buffer, offset: number, length: number, position: number) => { + requestedLength = length; + return originalRead(buffer, offset, length, position); + }, + close: handle.close.bind(handle), + } as Awaited>; + }); + + const result = await loadCommandCodeProjectContext(root); + + expect(requestedLength).toBe(32_768 + 1); + expect(Buffer.byteLength(result.memory, "utf8")).toBeLessThanOrEqual(32_768); + expect(result.memory.endsWith("\n")).toBe(true); + } finally { + openMock.mockImplementation(realOpen); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("bounds each skill read and the total bytes read", async () => { + const root = makeTempDir("ocx-cc-ctx-total-bytes-"); + let totalRead = 0; + const skillReads: number[] = []; + try { + for (let i = 0; i < 8; i++) writeSkill(root, ".commandcode/skills", `large-${i}`, "x".repeat(20_000)); + openMock.mockImplementation(async path => { + const handle = await realOpen(path); + if (!String(path).endsWith("SKILL.md")) return handle; + const originalRead = handle.read.bind(handle); + return { + ...handle, + read: async (buffer: Buffer, offset: number, length: number, position: number) => { + const result = await originalRead(buffer, offset, length, position); + skillReads.push(result.bytesRead); + totalRead += result.bytesRead; + return result; + }, + close: handle.close.bind(handle), + } as Awaited>; + }); + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).not.toBeNull(); + expect(skillReads.length).toBeGreaterThan(1); + expect(skillReads.length).toBeLessThan(8); + expect(Math.max(...skillReads)).toBeLessThanOrEqual(8_193); + expect(totalRead).toBeLessThanOrEqual(32_768); + expect(Buffer.byteLength(result.skills!, "utf8")).toBeLessThanOrEqual(32_768); + } finally { + openMock.mockImplementation(realOpen); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("times out and closes a hanging file read", async () => { + const root = makeTempDir("ocx-cc-ctx-read-timeout-"); + const agentsPath = join(root, "AGENTS.md"); + let closeCalls = 0; + const hangingFile = { + read: () => new Promise(() => {}), + close: async () => { + closeCalls++; + }, + }; + openMock.mockImplementation(async path => { + if (String(path) === agentsPath) { + return hangingFile as Awaited>; + } + return realOpen(path); + }); + + try { + writeFileSync(agentsPath, "hanging", "utf8"); + setCommandCodeFileOpTimeoutForTests(20); + const result = await loadCommandCodeProjectContext(root); + + expect(result).not.toBe("timeout"); + expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); + expect(closeCalls).toBe(1); + } finally { + openMock.mockImplementation(realOpen); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("uses the production file-operation timeout by default", async () => { + const root = makeTempDir("ocx-cc-ctx-default-timeout-"); + try { + writeFileSync(join(root, "AGENTS.md"), "memory", "utf8"); + expect((await loadCommandCodeProjectContext(root)).memory).toBe("memory"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("omits symlink escape for AGENTS.md", async () => { + if (process.platform === "win32") return; + const root = makeTempDir("ocx-cc-ctx-symlink-"); + try { + const outside = makeTempDir("ocx-cc-ctx-outside-"); + try { + writeFileSync(join(outside, "secret.txt"), "outside secret", "utf8"); + symlinkSync(join(outside, "secret.txt"), join(root, "AGENTS.md")); + const result = await loadCommandCodeProjectContext(root); + expect(result.memory).toBe(""); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("reads the canonical file after confinement check", async () => { + if (process.platform === "win32") return; + const root = makeTempDir("ocx-cc-ctx-toctou-"); + const outside = makeTempDir("ocx-cc-ctx-toctou-outside-"); + const agentsPath = join(root, "AGENTS.md"); + const insidePath = join(root, "agents-inside.md"); + const outsidePath = join(outside, "secret.txt"); + try { + writeFileSync(insidePath, "inside content", "utf8"); + writeFileSync(outsidePath, "outside secret", "utf8"); + symlinkSync(insidePath, agentsPath); + openMock.mockImplementation(async path => { + if (String(path) === agentsPath) { + unlinkSync(agentsPath); + symlinkSync(outsidePath, agentsPath); + } + return realOpen(path); + }); + + const result = await loadCommandCodeProjectContext(root); + + expect(result.memory).toBe("inside content"); + } finally { + openMock.mockImplementation(realOpen); + rmSync(outside, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("omits unreadable AGENTS.md when chmod is enforced", async () => { + if (process.platform === "win32") return; + const root = makeTempDir("ocx-cc-ctx-unreadable-"); + try { + const agentsPath = join(root, "AGENTS.md"); + writeFileSync(agentsPath, "secret", "utf8"); + chmodSync(agentsPath, 0o000); + const canRead = (() => { + try { + readFileSync(agentsPath, "utf8"); + return true; + } catch { + return false; + } + })(); + if (!canRead) { + const result = await loadCommandCodeProjectContext(root); + expect(result.memory).toBe(""); + } + } finally { + try { + chmodSync(join(root, "AGENTS.md"), 0o644); + } catch { + /* file may not exist */ + } + rmSync(root, { recursive: true, force: true }); + } + }); + + test("escapes XML special characters in skill names and bodies", async () => { + const root = makeTempDir("ocx-cc-ctx-xml-"); + try { + writeSkill( + root, + ".commandcode/skills", + "xml-skill", + 'body with & < > " chars', + 'name: Skill & "Quoted"', + ); + + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).toBe( + '\n' + + ' body with & < > " chars\n' + + "", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("does not treat a non-exact delimiter line as frontmatter closing", async () => { + const root = makeTempDir("ocx-cc-ctx-frontmatter-marker-"); + try { + writeSkill(root, ".commandcode/skills", "marker-skill", ""); + writeFileSync( + join(root, ".commandcode", "skills", "marker-skill", "SKILL.md"), + "---\nname: Marker\n---foo\nbody", + "utf8", + ); + + const result = await loadCommandCodeProjectContext(root); + + expect(result.skills).toBe( + '\n' + + ' ---\nname: Marker\n---foo\nbody\n' + + "", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("preserves the body for empty frontmatter", async () => { + const root = makeTempDir("ocx-cc-ctx-empty-frontmatter-"); + try { + writeSkill(root, ".commandcode/skills", "empty-frontmatter", ""); + writeFileSync( + join(root, ".commandcode", "skills", "empty-frontmatter", "SKILL.md"), + "---\n---\nbody", + "utf8", + ); + + const result = await loadCommandCodeProjectContext(root); + + expect(result.skills).toBe('\n body\n'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("accepts CRLF YAML frontmatter delimiters", async () => { + const root = makeTempDir("ocx-cc-ctx-crlf-"); + try { + writeSkill( + root, + ".commandcode/skills", + "crlf-skill", + "crlf body", + ); + writeFileSync( + join(root, ".commandcode", "skills", "crlf-skill", "SKILL.md"), + "---\r\nname: CRLF Named\r\n---\r\ncrlf body", + "utf8", + ); + + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).toBe('\n crlf body\n'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("fits XML-escaped repeated ampersands within the skills byte cap", async () => { + const root = makeTempDir("ocx-cc-ctx-xml-cap-"); + try { + writeSkill(root, ".commandcode/skills", "ampersands", "&".repeat(32_768)); + + const result = await loadCommandCodeProjectContext(root); + expect(result.skills).not.toBeNull(); + expect(Buffer.byteLength(result.skills!, "utf8")).toBeLessThanOrEqual(32_768); + expect(result.skills).toContain("&"); + expect(result.skills).toContain("<!-- truncated -->"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("prunes expired entries before refreshing an existing cache key", async () => { + const root = makeTempDir("ocx-cc-ctx-refresh-prune-"); + try { + writeFileSync(join(root, "AGENTS.md"), "refreshed", "utf8"); + const now = Date.now(); + const emptyValue = { memory: "", taste: null, skills: null }; + projectContextCache.set("/expired", { collectedAt: now - PROJECT_CONTEXT_TTL_MS - 1, value: emptyValue }); + projectContextCache.set(root, { collectedAt: now - PROJECT_CONTEXT_TTL_MS - 1, value: emptyValue }); + + const result = await loadCommandCodeProjectContext(root); + expect(result.memory).toBe("refreshed"); + expect(projectContextCache.has("/expired")).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("empty taste.md yields empty string not null", async () => { + const root = makeTempDir("ocx-cc-ctx-empty-taste-"); + try { + mkdirSync(join(root, ".commandcode", "taste"), { recursive: true }); + writeFileSync(join(root, ".commandcode", "taste", "taste.md"), "", "utf8"); + + const result = await loadCommandCodeProjectContext(root); + expect(result.taste).toBe(""); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("cache hit within TTL returns same object without re-read", async () => { + const root = makeTempDir("ocx-cc-ctx-cache-"); + try { + writeFileSync(join(root, "AGENTS.md"), "version one", "utf8"); + const first = await loadCommandCodeProjectContext(root); + writeFileSync(join(root, "AGENTS.md"), "version two", "utf8"); + const second = await loadCommandCodeProjectContext(root); + expect(second).toBe(first); + expect(second.memory).toBe("version one"); + + const cached = projectContextCache.get(root); + expect(cached).toBeDefined(); + cached!.collectedAt = Date.now() - PROJECT_CONTEXT_TTL_MS - 1; + const third = await loadCommandCodeProjectContext(root); + expect(third).not.toBe(first); + expect(third.memory).toBe("version two"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("projectContextCache eviction", () => { + const dummyValue = { memory: "", taste: null, skills: null }; + + test("expired entries are evicted before capacity check", () => { + const now = Date.now(); + projectContextCache.set("/old1", { collectedAt: now - 60_000, value: dummyValue }); + projectContextCache.set("/old2", { collectedAt: now - 45_000, value: dummyValue }); + projectContextCache.set("/fresh", { collectedAt: now - 1_000, value: dummyValue }); + + pruneProjectContextCache(now); + + expect(projectContextCache.size).toBe(1); + expect(projectContextCache.has("/fresh")).toBe(true); + expect(projectContextCache.has("/old1")).toBe(false); + expect(projectContextCache.has("/old2")).toBe(false); + }); + + test("oldest live entry is evicted when at capacity", () => { + const now = Date.now(); + for (let i = 0; i < MAX_PROJECT_CONTEXT_CACHE_ENTRIES; i++) { + projectContextCache.set(`/dir-${i}`, { + collectedAt: now - (MAX_PROJECT_CONTEXT_CACHE_ENTRIES - i), + value: dummyValue, + }); + } + + pruneProjectContextCache(now); + + expect(projectContextCache.size).toBe(MAX_PROJECT_CONTEXT_CACHE_ENTRIES - 1); + expect(projectContextCache.has("/dir-0")).toBe(false); + expect(projectContextCache.has(`/dir-${MAX_PROJECT_CONTEXT_CACHE_ENTRIES - 1}`)).toBe(true); + }); + + test("cache never exceeds the cap when inserting via loader", async () => { + const roots: string[] = []; + try { + for (let i = 0; i < MAX_PROJECT_CONTEXT_CACHE_ENTRIES + 10; i++) { + const root = makeTempDir(`ocx-cc-ctx-cap-${i}-`); + roots.push(root); + writeFileSync(join(root, "AGENTS.md"), `agents ${i}`, "utf8"); + await loadCommandCodeProjectContext(root); + } + expect(projectContextCache.size).toBeLessThanOrEqual(MAX_PROJECT_CONTEXT_CACHE_ENTRIES); + } finally { + for (const root of roots) { + if (existsSync(root)) rmSync(root, { recursive: true, force: true }); + } + } + }); + + test("refreshing an expired cached key does not evict a live sibling at capacity", async () => { + const root = makeTempDir("ocx-cc-ctx-refresh-capacity-"); + const now = Date.now(); + const emptyValue = { memory: "", taste: null, skills: null }; + let releaseFirstRead!: () => void; + let releaseSecondRead!: () => void; + let firstReadStarted!: () => void; + let secondReadStarted!: () => void; + const firstRead = new Promise(resolve => { firstReadStarted = resolve; }); + const secondRead = new Promise(resolve => { secondReadStarted = resolve; }); + const firstGate = new Promise(resolve => { releaseFirstRead = resolve; }); + const secondGate = new Promise(resolve => { releaseSecondRead = resolve; }); + let readCount = 0; + + for (let i = 0; i < MAX_PROJECT_CONTEXT_CACHE_ENTRIES - 1; i++) { + projectContextCache.set(`/sibling-${i}`, { collectedAt: now, value: emptyValue }); + } + projectContextCache.set(root, { + collectedAt: now - PROJECT_CONTEXT_TTL_MS - 1, + value: emptyValue, + }); + writeFileSync(join(root, "AGENTS.md"), "refreshed", "utf8"); + openMock.mockImplementation(async path => { + if (String(path) === join(root, "AGENTS.md")) { + const handle = await realOpen(path); + const originalRead = handle.read.bind(handle); + return { + ...handle, + read: async (buffer: Buffer, offset: number, length: number, position: number) => { + readCount++; + if (readCount === 1) { + firstReadStarted(); + await firstGate; + } else if (readCount === 2) { + secondReadStarted(); + await secondGate; + } + return originalRead(buffer, offset, length, position); + }, + close: handle.close.bind(handle), + } as Awaited>; + } + return realOpen(path); + }); + + try { + const firstLoad = loadCommandCodeProjectContext(root); + await firstRead; + const secondLoad = loadCommandCodeProjectContext(root); + await secondRead; + + releaseFirstRead(); + await firstLoad; + releaseSecondRead(); + await secondLoad; + + expect(projectContextCache.size).toBe(MAX_PROJECT_CONTEXT_CACHE_ENTRIES); + expect(projectContextCache.has("/sibling-0")).toBe(true); + expect(projectContextCache.get(root)?.value.memory).toBe("refreshed"); + } finally { + openMock.mockImplementation(realOpen); + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/providers/command-code-provider.test.ts b/tests/providers/command-code-provider.test.ts index 577991a041a..a473f630e4d 100644 --- a/tests/providers/command-code-provider.test.ts +++ b/tests/providers/command-code-provider.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync } from "node:fs"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveCredential, getAccountSet, setActiveAccount } from "../../src/oauth/store"; @@ -10,6 +10,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import { budgetOwner } from "../helpers/send-budget-owner"; import type { OcxConfig } from "../../src/types"; import { commandCodeSessionId, createCommandCodeAdapter } from "../../src/adapters/command-code"; +import { projectContextCache } from "../../src/adapters/command-code-project-context"; import { loginCommandCode, parseCommandCodeCallback, shouldImportLocalCommandCodeAuth } from "../../src/oauth/command-code"; import { buildModelsRequest, OAUTH_PROVIDERS, submitManualLoginCode } from "../../src/oauth"; import { clearManualCodeSlot, loginState, waitForManualLoginCode } from "../../src/oauth/login-flow-state"; @@ -1329,4 +1330,34 @@ describe("Command Code provider", () => { expect(commandCodeSessionId(unclassifiedCache)).not.toBe(commandCodeSessionId(unclassifiedCache)); expect(commandCodeSessionId(parsed())).not.toBe(commandCodeSessionId(parsed())); }); + test("buildRequest sends exact local context only when projectContext is on", async () => { + const root = mkdtempSync(join(tmpdir(), "ocx-command-context-wire-")); + const cwdSpy = spyOn(process, "cwd").mockReturnValue(root); + try { + writeFileSync(join(root, "AGENTS.md"), "wire memory", "utf8"); + mkdirSync(join(root, ".commandcode", "taste"), { recursive: true }); + writeFileSync(join(root, ".commandcode", "taste", "taste.md"), "wire taste", "utf8"); + const skillDir = join(root, ".commandcode", "skills", "wire-skill"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, "SKILL.md"), "wire skill body", "utf8"); + projectContextCache.clear(); + + for (const configured of [provider, { ...provider, projectContext: "off" as const }]) { + const body = JSON.parse((await createCommandCodeAdapter(configured).buildRequest(parsed())).body as string); + expect(body.memory).toBe(""); + expect(body.taste).toBeNull(); + expect(body.skills).toBeNull(); + expect(projectContextCache.has(root)).toBe(false); + } + + const body = JSON.parse((await createCommandCodeAdapter({ ...provider, projectContext: "on" }).buildRequest(parsed())).body as string); + expect(body.memory).toBe("wire memory"); + expect(body.taste).toBe("wire taste"); + expect(body.skills).toBe('\n wire skill body\n'); + } finally { + cwdSpy.mockRestore(); + projectContextCache.clear(); + removeTreeWithRetry(root); + } + }); }); diff --git a/tests/providers/provider-config-validation.test.ts b/tests/providers/provider-config-validation.test.ts index fc3a2808cac..dd0479d8ae7 100644 --- a/tests/providers/provider-config-validation.test.ts +++ b/tests/providers/provider-config-validation.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { providerConfigSchema } from "../../src/config/schema/leaf-validators"; import { apiKeyTransportConfigError, booleanRecordConfigError, @@ -10,6 +11,7 @@ import { positiveIntegerRecordConfigError, providerBaseUrlConfigError, providerHeadersConfigError, + projectContextConfigError, reasoningSummaryDeliveryRecordConfigError, upstreamHttpVersionConfigError, } from "../../src/config/provider-validation"; @@ -38,6 +40,16 @@ describe("provider config validation leaf", () => { expect(apiKeyTransportConfigError({ adapter: "anthropic", authMode: "key", apiKeyTransport: "invalid" as "bearer" })).toContain("x-api-key"); }); + test("accepts projectContext only on the native Command Code adapter", () => { + const base = { adapter: "command-code", baseUrl: "https://api.commandcode.ai" }; + expect(providerConfigSchema.safeParse({ ...base, projectContext: "on" }).success).toBe(true); + expect(providerConfigSchema.safeParse({ ...base, projectContext: "off" }).success).toBe(true); + expect(providerConfigSchema.safeParse({ ...base, projectContext: "invalid" }).success).toBe(false); + expect(providerConfigSchema.safeParse({ ...base, adapter: "openai-chat", projectContext: "on" }).success).toBe(false); + expect(projectContextConfigError({ adapter: "command-code", projectContext: "on" })).toBeNull(); + expect(projectContextConfigError({ adapter: "openai-chat", projectContext: "on" })).toContain("only by"); + }); + test("shares the upstream HTTP-version enum across write and load boundaries", () => { for (const value of [undefined, null, "auto", "http1.1", "h1", "http2", "h2"]) { expect(upstreamHttpVersionConfigError(value)).toBeNull(); From eb5feb350afcedd721636d98d6ca662e2f20f776 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 20:55:24 +0900 Subject: [PATCH 2/7] fix(command-code): classify project context in model renames Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- src/providers/model-rename-fields.ts | 1 + structure/providers-and-adapters.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/providers/model-rename-fields.ts b/src/providers/model-rename-fields.ts index 323868a41fa..a12fbf88083 100644 --- a/src/providers/model-rename-fields.ts +++ b/src/providers/model-rename-fields.ts @@ -10,6 +10,7 @@ export const PROVIDER_MODEL_RENAME_ROLES = { defaultAliases: "none", adapter: "none", codexToolMode: "none", + projectContext: "none", requestPacing: "nested-models", mcpMaxTools: "none", mcpMaxSchemaBytes: "none", diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a0bad3f9452..1b1efe39d9c 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -162,7 +162,7 @@ rewrite rules and the routed-id settlement. | `src/providers/registry.ts` | Compatibility facade; canonical provider presets for CLI, dashboard, OAuth, key providers, and metadata live in `src/providers/registry/entries-core.ts` and `entries-extended.ts`, with model seeds in `model-seeds.ts`. | | `src/providers/registry/model-ids.ts` | Classifies every `ProviderRegistryEntry` field by what its KEYS mean for selector decoding, and derives the native model ids an entry names. The classification is exhaustive by construction: a new registry field fails typecheck until its keys are given a meaning, which is what stops an identity-bearing map from being silently left out of decoding. Imported directly rather than through the facade, which is at its file-size cap. | | `src/providers/derive.ts` | Enrichment from provider presets into user config. | -| `src/providers/model-rename-fields.ts`, `src/providers/model-rename-migration.ts` | Classifies every provider config field for a declared model rename. Exact-model records, lists and nested request-pacing keys follow the replacement; an already saved replacement entry wins. Provider-wide settings, including response-tier authority, and credential fields are not model identities. | +| `src/providers/model-rename-fields.ts`, `src/providers/model-rename-migration.ts` | Classifies every provider config field for a declared model rename. Exact-model records, lists and nested request-pacing keys follow the replacement; an already saved replacement entry wins. Provider-wide settings, including response-tier authority and project-context consent, and credential fields are not model identities. | | `src/providers/resolved-model-policy.ts`, `src/providers/resolved-model-policy-merge.ts` | Static provider/model policy resolution for the final upstream wire model, plus its pure clone/merge/URL/family helpers. The resolver detaches and freezes registry defaults, operator overrides, exact explicit input-modality declarations, provider-scoped hard wire pins (including Command Code's `claude-` prefix), aliases, and explicit false/empty values with field-level provenance. Provider derivation, routing, catalog hints, gather admission, and adapter selection consume its detached frozen result. Callers supply transport match, the exact capability row, and a credential-free effective auth decision; credential bytes, usability evidence, account/quota/health state, and observed limits remain outside the result. | | `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Command Code manual callback JSON remains opaque to the shared `code#state` parser and is state-validated by its provider parser. A raw Command Code paste with an explicit `#state` suffix must match the flow state on the direct prompt as well. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. | From 92abaa531c58285502558c9b8b2eb41d5b669db7 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 21:37:55 +0900 Subject: [PATCH 3/7] fix(command-code): bound and confine project context file reads Address Codex findings on async canonicalization, regular-file checks, and filesystem-root containment. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 11 +- src/adapters/command-code-project-context.ts | 114 +++++++++++------- structure/providers-and-adapters.md | 11 +- .../command-code-project-context.test.ts | 80 +++++++++++- 4 files changed, 162 insertions(+), 54 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 1ce583050fc..85e2a2464c0 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -699,15 +699,18 @@ interrupted or filtered turns leave the markup as text. Create Provider-API keys For a provider using the native `command-code` adapter, `projectContext: "on"` opts into sending local project files in the `/alpha/generate` `memory`, `taste`, and `skills` fields. -Unset or `"off"` leaves those fields empty and does not read project-context files. +With `projectContext` unset or `"off"`, no project files (`AGENTS.md`, taste, or skills) +are read or sent; the existing `config` metadata payload is unchanged. The roots are resolved from the **opencodex proxy process working directory**, not from a caller's remote workspace. The loader reads `AGENTS.md`, `.commandcode/taste/taste.md`, and `SKILL.md` files in immediate child directories of `.commandcode/skills`, `.agents/skills`, and `.pi/skills` under that working directory. Enabling the option sends the collected contents upstream to the configured Command Code endpoint. It is rejected -for other adapters. Reads remain inside the resolved working directory, count every -visited skill-directory entry, select at most 16 skills, and limit file bytes, total -skill bytes, and skill-loading time; unreadable or missing files produce empty fields. +for other adapters. Asynchronous path checks keep reads inside the resolved working +directory, including when that directory is a filesystem root; only regular-file +content is read. Every visited skill-directory entry counts toward the scan budget; at +most 16 skills are selected. File bytes, total skill bytes, and loading time are bounded; +unreadable or missing files produce empty fields. Results are cached for 30 seconds. **OrcaRouter authentication and discovery.** Choose either `ocx login orcarouter-oauth` for diff --git a/src/adapters/command-code-project-context.ts b/src/adapters/command-code-project-context.ts index 3e88ede5da0..10b3dca2965 100644 --- a/src/adapters/command-code-project-context.ts +++ b/src/adapters/command-code-project-context.ts @@ -1,6 +1,6 @@ -import { lstatSync, realpathSync } from "node:fs"; -import { open, opendir } from "node:fs/promises"; -import { join, sep } from "node:path"; +import { constants } from "node:fs"; +import { lstat, open, opendir, realpath, stat } from "node:fs/promises"; +import { isAbsolute, join, relative, sep } from "node:path"; export type CommandCodeProjectContext = { memory: string; @@ -69,11 +69,18 @@ export function pruneProjectContextCache(now: number): void { } } -/** Fail-soft canonical path; returns null when the path does not exist or cannot be resolved. */ -function canonicalPath(candidate: string): string | null { +/** Keep filesystem metadata work off the request thread and inside one load deadline. */ +async function withinDeadline(operation: () => Promise, deadline: number): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error("timeout"); + return withTimeout(operation(), remaining); +} + +/** Fail-soft canonical path; no synchronous filesystem calls run on the request thread. */ +async function canonicalPath(candidate: string, deadline: number): Promise { try { - lstatSync(candidate); - return realpathSync.native(candidate); + await withinDeadline(() => lstat(candidate), deadline); + return await withinDeadline(() => realpath(candidate), deadline); } catch { return null; } @@ -83,13 +90,23 @@ function normalizePathIdentity(path: string): string { return process.platform === "win32" ? path.toLowerCase() : path; } -function confinedCanonicalPath(filePath: string, cwdCanonical: string): string | null { - const fileCanonical = canonicalPath(filePath); - if (!fileCanonical) return null; - const fileId = normalizePathIdentity(fileCanonical); - const cwdId = normalizePathIdentity(cwdCanonical); - if (fileId === cwdId || fileId.startsWith(cwdId + sep)) return fileCanonical; - return null; +/** Relative paths also work when cwd is a filesystem root; other volumes remain outside. */ +export function isContainedCanonicalPath(cwdCanonical: string, fileCanonical: string): boolean { + const rel = relative(normalizePathIdentity(cwdCanonical), normalizePathIdentity(fileCanonical)); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +async function confinedCanonicalPath( + filePath: string, cwdCanonical: string, deadline: number, kind: "file" | "directory", +): Promise { + const canonical = await canonicalPath(filePath, deadline); + if (!canonical || !isContainedCanonicalPath(cwdCanonical, canonical)) return null; + try { + const info = await withinDeadline(() => stat(canonical), deadline); + return (kind === "file" ? info.isFile() : info.isDirectory()) ? canonical : null; + } catch { + return null; + } } async function withTimeout(promise: Promise, ms: number): Promise { @@ -117,11 +134,15 @@ function truncateUtf8(text: string, capBytes: number): string { return buf.subarray(0, end).toString("utf8") + TRUNCATION_MARKER; } -async function readUtf8File(path: string, capBytes: number, timeoutMs = COMMAND_CODE_FILE_OP_TIMEOUT_MS): Promise { +async function readUtf8File(path: string, capBytes: number, deadline: number): Promise { type FileHandle = Awaited>; let fileHandle: FileHandle | undefined; const closedHandles = new WeakSet(); - const opened = open(path, "r"); + const remaining = deadline - Date.now(); + if (remaining <= 0) return null; + // O_NONBLOCK prevents a race that swaps a checked regular file for a FIFO. + const flags = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW; + const opened = open(path, flags); const closeBestEffort = (handle: FileHandle): Promise => { if (closedHandles.has(handle)) return Promise.resolve(); closedHandles.add(handle); @@ -136,6 +157,8 @@ async function readUtf8File(path: string, capBytes: number, timeoutMs = COMMAND_ const handle = await opened; fileHandle = handle; try { + if (Date.now() >= deadline || !(await handle.stat()).isFile()) return null; + if (Date.now() >= deadline) return null; const data = Buffer.alloc(capBytes + 1); const { bytesRead } = await handle.read(data, 0, data.length, 0); return data.subarray(0, bytesRead).toString("utf8"); @@ -146,7 +169,7 @@ async function readUtf8File(path: string, capBytes: number, timeoutMs = COMMAND_ })(); try { - return await withTimeout(read, timeoutMs); + return await withTimeout(read, remaining); } catch { if (fileHandle) void closeBestEffort(fileHandle); void opened.then(handle => closeBestEffort(handle), () => undefined); @@ -183,20 +206,20 @@ function parseSkillFrontmatter(text: string): { name: string | null; body: strin return { name, body }; } -async function readMemory(cwd: string, cwdCanonical: string, timeoutMs: number): Promise { +async function readMemory(cwd: string, cwdCanonical: string, deadline: number): Promise { const path = join(cwd, "AGENTS.md"); - const canonical = confinedCanonicalPath(path, cwdCanonical); + const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file"); if (!canonical) return ""; - const text = await readUtf8File(canonical, MEMORY_CAP_BYTES, timeoutMs); + const text = await readUtf8File(canonical, MEMORY_CAP_BYTES, deadline); if (text === null) return ""; return truncateUtf8(text, MEMORY_CAP_BYTES); } -async function readTaste(cwd: string, cwdCanonical: string, timeoutMs: number): Promise { +async function readTaste(cwd: string, cwdCanonical: string, deadline: number): Promise { const path = join(cwd, ".commandcode", "taste", "taste.md"); - const canonical = confinedCanonicalPath(path, cwdCanonical); + const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file"); if (!canonical) return null; - const text = await readUtf8File(canonical, TASTE_CAP_BYTES, timeoutMs); + const text = await readUtf8File(canonical, TASTE_CAP_BYTES, deadline); if (text === null) return null; return truncateUtf8(text, TASTE_CAP_BYTES); } @@ -207,25 +230,30 @@ interface SkillEntry { bytesRead: number; } -async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget: number, timeoutMs: number): Promise { +async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget: number, deadline: number): Promise { if (scanBudget <= 0) return []; - const skillRootCanonical = confinedCanonicalPath(skillRoot, cwdCanonical); + const skillRootCanonical = await confinedCanonicalPath(skillRoot, cwdCanonical, deadline, "directory"); if (!skillRootCanonical) return []; let dir: Awaited> | undefined; try { - return await withTimeout( - (async () => { + return await withinDeadline( + async () => { const openedDir = await opendir(skillRootCanonical); dir = openedDir; + if (Date.now() >= deadline) { + void openedDir.close().catch(() => undefined); + return []; + } const names: string[] = []; let visitedEntries = 0; try { for await (const entry of openedDir) { + if (Date.now() >= deadline) break; visitedEntries++; const atLimit = visitedEntries >= scanBudget; if (!entry.name.startsWith(".") && entry.isDirectory()) { const skillMd = join(skillRoot, entry.name, "SKILL.md"); - const skillMdCanonical = confinedCanonicalPath(skillMd, cwdCanonical); + const skillMdCanonical = await confinedCanonicalPath(skillMd, cwdCanonical, deadline, "file"); if (skillMdCanonical) { names.push(entry.name); } @@ -234,7 +262,7 @@ async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget } } catch { try { - await openedDir.close(); + void openedDir.close().catch(() => undefined); } catch { /* closing a failed iterator is best-effort */ } @@ -242,13 +270,13 @@ async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget } names.sort(); return names; - })(), - timeoutMs, + }, + deadline, ); } catch { if (dir) { try { - await dir.close(); + void dir.close().catch(() => undefined); } catch { /* closing a timed-out iterator is best-effort */ } @@ -257,11 +285,11 @@ async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget } } -async function readSkill(skillRoot: string, dirName: string, cwdCanonical: string, capBytes: number, timeoutMs: number): Promise { +async function readSkill(skillRoot: string, dirName: string, cwdCanonical: string, capBytes: number, deadline: number): Promise { const path = join(skillRoot, dirName, "SKILL.md"); - const canonical = confinedCanonicalPath(path, cwdCanonical); + const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file"); if (!canonical) return null; - const text = await readUtf8File(canonical, capBytes, timeoutMs); + const text = await readUtf8File(canonical, capBytes, deadline); if (text === null) return null; const { name, body } = parseSkillFrontmatter(truncateUtf8(text, capBytes)); return { name: name ?? dirName, body, bytesRead: Buffer.byteLength(text, "utf8") }; @@ -330,22 +358,21 @@ function truncateUtf8BodyForXml(body: string, capBytes: number): string | null { return best; } -async function readSkills(cwd: string, cwdCanonical: string, timeoutMs: number): Promise { +async function readSkills(cwd: string, cwdCanonical: string, deadline: number): Promise { const seen = new Set(); const collected: SkillEntry[] = []; - const deadline = Date.now() + timeoutMs; let remainingBytes = SKILLS_READ_CAP_BYTES; for (const rootRel of SKILL_ROOTS) { const remainingScanMs = deadline - Date.now(); if (remainingScanMs <= 0) break; const skillRoot = join(cwd, ...rootRel.split("/")); - const dirs = await listSkillDirs(skillRoot, cwdCanonical, MAX_SKILL_DIRS_TO_SCAN, remainingScanMs); + const dirs = await listSkillDirs(skillRoot, cwdCanonical, MAX_SKILL_DIRS_TO_SCAN, deadline); for (const dirName of dirs) { if (collected.length >= MAX_SKILLS || remainingBytes <= 1) break; const remainingReadMs = deadline - Date.now(); if (remainingReadMs <= 0) break; - const skill = await readSkill(skillRoot, dirName, cwdCanonical, Math.min(SKILL_FILE_CAP_BYTES, remainingBytes - 1), remainingReadMs); + const skill = await readSkill(skillRoot, dirName, cwdCanonical, Math.min(SKILL_FILE_CAP_BYTES, remainingBytes - 1), deadline); if (!skill) continue; remainingBytes -= skill.bytesRead; if (seen.has(skill.name)) continue; @@ -359,13 +386,14 @@ async function readSkills(cwd: string, cwdCanonical: string, timeoutMs: number): } async function collectProjectContext(cwd: string, timeoutMs: number): Promise { - const cwdCanonical = canonicalPath(cwd); + const deadline = Date.now() + timeoutMs; + const cwdCanonical = await canonicalPath(cwd, deadline); if (!cwdCanonical) return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT }; const [memory, taste, skills] = await Promise.all([ - readMemory(cwd, cwdCanonical, timeoutMs), - readTaste(cwd, cwdCanonical, timeoutMs), - readSkills(cwd, cwdCanonical, timeoutMs), + readMemory(cwd, cwdCanonical, deadline), + readTaste(cwd, cwdCanonical, deadline), + readSkills(cwd, cwdCanonical, deadline), ]); return { memory, taste, skills }; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 1b1efe39d9c..7a28da623c4 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -251,13 +251,16 @@ rows and wire values; `tests/providers/command-code-provider.test.ts` covers ope The native Command Code adapter in `src/adapters/command-code.ts` gates its `/alpha/generate` project envelope on the provider's literal `projectContext: "on"`. -Absent or `"off"` keeps empty `memory`, `taste`, and `skills` and never invokes +Absent or `"off"` reads or sends no project files, keeps empty `memory`, `taste`, +and `skills`, and leaves existing `config` metadata unchanged without invoking `src/adapters/command-code-project-context.ts`. The loader reads only the proxy process working directory's `AGENTS.md`, `.commandcode/taste/taste.md`, and immediate child `SKILL.md` files under `.commandcode/skills`, `.agents/skills`, and `.pi/skills`. -Canonical path containment rejects escapes; every visited directory entry consumes the -scan budget before filtering; at most 16 skills are selected. Individual files, aggregate -skill reads, serialized XML, and the full skill-loading interval are bounded. The +Asynchronous path checks share one deadline, check regular-file type before and after +nonblocking open, and use relative-path containment even at filesystem roots. Every +visited directory entry consumes the scan budget before filtering; at most 16 skills +are selected. Individual files, aggregate skill reads, serialized XML, and the full +skill-loading interval are bounded. The contents are sent to the configured Command Code endpoint when enabled, and missing or failed reads degrade to empty fields. A 30-second, 128-entry cache bounds repeated reads. diff --git a/tests/providers/command-code-project-context.test.ts b/tests/providers/command-code-project-context.test.ts index d4cbc29facc..7c08a30d45b 100644 --- a/tests/providers/command-code-project-context.test.ts +++ b/tests/providers/command-code-project-context.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"; +import { execFileSync } from "node:child_process"; import { chmodSync, existsSync, @@ -12,22 +13,29 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, parse } from "node:path"; const fsPromises = await import("node:fs/promises"); const realOpendir = fsPromises.opendir; const realOpen = fsPromises.open; +const realLstat = fsPromises.lstat; +const realRealpath = fsPromises.realpath; +const lstatMock = mock(realLstat); +const realpathMock = mock(realRealpath); const opendirMock = mock(realOpendir); const openMock = mock(realOpen); mock.module("node:fs/promises", () => ({ ...fsPromises, opendir: opendirMock, open: openMock, + lstat: lstatMock, + realpath: realpathMock, })); const { EMPTY_COMMAND_CODE_PROJECT_CONTEXT, loadCommandCodeProjectContext, + isContainedCanonicalPath, projectContextCache, pruneProjectContextCache, setCommandCodeFileOpTimeoutForTests, @@ -59,6 +67,8 @@ beforeEach(() => { }); afterEach(() => { + lstatMock.mockImplementation(realLstat); + realpathMock.mockImplementation(realRealpath); projectContextCache.clear(); setCommandCodeFileOpTimeoutForTests(undefined); }); @@ -69,6 +79,69 @@ describe("loadCommandCodeProjectContext", () => { expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); }); + test("relative containment includes descendants of a filesystem root", () => { + const fsRoot = parse(tmpdir()).root; + expect(isContainedCanonicalPath(fsRoot, join(fsRoot, "AGENTS.md"))).toBe(true); + const nested = join(fsRoot, "project"); + expect(isContainedCanonicalPath(nested, join(nested, "..hidden"))).toBe(true); + expect(isContainedCanonicalPath(nested, join(fsRoot, "project-sibling", "SKILL.md"))).toBe(false); + }); + + test("stalled asynchronous path metadata obeys the overall deadline", async () => { + const root = makeTempDir("ocx-cc-ctx-metadata-timeout-"); + const agentsPath = join(root, "AGENTS.md"); + try { + writeFileSync(agentsPath, "private memory", "utf8"); + for (const stalled of ["lstat", "realpath"] as const) { + projectContextCache.clear(); + let stalledCalls = 0; + if (stalled === "lstat") { + lstatMock.mockImplementation(async path => { + if (String(path) !== agentsPath) return realLstat(path); + stalledCalls++; + return new Promise(() => {}); + }); + } else { + realpathMock.mockImplementation(async path => { + if (String(path) !== agentsPath) return realRealpath(path); + stalledCalls++; + return new Promise(() => {}); + }); + } + setCommandCodeFileOpTimeoutForTests(500); + const result = await loadCommandCodeProjectContext(root); + expect(stalledCalls).toBe(1); + expect(result.memory).toBe(""); + lstatMock.mockImplementation(realLstat); + realpathMock.mockImplementation(realRealpath); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("rejects a FIFO without attempting a blocking open", async () => { + if (process.platform === "win32") return; + const root = makeTempDir("ocx-cc-ctx-fifo-"); + const fifo = join(root, "AGENTS.md"); + let openedFifo = false; + try { + execFileSync("mkfifo", [fifo]); + openMock.mockImplementation(async (path, flags) => { + if (String(path) === fifo) { + openedFifo = true; + throw new Error("FIFO must be rejected before open"); + } + return realOpen(path, flags); + }); + expect((await loadCommandCodeProjectContext(root)).memory).toBe(""); + expect(openedFifo).toBe(false); + } finally { + openMock.mockImplementation(realOpen); + rmSync(root, { recursive: true, force: true }); + } + }); + test("missing files return empty memory, null taste, null skills", async () => { const root = makeTempDir("ocx-cc-ctx-empty-"); try { @@ -162,7 +235,7 @@ describe("loadCommandCodeProjectContext", () => { }); try { - setCommandCodeFileOpTimeoutForTests(20); + setCommandCodeFileOpTimeoutForTests(250); const result = await loadCommandCodeProjectContext(root); expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); expect(closeCalls).toBe(1); @@ -361,6 +434,7 @@ describe("loadCommandCodeProjectContext", () => { const agentsPath = join(root, "AGENTS.md"); let closeCalls = 0; const hangingFile = { + stat: async () => ({ isFile: () => true }), read: () => new Promise(() => {}), close: async () => { closeCalls++; @@ -375,7 +449,7 @@ describe("loadCommandCodeProjectContext", () => { try { writeFileSync(agentsPath, "hanging", "utf8"); - setCommandCodeFileOpTimeoutForTests(20); + setCommandCodeFileOpTimeoutForTests(250); const result = await loadCommandCodeProjectContext(root); expect(result).not.toBe("timeout"); From 0e7ca51e7d02b179a0bea8362ac922f453b33b3e Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 21:45:20 +0900 Subject: [PATCH 4/7] test(command-code): preserve fstat in read instrumentation Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- tests/providers/command-code-project-context.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/providers/command-code-project-context.test.ts b/tests/providers/command-code-project-context.test.ts index 7c08a30d45b..73d235fb2f4 100644 --- a/tests/providers/command-code-project-context.test.ts +++ b/tests/providers/command-code-project-context.test.ts @@ -380,6 +380,7 @@ describe("loadCommandCodeProjectContext", () => { requestedLength = length; return originalRead(buffer, offset, length, position); }, + stat: handle.stat.bind(handle), close: handle.close.bind(handle), } as Awaited>; }); @@ -413,6 +414,7 @@ describe("loadCommandCodeProjectContext", () => { totalRead += result.bytesRead; return result; }, + stat: handle.stat.bind(handle), close: handle.close.bind(handle), } as Awaited>; }); @@ -787,6 +789,7 @@ describe("projectContextCache eviction", () => { } return originalRead(buffer, offset, length, position); }, + stat: handle.stat.bind(handle), close: handle.close.bind(handle), } as Awaited>; } From 611a7359dc08243a8172030078d3d8bcbf47d528 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 22:38:33 +0900 Subject: [PATCH 5/7] fix(command-code): recheck opened project file containment Verify the opened inode against a freshly resolved in-root path before and after reading, and cover an intermediate skill-directory swap. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- src/adapters/command-code-project-context.ts | 44 ++++++++++++++++--- structure/providers-and-adapters.md | 10 +++-- .../command-code-project-context.test.ts | 35 ++++++++++++++- 3 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/adapters/command-code-project-context.ts b/src/adapters/command-code-project-context.ts index 10b3dca2965..7770a4d90b0 100644 --- a/src/adapters/command-code-project-context.ts +++ b/src/adapters/command-code-project-context.ts @@ -25,6 +25,12 @@ const MAX_SKILLS = 16; const MAX_SKILL_DIRS_TO_SCAN = 256; const COMMAND_CODE_FILE_OP_TIMEOUT_MS = 2_000; let fileOpTimeoutForTests: number | undefined; +let beforeOpenForTests: ((path: string) => void | Promise) | undefined; + +/** Test seam for deterministic path replacement between confinement and open. */ +export function setCommandCodeBeforeOpenForTests(hook: typeof beforeOpenForTests): void { + beforeOpenForTests = hook; +} export function setCommandCodeFileOpTimeoutForTests(timeoutMs: number | undefined): void { fileOpTimeoutForTests = timeoutMs; @@ -134,13 +140,38 @@ function truncateUtf8(text: string, capBytes: number): string { return buf.subarray(0, end).toString("utf8") + TRUNCATION_MARKER; } -async function readUtf8File(path: string, capBytes: number, deadline: number): Promise { +/** Match the opened inode to a still-canonical path inside cwd before publishing bytes. */ +async function openedFileIsConfined( + handle: Awaited>, path: string, cwdCanonical: string, deadline: number, +): Promise { + const opened = await withinDeadline(() => handle.stat(), deadline); + if (!opened.isFile()) return false; + const current = await withinDeadline(() => lstat(path), deadline); + if (!current.isFile() || opened.dev !== current.dev || opened.ino !== current.ino) return false; + const resolved = await withinDeadline(() => realpath(path), deadline); + // The input path was canonical before open. A changed intermediate symlink changes this + // result even though O_NOFOLLOW protects only the final component on macOS and Linux. + if (!isContainedCanonicalPath(cwdCanonical, resolved) + || normalizePathIdentity(resolved) !== normalizePathIdentity(path)) return false; + const resolvedInfo = await withinDeadline(() => lstat(resolved), deadline); + return resolvedInfo.isFile() && opened.dev === resolvedInfo.dev && opened.ino === resolvedInfo.ino; +} + +async function readUtf8File(path: string, capBytes: number, deadline: number, cwdCanonical: string): Promise { type FileHandle = Awaited>; let fileHandle: FileHandle | undefined; const closedHandles = new WeakSet(); + if (beforeOpenForTests) { + try { + await withinDeadline(() => Promise.resolve(beforeOpenForTests!(path)), deadline); + } catch { + return null; + } + } const remaining = deadline - Date.now(); if (remaining <= 0) return null; // O_NONBLOCK prevents a race that swaps a checked regular file for a FIFO. + // Windows lacks these POSIX open guards; post-open path/identity checks remain best-effort there. const flags = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW; const opened = open(path, flags); const closeBestEffort = (handle: FileHandle): Promise => { @@ -157,10 +188,11 @@ async function readUtf8File(path: string, capBytes: number, deadline: number): P const handle = await opened; fileHandle = handle; try { - if (Date.now() >= deadline || !(await handle.stat()).isFile()) return null; - if (Date.now() >= deadline) return null; + if (Date.now() >= deadline || !await openedFileIsConfined(handle, path, cwdCanonical, deadline)) return null; const data = Buffer.alloc(capBytes + 1); const { bytesRead } = await handle.read(data, 0, data.length, 0); + // Do not return bytes if an intermediate directory changed while the read was pending. + if (!await openedFileIsConfined(handle, path, cwdCanonical, deadline)) return null; return data.subarray(0, bytesRead).toString("utf8"); } finally { await closeBestEffort(handle); @@ -210,7 +242,7 @@ async function readMemory(cwd: string, cwdCanonical: string, deadline: number): const path = join(cwd, "AGENTS.md"); const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file"); if (!canonical) return ""; - const text = await readUtf8File(canonical, MEMORY_CAP_BYTES, deadline); + const text = await readUtf8File(canonical, MEMORY_CAP_BYTES, deadline, cwdCanonical); if (text === null) return ""; return truncateUtf8(text, MEMORY_CAP_BYTES); } @@ -219,7 +251,7 @@ async function readTaste(cwd: string, cwdCanonical: string, deadline: number): P const path = join(cwd, ".commandcode", "taste", "taste.md"); const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file"); if (!canonical) return null; - const text = await readUtf8File(canonical, TASTE_CAP_BYTES, deadline); + const text = await readUtf8File(canonical, TASTE_CAP_BYTES, deadline, cwdCanonical); if (text === null) return null; return truncateUtf8(text, TASTE_CAP_BYTES); } @@ -289,7 +321,7 @@ async function readSkill(skillRoot: string, dirName: string, cwdCanonical: strin const path = join(skillRoot, dirName, "SKILL.md"); const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file"); if (!canonical) return null; - const text = await readUtf8File(canonical, capBytes, deadline); + const text = await readUtf8File(canonical, capBytes, deadline, cwdCanonical); if (text === null) return null; const { name, body } = parseSkillFrontmatter(truncateUtf8(text, capBytes)); return { name: name ?? dirName, body, bytesRead: Buffer.byteLength(text, "utf8") }; diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 7a28da623c4..9d9974ffed1 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -256,10 +256,12 @@ and `skills`, and leaves existing `config` metadata unchanged without invoking `src/adapters/command-code-project-context.ts`. The loader reads only the proxy process working directory's `AGENTS.md`, `.commandcode/taste/taste.md`, and immediate child `SKILL.md` files under `.commandcode/skills`, `.agents/skills`, and `.pi/skills`. -Asynchronous path checks share one deadline, check regular-file type before and after -nonblocking open, and use relative-path containment even at filesystem roots. Every -visited directory entry consumes the scan budget before filtering; at most 16 skills -are selected. Individual files, aggregate skill reads, serialized XML, and the full +Asynchronous path checks share one deadline and use relative-path containment even at +filesystem roots. On macOS/Linux a nonblocking, no-follow open is followed by file-inode +comparison and fresh canonical containment checks before and after reading; an intermediate +directory replaced by an outside symlink cannot publish its file contents. Windows applies +the path and identity checks as best effort. Every visited directory entry consumes the +scan budget before filtering; at most 16 skills are selected. Individual files, aggregate skill reads, serialized XML, and the full skill-loading interval are bounded. The contents are sent to the configured Command Code endpoint when enabled, and missing or failed reads degrade to empty fields. A 30-second, 128-entry cache bounds repeated reads. diff --git a/tests/providers/command-code-project-context.test.ts b/tests/providers/command-code-project-context.test.ts index 73d235fb2f4..0de9f316715 100644 --- a/tests/providers/command-code-project-context.test.ts +++ b/tests/providers/command-code-project-context.test.ts @@ -7,7 +7,9 @@ import { mkdtempSync, readFileSync, realpathSync, + renameSync, rmSync, + statSync, symlinkSync, unlinkSync, writeFileSync, @@ -39,6 +41,7 @@ const { projectContextCache, pruneProjectContextCache, setCommandCodeFileOpTimeoutForTests, + setCommandCodeBeforeOpenForTests, } = await import("../../src/adapters/command-code-project-context"); const MAX_PROJECT_CONTEXT_CACHE_ENTRIES = 128; @@ -71,6 +74,7 @@ afterEach(() => { realpathMock.mockImplementation(realRealpath); projectContextCache.clear(); setCommandCodeFileOpTimeoutForTests(undefined); + setCommandCodeBeforeOpenForTests(undefined); }); describe("loadCommandCodeProjectContext", () => { @@ -436,7 +440,7 @@ describe("loadCommandCodeProjectContext", () => { const agentsPath = join(root, "AGENTS.md"); let closeCalls = 0; const hangingFile = { - stat: async () => ({ isFile: () => true }), + stat: async () => statSync(agentsPath), read: () => new Promise(() => {}), close: async () => { closeCalls++; @@ -520,6 +524,35 @@ describe("loadCommandCodeProjectContext", () => { } }); + test("rejects a skill when its intermediate directory becomes an outside symlink before open", async () => { + if (process.platform === "win32") return; + const root = makeTempDir("ocx-cc-ctx-dir-swap-"); + const outside = makeTempDir("ocx-cc-ctx-dir-swap-outside-"); + const skillDir = join(root, ".commandcode", "skills", "swap-skill"); + const skillFile = join(skillDir, "SKILL.md"); + const outsideDir = join(outside, "outside-skill"); + let swapped = false; + try { + writeSkill(root, ".commandcode/skills", "swap-skill", "inside body"); + mkdirSync(outsideDir); + writeFileSync(join(outsideDir, "SKILL.md"), "outside secret body", "utf8"); + setCommandCodeBeforeOpenForTests(path => { + if (path !== skillFile) return; + swapped = true; + renameSync(skillDir, join(root, ".commandcode", "skills", "held-skill")); + symlinkSync(outsideDir, skillDir, "dir"); + }); + const result = await loadCommandCodeProjectContext(root); + expect(swapped).toBe(true); + expect(result.skills).toBeNull(); + expect(JSON.stringify(result)).not.toContain("outside secret body"); + } finally { + setCommandCodeBeforeOpenForTests(undefined); + rmSync(root, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + } + }); + test("omits unreadable AGENTS.md when chmod is enforced", async () => { if (process.platform === "win32") return; const root = makeTempDir("ocx-cc-ctx-unreadable-"); From 5f69d679f9268484f1bb55d110ab7b324538b812 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 23:24:40 +0900 Subject: [PATCH 6/7] fix(command-code): bound concurrent project context scans Share cold loads per cwd, retain timed-out scan slots until their operations settle, and recheck cache capacity before insertion. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- src/adapters/command-code-project-context.ts | 195 +++++++++++------ structure/providers-and-adapters.md | 5 +- .../command-code-project-context.test.ts | 204 ++++++++++++------ 3 files changed, 274 insertions(+), 130 deletions(-) diff --git a/src/adapters/command-code-project-context.ts b/src/adapters/command-code-project-context.ts index 7770a4d90b0..23e2018069d 100644 --- a/src/adapters/command-code-project-context.ts +++ b/src/adapters/command-code-project-context.ts @@ -37,6 +37,12 @@ export function setCommandCodeFileOpTimeoutForTests(timeoutMs: number | undefine } const PROJECT_CONTEXT_TTL_MS = 30_000; const MAX_PROJECT_CONTEXT_CACHE_ENTRIES = 128; +const MAX_CONCURRENT_PROJECT_CONTEXT_SCANS = 8; +const MAX_PENDING_PROJECT_CONTEXT_FILE_OPS = 64; +const projectContextInFlight = new Map>(); +type ScanScope = { cwd: string; pendingOps: number; finished: boolean }; +const outstandingProjectContextScans = new Map(); +const pendingProjectContextFileOps = new Set>(); const TRUNCATION_MARKER = "\n"; @@ -75,18 +81,41 @@ export function pruneProjectContextCache(now: number): void { } } +/** A timed-out scan retains its cwd slot until every operation it dispatched settles. */ +function releaseScanIfSettled(scope: ScanScope): void { + if (scope.finished && scope.pendingOps === 0 && outstandingProjectContextScans.get(scope.cwd) === scope) { + outstandingProjectContextScans.delete(scope.cwd); + } +} + +function trackedFileOperation(operation: () => Promise, scope: ScanScope, enforceLimit = true): Promise { + if (enforceLimit && pendingProjectContextFileOps.size >= MAX_PENDING_PROJECT_CONTEXT_FILE_OPS) { + throw new Error("project context filesystem admission limit"); + } + const pending = operation(); + scope.pendingOps++; + pendingProjectContextFileOps.add(pending); + const settle = (): void => { + pendingProjectContextFileOps.delete(pending); + scope.pendingOps--; + releaseScanIfSettled(scope); + }; + void pending.then(settle, settle); + return pending; +} + /** Keep filesystem metadata work off the request thread and inside one load deadline. */ -async function withinDeadline(operation: () => Promise, deadline: number): Promise { +async function withinDeadline(operation: () => Promise, deadline: number, scope: ScanScope): Promise { const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error("timeout"); - return withTimeout(operation(), remaining); + return withTimeout(trackedFileOperation(operation, scope), remaining); } /** Fail-soft canonical path; no synchronous filesystem calls run on the request thread. */ -async function canonicalPath(candidate: string, deadline: number): Promise { +async function canonicalPath(candidate: string, deadline: number, scope: ScanScope): Promise { try { - await withinDeadline(() => lstat(candidate), deadline); - return await withinDeadline(() => realpath(candidate), deadline); + await withinDeadline(() => lstat(candidate), deadline, scope); + return await withinDeadline(() => realpath(candidate), deadline, scope); } catch { return null; } @@ -103,12 +132,12 @@ export function isContainedCanonicalPath(cwdCanonical: string, fileCanonical: st } async function confinedCanonicalPath( - filePath: string, cwdCanonical: string, deadline: number, kind: "file" | "directory", + filePath: string, cwdCanonical: string, deadline: number, kind: "file" | "directory", scope: ScanScope, ): Promise { - const canonical = await canonicalPath(filePath, deadline); + const canonical = await canonicalPath(filePath, deadline, scope); if (!canonical || !isContainedCanonicalPath(cwdCanonical, canonical)) return null; try { - const info = await withinDeadline(() => stat(canonical), deadline); + const info = await withinDeadline(() => stat(canonical), deadline, scope); return (kind === "file" ? info.isFile() : info.isDirectory()) ? canonical : null; } catch { return null; @@ -142,28 +171,28 @@ function truncateUtf8(text: string, capBytes: number): string { /** Match the opened inode to a still-canonical path inside cwd before publishing bytes. */ async function openedFileIsConfined( - handle: Awaited>, path: string, cwdCanonical: string, deadline: number, + handle: Awaited>, path: string, cwdCanonical: string, deadline: number, scope: ScanScope, ): Promise { - const opened = await withinDeadline(() => handle.stat(), deadline); + const opened = await withinDeadline(() => handle.stat(), deadline, scope); if (!opened.isFile()) return false; - const current = await withinDeadline(() => lstat(path), deadline); + const current = await withinDeadline(() => lstat(path), deadline, scope); if (!current.isFile() || opened.dev !== current.dev || opened.ino !== current.ino) return false; - const resolved = await withinDeadline(() => realpath(path), deadline); + const resolved = await withinDeadline(() => realpath(path), deadline, scope); // The input path was canonical before open. A changed intermediate symlink changes this // result even though O_NOFOLLOW protects only the final component on macOS and Linux. if (!isContainedCanonicalPath(cwdCanonical, resolved) || normalizePathIdentity(resolved) !== normalizePathIdentity(path)) return false; - const resolvedInfo = await withinDeadline(() => lstat(resolved), deadline); + const resolvedInfo = await withinDeadline(() => lstat(resolved), deadline, scope); return resolvedInfo.isFile() && opened.dev === resolvedInfo.dev && opened.ino === resolvedInfo.ino; } -async function readUtf8File(path: string, capBytes: number, deadline: number, cwdCanonical: string): Promise { +async function readUtf8File(path: string, capBytes: number, deadline: number, cwdCanonical: string, scope: ScanScope): Promise { type FileHandle = Awaited>; let fileHandle: FileHandle | undefined; const closedHandles = new WeakSet(); if (beforeOpenForTests) { try { - await withinDeadline(() => Promise.resolve(beforeOpenForTests!(path)), deadline); + await withinDeadline(() => Promise.resolve(beforeOpenForTests!(path)), deadline, scope); } catch { return null; } @@ -173,32 +202,43 @@ async function readUtf8File(path: string, capBytes: number, deadline: number, cw // O_NONBLOCK prevents a race that swaps a checked regular file for a FIFO. // Windows lacks these POSIX open guards; post-open path/identity checks remain best-effort there. const flags = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW; - const opened = open(path, flags); + let opened: ReturnType; + try { + opened = trackedFileOperation(() => open(path, flags), scope); + } catch { + return null; + } const closeBestEffort = (handle: FileHandle): Promise => { if (closedHandles.has(handle)) return Promise.resolve(); closedHandles.add(handle); return Promise.resolve() - .then(() => handle.close()) + .then(() => trackedFileOperation(() => handle.close(), scope, false)) .catch(() => { /* closing a timed-out read is best-effort */ }); }; - const read = (async () => { - const handle = await opened; - fileHandle = handle; - try { - if (Date.now() >= deadline || !await openedFileIsConfined(handle, path, cwdCanonical, deadline)) return null; - const data = Buffer.alloc(capBytes + 1); - const { bytesRead } = await handle.read(data, 0, data.length, 0); - // Do not return bytes if an intermediate directory changed while the read was pending. - if (!await openedFileIsConfined(handle, path, cwdCanonical, deadline)) return null; - return data.subarray(0, bytesRead).toString("utf8"); - } finally { - await closeBestEffort(handle); - if (fileHandle === handle) fileHandle = undefined; - } - })(); + let read: Promise; + try { + read = trackedFileOperation(async () => { + const handle = await opened; + fileHandle = handle; + try { + if (Date.now() >= deadline || !await openedFileIsConfined(handle, path, cwdCanonical, deadline, scope)) return null; + const data = Buffer.alloc(capBytes + 1); + const { bytesRead } = await handle.read(data, 0, data.length, 0); + // Do not return bytes if an intermediate directory changed while the read was pending. + if (!await openedFileIsConfined(handle, path, cwdCanonical, deadline, scope)) return null; + return data.subarray(0, bytesRead).toString("utf8"); + } finally { + await closeBestEffort(handle); + if (fileHandle === handle) fileHandle = undefined; + } + }, scope); + } catch { + void opened.then(handle => closeBestEffort(handle), () => undefined); + return null; + } try { return await withTimeout(read, remaining); @@ -238,20 +278,20 @@ function parseSkillFrontmatter(text: string): { name: string | null; body: strin return { name, body }; } -async function readMemory(cwd: string, cwdCanonical: string, deadline: number): Promise { +async function readMemory(cwd: string, cwdCanonical: string, deadline: number, scope: ScanScope): Promise { const path = join(cwd, "AGENTS.md"); - const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file"); + const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file", scope); if (!canonical) return ""; - const text = await readUtf8File(canonical, MEMORY_CAP_BYTES, deadline, cwdCanonical); + const text = await readUtf8File(canonical, MEMORY_CAP_BYTES, deadline, cwdCanonical, scope); if (text === null) return ""; return truncateUtf8(text, MEMORY_CAP_BYTES); } -async function readTaste(cwd: string, cwdCanonical: string, deadline: number): Promise { +async function readTaste(cwd: string, cwdCanonical: string, deadline: number, scope: ScanScope): Promise { const path = join(cwd, ".commandcode", "taste", "taste.md"); - const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file"); + const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file", scope); if (!canonical) return null; - const text = await readUtf8File(canonical, TASTE_CAP_BYTES, deadline, cwdCanonical); + const text = await readUtf8File(canonical, TASTE_CAP_BYTES, deadline, cwdCanonical, scope); if (text === null) return null; return truncateUtf8(text, TASTE_CAP_BYTES); } @@ -262,9 +302,17 @@ interface SkillEntry { bytesRead: number; } -async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget: number, deadline: number): Promise { +function closeSkillDirectoryBestEffort(dir: Awaited>, scope: ScanScope): void { + try { + void trackedFileOperation(() => dir.close(), scope, false).catch(() => undefined); + } catch { + /* a failed cleanup must not reject the request */ + } +} + +async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget: number, deadline: number, scope: ScanScope): Promise { if (scanBudget <= 0) return []; - const skillRootCanonical = await confinedCanonicalPath(skillRoot, cwdCanonical, deadline, "directory"); + const skillRootCanonical = await confinedCanonicalPath(skillRoot, cwdCanonical, deadline, "directory", scope); if (!skillRootCanonical) return []; let dir: Awaited> | undefined; try { @@ -273,7 +321,7 @@ async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget const openedDir = await opendir(skillRootCanonical); dir = openedDir; if (Date.now() >= deadline) { - void openedDir.close().catch(() => undefined); + closeSkillDirectoryBestEffort(openedDir, scope); return []; } const names: string[] = []; @@ -285,7 +333,7 @@ async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget const atLimit = visitedEntries >= scanBudget; if (!entry.name.startsWith(".") && entry.isDirectory()) { const skillMd = join(skillRoot, entry.name, "SKILL.md"); - const skillMdCanonical = await confinedCanonicalPath(skillMd, cwdCanonical, deadline, "file"); + const skillMdCanonical = await confinedCanonicalPath(skillMd, cwdCanonical, deadline, "file", scope); if (skillMdCanonical) { names.push(entry.name); } @@ -294,7 +342,7 @@ async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget } } catch { try { - void openedDir.close().catch(() => undefined); + closeSkillDirectoryBestEffort(openedDir, scope); } catch { /* closing a failed iterator is best-effort */ } @@ -304,11 +352,12 @@ async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget return names; }, deadline, + scope, ); } catch { if (dir) { try { - void dir.close().catch(() => undefined); + closeSkillDirectoryBestEffort(dir, scope); } catch { /* closing a timed-out iterator is best-effort */ } @@ -317,11 +366,11 @@ async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget } } -async function readSkill(skillRoot: string, dirName: string, cwdCanonical: string, capBytes: number, deadline: number): Promise { +async function readSkill(skillRoot: string, dirName: string, cwdCanonical: string, capBytes: number, deadline: number, scope: ScanScope): Promise { const path = join(skillRoot, dirName, "SKILL.md"); - const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file"); + const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file", scope); if (!canonical) return null; - const text = await readUtf8File(canonical, capBytes, deadline, cwdCanonical); + const text = await readUtf8File(canonical, capBytes, deadline, cwdCanonical, scope); if (text === null) return null; const { name, body } = parseSkillFrontmatter(truncateUtf8(text, capBytes)); return { name: name ?? dirName, body, bytesRead: Buffer.byteLength(text, "utf8") }; @@ -390,7 +439,7 @@ function truncateUtf8BodyForXml(body: string, capBytes: number): string | null { return best; } -async function readSkills(cwd: string, cwdCanonical: string, deadline: number): Promise { +async function readSkills(cwd: string, cwdCanonical: string, deadline: number, scope: ScanScope): Promise { const seen = new Set(); const collected: SkillEntry[] = []; let remainingBytes = SKILLS_READ_CAP_BYTES; @@ -399,12 +448,12 @@ async function readSkills(cwd: string, cwdCanonical: string, deadline: number): const remainingScanMs = deadline - Date.now(); if (remainingScanMs <= 0) break; const skillRoot = join(cwd, ...rootRel.split("/")); - const dirs = await listSkillDirs(skillRoot, cwdCanonical, MAX_SKILL_DIRS_TO_SCAN, deadline); + const dirs = await listSkillDirs(skillRoot, cwdCanonical, MAX_SKILL_DIRS_TO_SCAN, deadline, scope); for (const dirName of dirs) { if (collected.length >= MAX_SKILLS || remainingBytes <= 1) break; const remainingReadMs = deadline - Date.now(); if (remainingReadMs <= 0) break; - const skill = await readSkill(skillRoot, dirName, cwdCanonical, Math.min(SKILL_FILE_CAP_BYTES, remainingBytes - 1), deadline); + const skill = await readSkill(skillRoot, dirName, cwdCanonical, Math.min(SKILL_FILE_CAP_BYTES, remainingBytes - 1), deadline, scope); if (!skill) continue; remainingBytes -= skill.bytesRead; if (seen.has(skill.name)) continue; @@ -417,15 +466,15 @@ async function readSkills(cwd: string, cwdCanonical: string, deadline: number): return buildSkillsXml(collected); } -async function collectProjectContext(cwd: string, timeoutMs: number): Promise { +async function collectProjectContext(cwd: string, timeoutMs: number, scope: ScanScope): Promise { const deadline = Date.now() + timeoutMs; - const cwdCanonical = await canonicalPath(cwd, deadline); + const cwdCanonical = await canonicalPath(cwd, deadline, scope); if (!cwdCanonical) return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT }; const [memory, taste, skills] = await Promise.all([ - readMemory(cwd, cwdCanonical, deadline), - readTaste(cwd, cwdCanonical, deadline), - readSkills(cwd, cwdCanonical, deadline), + readMemory(cwd, cwdCanonical, deadline, scope), + readTaste(cwd, cwdCanonical, deadline, scope), + readSkills(cwd, cwdCanonical, deadline, scope), ]); return { memory, taste, skills }; @@ -434,19 +483,33 @@ async function collectProjectContext(cwd: string, timeoutMs: number): Promise { if (!cwd) return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT }; - const hadCachedEntry = projectContextCache.has(cwd); const cached = projectContextCache.get(cwd); - if (cached && Date.now() - cached.collectedAt < PROJECT_CONTEXT_TTL_MS) { - return cached.value; + if (cached && Date.now() - cached.collectedAt < PROJECT_CONTEXT_TTL_MS) return cached.value; + const existing = projectContextInFlight.get(cwd); + if (existing) return existing; + // A stuck filesystem operation stays counted after its caller's timeout. Once the cap is + // reached, fail soft without dispatching another scan or consuming another I/O worker. + if (outstandingProjectContextScans.has(cwd) + || outstandingProjectContextScans.size >= MAX_CONCURRENT_PROJECT_CONTEXT_SCANS + || pendingProjectContextFileOps.size >= MAX_PENDING_PROJECT_CONTEXT_FILE_OPS) { + return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT }; } - - const value = await collectProjectContext(cwd, fileOpTimeoutForTests ?? COMMAND_CODE_FILE_OP_TIMEOUT_MS); - const now = Date.now(); - if (hadCachedEntry) { + const scope: ScanScope = { cwd, pendingOps: 0, finished: false }; + outstandingProjectContextScans.set(cwd, scope); + const scan = (async () => { + const value = await collectProjectContext(cwd, fileOpTimeoutForTests ?? COMMAND_CODE_FILE_OP_TIMEOUT_MS, scope); + const now = Date.now(); pruneExpiredProjectContextCache(now); - } else { - pruneProjectContextCache(now); + if (!projectContextCache.has(cwd)) pruneProjectContextCache(now); + projectContextCache.set(cwd, { collectedAt: now, value }); + return value; + })(); + projectContextInFlight.set(cwd, scan); + try { + return await scan; + } finally { + if (projectContextInFlight.get(cwd) === scan) projectContextInFlight.delete(cwd); + scope.finished = true; + releaseScanIfSettled(scope); } - projectContextCache.set(cwd, { collectedAt: now, value }); - return value; } diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 9d9974ffed1..787d5aa301a 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -264,7 +264,10 @@ the path and identity checks as best effort. Every visited directory entry consu scan budget before filtering; at most 16 skills are selected. Individual files, aggregate skill reads, serialized XML, and the full skill-loading interval are bounded. The contents are sent to the configured Command Code endpoint when enabled, and missing or -failed reads degrade to empty fields. A 30-second, 128-entry cache bounds repeated reads. +failed reads degrade to empty fields. A cwd-keyed single-flight shares cold or expired loads. +Eight outstanding scan slots remain occupied until every dispatched filesystem operation +settles, including after a caller timeout; a 64-operation global admission ceiling fails +soft on further work. The 30-second, 128-entry cache rechecks capacity at insertion time. ## TypeSafe JEV decision provider diff --git a/tests/providers/command-code-project-context.test.ts b/tests/providers/command-code-project-context.test.ts index 0de9f316715..7e838045f55 100644 --- a/tests/providers/command-code-project-context.test.ts +++ b/tests/providers/command-code-project-context.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"; import { execFileSync } from "node:child_process"; import { chmodSync, + type Stats, existsSync, mkdirSync, mkdtempSync, @@ -99,31 +100,101 @@ describe("loadCommandCodeProjectContext", () => { for (const stalled of ["lstat", "realpath"] as const) { projectContextCache.clear(); let stalledCalls = 0; + let releaseStalled: () => Promise = async () => {}; if (stalled === "lstat") { - lstatMock.mockImplementation(async path => { + lstatMock.mockImplementation(path => { if (String(path) !== agentsPath) return realLstat(path); stalledCalls++; - return new Promise(() => {}); + return new Promise(resolve => { + releaseStalled = async () => { resolve(await realLstat(path)); }; + }); }); } else { - realpathMock.mockImplementation(async path => { + realpathMock.mockImplementation(path => { if (String(path) !== agentsPath) return realRealpath(path); stalledCalls++; - return new Promise(() => {}); + return new Promise(resolve => { + releaseStalled = async () => { resolve(await realRealpath(path)); }; + }); }); } - setCommandCodeFileOpTimeoutForTests(500); - const result = await loadCommandCodeProjectContext(root); - expect(stalledCalls).toBe(1); - expect(result.memory).toBe(""); - lstatMock.mockImplementation(realLstat); - realpathMock.mockImplementation(realRealpath); + try { + setCommandCodeFileOpTimeoutForTests(500); + const result = await loadCommandCodeProjectContext(root); + expect(stalledCalls).toBe(1); + expect(result.memory).toBe(""); + } finally { + lstatMock.mockImplementation(realLstat); + realpathMock.mockImplementation(realRealpath); + await releaseStalled(); + await new Promise(resolve => setTimeout(resolve, 0)); + } } } finally { rmSync(root, { recursive: true, force: true }); } }); + test("concurrent cold-cache requests share one project scan", async () => { + const root = makeTempDir("ocx-cc-ctx-single-flight-"); + const agentsPath = join(root, "AGENTS.md"); + let releaseRead: () => void = () => {}; + let markStarted: () => void = () => {}; + const started = new Promise(resolve => { markStarted = resolve; }); + const gate = new Promise(resolve => { releaseRead = resolve; }); + let openGates = 0; + try { + writeFileSync(agentsPath, "shared memory", "utf8"); + setCommandCodeBeforeOpenForTests(path => { + if (path !== agentsPath) return; + openGates++; + markStarted(); + return gate; + }); + const first = loadCommandCodeProjectContext(root); + await started; + const second = loadCommandCodeProjectContext(root); + releaseRead(); + const [one, two] = await Promise.all([first, second]); + expect(openGates).toBe(1); + expect(one).toBe(two); + expect(one.memory).toBe("shared memory"); + } finally { + releaseRead(); + setCommandCodeBeforeOpenForTests(undefined); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("never-settling scans retain their cwd slots and cap work across keys", async () => { + const roots = Array.from({ length: 12 }, () => makeTempDir("ocx-cc-ctx-abandoned-cap-")); + const rootSet = new Set(roots); + const release: Array<() => Promise> = []; + let lstatCalls = 0; + try { + lstatMock.mockImplementation(path => { + if (!rootSet.has(String(path))) return realLstat(path); + lstatCalls++; + return new Promise(resolve => { + release.push(async () => { resolve(await realLstat(path)); }); + }); + }); + setCommandCodeFileOpTimeoutForTests(20); + for (const root of roots) { + expect(await loadCommandCodeProjectContext(root)).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); + } + expect(lstatCalls).toBe(8); + projectContextCache.delete(roots[0]!); + expect(await loadCommandCodeProjectContext(roots[0])).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); + expect(lstatCalls).toBe(8); + } finally { + lstatMock.mockImplementation(realLstat); + await Promise.all(release.map(settle => settle())); + await new Promise(resolve => setTimeout(resolve, 0)); + for (const root of roots) rmSync(root, { recursive: true, force: true }); + } + }); + test("rejects a FIFO without attempting a blocking open", async () => { if (process.platform === "win32") return; const root = makeTempDir("ocx-cc-ctx-fifo-"); @@ -787,64 +858,71 @@ describe("projectContextCache eviction", () => { const root = makeTempDir("ocx-cc-ctx-refresh-capacity-"); const now = Date.now(); const emptyValue = { memory: "", taste: null, skills: null }; - let releaseFirstRead!: () => void; - let releaseSecondRead!: () => void; - let firstReadStarted!: () => void; - let secondReadStarted!: () => void; - const firstRead = new Promise(resolve => { firstReadStarted = resolve; }); - const secondRead = new Promise(resolve => { secondReadStarted = resolve; }); - const firstGate = new Promise(resolve => { releaseFirstRead = resolve; }); - const secondGate = new Promise(resolve => { releaseSecondRead = resolve; }); - let readCount = 0; - - for (let i = 0; i < MAX_PROJECT_CONTEXT_CACHE_ENTRIES - 1; i++) { - projectContextCache.set(`/sibling-${i}`, { collectedAt: now, value: emptyValue }); - } - projectContextCache.set(root, { - collectedAt: now - PROJECT_CONTEXT_TTL_MS - 1, - value: emptyValue, - }); - writeFileSync(join(root, "AGENTS.md"), "refreshed", "utf8"); - openMock.mockImplementation(async path => { - if (String(path) === join(root, "AGENTS.md")) { - const handle = await realOpen(path); - const originalRead = handle.read.bind(handle); - return { - ...handle, - read: async (buffer: Buffer, offset: number, length: number, position: number) => { - readCount++; - if (readCount === 1) { - firstReadStarted(); - await firstGate; - } else if (readCount === 2) { - secondReadStarted(); - await secondGate; - } - return originalRead(buffer, offset, length, position); - }, - stat: handle.stat.bind(handle), - close: handle.close.bind(handle), - } as Awaited>; - } - return realOpen(path); - }); - + let releaseRead: () => void = () => {}; + let markStarted: () => void = () => {}; + const started = new Promise(resolve => { markStarted = resolve; }); + const gate = new Promise(resolve => { releaseRead = resolve; }); + let reads = 0; try { - const firstLoad = loadCommandCodeProjectContext(root); - await firstRead; - const secondLoad = loadCommandCodeProjectContext(root); - await secondRead; - - releaseFirstRead(); - await firstLoad; - releaseSecondRead(); - await secondLoad; - + for (let i = 0; i < MAX_PROJECT_CONTEXT_CACHE_ENTRIES - 1; i++) { + projectContextCache.set(`/sibling-${i}`, { collectedAt: now, value: emptyValue }); + } + projectContextCache.set(root, { collectedAt: now - PROJECT_CONTEXT_TTL_MS - 1, value: emptyValue }); + writeFileSync(join(root, "AGENTS.md"), "refreshed", "utf8"); + setCommandCodeBeforeOpenForTests(path => { + if (path !== join(root, "AGENTS.md")) return; + reads++; + markStarted(); + return gate; + }); + const first = loadCommandCodeProjectContext(root); + await started; + const second = loadCommandCodeProjectContext(root); + releaseRead(); + const [one, two] = await Promise.all([first, second]); + expect(one).toBe(two); + expect(reads).toBe(1); expect(projectContextCache.size).toBe(MAX_PROJECT_CONTEXT_CACHE_ENTRIES); expect(projectContextCache.has("/sibling-0")).toBe(true); expect(projectContextCache.get(root)?.value.memory).toBe("refreshed"); } finally { - openMock.mockImplementation(realOpen); + releaseRead(); + setCommandCodeBeforeOpenForTests(undefined); + rmSync(root, { recursive: true, force: true }); + } + }); + + test("cache insertion rechecks capacity after an in-flight key is evicted", async () => { + const root = makeTempDir("ocx-cc-ctx-interleaved-capacity-"); + const now = Date.now(); + const emptyValue = { memory: "", taste: null, skills: null }; + let releaseRead: () => void = () => {}; + let markStarted: () => void = () => {}; + const started = new Promise(resolve => { markStarted = resolve; }); + const gate = new Promise(resolve => { releaseRead = resolve; }); + try { + for (let i = 0; i < MAX_PROJECT_CONTEXT_CACHE_ENTRIES - 1; i++) { + projectContextCache.set(`/sibling-${i}`, { collectedAt: now, value: emptyValue }); + } + projectContextCache.set(root, { collectedAt: now - PROJECT_CONTEXT_TTL_MS - 1, value: emptyValue }); + writeFileSync(join(root, "AGENTS.md"), "new value", "utf8"); + setCommandCodeBeforeOpenForTests(path => { + if (path !== join(root, "AGENTS.md")) return; + markStarted(); + return gate; + }); + const loading = loadCommandCodeProjectContext(root); + await started; + projectContextCache.delete(root); + projectContextCache.set("/replacement", { collectedAt: Date.now(), value: emptyValue }); + releaseRead(); + expect((await loading).memory).toBe("new value"); + expect(projectContextCache.size).toBe(MAX_PROJECT_CONTEXT_CACHE_ENTRIES); + expect(projectContextCache.has(root)).toBe(true); + expect(projectContextCache.has("/replacement")).toBe(true); + } finally { + releaseRead(); + setCommandCodeBeforeOpenForTests(undefined); rmSync(root, { recursive: true, force: true }); } }); From c0db1df7156ae4ec3f18c28a635d981c77711647 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 28 Sep 2026 23:47:52 +0900 Subject: [PATCH 7/7] fix(command-code): recover degraded context and confined skill links Allow in-root skill symlinks, avoid caching timeout and admission failures, and release timed-out test operations. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 7 +- src/adapters/command-code-project-context.ts | 61 +++++++++---- structure/providers-and-adapters.md | 5 +- .../command-code-project-context.test.ts | 85 +++++++++++++++++-- 4 files changed, 132 insertions(+), 26 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 85e2a2464c0..b8c6c7510c6 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -708,10 +708,11 @@ and `SKILL.md` files in immediate child directories of `.commandcode/skills`, the collected contents upstream to the configured Command Code endpoint. It is rejected for other adapters. Asynchronous path checks keep reads inside the resolved working directory, including when that directory is a filesystem root; only regular-file -content is read. Every visited skill-directory entry counts toward the scan budget; at +content is read. Skill-directory symlinks resolving inside that directory are allowed; +those resolving outside are rejected. Every visited entry counts toward the scan budget; at most 16 skills are selected. File bytes, total skill bytes, and loading time are bounded; -unreadable or missing files produce empty fields. -Results are cached for 30 seconds. +unreadable or missing files produce empty fields. Stable results are cached for 30 seconds; +timeouts and filesystem-admission refusals are not cached, so a later request can retry. **OrcaRouter authentication and discovery.** Choose either `ocx login orcarouter-oauth` for one-click browser authorization or `ocx login orcarouter` to paste an existing API key. The PKCE diff --git a/src/adapters/command-code-project-context.ts b/src/adapters/command-code-project-context.ts index 23e2018069d..50b2b5e9cf9 100644 --- a/src/adapters/command-code-project-context.ts +++ b/src/adapters/command-code-project-context.ts @@ -39,11 +39,22 @@ const PROJECT_CONTEXT_TTL_MS = 30_000; const MAX_PROJECT_CONTEXT_CACHE_ENTRIES = 128; const MAX_CONCURRENT_PROJECT_CONTEXT_SCANS = 8; const MAX_PENDING_PROJECT_CONTEXT_FILE_OPS = 64; +class ProjectContextTimeoutError extends Error {} +class ProjectContextAdmissionError extends Error {} const projectContextInFlight = new Map>(); -type ScanScope = { cwd: string; pendingOps: number; finished: boolean }; +type ScanScope = { cwd: string; pendingOps: number; finished: boolean; degraded: boolean }; const outstandingProjectContextScans = new Map(); const pendingProjectContextFileOps = new Set>(); +/** Test-only observation of scan admission and abandoned filesystem work. */ +export function commandCodeProjectContextWorkCountsForTests(): { inFlight: number; outstandingScans: number; pendingFileOps: number } { + return { + inFlight: projectContextInFlight.size, + outstandingScans: outstandingProjectContextScans.size, + pendingFileOps: pendingProjectContextFileOps.size, + }; +} + const TRUNCATION_MARKER = "\n"; const SKILL_ROOTS = [ @@ -90,7 +101,7 @@ function releaseScanIfSettled(scope: ScanScope): void { function trackedFileOperation(operation: () => Promise, scope: ScanScope, enforceLimit = true): Promise { if (enforceLimit && pendingProjectContextFileOps.size >= MAX_PENDING_PROJECT_CONTEXT_FILE_OPS) { - throw new Error("project context filesystem admission limit"); + throw new ProjectContextAdmissionError("project context filesystem admission limit"); } const pending = operation(); scope.pendingOps++; @@ -107,8 +118,16 @@ function trackedFileOperation(operation: () => Promise, scope: ScanScope, /** Keep filesystem metadata work off the request thread and inside one load deadline. */ async function withinDeadline(operation: () => Promise, deadline: number, scope: ScanScope): Promise { const remaining = deadline - Date.now(); - if (remaining <= 0) throw new Error("timeout"); - return withTimeout(trackedFileOperation(operation, scope), remaining); + if (remaining <= 0) { + scope.degraded = true; + throw new ProjectContextTimeoutError("timeout"); + } + try { + return await withTimeout(trackedFileOperation(operation, scope), remaining); + } catch (error) { + if (error instanceof ProjectContextTimeoutError || error instanceof ProjectContextAdmissionError) scope.degraded = true; + throw error; + } } /** Fail-soft canonical path; no synchronous filesystem calls run on the request thread. */ @@ -150,7 +169,7 @@ async function withTimeout(promise: Promise, ms: number): Promise { return await Promise.race([ promise, new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error("timeout")), ms); + timer = setTimeout(() => reject(new ProjectContextTimeoutError("timeout")), ms); }), ]); } finally { @@ -198,14 +217,18 @@ async function readUtf8File(path: string, capBytes: number, deadline: number, cw } } const remaining = deadline - Date.now(); - if (remaining <= 0) return null; + if (remaining <= 0) { + scope.degraded = true; + return null; + } // O_NONBLOCK prevents a race that swaps a checked regular file for a FIFO. // Windows lacks these POSIX open guards; post-open path/identity checks remain best-effort there. const flags = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW; let opened: ReturnType; try { opened = trackedFileOperation(() => open(path, flags), scope); - } catch { + } catch (error) { + if (error instanceof ProjectContextAdmissionError) scope.degraded = true; return null; } const closeBestEffort = (handle: FileHandle): Promise => { @@ -235,14 +258,16 @@ async function readUtf8File(path: string, capBytes: number, deadline: number, cw if (fileHandle === handle) fileHandle = undefined; } }, scope); - } catch { + } catch (error) { + if (error instanceof ProjectContextAdmissionError) scope.degraded = true; void opened.then(handle => closeBestEffort(handle), () => undefined); return null; } try { return await withTimeout(read, remaining); - } catch { + } catch (error) { + if (error instanceof ProjectContextTimeoutError || error instanceof ProjectContextAdmissionError) scope.degraded = true; if (fileHandle) void closeBestEffort(fileHandle); void opened.then(handle => closeBestEffort(handle), () => undefined); return null; @@ -331,7 +356,7 @@ async function listSkillDirs(skillRoot: string, cwdCanonical: string, scanBudget if (Date.now() >= deadline) break; visitedEntries++; const atLimit = visitedEntries >= scanBudget; - if (!entry.name.startsWith(".") && entry.isDirectory()) { + if (!entry.name.startsWith(".") && (entry.isDirectory() || entry.isSymbolicLink())) { const skillMd = join(skillRoot, entry.name, "SKILL.md"); const skillMdCanonical = await confinedCanonicalPath(skillMd, cwdCanonical, deadline, "file", scope); if (skillMdCanonical) { @@ -446,13 +471,13 @@ async function readSkills(cwd: string, cwdCanonical: string, deadline: number, s for (const rootRel of SKILL_ROOTS) { const remainingScanMs = deadline - Date.now(); - if (remainingScanMs <= 0) break; + if (remainingScanMs <= 0) { scope.degraded = true; break; } const skillRoot = join(cwd, ...rootRel.split("/")); const dirs = await listSkillDirs(skillRoot, cwdCanonical, MAX_SKILL_DIRS_TO_SCAN, deadline, scope); for (const dirName of dirs) { if (collected.length >= MAX_SKILLS || remainingBytes <= 1) break; const remainingReadMs = deadline - Date.now(); - if (remainingReadMs <= 0) break; + if (remainingReadMs <= 0) { scope.degraded = true; break; } const skill = await readSkill(skillRoot, dirName, cwdCanonical, Math.min(SKILL_FILE_CAP_BYTES, remainingBytes - 1), deadline, scope); if (!skill) continue; remainingBytes -= skill.bytesRead; @@ -494,14 +519,16 @@ export async function loadCommandCodeProjectContext(cwd: string | undefined): Pr || pendingProjectContextFileOps.size >= MAX_PENDING_PROJECT_CONTEXT_FILE_OPS) { return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT }; } - const scope: ScanScope = { cwd, pendingOps: 0, finished: false }; + const scope: ScanScope = { cwd, pendingOps: 0, finished: false, degraded: false }; outstandingProjectContextScans.set(cwd, scope); const scan = (async () => { const value = await collectProjectContext(cwd, fileOpTimeoutForTests ?? COMMAND_CODE_FILE_OP_TIMEOUT_MS, scope); - const now = Date.now(); - pruneExpiredProjectContextCache(now); - if (!projectContextCache.has(cwd)) pruneProjectContextCache(now); - projectContextCache.set(cwd, { collectedAt: now, value }); + if (!scope.degraded) { + const now = Date.now(); + pruneExpiredProjectContextCache(now); + if (!projectContextCache.has(cwd)) pruneProjectContextCache(now); + projectContextCache.set(cwd, { collectedAt: now, value }); + } return value; })(); projectContextInFlight.set(cwd, scan); diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 787d5aa301a..c55750f8314 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -267,7 +267,10 @@ contents are sent to the configured Command Code endpoint when enabled, and miss failed reads degrade to empty fields. A cwd-keyed single-flight shares cold or expired loads. Eight outstanding scan slots remain occupied until every dispatched filesystem operation settles, including after a caller timeout; a 64-operation global admission ceiling fails -soft on further work. The 30-second, 128-entry cache rechecks capacity at insertion time. +soft on further work. Timed-out or admission-degraded loads are not cached, so a later +healthy request can retry; stable missing files still cache as empty. Symlinked skill +directories pass through canonical confinement: inside-cwd targets load, outside targets +do not. The 30-second, 128-entry cache rechecks capacity at insertion time. ## TypeSafe JEV decision provider diff --git a/tests/providers/command-code-project-context.test.ts b/tests/providers/command-code-project-context.test.ts index 7e838045f55..3774cee8c3b 100644 --- a/tests/providers/command-code-project-context.test.ts +++ b/tests/providers/command-code-project-context.test.ts @@ -37,6 +37,7 @@ mock.module("node:fs/promises", () => ({ const { EMPTY_COMMAND_CODE_PROJECT_CONTEXT, + commandCodeProjectContextWorkCountsForTests, loadCommandCodeProjectContext, isContainedCanonicalPath, projectContextCache, @@ -191,6 +192,7 @@ describe("loadCommandCodeProjectContext", () => { lstatMock.mockImplementation(realLstat); await Promise.all(release.map(settle => settle())); await new Promise(resolve => setTimeout(resolve, 0)); + expect(commandCodeProjectContextWorkCountsForTests()).toEqual({ inFlight: 0, outstandingScans: 0, pendingFileOps: 0 }); for (const root of roots) rmSync(root, { recursive: true, force: true }); } }); @@ -217,11 +219,74 @@ describe("loadCommandCodeProjectContext", () => { } }); + test("loads a skill directory symlink that resolves inside cwd", async () => { + if (process.platform === "win32") return; + const root = makeTempDir("ocx-cc-ctx-internal-skill-link-"); + try { + const skillRoot = join(root, ".commandcode", "skills"); + const target = join(root, "internal-skill-target"); + mkdirSync(skillRoot, { recursive: true }); + mkdirSync(target); + writeFileSync(join(target, "SKILL.md"), "inside symlink body", "utf8"); + symlinkSync(target, join(skillRoot, "linked"), "dir"); + expect((await loadCommandCodeProjectContext(root)).skills) + .toBe('\n inside symlink body\n'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("rejects a skill directory symlink that resolves outside cwd", async () => { + if (process.platform === "win32") return; + const root = makeTempDir("ocx-cc-ctx-external-skill-link-"); + const outside = makeTempDir("ocx-cc-ctx-external-skill-target-"); + try { + const skillRoot = join(root, ".commandcode", "skills"); + mkdirSync(skillRoot, { recursive: true }); + writeFileSync(join(outside, "SKILL.md"), "outside secret body", "utf8"); + symlinkSync(outside, join(skillRoot, "external"), "dir"); + expect((await loadCommandCodeProjectContext(root)).skills).toBeNull(); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + } + }); + + test("a timed-out scan is not cached and recovers after its operation settles", async () => { + const root = makeTempDir("ocx-cc-ctx-transient-timeout-"); + const agentsPath = join(root, "AGENTS.md"); + let release: () => Promise = async () => {}; + try { + writeFileSync(agentsPath, "recovered memory", "utf8"); + lstatMock.mockImplementation(path => { + if (String(path) !== agentsPath) return realLstat(path); + return new Promise(resolve => { + release = async () => { resolve(await realLstat(path)); }; + }); + }); + setCommandCodeFileOpTimeoutForTests(100); + expect((await loadCommandCodeProjectContext(root)).memory).toBe(""); + expect(projectContextCache.has(root)).toBe(false); + lstatMock.mockImplementation(realLstat); + await release(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(commandCodeProjectContextWorkCountsForTests()).toEqual({ inFlight: 0, outstandingScans: 0, pendingFileOps: 0 }); + expect((await loadCommandCodeProjectContext(root)).memory).toBe("recovered memory"); + expect(projectContextCache.has(root)).toBe(true); + } finally { + lstatMock.mockImplementation(realLstat); + await release(); + await new Promise(resolve => setTimeout(resolve, 0)); + rmSync(root, { recursive: true, force: true }); + } + }); + test("missing files return empty memory, null taste, null skills", async () => { const root = makeTempDir("ocx-cc-ctx-empty-"); try { const result = await loadCommandCodeProjectContext(root); expect(result).toEqual({ memory: "", taste: null, skills: null }); + expect(await loadCommandCodeProjectContext(root)).toBe(result); } finally { rmSync(root, { recursive: true, force: true }); } @@ -291,13 +356,14 @@ describe("loadCommandCodeProjectContext", () => { const skillRoot = join(root, ".commandcode", "skills"); mkdirSync(skillRoot, { recursive: true }); let closeCalls = 0; + let releaseNext: () => void = () => {}; const hangingDir = { - close: async () => { - closeCalls++; - }, + close: async () => { closeCalls++; }, [Symbol.asyncIterator]() { return { - next: () => new Promise(() => {}), + next: () => new Promise>(resolve => { + releaseNext = () => resolve({ done: true, value: undefined }); + }), }; }, }; @@ -316,6 +382,9 @@ describe("loadCommandCodeProjectContext", () => { expect(closeCalls).toBe(1); } finally { opendirMock.mockImplementation(realOpendir); + releaseNext(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(commandCodeProjectContextWorkCountsForTests()).toEqual({ inFlight: 0, outstandingScans: 0, pendingFileOps: 0 }); rmSync(root, { recursive: true, force: true }); } }); @@ -510,9 +579,12 @@ describe("loadCommandCodeProjectContext", () => { const root = makeTempDir("ocx-cc-ctx-read-timeout-"); const agentsPath = join(root, "AGENTS.md"); let closeCalls = 0; + let releaseRead: () => void = () => {}; const hangingFile = { stat: async () => statSync(agentsPath), - read: () => new Promise(() => {}), + read: () => new Promise<{ bytesRead: number; buffer: Buffer }>(resolve => { + releaseRead = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) }); + }), close: async () => { closeCalls++; }, @@ -534,6 +606,9 @@ describe("loadCommandCodeProjectContext", () => { expect(closeCalls).toBe(1); } finally { openMock.mockImplementation(realOpen); + releaseRead(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(commandCodeProjectContextWorkCountsForTests()).toEqual({ inFlight: 0, outstandingScans: 0, pendingFileOps: 0 }); rmSync(root, { recursive: true, force: true }); } });