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
47 changes: 46 additions & 1 deletion src/lib/workspace.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { findWorkspace, getWorkspaceConfig, scanAgents } from './workspace.js';

let testDir: string;
let fakeGenieHome: string;
const originalGenieHome = process.env.GENIE_HOME;

beforeEach(() => {
testDir = join(tmpdir(), `genie-workspace-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(testDir, { recursive: true });
// Isolate ~/.genie/config.json from the developer's real config for every test.
fakeGenieHome = join(testDir, '.fake-genie-home');
mkdirSync(fakeGenieHome, { recursive: true });
process.env.GENIE_HOME = fakeGenieHome;
});

afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
if (originalGenieHome === undefined) {
// biome-ignore lint/performance/noDelete: process.env assignment coerces undefined→"undefined"; delete is the only correct unset
delete process.env.GENIE_HOME;
} else {
process.env.GENIE_HOME = originalGenieHome;
}
});

function makeWorkspace(root: string, config: Record<string, unknown> = { name: 'test-ws' }) {
Expand Down Expand Up @@ -95,6 +107,39 @@ describe('getWorkspaceConfig', () => {
});
});

describe('workspaceRoot persistence', () => {
test('does not persist workspaces under tmpdir() to GENIE_HOME config', () => {
// testDir is under tmpdir() by construction — walk-up will succeed, but
// saveWorkspaceRoot must refuse to write it, so the global config stays clean.
makeWorkspace(testDir);
findWorkspace(testDir);

const configPath = join(fakeGenieHome, 'config.json');
if (existsSync(configPath)) {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
expect(config.workspaceRoot).toBeUndefined();
}
// (if the file doesn't exist at all, that's also a pass — nothing was persisted)
});

test('clears stale workspaceRoot from config when the saved path is gone', () => {
// Simulate a prior run that persisted a path which has since vanished.
const vanished = join(testDir, 'was-here-now-gone');
const configPath = join(fakeGenieHome, 'config.json');
writeFileSync(configPath, JSON.stringify({ workspaceRoot: vanished }));

// findWorkspace from an unrelated dir triggers the fallback → stale path detected → cleared.
const unrelated = join(testDir, 'unrelated');
mkdirSync(unrelated, { recursive: true });
const result = findWorkspace(unrelated);
expect(result).toBeNull();

// The poisoned entry should be scrubbed from the config so future calls don't keep hitting it.
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
expect(config.workspaceRoot).toBeUndefined();
});
});

describe('scanAgents', () => {
test('lists agents with AGENTS.md', () => {
makeWorkspace(testDir);
Expand Down
50 changes: 44 additions & 6 deletions src/lib/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import { dirname, join, resolve, sep } from 'node:path';

// ─── Types ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -62,15 +62,43 @@ export function findWorkspace(cwd?: string): WorkspaceInfo | null {
return null;
}

const GENIE_HOME = process.env.GENIE_HOME ?? join(homedir(), '.genie');
/** Resolved lazily so GENIE_HOME env overrides in tests take effect. */
function genieHome(): string {
return process.env.GENIE_HOME ?? join(homedir(), '.genie');
}

/** True if `root` is under the OS temp directory (avoids persisting test workspaces). */
function isTempPath(root: string): boolean {
const normalizedTmp = resolve(tmpdir());
const normalizedRoot = resolve(root);
return normalizedRoot === normalizedTmp || normalizedRoot.startsWith(normalizedTmp + sep);
}
Comment on lines +71 to +75

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

On macOS and some other Unix systems, the system temporary directory (e.g., /tmp or /var/folders/...) is often a symbolic link. Since resolve() does not resolve symlinks, isTempPath might return false even for paths within the temporary directory if they are accessed via their real (canonical) path. This would bypass the protection against 'poisoning' the global config. Using realpathSync ensures a robust comparison. Note: Since the top-level imports cannot be modified in this diff, require is used here for a self-contained suggestion.

function isTempPath(root: string): boolean {
  try {
    const { realpathSync } = require('node:fs');
    const normalizedTmp = realpathSync(tmpdir());
    const normalizedRoot = realpathSync(root);
    return normalizedRoot === normalizedTmp || normalizedRoot.startsWith(normalizedTmp + sep);
  } catch {
    return false;
  }
}
References
  1. Use dynamic imports (require() or import()) to break circular dependency cycles that would otherwise occur at module load time.


function saveWorkspaceRoot(root: string): void {
// Never persist tmp paths — tests run from tmpdir() and would poison the
// global fallback, causing subsequent daemons to resolve a stale path.
if (isTempPath(root)) return;
try {
const configPath = join(GENIE_HOME, 'config.json');
const home = genieHome();
const configPath = join(home, 'config.json');
const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf-8')) : {};
if (config.workspaceRoot === root) return;
config.workspaceRoot = root;
mkdirSync(GENIE_HOME, { recursive: true });
mkdirSync(home, { recursive: true });
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
} catch {
/* best-effort */
}
}

/** Clear the saved workspaceRoot from <GENIE_HOME>/config.json. */
function clearWorkspaceRoot(): void {
try {
const configPath = join(genieHome(), 'config.json');
if (!existsSync(configPath)) return;
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
if (config.workspaceRoot === undefined) return;
config.workspaceRoot = undefined;
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
} catch {
/* best-effort */
Expand All @@ -79,10 +107,20 @@ function saveWorkspaceRoot(root: string): void {

function loadWorkspaceRoot(): string | null {
try {
const configPath = join(GENIE_HOME, 'config.json');
const configPath = join(genieHome(), 'config.json');
if (!existsSync(configPath)) return null;
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
return typeof config.workspaceRoot === 'string' ? config.workspaceRoot : null;
const saved = typeof config.workspaceRoot === 'string' ? config.workspaceRoot : null;
if (!saved) return null;

// Self-heal: if the saved path no longer has a workspace marker (e.g. a
// test cleanup removed it, or the workspace was moved), clear it rather
// than keep returning a broken fallback.
if (!existsSync(join(saved, WORKSPACE_MARKER))) {
clearWorkspaceRoot();
return null;
}
return saved;
} catch {
return null;
}
Expand Down
22 changes: 20 additions & 2 deletions src/term-commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,13 +371,29 @@ async function startAgentSync(): Promise<{ close: () => void } | null> {
try {
const { findWorkspace } = require('../lib/workspace.js') as typeof import('../lib/workspace.js');
const ws = findWorkspace();
if (!ws) return null;
if (!ws) {
// Loud failure — silent return used to hide the whole discovery subsystem
// when serve booted from outside a workspace (or with a stale saved root).
console.warn(' Agent sync: DISABLED — no workspace found from cwd or ~/.genie/config.json');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The warning message hardcodes ~/.genie/config.json, which may be inaccurate if the GENIE_HOME environment variable is set. It's better to dynamically resolve the config path to provide accurate guidance to the user.

Suggested change
console.warn(' Agent sync: DISABLED — no workspace found from cwd or ~/.genie/config.json');
console.warn(" Agent sync: DISABLED — no workspace found from cwd or " + join(genieHome(), 'config.json'));

console.warn(' Fix: `cd <workspace> && genie serve restart`, or run `genie init` to bootstrap one');
return null;
}

const { syncAgentDirectory, watchAgentDirectory } = await import('../lib/agent-sync.js');
const syncResult = await syncAgentDirectory(ws.root);
const synced = syncResult.registered.length + syncResult.updated.length;
if (synced > 0) {
console.log(` Agent sync: ${syncResult.registered.length} registered, ${syncResult.updated.length} updated`);
console.log(
` Agent sync: ${syncResult.registered.length} registered, ${syncResult.updated.length} updated (workspace: ${ws.root})`,
);
} else {
console.log(` Agent sync: up to date (workspace: ${ws.root})`);
}
if (syncResult.errors.length > 0) {
console.warn(` Agent sync: ${syncResult.errors.length} error(s) — these agents were NOT registered:`);
for (const e of syncResult.errors) {
console.warn(` ${e.name}: ${e.error}`);
}
}

const watcher = watchAgentDirectory(ws.root, {
Expand All @@ -387,6 +403,8 @@ async function startAgentSync(): Promise<{ close: () => void } | null> {
});
if (watcher) {
console.log(' Agent watcher started (watching agents/ directory)');
} else {
console.warn(' Agent watcher: FAILED to start — new agents will not be auto-registered');
}
return watcher;
} catch (err) {
Expand Down
Loading