From 88c87a1637180adff489339ead9a0263a0cea1e2 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:14:42 -0600 Subject: [PATCH] feat(command-code): add opt-in projectContext envelope for /alpha/generate --- .../src/content/docs/guides/providers.md | 5 + src/adapters/command-code-project-context.ts | 382 ++++++++++ src/adapters/command-code.ts | 6 +- src/config.ts | 1 + src/server/auth-cors.ts | 1 + src/types/provider.ts | 2 + .../command-code-project-context.test.ts | 665 ++++++++++++++++++ 7 files changed, 1061 insertions(+), 1 deletion(-) 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 0986a883c0..b90b037459 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -511,6 +511,11 @@ preset (`commandcode`) uses the active configured Bearer key for chat requests; (`command-code`) uses the stored account bearer for authenticated discovery and chat. Create Provider-API keys at [Command Code Studio](https://commandcode.ai/studio/). +The OAuth adapter sends empty `memory`, `taste`, and `skills` fields by default. Set +`projectContext: "on"` on a `command-code` provider to include bounded local +`AGENTS.md`, `taste.md`, and skill files from the current working directory. The +loader is fail-soft, caps each payload, and stays off unless explicitly enabled. + **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/src/adapters/command-code-project-context.ts b/src/adapters/command-code-project-context.ts new file mode 100644 index 0000000000..8c064455b0 --- /dev/null +++ b/src/adapters/command-code-project-context.ts @@ -0,0 +1,382 @@ +import { existsSync, 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 MAX_SKILLS = 16; +// Upper bound on how many candidate skill directories one root may enumerate before +// stopping. Realistic projects have far fewer; this bounds pathological/hostile +// directories (millions of entries) to a finite scan while preserving alphabetical +// selection for any realistic case (≤ MAX_SKILL_DIRS_TO_SCAN valid dirs per root). +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 { + if (!existsSync(candidate)) return null; + try { + 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; + if (!existsSync(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; +} + +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[] = []; + try { + for await (const entry of openedDir) { + if (entry.name.startsWith(".")) continue; + if (!entry.isDirectory()) continue; + const skillMd = join(skillRoot, entry.name, "SKILL.md"); + const skillMdCanonical = confinedCanonicalPath(skillMd, cwdCanonical); + if (!skillMdCanonical) continue; + if (!existsSync(skillMdCanonical)) continue; + names.push(entry.name); + if (names.length >= scanBudget) 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, timeoutMs: number): Promise { + const path = join(skillRoot, dirName, "SKILL.md"); + const canonical = confinedCanonicalPath(path, cwdCanonical); + if (!canonical) return null; + const text = await readUtf8File(canonical, SKILLS_XML_CAP_BYTES, timeoutMs); + if (text === null) return null; + const { name, body } = parseSkillFrontmatter(text); + return { name: name ?? dirName, body }; +} + +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[] = []; + + for (const rootRel of SKILL_ROOTS) { + const skillRoot = join(cwd, ...rootRel.split("/")); + const dirs = await listSkillDirs(skillRoot, cwdCanonical, MAX_SKILL_DIRS_TO_SCAN, timeoutMs); + for (const dirName of dirs) { + if (collected.length >= MAX_SKILLS) break; + const skill = await readSkill(skillRoot, dirName, cwdCanonical, timeoutMs); + if (!skill) continue; + if (seen.has(skill.name)) continue; + seen.add(skill.name); + collected.push(skill); + } + if (collected.length >= MAX_SKILLS) 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 c20dc88be6..a267f381ea 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -13,6 +13,7 @@ import { commandCodeReasoningEfforts, refreshCommandCodeReasoningEfforts } from import { identifyRoutedModel } from "./identity"; import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge"; import { parseDataUrl } from "./image"; +import { EMPTY_COMMAND_CODE_PROJECT_CONTEXT, loadCommandCodeProjectContext } from "./command-code-project-context"; // Retain the short ids emitted by the first local integration. New requests use the live catalog's // provider-native IDs directly; this map is compatibility-only and is not a model fallback list. @@ -522,8 +523,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.ts b/src/config.ts index 4311e54eef..e86e66675e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -635,6 +635,7 @@ 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(), diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index d103721f35..44620e99db 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -781,6 +781,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 0beb5d3371..67877cb6c5 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -278,6 +278,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/tests/providers/command-code-project-context.test.ts b/tests/providers/command-code-project-context.test.ts new file mode 100644 index 0000000000..f39fc4f915 --- /dev/null +++ b/tests/providers/command-code-project-context.test.ts @@ -0,0 +1,665 @@ +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", "from commandcode"); + writeSkill(root, ".agents/skills", "shared", "from agents"); + writeSkill(root, ".pi/skills", "shared", "from pi"); + 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 near the scan cap, not walk all 300 (each costs canonicalize + existsSync). + 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-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("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 now = Date.now(); + 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"); + pruneProjectContextCache(now + i); + 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 }); + } + }); +});