diff --git a/packages/core/src/__tests__/redact-secrets.test.ts b/packages/core/src/__tests__/redact-secrets.test.ts new file mode 100644 index 0000000000..fa60c38e24 --- /dev/null +++ b/packages/core/src/__tests__/redact-secrets.test.ts @@ -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]"); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 999eb211cb..735127bb72 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/core/src/redact-secrets.ts b/packages/core/src/redact-secrets.ts new file mode 100644 index 0000000000..ad998b31b8 --- /dev/null +++ b/packages/core/src/redact-secrets.ts @@ -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 / Authorization: + .replace(/(authorization\s*[:=]\s*)(bearer\s+)?[^\s,;"']+/gi, "$1$2[REDACTED]") + // Bearer + .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]") + ); +} diff --git a/packages/dashboard/src/terminal-service.ts b/packages/dashboard/src/terminal-service.ts index 69210d19f6..cb674dc1f8 100644 --- a/packages/dashboard/src/terminal-service.ts +++ b/packages/dashboard/src/terminal-service.ts @@ -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// 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// 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(); - 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 { - 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 diff --git a/packages/engine/package.json b/packages/engine/package.json index 61df34611a..d4d403fffc 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -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" }, diff --git a/packages/engine/src/__tests__/pty-native.test.ts b/packages/engine/src/__tests__/pty-native.test.ts new file mode 100644 index 0000000000..4bfc1cc30c --- /dev/null +++ b/packages/engine/src/__tests__/pty-native.test.ts @@ -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 `//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 - 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(); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index dec5919a47..addb8e5e78 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -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"; diff --git a/packages/engine/src/pty-native.ts b/packages/engine/src/pty-native.ts new file mode 100644 index 0000000000..1a8564e72b --- /dev/null +++ b/packages/engine/src/pty-native.ts @@ -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 `-` 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// 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//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(); + 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 { + 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; +} diff --git a/packages/engine/src/types/node-pty/index.d.ts b/packages/engine/src/types/node-pty/index.d.ts new file mode 100644 index 0000000000..34b55c56fb --- /dev/null +++ b/packages/engine/src/types/node-pty/index.d.ts @@ -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 { + (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; + 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; +} diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json index 0f23f665e4..6a3abdd9a5 100644 --- a/packages/engine/tsconfig.json +++ b/packages/engine/tsconfig.json @@ -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/**/*"] diff --git a/plugins/fusion-plugin-acp-runtime/package.json b/plugins/fusion-plugin-acp-runtime/package.json index 73e53beb74..86e32bc70d 100644 --- a/plugins/fusion-plugin-acp-runtime/package.json +++ b/plugins/fusion-plugin-acp-runtime/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "0.24.0", + "@fusion/core": "workspace:*", "@fusion/plugin-sdk": "workspace:*" }, "peerDependencies": { diff --git a/plugins/fusion-plugin-acp-runtime/src/process-manager.ts b/plugins/fusion-plugin-acp-runtime/src/process-manager.ts index 2e52335f5b..d55fbc23cd 100644 --- a/plugins/fusion-plugin-acp-runtime/src/process-manager.ts +++ b/plugins/fusion-plugin-acp-runtime/src/process-manager.ts @@ -13,6 +13,7 @@ // to the agent. import { spawn, type ChildProcess } from "node:child_process"; +import { redactSecrets } from "@fusion/core"; function debugLog(message: string): void { if (process.env.PI_ACP_DEBUG !== "1") return; @@ -113,31 +114,9 @@ export function spawnAgent(options: SpawnAgentOptions): ChildProcess { /** Maximum stderr bytes retained; older output is dropped to bound memory. */ const STDERR_BUFFER_CEILING = 64 * 1024; -/** - * Redact token-like / auth patterns from text so auth errors don't leak - * verbatim into the stderr buffer or logs (Risk S8). Best-effort: covers - * bearer tokens, `Authorization:` header values, `key=`/`token=`/`secret=` - * assignments, and long base64/hex secrets. - */ -export function redactSecrets(text: string): string { - return ( - text - // Authorization: Bearer / Authorization: - .replace(/(authorization\s*[:=]\s*)(bearer\s+)?[^\s,;"']+/gi, "$1$2[REDACTED]") - // Bearer - .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]") - ); -} +// Secret redaction (Risk S8) lives in @fusion/core so PTY/process owners share +// one implementation; re-exported here to preserve this module's public surface. +export { redactSecrets }; /** * Accumulate stderr into a bounded, secret-redacted buffer. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 375685e675..f080060e31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,10 +46,10 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.78.0 - version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@earendil-works/pi-coding-agent': specifier: ^0.78.0 - version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) dockerode: specifier: ^4.0.12 version: 4.0.12 @@ -482,6 +482,9 @@ importers: esbuild: specifier: ^0.25.12 version: 0.25.12 + node-pty: + specifier: npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1 + version: '@homebridge/node-pty-prebuilt-multiarch@0.13.1' proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -693,6 +696,9 @@ importers: '@earendil-works/pi-coding-agent': specifier: '*' version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@fusion/core': + specifier: workspace:* + version: link:../../packages/core '@fusion/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk @@ -7102,6 +7108,10 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@anthropic-ai/sdk@0.91.1': + dependencies: + json-schema-to-ts: 3.1.1 + '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -7836,6 +7846,20 @@ snapshots: - ws - zod + '@earendil-works/pi-agent-core@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-agent-core@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -7866,14 +7890,14 @@ snapshots: '@earendil-works/pi-ai@0.77.0': dependencies: - '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) + '@anthropic-ai/sdk': 0.91.1 '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) + '@google/genai': 1.52.0 '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - openai: 6.26.0(ws@8.20.0)(zod@3.25.76) + openai: 6.26.0 partial-json: 0.1.7 typebox: 1.1.38 transitivePeerDependencies: @@ -7904,6 +7928,26 @@ snapshots: - ws - zod + '@earendil-works/pi-ai@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) + '@mistralai/mistralai': 2.2.1 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.20.0)(zod@3.25.76) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-ai@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) @@ -7928,7 +7972,7 @@ snapshots: dependencies: '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) + '@google/genai': 1.52.0 '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 @@ -8002,6 +8046,35 @@ snapshots: - ws - zod + '@earendil-works/pi-coding-agent@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-agent-core': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-tui': 0.78.0 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + typebox: 1.1.38 + undici: 8.3.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-coding-agent@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-agent-core': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8381,6 +8454,30 @@ snapshots: '@exodus/bytes@1.15.0': {} + '@google/genai@1.52.0': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.8 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.8 + ws: 8.20.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.28.0(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))': dependencies: google-auth-library: 10.6.2 @@ -8887,6 +8984,29 @@ snapshots: - bufferutil - utf-8-validate + '@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.12(hono@4.12.9) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.9 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - supports-color + optional: true + '@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.12(hono@4.12.9) @@ -12572,6 +12692,8 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openai@6.26.0: {} + openai@6.26.0(ws@8.20.0)(zod@3.25.76): optionalDependencies: ws: 8.20.0