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
3 changes: 2 additions & 1 deletion src/app/api/sessions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import {
buildSessionRuntimeAuthInstructions,
getSessionRuntimeAuthInputFromPayload,
getPersistedSessionAuthMode,
getPersistedSessionBaseUrl,
resolveSessionRuntimeAuthConfig,
} from "@/core/session-runtime-auth";
Expand Down Expand Up @@ -123,7 +124,7 @@ export async function POST(req: NextRequest) {
typeof branch_name === "string" ? branch_name : null,
agentConfig.codingAgent.id,
agentConfig.agentTeam.id,
runtimeAuthConfig.mode,
getPersistedSessionAuthMode(runtimeAuthConfig),
runtimeAuthConfig.agentApiKeyEnvVar,
runtimeAuthConfig.localCliAgentId,
runtimeAuthConfig.model,
Expand Down
17 changes: 8 additions & 9 deletions src/cli/commands/setup-statusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { execFileSync } from "child_process";
import { fileURLToPath } from "url";
import chalk from "chalk";
import { resolvePackageRoot } from "../lib/package-root.js";
import { loadClaudeSettings, writeClaudeSettingsWithBackup } from "../lib/claude-settings.js";
import { ensureInit } from "../../core/config.js";
import { discoverProjects, computeStats } from "../../core/discovery.js";
import { updateCacheFromStats } from "../../core/cache.js";
Expand Down Expand Up @@ -83,14 +84,13 @@ export async function setupStatuslineCommand(): Promise<void> {

// ── Claude Code: statusLine + hooks ───────────────────
const claudeSettingsPath = join(homedir(), ".claude", "settings.json");
let settings: Record<string, unknown> = {};
if (existsSync(claudeSettingsPath)) {
try {
settings = JSON.parse(readFileSync(claudeSettingsPath, "utf-8"));
} catch {
settings = {};
}
const loaded = loadClaudeSettings(claudeSettingsPath);
if (!loaded.ok) {
console.error(chalk.red(`\n ${loaded.error}`));
console.error(chalk.dim(" Fix or remove the file, then re-run. Nothing was modified.\n"));
process.exit(1);
}
const settings = loaded.settings;

settings.statusLine = {
type: "command",
Expand All @@ -106,8 +106,7 @@ export async function setupStatuslineCommand(): Promise<void> {
}
settings.hooks = hooks;

mkdirSync(dirname(claudeSettingsPath), { recursive: true });
writeFileSync(claudeSettingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
writeClaudeSettingsWithBackup(claudeSettingsPath, settings);

console.log();
console.log(chalk.green(" \u2713") + chalk.bold.white(" Claude Code status line + hooks configured"));
Expand Down
24 changes: 12 additions & 12 deletions src/cli/commands/setup-tmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { execFileSync } from "child_process";
import { fileURLToPath } from "url";
import chalk from "chalk";
import { resolvePackageRoot } from "../lib/package-root.js";
import { loadClaudeSettings, writeClaudeSettingsWithBackup } from "../lib/claude-settings.js";

interface Hook {
type: string;
command: string;
type?: string;
command?: string;
[key: string]: unknown;
}

interface ClaudeSettings {
Expand All @@ -26,7 +28,7 @@ function makeHookCommand(state: string): string {
}

function removeExistingDevlogHooks(hooks: Hook[]): Hook[] {
return hooks.filter((h) => !h.command.includes(".claude-status"));
return hooks.filter((h) => !h.command?.includes(".claude-status"));
}

function generateTmuxScript(devlogBin: string): string {
Expand Down Expand Up @@ -103,14 +105,13 @@ export async function setupTmuxCommand(): Promise<void> {

// ── Step 1: Install Claude Code hooks into ~/.claude/settings.json ──
const claudeSettingsPath = join(homedir(), ".claude", "settings.json");
let settings: ClaudeSettings = {};
if (existsSync(claudeSettingsPath)) {
try {
settings = JSON.parse(readFileSync(claudeSettingsPath, "utf-8"));
} catch {
settings = {};
}
const loaded = loadClaudeSettings(claudeSettingsPath);
if (!loaded.ok) {
console.error(chalk.red(`\n ${loaded.error}`));
console.error(chalk.dim(" Fix or remove the file, then re-run. Nothing was modified.\n"));
process.exit(1);
}
const settings = loaded.settings as ClaudeSettings;

// Ensure hooks structure exists
if (!settings.hooks) {
Expand All @@ -130,8 +131,7 @@ export async function setupTmuxCommand(): Promise<void> {
stop.push({ type: "command", command: makeHookCommand("idle") });
settings.hooks.Stop = stop;

mkdirSync(dirname(claudeSettingsPath), { recursive: true });
writeFileSync(claudeSettingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
writeClaudeSettingsWithBackup(claudeSettingsPath, settings);

console.log();
console.log(chalk.green(" \u2713") + chalk.bold.white(" Claude Code hooks installed"));
Expand Down
59 changes: 59 additions & 0 deletions src/cli/lib/claude-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname } from "path";

export type ClaudeSettings = Record<string, unknown>;

function isFileMissing(err: unknown): boolean {
return (err as NodeJS.ErrnoException)?.code === "ENOENT";
}

/**
* Reads ~/.claude/settings.json without destroying it (IM-18). A parse
* failure used to fall back to `{}` and the setup commands then overwrote
* the user's file with a devlog-only object — corrupt settings must abort
* instead, and writes keep a .bak of the previous content.
*/
export function loadClaudeSettings(
path: string,
):
| { ok: true; settings: ClaudeSettings }
| { ok: false; error: string } {
let raw: string;
try {
raw = readFileSync(path, "utf-8");
} catch (err) {
if (isFileMissing(err)) {
return { ok: true, settings: {} };
}
return {
ok: false,
error: `Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`,
};
}
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return { ok: true, settings: parsed as ClaudeSettings };
}
return { ok: false, error: `${path} is not a JSON object` };
} catch (err) {
return {
ok: false,
error: `Could not parse ${path}: ${err instanceof Error ? err.message : String(err)}`,
};
}
}

export function writeClaudeSettingsWithBackup(
path: string,
settings: ClaudeSettings,
): void {
mkdirSync(dirname(path), { recursive: true });
try {
copyFileSync(path, `${path}.bak`);
} catch (err) {
// No existing file means nothing to back up.
if (!isFileMissing(err)) throw err;
}
writeFileSync(path, JSON.stringify(settings, null, 2) + "\n", "utf-8");
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}
66 changes: 66 additions & 0 deletions src/core/__tests__/claude-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
loadClaudeSettings,
writeClaudeSettingsWithBackup,
} from "../../cli/lib/claude-settings";

/**
* Regression tests for IM-18 (REVIEW-2026-06-10): a corrupt
* ~/.claude/settings.json was parsed to `{}` and silently overwritten with
* a devlog-only object — destroying the user's hooks, model config, etc.
*/

function tempPath(): { dir: string; path: string; cleanup: () => void } {
const dir = mkdtempSync(join(tmpdir(), "devlog-settings-"));
return {
dir,
path: join(dir, "settings.json"),
cleanup: () => rmSync(dir, { recursive: true, force: true }),
};
}

test("corrupt settings files abort instead of resolving to {} (IM-18)", () => {
const { path, cleanup } = tempPath();
try {
writeFileSync(path, "{ definitely not json");
const result = loadClaudeSettings(path);
assert.equal(result.ok, false);
} finally {
cleanup();
}
});

test("missing files load as empty settings; valid files round-trip", () => {
const { path, cleanup } = tempPath();
try {
const missing = loadClaudeSettings(path);
assert.deepEqual(missing, { ok: true, settings: {} });

writeFileSync(path, JSON.stringify({ model: "opus" }));
const loaded = loadClaudeSettings(path);
assert.equal(loaded.ok, true);
if (loaded.ok) assert.equal(loaded.settings.model, "opus");
} finally {
cleanup();
}
});

test("writes keep a .bak of the previous content (IM-18)", () => {
const { path, cleanup } = tempPath();
try {
writeFileSync(path, JSON.stringify({ precious: true }));
writeClaudeSettingsWithBackup(path, { precious: true, statusLine: {} });

const backup = JSON.parse(readFileSync(`${path}.bak`, "utf-8"));
assert.deepEqual(backup, { precious: true });
const current = JSON.parse(readFileSync(path, "utf-8"));
assert.equal(current.precious, true);
assert.ok("statusLine" in current);
} finally {
cleanup();
}
});
90 changes: 90 additions & 0 deletions src/core/__tests__/discovery-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
clearDiscoveryCache,
discoverProjects,
getDiscoveryCacheStats,
} from "../discovery";

/**
* Regression tests for IM-23 (REVIEW-2026-06-10): every dashboard poll
* re-parsed every JSONL under ~/.claude/projects serially — multi-second
* CPU+IO per request at a few thousand sessions. Scans are now memoized by
* (path, mtime, size) and only changed files are re-read.
*/

function line(text: string, timestamp: string): string {
return (
JSON.stringify({
type: "assistant",
timestamp,
message: { role: "assistant", content: [{ type: "text", text }] },
}) + "\n"
);
}

function makeProjectsDir(): { dir: string; file: string; cleanup: () => void } {
const dir = mkdtempSync(join(tmpdir(), "devlog-discovery-"));
const projectDir = join(dir, "-tmp-proj-a");
mkdirSync(projectDir);
const file = join(projectDir, "session-1.jsonl");
writeFileSync(file, line("hello", "2026-06-01T10:00:00.000Z"));
return { dir, file, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
}

test("unchanged files are served from the scan cache (IM-23)", async () => {
const { dir, cleanup } = makeProjectsDir();
try {
clearDiscoveryCache();

const first = await discoverProjects(dir);
assert.equal(first.length, 1);
assert.equal(first[0].sessions[0].meta.assistantTurns, 1);
const afterFirst = getDiscoveryCacheStats();
assert.equal(afterFirst.misses, 1);
assert.equal(afterFirst.hits, 0);

const second = await discoverProjects(dir);
assert.equal(second[0].sessions[0].meta.assistantTurns, 1);
const afterSecond = getDiscoveryCacheStats();
assert.equal(afterSecond.misses, 1);
assert.equal(afterSecond.hits, 1);
} finally {
cleanup();
}
});

test("changed files are rescanned and fresh metadata returned", async () => {
const { dir, file, cleanup } = makeProjectsDir();
try {
clearDiscoveryCache();
await discoverProjects(dir);

appendFileSync(file, line("more work", "2026-06-01T11:00:00.000Z"));

const after = await discoverProjects(dir);
assert.equal(after[0].sessions[0].meta.assistantTurns, 2);
const stats = getDiscoveryCacheStats();
assert.equal(stats.misses, 2);
} finally {
cleanup();
}
});

test("cache entries for deleted files are pruned", async () => {
const { dir, file, cleanup } = makeProjectsDir();
try {
clearDiscoveryCache();
await discoverProjects(dir);
assert.equal(getDiscoveryCacheStats().entries, 1);

rmSync(file);
await discoverProjects(dir);
assert.equal(getDiscoveryCacheStats().entries, 0);
} finally {
cleanup();
}
});
49 changes: 49 additions & 0 deletions src/core/__tests__/legacy-auth-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
getPersistedSessionAuthMode,
resolveSessionRuntimeAuthConfig,
resolveStoredSessionRuntimeAuthConfig,
} from "../session-runtime-auth";

/**
* Regression test for IM-7 (REVIEW-2026-06-10): legacy agent-api-key
* sessions were persisted as "anthropic-api-key", losing the env-var
* marker — the next stored-config resolution treated them as browser BYOK
* and threw "API key is required" although preflight had passed.
*/

test("legacy env-var sessions survive a persist/resolve round-trip (IM-7)", () => {
const original = resolveSessionRuntimeAuthConfig({
session_auth_mode: "agent-api-key",
agent_api_key_env_var: "MY_BACKEND_KEY",
});
assert.equal(original.usesLegacyEnvVar, true);
assert.equal(original.agentApiKeyEnvVar, "MY_BACKEND_KEY");

const persistedMode = getPersistedSessionAuthMode(original);
assert.equal(persistedMode, "agent-api-key");

// Simulate the next turn: stored row resolved with no transient key.
const restored = resolveStoredSessionRuntimeAuthConfig({
session_auth_mode: persistedMode,
agent_api_key_env_var: "MY_BACKEND_KEY",
});
assert.equal(restored.usesLegacyEnvVar, true);
assert.equal(restored.agentApiKeyEnvVar, "MY_BACKEND_KEY");
assert.equal(restored.anthropicApiKey, null);
});

test("browser BYOK sessions persist their bare mode", () => {
const config = resolveSessionRuntimeAuthConfig({
session_auth_mode: "anthropic-api-key",
anthropic_api_key: "sk-ant-test",
});
assert.equal(config.usesLegacyEnvVar, false);
assert.equal(getPersistedSessionAuthMode(config), "anthropic-api-key");

const localCli = resolveSessionRuntimeAuthConfig({
session_auth_mode: "local-cli",
});
assert.equal(getPersistedSessionAuthMode(localCli), "local-cli");
});
Loading
Loading