fix: project-scope terminal, file browser, agent execution, and heartbeat monitor

- TerminalService: use per-project instance map instead of destructive singleton
- File browser: thread projectId through API functions, hooks, and components
- Workspace file editor: pass projectId to read/write operations
- Agent heartbeat: guard execution with project scope validation
- Agent runs/stop: validate heartbeatMonitor matches scoped project
- CLI dashboard/serve: pass rootDir to heartbeatMonitor via server options
- Add getRootDir() to HeartbeatMonitor for scope comparison

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
gsxdsm
2026-04-12 18:38:26 -07:00
parent 9204fc673c
commit 2562814518
16 changed files with 172 additions and 65 deletions

View File

@@ -1802,6 +1802,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const hasHeartbeatExecutor = Boolean(heartbeatMonitor);
const aiSessionStore = options?.aiSessionStore;
/**
* Check whether the heartbeatMonitor is bound to the same project as scopedStore.
* Returns false when the monitor's rootDir is set and differs from the store's root.
* Returns true when rootDir is not exposed (backward compatible) or paths match.
*/
function isHeartbeatMonitorForProject(scopedStore: TaskStore): boolean {
if (!heartbeatMonitor?.rootDir) return true; // no rootDir exposed — assume compatible
try {
const monitorRoot = resolve(heartbeatMonitor.rootDir);
const storeRoot = resolve(scopedStore.getRootDir());
return monitorRoot === storeRoot;
} catch {
return true; // path resolution failure — assume compatible
}
}
const triggerCommentWakeForAssignedAgent = async (
scopedStore: TaskStore,
task: Task,
@@ -1815,6 +1831,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
// Guard: heartbeatMonitor is bound to a specific project root directory.
// Skip the wake when the scoped store belongs to a different project.
if (!isHeartbeatMonitorForProject(scopedStore)) {
return;
}
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
@@ -9585,13 +9607,15 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest(`Invalid companies.sh slug: "${importCompanySlug}". Slugs must be lowercase alphanumeric with hyphens.`);
}
// Fetch company info from companies.sh API
const companyApiUrl = `https://companies.sh/api/companies/${encodeURIComponent(importCompanySlug)}`;
// Fetch company info from companies.sh catalog API
// Note: The per-company endpoint (/api/companies/:slug) returns HTML (SPA),
// so we fetch the full list and filter by slug.
const companyApiUrl = "https://companies.sh/api/companies";
let companyInfo: { name: string; repo?: string; tagline?: string } | null = null;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const timeout = setTimeout(() => controller.abort(), 15000);
const response = await fetch(companyApiUrl, {
signal: controller.signal,
@@ -9604,21 +9628,31 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
clearTimeout(timeout);
if (!response.ok) {
if (response.status === 404) {
throw badRequest(`Company not found: "${importCompanySlug}"`);
}
throw new Error(`companies.sh API returned ${response.status}`);
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
throw new Error("companies.sh API returned non-JSON");
throw new Error("companies.sh API returned non-JSON content");
}
const data = await response.json() as Record<string, unknown>;
const name = typeof data.name === "string" ? data.name : importCompanySlug;
const repo = typeof data.repo === "string" ? data.repo : undefined;
const tagline = typeof data.tagline === "string" ? data.tagline : undefined;
// The API returns { items: [...] } — find the matching company by slug
const items = Array.isArray(data.items)
? data.items as Record<string, unknown>[]
: Array.isArray(data)
? data as Record<string, unknown>[]
: [];
const match = items.find((item) => item.slug === importCompanySlug);
if (!match) {
throw badRequest(`Company not found: "${importCompanySlug}"`);
}
const name = typeof match.name === "string" ? match.name : importCompanySlug;
const repo = typeof match.repo === "string" ? match.repo : undefined;
const tagline = typeof match.tagline === "string" ? match.tagline : undefined;
companyInfo = { name, repo, tagline };
} catch (fetchErr) {
@@ -10671,7 +10705,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Optionally trigger execution
let run: import("@fusion/core").AgentHeartbeatRun | undefined;
if (triggerExecution && hasHeartbeatExecutor && heartbeatMonitor) {
if (triggerExecution && hasHeartbeatExecutor && heartbeatMonitor && isHeartbeatMonitorForProject(scopedStore)) {
run = await heartbeatMonitor.executeHeartbeat({
agentId: req.params.id,
source: "on_demand",
@@ -10810,6 +10844,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
if (hasHeartbeatExecutor && heartbeatMonitor) {
// Check for existing active run
const scopedStore = await getScopedStore(req);
// Guard: heartbeatMonitor is bound to a specific project root directory.
// Reject when the scoped store belongs to a different project.
if (!isHeartbeatMonitorForProject(scopedStore)) {
throw new ApiError(400, "Agent execution is only available for the server's primary project. The heartbeat monitor is not bound to this project.");
}
const { AgentStore: AgentStoreClass } = await import("@fusion/core");
const agentStore = new AgentStoreClass({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
@@ -10888,7 +10929,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
if (hasHeartbeatExecutor && heartbeatMonitor) {
if (hasHeartbeatExecutor && heartbeatMonitor && isHeartbeatMonitorForProject(scopedStore)) {
await heartbeatMonitor.stopRun(req.params.id);
} else {
const existingRun = await agentStore.getRunDetail(req.params.id, activeRun.id);

View File

@@ -109,6 +109,8 @@ export interface ServerOptions {
};
/** Optional HeartbeatMonitor for triggering agent execution runs */
heartbeatMonitor?: {
/** Project root directory this monitor is bound to. Used for scope validation. */
rootDir?: string;
startRun(agentId: string, options?: { source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; contextSnapshot?: Record<string, unknown> }): Promise<import("@fusion/core").AgentHeartbeatRun>;
executeHeartbeat(options: {
agentId: string;

View File

@@ -832,21 +832,24 @@ export class TerminalService extends EventEmitter {
}
}
// Singleton instance (initialized lazily with project root)
let terminalService: TerminalService | null = null;
let initializedRoot: string | null = null;
// Per-project service instances (keyed by resolved project root)
const terminalServices: Map<string, TerminalService> = new Map();
export function getTerminalService(projectRoot?: string, maxSessions?: number): TerminalService {
if (!terminalService || (projectRoot && projectRoot !== initializedRoot)) {
if (!projectRoot) {
if (!projectRoot) {
// Fallback: return the first available instance or throw
const first = terminalServices.values().next();
if (first.done) {
throw new Error("TerminalService requires projectRoot for initialization");
}
// Clean up old instance to avoid leaking PTY processes
if (terminalService) {
terminalService.cleanup();
}
terminalService = new TerminalService(projectRoot, maxSessions);
initializedRoot = projectRoot;
return first.value;
}
return terminalService;
const resolvedRoot = path.resolve(projectRoot);
const existing = terminalServices.get(resolvedRoot);
if (existing) {
return existing;
}
const service = new TerminalService(resolvedRoot, maxSessions);
terminalServices.set(resolvedRoot, service);
return service;
}