diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index d41f9bfe95c..b8c6c7510c6 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -697,6 +697,23 @@ 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. +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. Asynchronous path checks keep reads inside the resolved working +directory, including when that directory is a filesystem root; only regular-file +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. 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 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..50b2b5e9cf9 --- /dev/null +++ b/src/adapters/command-code-project-context.ts @@ -0,0 +1,542 @@ +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; + 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; +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; +} +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; 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 = [ + ".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); + } +} + +/** 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 ProjectContextAdmissionError("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, scope: ScanScope): Promise { + const remaining = deadline - Date.now(); + 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. */ +async function canonicalPath(candidate: string, deadline: number, scope: ScanScope): Promise { + try { + await withinDeadline(() => lstat(candidate), deadline, scope); + return await withinDeadline(() => realpath(candidate), deadline, scope); + } catch { + return null; + } +} + +function normalizePathIdentity(path: string): string { + return process.platform === "win32" ? path.toLowerCase() : path; +} + +/** 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", scope: ScanScope, +): Promise { + const canonical = await canonicalPath(filePath, deadline, scope); + if (!canonical || !isContainedCanonicalPath(cwdCanonical, canonical)) return null; + try { + const info = await withinDeadline(() => stat(canonical), deadline, scope); + return (kind === "file" ? info.isFile() : info.isDirectory()) ? canonical : null; + } catch { + 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 ProjectContextTimeoutError("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; +} + +/** 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, scope: ScanScope, +): Promise { + const opened = await withinDeadline(() => handle.stat(), deadline, scope); + if (!opened.isFile()) return false; + 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, 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, scope); + return resolvedInfo.isFile() && opened.dev === resolvedInfo.dev && opened.ino === resolvedInfo.ino; +} + +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, scope); + } catch { + return null; + } + } + const remaining = deadline - Date.now(); + 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 (error) { + if (error instanceof ProjectContextAdmissionError) scope.degraded = true; + return null; + } + const closeBestEffort = (handle: FileHandle): Promise => { + if (closedHandles.has(handle)) return Promise.resolve(); + closedHandles.add(handle); + return Promise.resolve() + .then(() => trackedFileOperation(() => handle.close(), scope, false)) + .catch(() => { + /* closing a timed-out read is best-effort */ + }); + }; + + 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 (error) { + if (error instanceof ProjectContextAdmissionError) scope.degraded = true; + void opened.then(handle => closeBestEffort(handle), () => undefined); + return null; + } + + try { + return await withTimeout(read, remaining); + } 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; + } +} + +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, deadline: number, scope: ScanScope): Promise { + const path = join(cwd, "AGENTS.md"); + const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file", scope); + if (!canonical) return ""; + 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, scope: ScanScope): Promise { + const path = join(cwd, ".commandcode", "taste", "taste.md"); + const canonical = await confinedCanonicalPath(path, cwdCanonical, deadline, "file", scope); + if (!canonical) return null; + const text = await readUtf8File(canonical, TASTE_CAP_BYTES, deadline, cwdCanonical, scope); + if (text === null) return null; + return truncateUtf8(text, TASTE_CAP_BYTES); +} + +interface SkillEntry { + name: string; + body: string; + bytesRead: number; +} + +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", scope); + if (!skillRootCanonical) return []; + let dir: Awaited> | undefined; + try { + return await withinDeadline( + async () => { + const openedDir = await opendir(skillRootCanonical); + dir = openedDir; + if (Date.now() >= deadline) { + closeSkillDirectoryBestEffort(openedDir, scope); + 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() || entry.isSymbolicLink())) { + const skillMd = join(skillRoot, entry.name, "SKILL.md"); + const skillMdCanonical = await confinedCanonicalPath(skillMd, cwdCanonical, deadline, "file", scope); + if (skillMdCanonical) { + names.push(entry.name); + } + } + if (atLimit) break; + } + } catch { + try { + closeSkillDirectoryBestEffort(openedDir, scope); + } catch { + /* closing a failed iterator is best-effort */ + } + /* directory iteration is best-effort */ + } + names.sort(); + return names; + }, + deadline, + scope, + ); + } catch { + if (dir) { + try { + closeSkillDirectoryBestEffort(dir, scope); + } catch { + /* closing a timed-out iterator is best-effort */ + } + } + return []; + } +} + +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", scope); + if (!canonical) return null; + 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") }; +} + +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, deadline: number, scope: ScanScope): Promise { + const seen = new Set(); + const collected: SkillEntry[] = []; + let remainingBytes = SKILLS_READ_CAP_BYTES; + + for (const rootRel of SKILL_ROOTS) { + const remainingScanMs = deadline - Date.now(); + 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) { 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; + 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, scope: ScanScope): Promise { + const deadline = Date.now() + timeoutMs; + 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, scope), + readTaste(cwd, cwdCanonical, deadline, scope), + readSkills(cwd, cwdCanonical, deadline, scope), + ]); + + return { memory, taste, skills }; +} + +export async function loadCommandCodeProjectContext(cwd: string | undefined): Promise { + if (!cwd) return { ...EMPTY_COMMAND_CODE_PROJECT_CONTEXT }; + + const cached = projectContextCache.get(cwd); + 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 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); + 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); + try { + return await scan; + } finally { + if (projectContextInFlight.get(cwd) === scan) projectContextInFlight.delete(cwd); + scope.finished = true; + releaseScanIfSettled(scope); + } +} 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/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/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..c55750f8314 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`. | @@ -249,6 +249,29 @@ 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"` 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`. +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 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. 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 `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..3774cee8c3b --- /dev/null +++ b/tests/providers/command-code-project-context.test.ts @@ -0,0 +1,1004 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { + chmodSync, + type Stats, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +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, + commandCodeProjectContextWorkCountsForTests, + loadCommandCodeProjectContext, + isContainedCanonicalPath, + projectContextCache, + pruneProjectContextCache, + setCommandCodeFileOpTimeoutForTests, + setCommandCodeBeforeOpenForTests, +} = 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(() => { + lstatMock.mockImplementation(realLstat); + realpathMock.mockImplementation(realRealpath); + projectContextCache.clear(); + setCommandCodeFileOpTimeoutForTests(undefined); + setCommandCodeBeforeOpenForTests(undefined); +}); + +describe("loadCommandCodeProjectContext", () => { + test("undefined cwd returns empty context", async () => { + const result = await loadCommandCodeProjectContext(undefined); + 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; + let releaseStalled: () => Promise = async () => {}; + if (stalled === "lstat") { + lstatMock.mockImplementation(path => { + if (String(path) !== agentsPath) return realLstat(path); + stalledCalls++; + return new Promise(resolve => { + releaseStalled = async () => { resolve(await realLstat(path)); }; + }); + }); + } else { + realpathMock.mockImplementation(path => { + if (String(path) !== agentsPath) return realRealpath(path); + stalledCalls++; + return new Promise(resolve => { + releaseStalled = async () => { resolve(await realRealpath(path)); }; + }); + }); + } + 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)); + expect(commandCodeProjectContextWorkCountsForTests()).toEqual({ inFlight: 0, outstandingScans: 0, pendingFileOps: 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-"); + 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("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 }); + } + }); + + 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; + let releaseNext: () => void = () => {}; + const hangingDir = { + close: async () => { closeCalls++; }, + [Symbol.asyncIterator]() { + return { + next: () => new Promise>(resolve => { + releaseNext = () => resolve({ done: true, value: undefined }); + }), + }; + }, + }; + + opendirMock.mockImplementation(async path => { + if (String(path) === skillRoot) { + return hangingDir as Awaited>; + } + return realOpendir(path); + }); + + try { + setCommandCodeFileOpTimeoutForTests(250); + const result = await loadCommandCodeProjectContext(root); + expect(result).toEqual(EMPTY_COMMAND_CODE_PROJECT_CONTEXT); + 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 }); + } + }); + + 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); + }, + stat: handle.stat.bind(handle), + 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; + }, + stat: handle.stat.bind(handle), + 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; + let releaseRead: () => void = () => {}; + const hangingFile = { + stat: async () => statSync(agentsPath), + read: () => new Promise<{ bytesRead: number; buffer: Buffer }>(resolve => { + releaseRead = () => resolve({ bytesRead: 0, buffer: Buffer.alloc(0) }); + }), + close: async () => { + closeCalls++; + }, + }; + openMock.mockImplementation(async path => { + if (String(path) === agentsPath) { + return hangingFile as Awaited>; + } + return realOpen(path); + }); + + try { + writeFileSync(agentsPath, "hanging", "utf8"); + setCommandCodeFileOpTimeoutForTests(250); + 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); + releaseRead(); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(commandCodeProjectContextWorkCountsForTests()).toEqual({ inFlight: 0, outstandingScans: 0, pendingFileOps: 0 }); + 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("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-"); + 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 releaseRead: () => void = () => {}; + let markStarted: () => void = () => {}; + const started = new Promise(resolve => { markStarted = resolve; }); + const gate = new Promise(resolve => { releaseRead = resolve; }); + let reads = 0; + 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"), "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 { + 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 }); + } + }); +}); 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();