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
6 changes: 3 additions & 3 deletions codev/resources/arch.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,15 +312,15 @@ A workspace can host more than one architect terminal. Each architect has a stab
- `terminal-manager.ts` keys terminal slots by architect name (`architect:${name}`), not the pre-786 singleton `'architect'`. Each architect gets its own VSCode terminal.
- Right-click context menu on a sibling entry → "Remove Architect" (gated on `viewItem == workspace-architect-sibling`; `main` uses `'workspace-architect-main'` and gets no remove option).
- `codev.referenceIssueInArchitect` (Backlog inline button) always targets `main` regardless of how many siblings exist — preserves the pre-786 Backlog UX.
- **Spec 823**: the tree auto-refreshes when an architect is added or removed from outside VSCode (CLI, dashboard close-button, mobile TabBar). Tower emits an `architects-updated` SSE notification from every successful add/remove path; `WorkspaceProvider` subscribes via its existing `connectionManager.onSSEEvent` callback and fires `changeEmitter` on a matching envelope. Same JSON-envelope-on-`data:` shape as `worktree-config-updated`, no workspace filter at the SSE-subscriber layer.
- **Spec 823**: the tree auto-refreshes when an architect is added or removed from outside VSCode (CLI, dashboard close-button, mobile TabBar). Tower emits an `architects-updated` SSE notification from every successful add/remove path; `WorkspaceProvider` subscribes via its existing `connectionManager.onSSEEvent` callback and fires `changeEmitter` on a matching envelope. Same JSON-envelope-on-`data:` shape as `codev-config-updated`, no workspace filter at the SSE-subscriber layer.

#### Tower SSE Event Conventions

Tower fans events to subscribers via an SSE stream. The shape convention (used by `worktree-config-updated`, `architects-updated`, and `builder-spawned`):
Tower fans events to subscribers via an SSE stream. The shape convention (used by `codev-config-updated`, `architects-updated`, and `builder-spawned`):

- Events ride the generic `notification` SSE event type — no per-event-type `event:` name on the SSE wire. The SSE-client-level `type` is always `''`; the real event-type lives inside the JSON envelope at `data.type`.
- Subscribers parse the `data:` JSON in a `try/catch` to swallow malformed payloads, then match `envelope.type === '<known>'` to decide whether to act.
- `NotifyFn` shape (`worktree-config-watcher.ts:19-24`): `{ type: string; title: string; body: string; workspace?: string }`. `body` is `JSON.stringify({ workspace })` for events that are workspace-scoped.
- `NotifyFn` shape (`codev-config-watcher.ts:19-24`): `{ type: string; title: string; body: string; workspace?: string }`. `body` is `JSON.stringify({ workspace })` for events that are workspace-scoped.
- `ctx.broadcastNotification` is available directly on the `RouteContext` for route handlers; standalone modules (like the worktree config watcher) wire their own notifier via a setter (`setWorktreeConfigNotifier`).

#### Builder Gate Notifications (Spec 0100, replaced by Spec 0108)
Expand Down
36 changes: 36 additions & 0 deletions packages/codev/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { deepMerge, loadConfig, resolveProjectConfigPath, resolveLocalConfigPath } from '../lib/config.js';
import { getActivityHooks } from '../agent-farm/utils/config.js';

// Helpers
let tmpDir: string;
Expand Down Expand Up @@ -254,3 +255,38 @@ describe('loadConfig', () => {
expect(config.shell?.architect).toBe('project-architect');
});
});

