feat(engine): extract shared PTY native-asset loader and redactSecrets (U16)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-04 22:53:02 -07:00
parent e5bab640f9
commit ea18ef8d44
13 changed files with 602 additions and 202 deletions

View File

@@ -43,6 +43,7 @@
"@earendil-works/pi-coding-agent": "^0.78.0",
"cron-parser": "^5.5.0",
"esbuild": "^0.25.12",
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
"proper-lockfile": "^4.1.2",
"typebox": "^1.0.0"
},

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import { join } from "node:path";
import {
getNativePrebuildName,
findStagedNativeDir,
ensureNodePtyNativePermissions,
} from "../pty-native.js";
const SAVED_ENV = {
FUSION_RUNTIME_DIR: process.env.FUSION_RUNTIME_DIR,
NODE_PTY_SPAWN_HELPER_DIR: process.env.NODE_PTY_SPAWN_HELPER_DIR,
FUSION_NATIVE_ASSETS_PATH: process.env.FUSION_NATIVE_ASSETS_PATH,
};
let tmpRoot: string;
beforeEach(() => {
tmpRoot = fs.mkdtempSync(join(os.tmpdir(), "pty-native-"));
delete process.env.FUSION_RUNTIME_DIR;
delete process.env.NODE_PTY_SPAWN_HELPER_DIR;
delete process.env.FUSION_NATIVE_ASSETS_PATH;
});
afterEach(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
for (const [k, v] of Object.entries(SAVED_ENV)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
/** Create a fixture `<root>/<prebuildName>/pty.node` (+ spawn-helper) directory. */
function makeStagedDir(root: string, opts: { broken?: boolean } = {}): string {
const dir = join(root, getNativePrebuildName());
fs.mkdirSync(dir, { recursive: true });
const nativePath = join(dir, "pty.node");
const helperPath = join(dir, "spawn-helper");
fs.writeFileSync(nativePath, "fake-native");
fs.writeFileSync(helperPath, "fake-helper");
if (opts.broken) {
// Strip executable + write/read bits to simulate a broken-mode install.
fs.chmodSync(nativePath, 0o400);
fs.chmodSync(helperPath, 0o400);
}
return dir;
}
describe("getNativePrebuildName", () => {
it("returns a <platform>-<arch> token", () => {
const name = getNativePrebuildName();
expect(name).toMatch(/^(darwin|linux|win32|unknown)-(arm64|x64|unknown)$/);
});
});
describe("findStagedNativeDir (packaged-binary mode)", () => {
it("resolves the staged dir via FUSION_RUNTIME_DIR fixture", () => {
const staged = makeStagedDir(tmpRoot);
process.env.FUSION_RUNTIME_DIR = tmpRoot;
expect(findStagedNativeDir()).toBe(staged);
});
it("returns null when no staged pty.node is present", () => {
process.env.FUSION_RUNTIME_DIR = tmpRoot; // empty, no pty.node
expect(findStagedNativeDir()).toBeNull();
});
});
describe("ensureNodePtyNativePermissions (permission repair)", () => {
// chmod semantics don't apply on win32; skip there.
const maybe = process.platform === "win32" ? it.skip : it;
maybe("repairs broken modes on a fixture native dir to 0o755", () => {
const dir = makeStagedDir(tmpRoot, { broken: true });
process.env.FUSION_RUNTIME_DIR = tmpRoot;
const nativePath = join(dir, "pty.node");
const helperPath = join(dir, "spawn-helper");
// Precondition: not executable.
expect(fs.statSync(nativePath).mode & 0o111).toBe(0);
ensureNodePtyNativePermissions();
expect(fs.statSync(nativePath).mode & 0o777).toBe(0o755);
expect(fs.statSync(helperPath).mode & 0o777).toBe(0o755);
});
maybe("is a no-op (does not throw) when no candidate dirs exist", () => {
expect(() => ensureNodePtyNativePermissions()).not.toThrow();
});
});

View File

@@ -614,3 +614,12 @@ export {
type RuntimeStatus,
type RuntimeMetrics,
} from "./project-runtime.js";
// Shared node-pty native-asset loader
export {
loadPtyModule,
ensureNodePtyNativePermissions,
findStagedNativeDir,
findInstalledNodePtyNativeDir,
getNativePrebuildName,
resetPtyModuleCacheForTests,
} from "./pty-native.js";

View File

@@ -0,0 +1,211 @@
/**
* Shared node-pty native-asset loader.
*
* Centralizes the lazy-load, prebuild path resolution, dlopen fallback, and
* native-permission repair machinery so PTY owners (the dashboard terminal
* service and the CLI agent executor) share one implementation. The runtime
* package is `@homebridge/node-pty-prebuilt-multiarch`, aliased as `node-pty`
* in package.json.
*/
import * as fs from "node:fs";
import { createRequire } from "node:module";
import { join, dirname } from "node:path";
// Detect if we're running as a Bun-compiled binary
// @ts-expect-error - Bun global is only available in Bun runtime
const isBunBinary = typeof Bun !== "undefined" && !!Bun.embeddedFiles;
// Lazy-loaded node-pty module (only loaded when a PTY is actually used)
let ptyModule: typeof import("node-pty") | null = null;
let ptyLoadError: Error | null = null;
const require = createRequire(import.meta.url);
/**
* Resolve the `<platform>-<arch>` directory name used for staged native
* prebuilds next to a Bun-compiled binary.
*/
export 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";
return `${platform}-${arch}`;
}
/**
* Locate the installed node-pty native module directory in dev/workspace mode.
*
* NOTE: The fs.existsSync() calls in this function run during loader
* initialization (when a PTY is first used). This is acceptable as it only
* executes once per process lifetime, not per-request.
*/
export function findInstalledNodePtyNativeDir(): string | null {
try {
const packageJsonPath = require.resolve("node-pty/package.json");
const pkgRoot = dirname(packageJsonPath);
// @homebridge/node-pty-prebuilt-multiarch (aliased as node-pty) places the binary
// in build/Release/pty.node after prebuild-install runs at install time.
// Prefer this location as it is the fork's standard output path.
const releaseDir = join(pkgRoot, "build", "Release");
if (fs.existsSync(join(releaseDir, "pty.node"))) {
return releaseDir;
}
// Fallback: check the old prebuilds/<plat-arch>/ layout (upstream node-pty style).
const prebuildDir = join(pkgRoot, "prebuilds", getNativePrebuildName());
if (fs.existsSync(join(prebuildDir, "pty.node"))) {
return prebuildDir;
}
return null;
} catch {
return null;
}
}
/**
* Locate the native assets directory staged next to a Bun-compiled binary
* (packaged-binary mode). Looks for `runtime/<platform-arch>/pty.node`.
*/
export function findStagedNativeDir(): string | null {
const prebuildName = getNativePrebuildName();
// Check FUSION_RUNTIME_DIR env var first
if (process.env.FUSION_RUNTIME_DIR) {
const envPath = join(process.env.FUSION_RUNTIME_DIR, prebuildName);
if (fs.existsSync(join(envPath, "pty.node"))) {
return envPath;
}
}
// Look next to the executable
const execDir = dirname(process.execPath);
const nextToBinary = join(execDir, "runtime", prebuildName);
if (fs.existsSync(join(nextToBinary, "pty.node"))) {
return nextToBinary;
}
return null;
}
/**
* Best-effort repair of native-asset permissions so node-pty's `pty.node` and
* `spawn-helper` are executable. No-op on Windows.
*/
export 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 {
fs.chmodSync(helperPath, 0o755);
} catch {
// Best-effort permission repair; helper may not exist in some layouts.
}
try {
fs.chmodSync(nativeModulePath, 0o755);
} catch (err) {
// Keep diagnostics for the native module path since missing/invalid perms
// here are more likely to prevent PTY startup.
console.warn("[terminal] Failed to repair node-pty native permissions:", {
nativeDir,
error: err instanceof Error ? err.message : String(err),
});
}
}
}
/**
* Lazily load the node-pty module, repairing native permissions and (for
* Bun-compiled binaries) pre-loading the native module via dlopen. The loaded
* module is cached; a load failure is cached and re-thrown on subsequent calls.
*/
export async function loadPtyModule(): Promise<typeof import("node-pty")> {
ensureNodePtyNativePermissions();
if (ptyModule) {
return ptyModule;
}
if (ptyLoadError) {
throw ptyLoadError;
}
// For Bun-compiled binary, set up native paths before loading
if (isBunBinary) {
const nativeDir = findStagedNativeDir();
if (nativeDir) {
// Set spawn-helper directory
if (process.platform !== "win32") {
process.env.NODE_PTY_SPAWN_HELPER_DIR = nativeDir;
}
// Store reference for debugging
process.env.FUSION_NATIVE_ASSETS_PATH = nativeDir;
// Try to pre-load the native module using process.dlopen
// This can help when the normal require() path fails
const nativePath = join(nativeDir, "pty.node");
if (fs.existsSync(nativePath)) {
try {
const nativeModule: { exports?: unknown } = { exports: {} };
// process.dlopen is a Node internal API
process.dlopen(nativeModule, nativePath);
console.log("[terminal] Pre-loaded native module via dlopen");
} catch (dlopenErr) {
// dlopen failed - log but continue, normal import might still work
console.log("[terminal] dlopen pre-load failed (continuing):", dlopenErr);
}
}
}
}
try {
// 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");
ptyModule = mod;
return ptyModule as typeof import("node-pty");
} catch (err) {
ptyLoadError = err instanceof Error ? err : new Error(String(err));
throw ptyLoadError;
}
}
/**
* Reset the cached module / error state. Intended for tests that exercise the
* loader across multiple scenarios.
*/
export function resetPtyModuleCacheForTests(): void {
ptyModule = null;
ptyLoadError = null;
}

