feat(FN-2276): merge fusion/fn-2276

This commit is contained in:
Fusion
2026-04-22 21:48:56 -07:00
committed by gsxdsm
parent 780bb21fa3
commit 9cb131cc70
7 changed files with 583 additions and 17 deletions

View File

@@ -50,6 +50,29 @@ These fields are managed by the engine and cannot be directly edited:
- `lastError` — Last error message (managed by engine)
- `pauseReason` — Reason for paused state (managed by engine)
### Stale Task Link Sanitization
The `taskId` field is suppressed in API responses when the linked task is in a terminal state (`done` or `archived`). This prevents stale "working on" UI indicators in the Agents dashboard for agents whose task has already completed.
**Terminal task statuses:**
- `done` — Task completed successfully
- `archived` — Task archived
**Affected API endpoints:**
- `GET /api/agents` — `taskId` is omitted from agents with terminal linked tasks
- `GET /api/agents/:id` — `taskId` is omitted when the linked task is terminal
- `GET /api/agents/stats` — `assignedTaskCount` excludes agents with terminal linked tasks
**Non-terminal task statuses (taskId is preserved):**
- `triage`
- `todo`
- `in-progress`
- `in-review`
**Graceful degradation:**
- If task lookup fails (e.g., task deleted), `taskId` is preserved in the response to avoid false negatives
- The underlying `taskId` is NOT modified in storage — only the API response is sanitized
### Update-Only Fields
These fields can only be set during update (not on create):

View File