describe('getActivityHooks (trusted personal layers only — never the committed config)', () => {
it('resolves hooks from .codev/config.local.json, dropping malformed entries', () => {
writeLocalConfig(tmpDir, {
activityHooks: [
{ on: ['window-focus', 'builder-active'], url: 'app://x?w={workspace}', background: true },
{ on: ['bogus-event'], url: 'app://drop-me' }, // no valid event → dropped
{ on: ['window-focus'] }, // no url → dropped
],
});
expect(getActivityHooks(tmpDir).hooks).toEqual([
{ on: ['window-focus', 'builder-active'], url: 'app://x?w={workspace}', background: true },
]);
});

it('IGNORES the committed .codev/config.json (closes the zero-click RCE vector)', () => {
writeProjectConfig(tmpDir, { activityHooks: [{ on: ['window-focus'], url: 'app://committed-rce' }] });
expect(getActivityHooks(tmpDir).hooks).toEqual([]);
});

it('a per-engineer config.local.json replaces the global hook', () => {
writeGlobalConfig({ activityHooks: [{ on: ['window-focus'], url: 'app://global' }] });
writeLocalConfig(tmpDir, { activityHooks: [{ on: ['window-focus'], url: 'app://mine' }] });
expect(getActivityHooks(tmpDir).hooks.map((h) => h.url)).toEqual(['app://mine']);
});

it('picks up a personal hook from ~/.codev/config.json (global)', () => {
writeGlobalConfig({ activityHooks: [{ on: ['builder-active'], url: 'app://global' }] });
expect(getActivityHooks(tmpDir).hooks.map((h) => h.url)).toEqual(['app://global']);
});

it('returns [] when nothing is configured', () => {
expect(getActivityHooks(tmpDir).hooks).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
/**
* Per-workspace file watcher for `.codev/config.json` and
* `.codev/config.local.json`. Lazily installed by the
* `/api/worktree-config` route handler on first request, then persists
* for the Tower process lifetime. On each detected change it fans out
* a `worktree-config-updated` SSE event so subscribed clients (the
* VSCode extension, the dashboard) can refetch via the route and
* re-render.
* `.codev/config.local.json`. Lazily installed by any config-resolving route
* handler (`/api/worktree-config`, `/api/activity-hooks`) on first request, then
* persists for the Tower process lifetime. On each detected change it fans out a
* `codev-config-updated` SSE event so subscribed clients (the VSCode extension, the
* dashboard) refetch whichever resolved config they consume and re-render.
*
* Watches the codev config FILES, not any one config section — so a single watcher
* + event serves every consumer of `.codev/config(.local).json`.
*
* Pattern follows `tower-tunnel.ts:startConfigWatcher` (which watches
* `~/.codev/cloud.json` for OAuth credential changes) — `node:fs.watch`
Expand All @@ -32,10 +34,9 @@ let notify: NotifyFn | undefined;

/**
* Wire the broadcast function once at Tower startup. Subsequent calls
* to `ensureWorktreeConfigWatcher` will use this notifier when files
* change.
* to `ensureCodevConfigWatcher` will use this notifier when files change.
*/
export function setWorktreeConfigNotifier(fn: NotifyFn): void {
export function setCodevConfigNotifier(fn: NotifyFn): void {
notify = fn;
}

Expand All @@ -44,7 +45,7 @@ export function setWorktreeConfigNotifier(fn: NotifyFn): void {
* `<workspacePath>/.codev/{config.json,config.local.json}`. Safe to
* call on every route hit.
*/
export function ensureWorktreeConfigWatcher(workspacePath: string): void {
export function ensureCodevConfigWatcher(workspacePath: string): void {
if (watchers.has(workspacePath)) { return; }
const dir = path.join(workspacePath, '.codev');
try {
Expand All @@ -57,8 +58,8 @@ export function ensureWorktreeConfigWatcher(workspacePath: string): void {
setTimeout(() => {
debounces.delete(workspacePath);
notify?.({
type: 'worktree-config-updated',
title: 'Worktree config changed',
type: 'codev-config-updated',
title: 'Codev config changed',
body: JSON.stringify({ workspace: workspacePath }),
workspace: workspacePath,
});
Expand All @@ -73,7 +74,7 @@ export function ensureWorktreeConfigWatcher(workspacePath: string): void {
}

/** Test / shutdown helper — close every watcher and clear pending debounces. */
export function stopAllWorktreeConfigWatchers(): void {
export function stopAllCodevConfigWatchers(): void {
for (const t of debounces.values()) { clearTimeout(t); }
debounces.clear();
for (const w of watchers.values()) { try { w.close(); } catch { /* benign */ } }
Expand Down
40 changes: 35 additions & 5 deletions packages/codev/src/agent-farm/servers/tower-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ import {
serveStaticFile,
} from './tower-utils.js';
import { handleTunnelEndpoint } from './tower-tunnel.js';
import { getWorktreeConfig } from '../utils/config.js';
import { ensureWorktreeConfigWatcher } from './worktree-config-watcher.js';
import { getWorktreeConfig, getActivityHooks } from '../utils/config.js';
import { ensureCodevConfigWatcher } from './codev-config-watcher.js';
import { hasTeam, loadTeamMembers, loadMessages, type TeamMember, type TeamMessage } from '../../lib/team.js';
import { fetchTeamGitHubData, type TeamMemberGitHubData } from '../../lib/team-github.js';
import { resolveTarget, broadcastMessage, isResolveError } from './tower-messages.js';
Expand Down Expand Up @@ -168,6 +168,7 @@ const ROUTES: Record<string, RouteEntry> = {
'GET /api/issue': (_req, res, url) => handleIssueView(res, url),
'GET /api/issue-search': (_req, res, url) => handleIssueSearch(res, url),
'GET /api/worktree-config': (_req, res, url) => handleWorktreeConfigView(res, url),
'GET /api/activity-hooks': (_req, res, url) => handleActivityHooksView(res, url),
'GET /api/analytics': (_req, res, url) => handleAnalytics(res, url),
'POST /api/overview/refresh': (_req, res, _url, ctx) => handleOverviewRefresh(res, ctx),
'GET /api/events': (req, res, _url, ctx) => handleSSEEvents(req, res, ctx),
Expand Down Expand Up @@ -387,7 +388,7 @@ async function handleAddArchitect(
// Spec 823: emit an `architects-updated` SSE event so VSCode's
// WorkspaceProvider tree refreshes when the add happens via the CLI
// (today the tree only refreshes when add is triggered from within
// VSCode itself). Mirrors `worktree-config-updated`'s broadcast shape.
// VSCode itself). Mirrors `codev-config-updated`'s broadcast shape.
ctx.broadcastNotification({
type: 'architects-updated',
title: 'Architects updated',
Expand Down Expand Up @@ -1031,7 +1032,7 @@ async function handleIssueSearch(res: http.ServerResponse, url: URL): Promise<vo
*
* Side effect: lazily installs a directory watcher on the workspace's
* `.codev/` so any subsequent edit to `config.json` /
* `config.local.json` fans out a `worktree-config-updated` SSE event
* `config.local.json` fans out a `codev-config-updated` SSE event
* — clients refetch via this same endpoint and re-render.
*/
function handleWorktreeConfigView(res: http.ServerResponse, url: URL): void {
Expand All @@ -1047,7 +1048,7 @@ function handleWorktreeConfigView(res: http.ServerResponse, url: URL): void {
}
try {
const config = getWorktreeConfig(workspaceRoot);
ensureWorktreeConfigWatcher(workspaceRoot);
ensureCodevConfigWatcher(workspaceRoot);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(config));
} catch (error) {
Expand All @@ -1057,6 +1058,35 @@ function handleWorktreeConfigView(res: http.ServerResponse, url: URL): void {
}
}

/**
* GET /api/activity-hooks — returns the canonical `ResolvedActivityHooks` (the
* `activityHooks` block merged across the loadConfig layer chain). Installs the
* shared config-file watcher, so an edit to `.codev/config(.local).json` fans out a
* `codev-config-updated` SSE (the config-file-change signal) and clients re-fetch.
*/
function handleActivityHooksView(res: http.ServerResponse, url: URL): void {
let workspaceRoot = url.searchParams.get('workspace');
if (!workspaceRoot) {
const knownPaths = getKnownWorkspacePaths();
workspaceRoot = knownPaths.find(p => !p.includes('/.builders/')) || null;
}
if (!workspaceRoot) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing workspace' }));
return;
}
try {
const config = getActivityHooks(workspaceRoot);
ensureCodevConfigWatcher(workspaceRoot);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(config));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Failed to resolve activity hooks: ${message}` }));
}
}

function handleOverviewRefresh(res: http.ServerResponse, ctx?: RouteContext): void {
overviewCache.invalidate();
// Bugfix #388: Broadcast SSE event so all connected dashboard clients
Expand Down
14 changes: 7 additions & 7 deletions packages/codev/src/agent-farm/servers/tower-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import {
} from './tower-websocket.js';
import { handleRequest, startSendBuffer, stopSendBuffer } from './tower-routes.js';
import type { RouteContext } from './tower-routes.js';
import { setWorktreeConfigNotifier, stopAllWorktreeConfigWatchers } from './worktree-config-watcher.js';
import { setCodevConfigNotifier, stopAllCodevConfigWatchers } from './codev-config-watcher.js';
import { DEFAULT_TOWER_PORT } from '../lib/tower-client.js';
import { validateHost } from '../utils/server-utils.js';
import { version } from '../../version.js';
Expand Down Expand Up @@ -176,7 +176,7 @@ async function gracefulShutdown(signal: string): Promise<void> {
shutdownTunnel();

// 6b. Close per-workspace .codev/config(.local).json watchers.
stopAllWorktreeConfigWatchers();
stopAllCodevConfigWatchers();

// 7. Tear down instance module (Spec 0105 Phase 3)
shutdownInstances();
Expand Down Expand Up @@ -340,11 +340,11 @@ const routeCtx: RouteContext = {
},
};

// Wire the broadcast function into the worktree config watcher so file
// edits to .codev/config{,.local}.json fan out as
// `worktree-config-updated` SSE events. The actual watcher is installed
// lazily by the /api/worktree-config route handler on first request.
setWorktreeConfigNotifier(broadcastNotification);
// Wire the broadcast function into the codev config-file watcher so edits
// to .codev/config{,.local}.json fan out as `codev-config-updated` SSE
// events. The actual watcher is installed lazily by any config-resolving
// route handler (/api/worktree-config, /api/activity-hooks) on first request.
setCodevConfigNotifier(broadcastNotification);

// ============================================================================
// Create server — delegates all HTTP handling to tower-routes.ts
Expand Down
13 changes: 13 additions & 0 deletions packages/codev/src/agent-farm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,19 @@ export interface UserConfig {
*/
devUrls?: Array<{ label: string; url: string }>;
};
/**
* Activity hooks: URL sinks the VSCode extension fires when an abstract event
* occurs (`window-focus`, `builder-active`). Integration-agnostic — the
* destination url (a deep link, a companion app, a webhook launcher) is yours.
* Like other array settings, a higher config layer REPLACES a lower one's list,
* so define them in a single layer: `~/.codev/config.json` for a personal hook
* across all repos, or `.codev/config.local.json` for a per-repo personal one.
*/
activityHooks?: Array<{
on?: Array<'window-focus' | 'builder-active'>;
url?: string;
background?: boolean;
}>;
}

/**
Expand Down
48 changes: 46 additions & 2 deletions packages/codev/src/agent-farm/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* Configuration management for Agent Farm
*/

import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
Expand All @@ -11,7 +12,7 @@ import { getSkeletonDir } from '../../lib/skeleton.js';
import { loadConfig } from '../../lib/config.js';
import type { CodevConfig } from '../../lib/config.js';
import { resolveHarness, type HarnessProvider, type CustomHarnessConfig } from './harness.js';
import type { ResolvedWorktreeConfig, WorktreeDevUrl } from '@cluesmith/codev-types';
import type { ResolvedWorktreeConfig, WorktreeDevUrl, ResolvedActivityHooks, ActivityHook, ActivityEvent } from '@cluesmith/codev-types';

// Re-export so existing internal callers that import the resolved types
// from this module keep working. The canonical home is now
Expand Down Expand Up @@ -298,6 +299,49 @@ export function getWorktreeConfig(workspaceRoot?: string): ResolvedWorktreeConfi
};
}

const ACTIVITY_EVENTS: ReadonlySet<string> = new Set<ActivityEvent>(['window-focus', 'builder-active']);

interface RawActivityHook { on?: string[]; url?: string; background?: boolean }

/**
* Read the `activityHooks` array from one config file. Returns `undefined` when the
* file is missing/invalid or doesn't define the key — so a present-but-empty list in
* a higher layer can still REPLACE a lower one (array-replace, matching deepMerge).
*/
function readActivityHooksLayer(configPath: string): RawActivityHook[] | undefined {
try {
const parsed = JSON.parse(readFileSync(configPath, 'utf8')) as { activityHooks?: RawActivityHook[] };
if (!('activityHooks' in parsed)) { return undefined; }
return Array.isArray(parsed.activityHooks) ? parsed.activityHooks : [];
} catch {
return undefined; // absent / unreadable / invalid JSON → layer not present
}
}

/**
* Resolved `activityHooks` for a workspace.
*
* SECURITY: hooks EXECUTE (the VSCode extension opens their url), so they are
* resolved ONLY from the user's trusted personal config layers — `~/.codev/config.json`
* (global, across all repos) and `<root>/.codev/config.local.json` (per-engineer,
* gitignored) — and NEVER from the committed `.codev/config.json`, which a cloned repo
* controls (a committed hook would be a zero-click RCE). The project-local layer
* replaces the global one when present. Malformed entries (no url, or no valid `on`
* event) are dropped.
*/
export function getActivityHooks(workspaceRoot?: string): ResolvedActivityHooks {
const root = workspaceRoot || findWorkspaceRoot();
const local = readActivityHooksLayer(resolve(root, '.codev', 'config.local.json'));
const global = readActivityHooksLayer(resolve(homedir(), '.codev', 'config.json'));
const raw = local ?? global ?? [];
const hooks: ActivityHook[] = raw.flatMap((h) => {
const on = (h?.on ?? []).filter((e): e is ActivityEvent => ACTIVITY_EVENTS.has(e));
if (!h?.url || on.length === 0) { return []; }
return [{ on, url: h.url, background: h.background ?? false }];
});
return { hooks };
}

/**
* Filter malformed entries (missing/empty `label` or `url`). Both
* fields are mandatory by schema — no default-label fallback.
Expand Down
Loading
Loading