Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/core/auth/claude-code-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
// Tests for claude-code-bridge — credential loading + OAuth refresh paths
// Critical security code — these tests cover the gap identified in the audit
//
// SKIPPED (2026-04-13): The original mocking approach is fundamentally broken.
// It stubs `node:fs` with only `readFileSync`/`writeFileSync`, but
// `claude-code-bridge.ts` imports `log` from `../logger`, which imports
// `mkdirSync`, `appendFileSync`, etc. from `node:fs`. Loading the bridge
// inside any test crashes with `Export named 'mkdirSync' not found`.
// Worse: Bun 1.3.x's `mock.restore()` does NOT undo `mock.module()`, so the
// truncated stub leaks into every test file that runs later in the same Bun
// worker, breaking ~150 unrelated tests across web-engine, plugin-sdk,
// audit-engine, and training. The whole describe is skipped to stop the
// pollution; the file should be rewritten to use a real temp HOME and
// real fs (which requires making bridge.ts compute its credentials paths
// lazily so HOME can be overridden).

import { beforeEach, describe, expect, mock, test } from "bun:test";

describe("claude-code-bridge", () => {
describe.skip("claude-code-bridge", () => {
beforeEach(() => {
// Clear module cache to reset the credential cache between tests
delete require.cache[require.resolve("./claude-code-bridge.ts")];
Expand Down
47 changes: 26 additions & 21 deletions src/core/auto-agents.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Tests for AutoAgentManager — plan evaluation + spawn orchestration
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { AutoAgentManager, type AgentStatus } from "./auto-agents";
import type { Plan, PlanStep } from "../tools/plan";
import { clearActivePlan, type Plan, type PlanStep, setActivePlanForTesting } from "../tools/plan";
import type { KCodeConfig } from "./types";

const mockConfig: KCodeConfig = {
Expand All @@ -22,23 +22,28 @@ function makeMockPlan(steps: PlanStep[]): Plan {
};
}

// Use module mocking via internal state
// Use the real plan.ts module with a test-only setter — DO NOT use
// mock.module() here. Bun 1.3.x leaves mock.module() hooks installed for
// the lifetime of the test worker, and a partial stub of ../tools/plan
// would then poison plan.test.ts (and anything else that runs after this
// file in the same worker) by stripping executePlan/formatPlan.
let mockPlan: Plan | null = null;

beforeEach(async () => {
beforeEach(() => {
mockPlan = null;
mock.module("../tools/plan.js", () => ({
getActivePlan: () => mockPlan,
clearActivePlan: () => {
mockPlan = null;
},
}));
setActivePlanForTesting(null);
});

afterEach(() => {
mockPlan = null;
clearActivePlan();
});

function installPlan(plan: Plan | null): void {
mockPlan = plan;
setActivePlanForTesting(plan);
}

describe("AutoAgentManager — evaluate", () => {
test("returns shouldSpawn=false when no active plan", async () => {
const statuses: AgentStatus[] = [];
Expand All @@ -54,10 +59,10 @@ describe("AutoAgentManager — evaluate", () => {
});

test("returns shouldSpawn=false when plan has fewer than minPendingSteps", async () => {
mockPlan = makeMockPlan([
installPlan(makeMockPlan([
{ id: "1", title: "Step 1", status: "pending" },
{ id: "2", title: "Step 2", status: "pending" },
]);
]));
const mgr = new AutoAgentManager(
{ cwd: "/tmp", model: "m", config: mockConfig, minPendingSteps: 3 },
() => {},
Expand All @@ -67,12 +72,12 @@ describe("AutoAgentManager — evaluate", () => {
});

test("returns shouldSpawn=true with steps when threshold reached", async () => {
mockPlan = makeMockPlan([
installPlan(makeMockPlan([
{ id: "1", title: "Fix bug A", status: "pending" },
{ id: "2", title: "Add test B", status: "pending" },
{ id: "3", title: "Refactor C", status: "pending" },
{ id: "4", title: "Document D", status: "pending" },
]);
]));
const mgr = new AutoAgentManager(
{ cwd: "/tmp", model: "m", config: mockConfig, minPendingSteps: 3 },
() => {},
Expand All @@ -84,14 +89,14 @@ describe("AutoAgentManager — evaluate", () => {
});

test("caps steps at maxAgents", async () => {
mockPlan = makeMockPlan([
installPlan(makeMockPlan([
{ id: "1", title: "A", status: "pending" },
{ id: "2", title: "B", status: "pending" },
{ id: "3", title: "C", status: "pending" },
{ id: "4", title: "D", status: "pending" },
{ id: "5", title: "E", status: "pending" },
{ id: "6", title: "F", status: "pending" },
]);
]));
const mgr = new AutoAgentManager(
{ cwd: "/tmp", model: "m", config: mockConfig, minPendingSteps: 3, maxAgents: 2 },
() => {},
Expand All @@ -101,12 +106,12 @@ describe("AutoAgentManager — evaluate", () => {
});

test("only counts pending steps (ignores done/in_progress)", async () => {
mockPlan = makeMockPlan([
installPlan(makeMockPlan([
{ id: "1", title: "A", status: "done" },
{ id: "2", title: "B", status: "in_progress" },
{ id: "3", title: "C", status: "pending" },
{ id: "4", title: "D", status: "pending" },
]);
]));
const mgr = new AutoAgentManager(
{ cwd: "/tmp", model: "m", config: mockConfig, minPendingSteps: 3 },
() => {},
Expand Down Expand Up @@ -145,10 +150,10 @@ describe("AutoAgentManager — state", () => {

describe("AutoAgentManager — config defaults", () => {
test("uses default minPendingSteps of 3", async () => {
mockPlan = makeMockPlan([
installPlan(makeMockPlan([
{ id: "1", title: "A", status: "pending" },
{ id: "2", title: "B", status: "pending" },
]);
]));
const mgr = new AutoAgentManager(
{ cwd: "/tmp", model: "m", config: mockConfig },
() => {},
Expand All @@ -164,7 +169,7 @@ describe("AutoAgentManager — config defaults", () => {
title: `Step ${i + 1}`,
status: "pending" as const,
}));
mockPlan = makeMockPlan(steps);
installPlan(makeMockPlan(steps));
const mgr = new AutoAgentManager(
{ cwd: "/tmp", model: "m", config: mockConfig },
() => {},
Expand Down
20 changes: 18 additions & 2 deletions src/core/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,24 @@ export async function copyToClipboard(text: string): Promise<boolean> {
proc.stdin.flush();
proc.stdin.end();

// Wait for the process to finish
const exitCode = await proc.exited;
// Wait for the process to finish with a 1s cap. wl-copy hangs
// forever when WAYLAND_DISPLAY is unset, and xsel/xclip block on
// their daemon fork when DISPLAY is missing — without a timeout
// the whole clipboard tool stalls the UI.
const timeoutMs = 1000;
const exitCode = await Promise.race<number | null>([
proc.exited,
new Promise<null>((resolve) =>
setTimeout(() => {
try {
proc.kill();
} catch {
/* already exited */
}
resolve(null);
}, timeoutMs),
),
]);

if (exitCode === 0) {
return true;
Expand Down
9 changes: 7 additions & 2 deletions src/core/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { log } from "./logger.js";
import { getModelBaseUrl, getModelProvider } from "./models.js";
import type { ContentBlock, Message, TextBlock } from "./types.js";

type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;

// ─── Constants ───────────────────────────────────────────────────

const SUMMARY_MAX_TOKENS = 1024;
Expand Down Expand Up @@ -32,8 +34,9 @@ export class CompactionManager {
private compactionCount = 0;
private consecutiveFailures = 0;
private circuitBreakerTripped = false;
private customFetch?: FetchFn;

constructor(apiKey?: string, model?: string, apiBase?: string) {
constructor(apiKey?: string, model?: string, apiBase?: string, customFetch?: FetchFn) {
if (model) {
this.model = model;
} else {
Expand All @@ -45,6 +48,7 @@ export class CompactionManager {
}
this.apiKey = apiKey;
this.apiBase = apiBase; // resolved lazily via getModelBaseUrl if not provided
this.customFetch = customFetch;
}

private async resolveApiBase(): Promise<string> {
Expand Down Expand Up @@ -107,7 +111,8 @@ export class CompactionManager {
],
};

const response = await fetch(url, {
const fetchFn = this.customFetch ?? fetch;
const response = await fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify(body),
Expand Down
9 changes: 9 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,23 @@ async function writeTextFile(path: string, content: string) {
}

describe("config", () => {
let originalKcodeHome: string | undefined;

beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "kcode-config-test-"));
// Isolate KCODE_HOME inside tempDir so the developer's real ~/.kcode
// (which may contain permissionMode, proKey, etc.) does not bleed in.
originalKcodeHome = process.env.KCODE_HOME;
process.env.KCODE_HOME = join(tempDir, "kcode-home");
await mkdir(process.env.KCODE_HOME, { recursive: true });
// Trust the temp workspace so project-level settings load in tests
trustWorkspace(tempDir);
});

afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
if (originalKcodeHome === undefined) delete process.env.KCODE_HOME;
else process.env.KCODE_HOME = originalKcodeHome;
// Clean up env vars we may have set
delete process.env.KCODE_MODEL;
delete process.env.KCODE_API_KEY;
Expand Down
71 changes: 51 additions & 20 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export interface Settings {
autoMemory?: boolean | AutoMemorySettings;
effortLevel?: EffortLevel;
apiKey?: string;
anthropicApiKey?: string; // Anthropic API key (preferred over generic apiKey for Claude models)
xaiApiKey?: string; // xAI/Grok API key
groqApiKey?: string; // Groq API key
geminiApiKey?: string; // Gemini API key
deepseekApiKey?: string; // DeepSeek API key
togetherApiKey?: string; // Together AI API key
apiBase?: string;
systemPromptExtra?: string;
autoRoute?: boolean;
Expand Down Expand Up @@ -136,12 +142,18 @@ export interface ManagedPolicy {

// ─── Paths ──────────────────────────────────────────────────────

const KCODE_HOME = kcodeHome();
const USER_SETTINGS_PATH = kcodePath("settings.json");
const MANAGED_SETTINGS_PATHS = [
"/etc/kcode/policy.json", // System-wide admin policy
kcodePath("managed-settings.json"), // Per-user admin-deployed policy
];
// Resolve paths at call time so tests can override KCODE_HOME per-case via
// beforeEach. Module-level constants would freeze the path at import time
// and bleed the developer's real ~/.kcode into every test run.
function userSettingsPath(): string {
return kcodePath("settings.json");
}
function managedSettingsPaths(): string[] {
return [
"/etc/kcode/policy.json", // System-wide admin policy
kcodePath("managed-settings.json"), // Per-user admin-deployed policy
];
}

// Cached managed policy with mtime tracking for invalidation
let _managedPolicy: ManagedPolicy | null = null;
Expand Down Expand Up @@ -200,6 +212,12 @@ function parseSettings(raw: Record<string, unknown> | null): Settings {
: undefined,
effortLevel: isEffortLevel(raw.effortLevel) ? raw.effortLevel : undefined,
apiKey: typeof raw.apiKey === "string" ? raw.apiKey : undefined,
anthropicApiKey: typeof raw.anthropicApiKey === "string" ? raw.anthropicApiKey : undefined,
xaiApiKey: typeof raw.xaiApiKey === "string" ? raw.xaiApiKey : undefined,
groqApiKey: typeof raw.groqApiKey === "string" ? raw.groqApiKey : undefined,
geminiApiKey: typeof raw.geminiApiKey === "string" ? raw.geminiApiKey : undefined,
deepseekApiKey: typeof raw.deepseekApiKey === "string" ? raw.deepseekApiKey : undefined,
togetherApiKey: typeof raw.togetherApiKey === "string" ? raw.togetherApiKey : undefined,
apiBase: typeof raw.apiBase === "string" ? raw.apiBase : undefined,
systemPromptExtra:
typeof raw.systemPromptExtra === "string" ? raw.systemPromptExtra : undefined,
Expand Down Expand Up @@ -359,6 +377,12 @@ function mergeSettings(...layers: Settings[]): Settings {
if (layer.autoMemory !== undefined) result.autoMemory = layer.autoMemory;
if (layer.effortLevel !== undefined) result.effortLevel = layer.effortLevel;
if (layer.apiKey !== undefined) result.apiKey = layer.apiKey;
if (layer.anthropicApiKey !== undefined) result.anthropicApiKey = layer.anthropicApiKey;
if (layer.xaiApiKey !== undefined) result.xaiApiKey = layer.xaiApiKey;
if (layer.groqApiKey !== undefined) result.groqApiKey = layer.groqApiKey;
if (layer.geminiApiKey !== undefined) result.geminiApiKey = layer.geminiApiKey;
if (layer.deepseekApiKey !== undefined) result.deepseekApiKey = layer.deepseekApiKey;
if (layer.togetherApiKey !== undefined) result.togetherApiKey = layer.togetherApiKey;
if (layer.apiBase !== undefined) result.apiBase = layer.apiBase;
if (layer.systemPromptExtra !== undefined) result.systemPromptExtra = layer.systemPromptExtra;
if (layer.autoRoute !== undefined) result.autoRoute = layer.autoRoute;
Expand Down Expand Up @@ -437,7 +461,7 @@ export async function loadManagedPolicy(): Promise<ManagedPolicy> {
}
if (_managedPolicy) return _managedPolicy;

for (const path of MANAGED_SETTINGS_PATHS) {
for (const path of managedSettingsPaths()) {
try {
const file = Bun.file(path);
if (!(await file.exists())) continue;
Expand Down Expand Up @@ -625,7 +649,7 @@ function applyManagedPolicy(settings: Settings, policy: ManagedPolicy): Settings
*/
async function loadPermissionsFile(cwd: string, trusted: boolean): Promise<PermissionRule[]> {
const sources: Array<{ path: string; isProject: boolean }> = [
{ path: join(KCODE_HOME, "permissions.json"), isProject: false },
{ path: join(kcodeHome(), "permissions.json"), isProject: false },
{ path: join(cwd, ".kcode", "permissions.json"), isProject: true },
];
const rules: PermissionRule[] = [];
Expand Down Expand Up @@ -708,7 +732,7 @@ export async function loadSettings(cwd: string): Promise<Settings> {
: warnUntrustedProjectConfig(localSettingsPath(cwd));

const [userRaw, projectRaw, localRaw, permissionFileRules, policy] = await Promise.all([
readJsonFile(USER_SETTINGS_PATH),
readJsonFile(userSettingsPath()),
projectSettingsPromise,
localSettingsPromise,
loadPermissionsFile(cwd, trusted),
Expand Down Expand Up @@ -759,13 +783,13 @@ let _settingsSaveLock: Promise<void> = Promise.resolve();

export function saveUserSettings(settings: Settings): Promise<void> {
const op = async () => {
const dir = KCODE_HOME;
await Bun.write(join(dir, ".gitkeep"), ""); // ensure dir exists
await Bun.write(USER_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
const path = userSettingsPath();
await Bun.write(join(kcodeHome(), ".gitkeep"), ""); // ensure dir exists
await Bun.write(path, JSON.stringify(settings, null, 2) + "\n");
// Restrict permissions — settings may contain API keys and Pro license keys
try {
const { chmodSync } = require("node:fs") as typeof import("node:fs");
chmodSync(USER_SETTINGS_PATH, 0o600);
chmodSync(path, 0o600);
} catch (err) {
log.debug("config", `Failed to chmod user settings: ${err}`);
}
Expand All @@ -776,25 +800,25 @@ export function saveUserSettings(settings: Settings): Promise<void> {

/** Load raw user settings JSON (preserves extra fields like provider-specific API keys). */
export async function loadUserSettingsRaw(): Promise<Record<string, unknown>> {
return (await readJsonFile(USER_SETTINGS_PATH)) ?? {};
return (await readJsonFile(userSettingsPath())) ?? {};
}

/** Save raw user settings JSON (merges with existing to prevent data loss). */
export function saveUserSettingsRaw(raw: Record<string, unknown>): Promise<void> {
const op = async () => {
const dir = KCODE_HOME;
await Bun.write(join(dir, ".gitkeep"), ""); // ensure dir exists
const path = userSettingsPath();
await Bun.write(join(kcodeHome(), ".gitkeep"), ""); // ensure dir exists
// Merge with existing settings to prevent losing fields (e.g., proKey) due to concurrent writes
const existing = (await readJsonFile(USER_SETTINGS_PATH)) ?? {};
const existing = (await readJsonFile(path)) ?? {};
const merged = { ...existing, ...raw };
// Explicitly delete fields set to undefined (allows intentional removal)
for (const [k, v] of Object.entries(raw)) {
if (v === undefined) delete merged[k];
}
await Bun.write(USER_SETTINGS_PATH, JSON.stringify(merged, null, 2) + "\n");
await Bun.write(path, JSON.stringify(merged, null, 2) + "\n");
try {
const { chmodSync } = require("node:fs") as typeof import("node:fs");
chmodSync(USER_SETTINGS_PATH, 0o600);
chmodSync(path, 0o600);
} catch (err) {
log.debug("config", `Failed to chmod raw user settings: ${err}`);
}
Expand Down Expand Up @@ -849,7 +873,14 @@ export async function buildConfig(cwd: string): Promise<KCodeConfig> {
apiKey: lockedApiKey ?? settings.apiKey ?? process.env.ASTROLEXIS_API_KEY,
anthropicApiKey:
process.env.ANTHROPIC_API_KEY ??
((await loadUserSettingsRaw()).anthropicApiKey as string | undefined),
(settings.anthropicApiKey as string | undefined),
xaiApiKey: process.env.XAI_API_KEY ?? (settings.xaiApiKey as string | undefined),
groqApiKey: process.env.GROQ_API_KEY ?? (settings.groqApiKey as string | undefined),
geminiApiKey: process.env.GEMINI_API_KEY ?? (settings.geminiApiKey as string | undefined),
deepseekApiKey:
process.env.DEEPSEEK_API_KEY ?? (settings.deepseekApiKey as string | undefined),
togetherApiKey:
process.env.TOGETHER_API_KEY ?? (settings.togetherApiKey as string | undefined),
apiBase,
model,
maxTokens: settings.maxTokens ?? 16384,
Expand Down
Loading
Loading