@@ -5,9 +5,10 @@ import { join } from "node:path";
const repoRoot = join(import.meta.dirname!, "..", "..", "..", "..");
describe("Changeset configuration", () => {
// Note: .changeset directory was intentionally removed to stop tracking local-only artifacts (FN-LOCAL).
// These tests are skipped as the changeset files are no longer present.
it.skip("should have a valid .changeset/config.json", () => {
// Release guardrail: These tests protect the @runfusion/fusion release pipeline
// by ensuring changeset configuration required for npm publishing remains intact.
// If these tests fail, the automated release workflow will break.
it("should have a valid .changeset/config.json", () => {
const configPath = join(repoRoot, ".changeset", "config.json");
expect(existsSync(configPath)).toBe(true);
@@ -16,13 +17,14 @@ describe("Changeset configuration", () => {
expect(typeof config).toBe("object");
});
it.skip("should have baseBranch set to main", () => {
it("should have baseBranch set to 'main' for the default branch", () => {
const configPath = join(repoRoot, ".changeset", "config.json");
const config = JSON.parse(readFileSync(configPath, "utf-8"));
expect(config.baseBranch).toBe("main");
});
it.skip("should have changeset scripts in root package.json", () => {
it("should have changeset scripts in root package.json", () => {
// These scripts drive the changesets CLI workflow: changeset (add), version (bump), release:version (apply)
const pkgPath = join(repoRoot, "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
@@ -31,7 +33,7 @@ describe("Changeset configuration", () => {
expect(pkg.scripts["release:version"]).toBe("changeset version");
});
it("should have .github/workflows/version.yml with expected content", () => {
it("should have .github/workflows/version.yml configured for manual releases", () => {
const workflowPath = join(
repoRoot,
".github",
@@ -41,7 +43,9 @@ describe("Changeset configuration", () => {
expect(existsSync(workflowPath)).toBe(true);
const content = readFileSync(workflowPath, "utf-8");
// Guardrail: workflow must use changesets/action for npm publishing
expect(content).toContain("changesets/action");
// Guardrail: workflow must be manually triggered (auto-trigger disabled for safety)
expect(content).toContain("workflow_dispatch");
expect(content).toContain("Auto-trigger disabled");
});

View File

@@ -56,7 +56,7 @@ function getStateBadgeClass(state: AgentState): string {
}
}
function getStateCardClass(prefix: "agent-card", state: AgentState): string {
function getStateCardClass(prefix: "agent-card" | "agent-board-card", state: AgentState): string {
switch (state) {
case "running":
return `${prefix}--running`;
@@ -253,18 +253,17 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
const [isImporting, setIsImporting] = useState(false);
const [filterState, setFilterState] = useState<AgentState | "all">("all");
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentView, setAgentView] = useState<"list" | "tree" | "org">(() => {
const [agentView, setAgentView] = useState<"list" | "board" | "tree" | "org">(() => {
if (typeof window === "undefined") return "list";
const saved = getScopedItem("fn-agent-view", projectId);
// "board" is a retired option — migrate any persisted preference to "list".
return (saved === "list" || saved === "tree" || saved === "org") ? saved : "list";
return (saved === "list" || saved === "board" || saved === "tree" || saved === "org") ? saved : "list";
});
const [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]);
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
useEffect(() => {
const saved = getScopedItem("fn-agent-view", projectId);
if (saved === "list" || saved === "tree" || saved === "org") {
if (saved === "list" || saved === "board" || saved === "tree" || saved === "org") {
setAgentView(saved);
return;
}
@@ -516,6 +515,15 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
>
<List size={16} />
</button>
<button
className={`view-toggle-btn${agentView === "board" ? " active" : ""}`}
onClick={() => setAgentView("board")}
title="Board view"
aria-label="Board view"
aria-pressed={agentView === "board"}
>
<Activity size={16} />
</button>
<button
className={`view-toggle-btn${agentView === "tree" ? " active" : ""}`}
onClick={() => setAgentView("tree")}
@@ -660,6 +668,40 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
))
)}
</div>
) : agentView === "board" ? (
<div className="agent-board">
{displayAgents.length === 0 ? (
<AgentEmptyState onCtaClick={() => setIsCreating(true)} />
) : (
displayAgents.map((agent) => {
const health = getHealthStatus(agent);
const stateBadgeClass = getStateBadgeClass(agent.state);
const stateCardClass = getStateCardClass("agent-board-card", agent.state);
return (
<div key={agent.id} className={`agent-board-card ${stateCardClass}`}>
<div
className="agent-board-clickable"
onClick={() => setSelectedAgentId(agent.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && setSelectedAgentId(agent.id)}
>
<div className="agent-board-header">
<span className="agent-board-icon">{getRoleIcon(agent.role)}</span>
<span className="agent-board-badge badge text-secondary">{getRoleLabel(agent.role)}</span>
<span className={`agent-board-badge badge ${stateBadgeClass}`}>{agent.state}</span>
</div>
<div className="agent-board-name">{agent.name}</div>
<div className="agent-board-id">{agent.id}</div>
<div className="agent-board-health" style={{ color: health.color }} title={health.label}>
{health.icon}{!health.stateDerived && ` ${health.label}`}
</div>
</div>
</div>
);
})
)}
</div>
) : (
<div className="agent-list">
{displayAgents.length === 0 ? (

View File

@@ -16469,3 +16469,363 @@ describe("Messaging Routes", () => {
// Note: Project pause/resume route tests are in src/__tests__/project-pause-resume-routes.test.ts
// to avoid test isolation issues with vi.restoreAllMocks() from other tests in routes.test.ts
describe("Agent stale task-link sanitization", () => {
let tempDir: string;
let fusionDir: string;
let agentId: string;
beforeEach(async () => {
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agent-stale-"));
fusionDir = join(tempDir, ".fusion");
mkdirSync(fusionDir, { recursive: true });
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const agent = await agentStore.createAgent({
name: "Test Agent",
role: "executor",
});
agentId = agent.id;
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function buildAgentApp() {
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("GET /api/agents omits taskId when linked task is done", async () => {
const doneTaskId = "FN-DONE";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockResolvedValue({
id: doneTaskId,
column: "done",
description: "Completed task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Done\n\nDone task",
}),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Assign done task to agent
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.assignTask(agentId, doneTaskId);
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
expect(testAgent).toBeDefined();
expect(testAgent).not.toHaveProperty("taskId");
});
it("GET /api/agents omits taskId when linked task is archived", async () => {
const archivedTaskId = "FN-ARCHIVED";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockResolvedValue({
id: archivedTaskId,
column: "archived",
description: "Archived task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Archived\n\nArchived task",
}),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Assign archived task to agent
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.assignTask(agentId, archivedTaskId);
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
expect(testAgent).toBeDefined();
expect(testAgent).not.toHaveProperty("taskId");
});
it("GET /api/agents preserves taskId for non-terminal linked tasks", async () => {
const activeTaskId = "FN-ACTIVE";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockResolvedValue({
id: activeTaskId,
column: "in-progress",
description: "Active task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Active\n\nActive task",
}),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Assign active task to agent
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.assignTask(agentId, activeTaskId);
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
expect(testAgent).toBeDefined();
expect(testAgent.taskId).toBe(activeTaskId);
});
it("GET /api/agents/:id omits taskId when linked task is done", async () => {
const doneTaskId = "FN-DONE-DETAIL";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockResolvedValue({
id: doneTaskId,
column: "done",
description: "Done task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Done\n\nDone task",
}),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Assign done task to agent
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.assignTask(agentId, doneTaskId);
const res = await GET(app, `/api/agents/${agentId}`);
expect(res.status).toBe(200);
expect(res.body).toBeDefined();
expect(res.body.id).toBe(agentId);
expect(res.body).not.toHaveProperty("taskId");
});
it("GET /api/agents/:id omits taskId when linked task is archived", async () => {
const archivedTaskId = "FN-ARCHIVED-DETAIL";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockResolvedValue({
id: archivedTaskId,
column: "archived",
description: "Archived task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Archived\n\nArchived task",
}),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Assign archived task to agent
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.assignTask(agentId, archivedTaskId);
const res = await GET(app, `/api/agents/${agentId}`);
expect(res.status).toBe(200);
expect(res.body).toBeDefined();
expect(res.body.id).toBe(agentId);
expect(res.body).not.toHaveProperty("taskId");
});
it("GET /api/agents/:id preserves taskId for in-review linked tasks", async () => {
const inReviewTaskId = "FN-IN-REVIEW";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockResolvedValue({
id: inReviewTaskId,
column: "in-review",
description: "In review task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# In Review\n\nIn review task",
}),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Assign in-review task to agent
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.assignTask(agentId, inReviewTaskId);
const res = await GET(app, `/api/agents/${agentId}`);
expect(res.status).toBe(200);
expect(res.body).toBeDefined();
expect(res.body.id).toBe(agentId);
expect(res.body.taskId).toBe(inReviewTaskId);
});
it("GET /api/agents/stats excludes terminal task links from assignedTaskCount", async () => {
const doneTaskId = "FN-STATS-DONE";
const activeTaskId = "FN-STATS-ACTIVE";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockImplementation(async (taskId: string) => {
if (taskId === doneTaskId) {
return {
id: doneTaskId,
column: "done",
description: "Done task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Done\n\nDone task",
};
}
if (taskId === activeTaskId) {
return {
id: activeTaskId,
column: "in-progress",
description: "Active task",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# Active\n\nActive task",
};
}
return null;
}),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Create two agents: one with done task, one with active task
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const agent1 = await agentStore.createAgent({ name: "Agent 1", role: "executor" });
const agent2 = await agentStore.createAgent({ name: "Agent 2", role: "executor" });
await agentStore.assignTask(agent1.id, doneTaskId);
await agentStore.assignTask(agent2.id, activeTaskId);
const res = await GET(app, "/api/agents/stats");
expect(res.status).toBe(200);
// Only agent2 should count (active task), agent1 should be excluded (done task)
expect(res.body.assignedTaskCount).toBe(1);
});
it("GET /api/agents/stats preserves taskId for agents with no linked task", async () => {
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Agent already created in beforeEach with no task assigned
const res = await GET(app, "/api/agents/stats");
expect(res.status).toBe(200);
// Agent has no task, so assignedTaskCount should be 0
expect(res.body.assignedTaskCount).toBe(0);
});
it("GET /api/agents handles task lookup failure gracefully", async () => {
const taskId = "FN-LOOKUP-FAIL";
const store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
getTask: vi.fn().mockRejectedValue(new Error("Database error")),
} as any);
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
// Assign task to agent
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
await agentStore.assignTask(agentId, taskId);
// Should not throw, taskId should be preserved on lookup failure
const res = await GET(app, "/api/agents");
expect(res.status).toBe(200);
const agents = Array.isArray(res.body) ? res.body : [res.body];
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
expect(testAgent).toBeDefined();
// On lookup failure, taskId should be preserved (treated as non-terminal)
expect(testAgent.taskId).toBe(taskId);
});
});

View File

@@ -11333,6 +11333,62 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// ── Agent Routes ───────────────────────────────────────────────────────────
/**
* Terminal task statuses — tasks in these states should not be displayed
* as "working on" in agent UI surfaces to avoid stale activity indicators.
*/
const TERMINAL_TASK_STATUSES = new Set(["done", "archived"]);
/**
* Check if a task status is terminal (done or archived).
*/
function isTerminalTaskStatus(status: string | undefined): boolean {
return status !== undefined && TERMINAL_TASK_STATUSES.has(status);
}
/**
* Sanitize agent responses to omit taskId when the linked task is in a terminal state.
* This prevents stale "working on" UI indicators for completed/archived tasks.
*
* @param agents - Array of agents to sanitize
* @param scopedStore - Task store for looking up linked task status
* @returns Agents with terminal-linked taskId omitted from response
*/
async function sanitizeAgentTaskLinks(
agents: Array<import("@fusion/core").Agent>,
scopedStore: TaskStore,
): Promise<Array<import("@fusion/core").Agent>> {
// Batch lookup all unique taskIds to minimize individual store calls
const taskIds = [...new Set(agents.map((a) => a.taskId).filter((id): id is string => id !== undefined))];
const taskStatusMap = new Map<string, string>();
// Parallel fetch all linked tasks
await Promise.all(
taskIds.map(async (taskId) => {
try {
const task = await scopedStore.getTask(taskId);
if (task) {
taskStatusMap.set(taskId, task.column);
}
} catch {
// Task lookup failed — treat as non-terminal (preserve taskId)
}
}),
);
return agents.map((agent) => {
if (!agent.taskId) return agent;
const taskStatus = taskStatusMap.get(agent.taskId);
if (isTerminalTaskStatus(taskStatus)) {
// Omit taskId for terminal tasks — use spread to create shallow copy without taskId
const { taskId: _omitted, ...sanitized } = agent;
return sanitized as import("@fusion/core").Agent;
}
return agent;
});
}
/**
* GET /api/agents
* List all agents with optional filtering.
@@ -11357,7 +11413,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
await agentStore.init();
const agents = await agentStore.listAgents(filter as { state?: "idle" | "active" | "paused" | "terminated"; role?: import("@fusion/core").AgentCapability; includeEphemeral?: boolean });
res.json(agents);
const sanitizedAgents = await sanitizeAgentTaskLinks(agents, scopedStore);
res.json(sanitizedAgents);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
@@ -12313,6 +12370,7 @@ async function persistImportedSkills(
* GET /api/agents/stats
* Return aggregate stats across all agents.
* Must be registered before /agents/:id to avoid "stats" matching :id.
* Note: assignedTaskCount excludes agents whose linked task is in a terminal state.
*/
router.get("/agents/stats", async (req, res) => {
try {
@@ -12323,7 +12381,10 @@ async function persistImportedSkills(
const agents = await agentStore.listAgents();
const activeCount = agents.filter((a) => a.state === "active" || a.state === "running").length;
const assignedTaskCount = agents.filter((a) => a.taskId).length;
// Count only agents with non-terminal linked tasks
const sanitizedAgents = await sanitizeAgentTaskLinks(agents, scopedStore);
const assignedTaskCount = sanitizedAgents.filter((a) => a.taskId).length;
let completedRuns = 0;
let failedRuns = 0;
@@ -12396,6 +12457,7 @@ async function persistImportedSkills(
/**
* GET /api/agents/:id
* Get agent by ID with heartbeat history.
* taskId is omitted from response if the linked task is in a terminal state.
*/
router.get("/agents/:id", async (req, res) => {
try {
@@ -12408,7 +12470,9 @@ async function persistImportedSkills(
if (!agent) {
throw notFound("Agent not found");
}
res.json(agent);
// Sanitize taskId for single-agent responses (omit if linked task is terminal)
const [sanitizedAgent] = await sanitizeAgentTaskLinks([agent], scopedStore);
res.json(sanitizedAgent);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;

View File

@@ -27,6 +27,7 @@ vi.mock("node:fs", async (importOriginal) => {
return {
...actual,
existsSync: vi.fn(() => true),
chmodSync: vi.fn(),
};
});
@@ -81,6 +82,21 @@ describe("TerminalService", () => {
error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).",
});
});
it("repairs env-provided node-pty native permissions before spawn", async () => {
process.env.NODE_PTY_SPAWN_HELPER_DIR = "/native-assets";
const fs = await import("node:fs");
const chmodSyncMock = vi.mocked(fs.chmodSync);
const result = await service.createSession();
expect(result.success).toBe(true);
expect(chmodSyncMock).toHaveBeenCalledWith("/native-assets/spawn-helper", 0o755);
expect(chmodSyncMock).toHaveBeenCalledWith("/native-assets/pty.node", 0o755);
delete process.env.NODE_PTY_SPAWN_HELPER_DIR;
});
});
describe("write", () => {

View File

@@ -10,7 +10,7 @@ import type { IPty, IPtyForkOptions, IWindowsPtyForkOptions } from "node-pty";
import { EventEmitter } from "events";
import * as os from "os";
import * as path from "path";
import { existsSync } from "node:fs";
import { existsSync, chmodSync } from "node:fs";
import { join, dirname } from "node:path";
// Detect if we're running as a Bun-compiled binary
@@ -29,13 +29,68 @@ let ptyLoadError: Error | null = null;
* (when terminal is first used). This is acceptable as it only executes once per
* service lifetime, not per-request.
*/
function findStagedNativeDir(): string | null {
function getNativePrebuildName(): string {
const platform = process.platform === "darwin" ? "darwin" :
process.platform === "linux" ? "linux" :
process.platform === "win32" ? "win32" : "unknown";
const arch = process.arch === "arm64" ? "arm64" :
process.arch === "x64" ? "x64" : "unknown";
const prebuildName = `${platform}-${arch}`;
return `${platform}-${arch}`;
}
function findInstalledNodePtyNativeDir(): string | null {
try {
const packageJsonPath = require.resolve("node-pty/package.json");
const nativeDir = join(dirname(packageJsonPath), "prebuilds", getNativePrebuildName());
return existsSync(join(nativeDir, "pty.node")) ? nativeDir : null;
} catch {
return null;
}
}
function ensureNodePtyNativePermissions(): void {
if (process.platform === "win32") {
return;
}
const candidateDirs = new Set<string>();
const envNativeDir = process.env.NODE_PTY_SPAWN_HELPER_DIR || process.env.FUSION_NATIVE_ASSETS_PATH;
if (envNativeDir) {
candidateDirs.add(envNativeDir);
}
const stagedNativeDir = findStagedNativeDir();
if (stagedNativeDir) {
candidateDirs.add(stagedNativeDir);
}
const installedNativeDir = findInstalledNodePtyNativeDir();
if (installedNativeDir) {
candidateDirs.add(installedNativeDir);
}
for (const nativeDir of candidateDirs) {
const helperPath = join(nativeDir, "spawn-helper");
const nativeModulePath = join(nativeDir, "pty.node");
try {
if (existsSync(helperPath)) {
chmodSync(helperPath, 0o755);
}
if (existsSync(nativeModulePath)) {
chmodSync(nativeModulePath, 0o755);
}
} catch (err) {
console.warn("[terminal] Failed to repair node-pty native permissions:", {
nativeDir,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
function findStagedNativeDir(): string | null {
const prebuildName = getNativePrebuildName();
// Check FUSION_RUNTIME_DIR env var first
if (process.env.FUSION_RUNTIME_DIR) {
@@ -93,6 +148,8 @@ async function loadPtyModule(): Promise<typeof import("node-pty")> {
}
try {
ensureNodePtyNativePermissions();
// Standard import path - the native-patch setup should have created
// the necessary symlink structure for node-pty to find the module
const mod = await import("node-pty");