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:
38
packages/core/src/__tests__/redact-secrets.test.ts
Normal file
38
packages/core/src/__tests__/redact-secrets.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { redactSecrets } from "../redact-secrets.js";
|
||||
|
||||
// Parity fixtures mirror the original ACP plugin's process-manager tests so the
|
||||
// shared implementation produces identical behavior (Risk S8).
|
||||
describe("redactSecrets (shared @fusion/core)", () => {
|
||||
it("redacts bearer tokens", () => {
|
||||
const out = redactSecrets("Authorization: Bearer sk-live-ABCDEFG1234567890abcdef");
|
||||
expect(out).not.toContain("sk-live-ABCDEFG1234567890abcdef");
|
||||
expect(out).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts key=/token= assignments", () => {
|
||||
const out = redactSecrets("api_key=abcdef0123456789 token=ZZZ987654321");
|
||||
expect(out).not.toContain("abcdef0123456789");
|
||||
expect(out).not.toContain("ZZZ987654321");
|
||||
});
|
||||
|
||||
it("redacts long opaque hex/base64 secrets", () => {
|
||||
const out = redactSecrets("value 0123456789abcdef0123456789abcdef done");
|
||||
expect(out).not.toContain("0123456789abcdef0123456789abcdef");
|
||||
});
|
||||
|
||||
it("leaves benign text intact", () => {
|
||||
expect(redactSecrets("hello world")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("redacts standalone sk-/ghp_/AKIA opaque tokens", () => {
|
||||
const out = redactSecrets("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789");
|
||||
expect(out).toBe("[REDACTED]");
|
||||
});
|
||||
|
||||
it("redacts quoted secret assignments", () => {
|
||||
const out = redactSecrets('client_secret="topsecretvalue123"');
|
||||
expect(out).not.toContain("topsecretvalue123");
|
||||
expect(out).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ export type {
|
||||
EntryPointBranchAssignment,
|
||||
} from "./branch-assignment.js";
|
||||
export { customProviderRegistryKey } from "./custom-provider-key.js";
|
||||
export { redactSecrets } from "./redact-secrets.js";
|
||||
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
|
||||
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";
|
||||
export {
|
||||
|
||||
31
packages/core/src/redact-secrets.ts
Normal file
31
packages/core/src/redact-secrets.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Shared secret-redaction helper.
|
||||
*
|
||||
* Pure string logic that strips token-like / auth patterns from text so auth
|
||||
* errors and process output don't leak verbatim into logs or buffers. Best
|
||||
* effort: covers bearer tokens, `Authorization:` header values,
|
||||
* `key=`/`token=`/`secret=` assignments, and long base64/hex secrets.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Redact token-like / auth patterns from `text`.
|
||||
*/
|
||||
export function redactSecrets(text: string): string {
|
||||
return (
|
||||
text
|
||||
// Authorization: Bearer <token> / Authorization: <token>
|
||||
.replace(/(authorization\s*[:=]\s*)(bearer\s+)?[^\s,;"']+/gi, "$1$2[REDACTED]")
|
||||
// Bearer <token>
|
||||
.replace(/\b(bearer)\s+[A-Za-z0-9._\-+/=]+/gi, "$1 [REDACTED]")
|
||||
// key=... token=... secret=... password=... apikey=... (quoted or bare)
|
||||
.replace(
|
||||
/\b((?:api[_-]?key|key|token|secret|password|passwd|pwd|access[_-]?token|refresh[_-]?token|client[_-]?secret)\s*[:=]\s*)("?)[^\s,;"']+\2/gi,
|
||||
"$1$2[REDACTED]$2",
|
||||
)
|
||||
// sk-/ghp_/github_pat_/xoxb-/AKIA-style long opaque tokens
|
||||
.replace(/\b(sk-|ghp_|gho_|github_pat_|xox[abpr]-|AKIA)[A-Za-z0-9_\-]{8,}/g, "[REDACTED]")
|
||||
// standalone long base64/hex secrets (>=32 chars)
|
||||
.replace(/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]")
|
||||
.replace(/\b[0-9a-fA-F]{32,}\b/g, "[REDACTED]")
|
||||
);
|
||||
}
|
||||
@@ -11,176 +11,10 @@ import { EventEmitter } from "events";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
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 terminal is actually used)
|
||||
let ptyModule: typeof import("node-pty") | null = null;
|
||||
let ptyLoadError: Error | null = null;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Find the staged native assets directory for Bun-compiled binaries.
|
||||
* Looks for runtime/<platform-arch>/ next to the binary.
|
||||
*
|
||||
* NOTE: The fs.existsSync() calls in this function run during service initialization
|
||||
* (when terminal is first used). This is acceptable as it only executes once per
|
||||
* service lifetime, not per-request.
|
||||
*/
|
||||
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}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
// The node-pty native-asset loader (lazy-load, prebuild resolution, dlopen
|
||||
// fallback, and permission repair) lives in @fusion/engine so PTY owners share
|
||||
// one implementation. See packages/engine/src/pty-native.ts.
|
||||
import { loadPtyModule } from "@fusion/engine";
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
92
packages/engine/src/__tests__/pty-native.test.ts
Normal file
92
packages/engine/src/__tests__/pty-native.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
211
packages/engine/src/pty-native.ts
Normal file
211
packages/engine/src/pty-native.ts
Normal 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;
|
||||
}
|
||||
80
packages/engine/src/types/node-pty/index.d.ts
vendored
Normal file
80
packages/engine/src/types/node-pty/index.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
|
||||
Reference in New Issue
Block a user