View File

@@ -0,0 +1,80 @@
/**
* Type shim for the `node-pty` import specifier.
*
* The runtime package is @homebridge/node-pty-prebuilt-multiarch, aliased as
* "node-pty" in package.json. Its bundled typings use `declare module
* '@homebridge/node-pty-prebuilt-multiarch'` which TypeScript cannot resolve
* via the npm alias alone. This shim re-declares the module under the `node-pty`
* specifier so all source imports of `"node-pty"` resolve correctly.
*
* API surface matches node-pty 0.10.x / @homebridge/node-pty-prebuilt-multiarch 0.13.x.
*/
declare module "node-pty" {
/**
* An object that can be disposed via a dispose function.
*/
export interface IDisposable {
dispose(): void;
}
/**
* An event that can be listened to.
* @returns an IDisposable to stop listening.
*/
export interface IEvent<T> {
(listener: (e: T) => unknown): IDisposable;
}
export interface IBasePtyForkOptions {
name?: string;
cols?: number;
rows?: number;
cwd?: string;
env?: { [key: string]: string | undefined };
encoding?: string | null;
handleFlowControl?: boolean;
flowControlPause?: string;
flowControlResume?: string;
}
export interface IPtyForkOptions extends IBasePtyForkOptions {
uid?: number;
gid?: number;
}
export interface IWindowsPtyForkOptions extends IBasePtyForkOptions {
useConpty?: boolean;
useConptyDll?: boolean;
conptyInheritCursor?: boolean;
}
/**
* An interface representing a pseudoterminal.
*/
export interface IPty {
readonly pid: number;
readonly cols: number;
readonly rows: number;
readonly process: string;
handleFlowControl: boolean;
readonly onData: IEvent<string>;
readonly onExit: IEvent<{ exitCode: number; signal?: number }>;
resize(columns: number, rows: number): void;
on(event: "data", listener: (data: string) => void): void;
on(event: "exit", listener: (exitCode: number, signal?: number) => void): void;
clear(): void;
write(data: string): void;
kill(signal?: string): void;
pause(): void;
resume(): void;
}
/**
* Forks a process as a pseudoterminal.
*/
export function spawn(
file: string,
args: string[] | string,
options: IPtyForkOptions | IWindowsPtyForkOptions,
): IPty;
}

View File

@@ -5,7 +5,8 @@
"rootDir": "src",
"types": ["node", "vitest/globals"],
"paths": {
"@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"]
"@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"],
"node-pty": ["./src/types/node-pty/index.d.ts"]
}
},
"include": ["src/**/*"]