Files
wehub-resource-sync 426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

1718 lines
51 KiB
TypeScript

/**
* ShellService — the shell plugin's core command executor. Runs commands via
* executeCommand() (simple, timeout-bounded) or exec() (PTY, background/yield,
* session tracking), and manages live sessions through processAction().
*
* Short-circuits in cloud mode and routes through SandboxManager under sandbox
* mode; PTY spawn (@lydell/node-pty) is optional and degrades to cross-spawn
* when the native module is unavailable.
*/
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import path from "node:path";
import { type IAgentRuntime, logger, Service } from "@elizaos/core";
import { isCloudExecutionMode, shouldUseSandboxExecution } from "@elizaos/shared";
import spawn from "cross-spawn";
import type {
CommandHistoryEntry,
CommandResult,
ExecResult,
ExecuteOptions,
FileOperation,
FileOperationType,
FinishedSession,
ProcessActionParams,
ProcessSession,
PtyHandle,
PtySpawn,
ShellConfig,
} from "../types";
import { isForbiddenCommand, isSafeCommand, loadShellConfig, validatePath } from "../utils";
import {
buildCursorPositionResponse,
encodeKeySequence,
encodePaste,
stripDsrRequests,
} from "../utils/ptyKeys";
import {
chunkString,
clampNumber,
coerceEnv,
deriveSessionName,
formatDuration,
getShellConfig,
killSession,
pad,
resolveWorkdir,
sanitizeBinaryOutput,
sliceLogLines,
truncateMiddle,
} from "../utils/shellUtils";
import {
detectTerminalSupport,
missingTerminalToolForCommand,
missingToolMessage,
} from "../utils/terminalCapabilities";
import {
addSession,
appendOutput,
createSessionSlug,
deleteSession,
drainSession,
getFinishedSession,
getSession,
listFinishedSessions,
listRunningSessions,
markBackgrounded,
markExited,
} from "./processRegistry";
const DEFAULT_TIMEOUT_SEC = 1800; // 30 minutes
interface RuntimeSandboxManager {
getState?: () => string;
isReady?: () => boolean;
start?: () => Promise<void>;
exec: (options: {
command: string;
workdir?: string;
env?: Record<string, string>;
timeoutMs?: number;
stdin?: string;
}) => Promise<{
exitCode: number;
stdout: string;
stderr: string;
durationMs: number;
executedInSandbox: boolean;
}>;
}
export class ShellService extends Service {
public static serviceType = "shell";
private shellConfig: ShellConfig;
private currentDirectory: string;
private commandHistory: Map<string, CommandHistoryEntry[]>;
private maxHistoryPerConversation = 100;
private scopeKey?: string;
constructor(runtime?: IAgentRuntime) {
super(runtime);
this.shellConfig = loadShellConfig();
this.currentDirectory = this.shellConfig.allowedDirectory;
this.commandHistory = new Map();
}
static async start(runtime: IAgentRuntime): Promise<ShellService> {
const instance = new ShellService(runtime);
logger.info("Shell service initialized with PTY, background execution, and history tracking");
return instance;
}
async stop(): Promise<void> {
// Clean up all running sessions by killing their processes
const runningSessions = listRunningSessions();
for (const session of runningSessions) {
try {
killSession(session);
logger.debug(`Killed shell session: ${session.id}`);
} catch (err) {
// error-policy:J6 best-effort teardown on service stop; a session that
// refuses to die is warned and the remaining sessions are still killed.
logger.warn(`Failed to kill shell session ${session.id}: ${err}`);
}
}
// Clear command history
this.commandHistory.clear();
logger.info(`Shell service stopped, cleaned up ${runningSessions.length} running sessions`);
}
get capabilityDescription(): string {
return "Execute shell commands with PTY support, background execution, and session management";
}
private getSandboxManager(): RuntimeSandboxManager | null {
const candidate = (
this.runtime as unknown as {
getSandboxManager?: () => RuntimeSandboxManager | null;
}
).getSandboxManager?.();
return candidate ?? null;
}
private toSandboxWorkdir(workdir: string): string | undefined {
const relative = path.relative(process.cwd(), path.resolve(workdir));
if (relative === "") return "/workspace";
if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
return `/workspace/${relative}`;
}
return undefined;
}
private async runSandboxCommand(
command: string,
workdir: string,
timeoutMs: number,
env?: Record<string, string>
): Promise<CommandResult> {
const sandboxManager = this.getSandboxManager();
if (!sandboxManager) {
logger.error("[shell:sandbox] local-safe denied: SandboxManager unavailable");
return {
success: false,
stdout: "",
stderr:
"local-safe mode requires SandboxManager, but no sandbox manager is available for command execution.",
exitCode: 1,
error: "Sandbox unavailable",
executedIn: workdir,
};
}
const sandboxWorkdir = this.toSandboxWorkdir(workdir);
if (!sandboxWorkdir) {
return {
success: false,
stdout: "",
stderr: `local-safe mode can only execute inside the sandbox workspace; cwd is outside process workspace: ${workdir}`,
exitCode: 1,
error: "Sandbox unavailable",
executedIn: workdir,
};
}
logger.info(`[shell:sandbox] routing exec via SandboxManager: ${command.substring(0, 100)}`);
const result = await sandboxManager.exec({
command,
workdir: sandboxWorkdir,
timeoutMs,
env,
});
logger.info(
`[shell:sandbox] exec completed: exit=${result.exitCode} duration=${result.durationMs}ms executedInSandbox=${result.executedInSandbox}`
);
return {
success: result.exitCode === 0,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
executedIn: workdir,
};
}
private localTerminalUnsupportedMessage(): string | null {
const support = detectTerminalSupport();
return support.supported
? null
: (support.message ?? "Local terminal execution is unavailable.");
}
/**
* Set scope key for session isolation
*/
setScopeKey(scopeKey: string): void {
this.scopeKey = scopeKey;
}
/**
* Simple command execution (original API for backward compatibility)
*/
async executeCommand(command: string, conversationId?: string): Promise<CommandResult> {
if (!command || typeof command !== "string") {
return {
success: false,
stdout: "",
stderr: "Invalid command",
exitCode: 1,
error: "Command must be a non-empty string",
executedIn: this.currentDirectory,
};
}
if (isCloudExecutionMode(this.runtime)) {
logger.error("[shell:cloud] local exec disabled in cloud mode");
return {
success: false,
stdout: "",
stderr: "Local shell execution disabled in cloud mode.",
exitCode: 1,
error: "Local shell execution disabled in cloud mode.",
executedIn: this.currentDirectory,
};
}
const unsupported = this.localTerminalUnsupportedMessage();
if (unsupported) {
logger.error(`[shell:unsupported] ${unsupported}`);
return {
success: false,
stdout: "",
stderr: unsupported,
exitCode: 1,
error: unsupported,
executedIn: this.currentDirectory,
};
}
// Sandbox remote mode: route to host capability API
if (
!shouldUseSandboxExecution(this.runtime) &&
this.runtime &&
"sandboxMode" in this.runtime &&
this.runtime.sandboxMode
) {
const hostApiUrl =
(this.runtime.getSetting("SANDBOX_HOST_API_URL") as string | null) ??
"http://localhost:2138";
const runtimeFetch = this.runtime.fetch ?? globalThis.fetch;
logger.info(`[shell:sandbox] routing exec to ${hostApiUrl}: ${command.substring(0, 100)}`);
try {
const response = await runtimeFetch(`${hostApiUrl}/api/sandbox/exec`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
command,
workdir: this.currentDirectory,
timeoutMs: 30000,
}),
});
const result = (await response.json()) as {
exitCode: number;
stdout: string;
stderr: string;
durationMs: number;
executedInSandbox: boolean;
};
logger.info(
`[shell:sandbox] exec completed: exit=${result.exitCode} duration=${result.durationMs}ms`
);
return {
success: result.exitCode === 0,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
executedIn: this.currentDirectory,
};
} catch (err) {
// error-policy:J1 execution boundary; a sandbox exec failure is
// translated into a structured `success:false` ExecResult carrying the
// real message, which the SHELL action forwards to the model.
const errMsg = err instanceof Error ? err.message : String(err);
logger.error(`[shell:sandbox] exec failed: ${errMsg}`);
return {
success: false,
stdout: "",
stderr: `Sandbox exec failed: ${errMsg}`,
exitCode: 1,
error: "Sandbox remote execution failed",
executedIn: this.currentDirectory,
};
}
}
const trimmedCommand = command.trim();
const missingTool = missingTerminalToolForCommand(trimmedCommand);
if (missingTool) {
const message = missingToolMessage(missingTool);
return {
success: false,
stdout: "",
stderr: message,
exitCode: 1,
error: message,
executedIn: this.currentDirectory,
};
}
if (!isSafeCommand(trimmedCommand)) {
return {
success: false,
stdout: "",
stderr: "Command contains forbidden patterns",
exitCode: 1,
error: "Security policy violation",
executedIn: this.currentDirectory,
};
}
if (isForbiddenCommand(trimmedCommand, this.shellConfig.forbiddenCommands)) {
return {
success: false,
stdout: "",
stderr: "Command is forbidden by security policy",
exitCode: 1,
error: "Forbidden command",
executedIn: this.currentDirectory,
};
}
if (trimmedCommand.startsWith("cd ")) {
const result = await this.handleCdCommand(trimmedCommand);
this.addToHistory(conversationId, trimmedCommand, result);
return result;
}
const result = shouldUseSandboxExecution(this.runtime)
? await this.runSandboxCommand(trimmedCommand, this.currentDirectory, 30_000)
: await this.runCommandSimple(trimmedCommand);
if (result.success) {
const fileOps = this.detectFileOperations(trimmedCommand, this.currentDirectory);
if (fileOps && conversationId) {
this.addToHistory(conversationId, trimmedCommand, result, fileOps);
} else {
this.addToHistory(conversationId, trimmedCommand, result);
}
} else {
this.addToHistory(conversationId, trimmedCommand, result);
}
return result;
}
/**
* Enhanced command execution with PTY, background support, and session management
* This is the main execution method that supports all advanced features
*/
async exec(command: string, options: ExecuteOptions = {}): Promise<ExecResult> {
if (!command || typeof command !== "string") {
return {
status: "failed",
exitCode: 1,
durationMs: 0,
aggregated: "Invalid command",
reason: "Command must be a non-empty string",
};
}
if (isCloudExecutionMode(this.runtime)) {
logger.error("[shell:cloud] local exec disabled in cloud mode");
return {
status: "failed",
exitCode: 1,
durationMs: 0,
aggregated: "",
reason: "Local shell execution disabled in cloud mode.",
};
}
const unsupported = this.localTerminalUnsupportedMessage();
if (unsupported) {
logger.error(`[shell:unsupported] ${unsupported}`);
return {
status: "failed",
exitCode: 1,
durationMs: 0,
aggregated: "",
reason: unsupported,
};
}
const trimmedCommand = command.trim();
const missingTool = missingTerminalToolForCommand(trimmedCommand);
if (missingTool) {
const message = missingToolMessage(missingTool);
return {
status: "failed",
exitCode: 1,
durationMs: 0,
aggregated: "",
reason: message,
};
}
if (!isSafeCommand(trimmedCommand)) {
return {
status: "failed",
exitCode: 1,
durationMs: 0,
aggregated: "Command contains forbidden patterns",
reason: "Security policy violation",
};
}
if (isForbiddenCommand(trimmedCommand, this.shellConfig.forbiddenCommands)) {
return {
status: "failed",
exitCode: 1,
durationMs: 0,
aggregated: "Command is forbidden by security policy",
reason: "Forbidden command",
};
}
const warnings: string[] = [];
const maxOutput = this.shellConfig.maxOutputChars;
const pendingMaxOutput = this.shellConfig.pendingMaxOutputChars;
const defaultBackgroundMs = this.shellConfig.defaultBackgroundMs;
const allowBackground = this.shellConfig.allowBackground;
// Resolve background/yield settings
const backgroundRequested = options.background === true;
const yieldRequested = typeof options.yieldMs === "number";
if (!allowBackground && (backgroundRequested || yieldRequested)) {
warnings.push("Warning: background execution is disabled; running synchronously.");
}
const yieldWindow = allowBackground
? backgroundRequested
? 0
: clampNumber(options.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
: null;
// Resolve workdir
const rawWorkdir = options.workdir?.trim() || this.currentDirectory || process.cwd();
const resolvedWorkdir = resolveWorkdir(rawWorkdir, warnings);
const validatedWorkdir = validatePath(
resolvedWorkdir,
this.shellConfig.allowedDirectory,
this.currentDirectory
);
if (!validatedWorkdir) {
return {
status: "failed",
exitCode: 1,
durationMs: 0,
aggregated: "",
reason: `workdir is outside allowed directory: ${resolvedWorkdir}`,
};
}
const workdir = validatedWorkdir;
// Build environment
const baseEnv = coerceEnv(process.env);
const mergedEnv = options.env ? { ...baseEnv, ...options.env } : baseEnv;
const timeoutSec =
typeof options.timeout === "number" && options.timeout > 0
? options.timeout
: DEFAULT_TIMEOUT_SEC;
const usePty = options.pty === true;
const notifyOnExit = options.notifyOnExit !== false;
if (shouldUseSandboxExecution(this.runtime)) {
if (backgroundRequested || yieldRequested || usePty) {
warnings.push(
"Warning: local-safe sandbox execution runs synchronously; background, yield, and PTY options are ignored."
);
}
const startedAt = Date.now();
const sandboxResult = await this.runSandboxCommand(
trimmedCommand,
workdir,
timeoutSec * 1000,
mergedEnv
);
const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : "";
const aggregated = [sandboxResult.stdout, sandboxResult.stderr].filter(Boolean).join("\n");
if (!sandboxResult.success) {
return {
status: "failed",
exitCode: sandboxResult.exitCode,
durationMs: Date.now() - startedAt,
aggregated,
cwd: workdir,
reason: `${warningText}${sandboxResult.error ?? sandboxResult.stderr}`,
};
}
return {
status: "completed",
exitCode: sandboxResult.exitCode,
durationMs: Date.now() - startedAt,
aggregated: `${warningText}${aggregated || "(no output)"}`,
cwd: workdir,
};
}
// Run the process
const handle = await this.runExecProcess({
command: trimmedCommand,
workdir,
env: mergedEnv,
usePty,
warnings,
maxOutput,
pendingMaxOutput,
notifyOnExit,
scopeKey: options.scopeKey ?? this.scopeKey,
sessionKey: options.sessionKey,
timeoutSec,
onUpdate: options.onUpdate,
});
// Handle background/yield
if (allowBackground && yieldWindow !== null) {
if (yieldWindow === 0) {
markBackgrounded(handle.session);
return {
status: "running",
sessionId: handle.session.id,
pid: handle.session.pid,
startedAt: handle.startedAt,
cwd: handle.session.cwd,
tail: handle.session.tail,
};
}
// Wait for yieldWindow or completion
const raceResult = await Promise.race([
handle.promise,
new Promise<"yield">((resolve) => setTimeout(() => resolve("yield"), yieldWindow)),
]);
if (raceResult === "yield" && !handle.session.exited) {
markBackgrounded(handle.session);
const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : "";
return {
status: "running",
sessionId: handle.session.id,
pid: handle.session.pid,
startedAt: handle.startedAt,
cwd: handle.session.cwd,
tail: `${warningText}Command still running (session ${handle.session.id}, pid ${handle.session.pid ?? "n/a"}).`,
};
}
// Process completed within yield window
const outcome = raceResult === "yield" ? await handle.promise : raceResult;
const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : "";
if (outcome.status === "failed") {
return {
status: "failed",
exitCode: outcome.exitCode ?? null,
durationMs: outcome.durationMs,
aggregated: outcome.aggregated,
cwd: workdir,
timedOut: outcome.timedOut,
reason: `${warningText}${outcome.reason ?? "Command failed."}`,
};
}
return {
status: "completed",
exitCode: outcome.exitCode ?? 0,
durationMs: outcome.durationMs,
aggregated: `${warningText}${outcome.aggregated || "(no output)"}`,
cwd: workdir,
};
}
// Synchronous execution (no yield/background)
const outcome = await handle.promise;
const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : "";
if (outcome.status === "failed") {
return {
status: "failed",
exitCode: outcome.exitCode ?? null,
durationMs: outcome.durationMs,
aggregated: outcome.aggregated,
cwd: workdir,
timedOut: outcome.timedOut,
reason: `${warningText}${outcome.reason ?? "Command failed."}`,
};
}
return {
status: "completed",
exitCode: outcome.exitCode ?? 0,
durationMs: outcome.durationMs,
aggregated: `${warningText}${outcome.aggregated || "(no output)"}`,
cwd: workdir,
};
}
/**
* Process management action handler
* Supports: list, poll, log, write, send-keys, submit, paste, kill, clear, remove
*/
async processAction(params: ProcessActionParams): Promise<{
success: boolean;
message: string;
data?: Record<string, unknown>;
}> {
const scopeKey = this.scopeKey;
const isInScope = (session?: { scopeKey?: string } | null) =>
!scopeKey || session?.scopeKey === scopeKey;
if (params.action === "list") {
const running = listRunningSessions()
.filter((s) => isInScope(s))
.map((s) => ({
sessionId: s.id,
status: "running",
pid: s.pid ?? undefined,
startedAt: s.startedAt,
runtimeMs: Date.now() - s.startedAt,
cwd: s.cwd,
command: s.command,
name: deriveSessionName(s.command),
tail: s.tail,
truncated: s.truncated,
}));
const finished = listFinishedSessions()
.filter((s) => isInScope(s))
.map((s) => ({
sessionId: s.id,
status: s.status,
startedAt: s.startedAt,
endedAt: s.endedAt,
runtimeMs: s.endedAt - s.startedAt,
cwd: s.cwd,
command: s.command,
name: deriveSessionName(s.command),
tail: s.tail,
truncated: s.truncated,
exitCode: s.exitCode ?? undefined,
exitSignal: s.exitSignal ?? undefined,
}));
const sessions = [...running, ...finished]
.slice()
.sort(
(
a: (typeof running)[number] | (typeof finished)[number],
b: (typeof running)[number] | (typeof finished)[number]
) => b.startedAt - a.startedAt
);
const lines = sessions.map((s: (typeof sessions)[number]) => {
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
return `${s.sessionId} ${pad(s.status, 9)} ${formatDuration(s.runtimeMs)} :: ${label}`;
});
return {
success: true,
message: lines.join("\n") || "No running or recent sessions.",
data: { sessions },
};
}
if (!params.sessionId) {
return {
success: false,
message: "sessionId is required for this action.",
};
}
const session = getSession(params.sessionId);
const finished = getFinishedSession(params.sessionId);
const scopedSession = isInScope(session) ? session : undefined;
const scopedFinished = isInScope(finished) ? finished : undefined;
switch (params.action) {
case "poll": {
if (!scopedSession) {
if (scopedFinished) {
return {
success: true,
message:
(scopedFinished.tail ||
`(no output recorded${scopedFinished.truncated ? " — truncated to cap" : ""})`) +
`\n\nProcess exited with ${
scopedFinished.exitSignal
? `signal ${scopedFinished.exitSignal}`
: `code ${scopedFinished.exitCode ?? 0}`
}.`,
data: {
status: scopedFinished.status === "completed" ? "completed" : "failed",
sessionId: params.sessionId,
exitCode: scopedFinished.exitCode ?? undefined,
aggregated: scopedFinished.aggregated,
name: deriveSessionName(scopedFinished.command),
},
};
}
return {
success: false,
message: `No session found for ${params.sessionId}`,
};
}
if (!scopedSession.backgrounded) {
return {
success: false,
message: `Session ${params.sessionId} is not backgrounded.`,
};
}
const { stdout, stderr } = drainSession(scopedSession);
const exited = scopedSession.exited;
const exitCode = scopedSession.exitCode ?? 0;
const exitSignal = scopedSession.exitSignal ?? undefined;
if (exited) {
const status = exitCode === 0 && exitSignal == null ? "completed" : "failed";
markExited(
scopedSession,
scopedSession.exitCode ?? null,
scopedSession.exitSignal ?? null,
status
);
}
const status = exited
? exitCode === 0 && exitSignal == null
? "completed"
: "failed"
: "running";
const output = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n").trim();
return {
success: true,
message:
(output || "(no new output)") +
(exited
? `\n\nProcess exited with ${
exitSignal ? `signal ${exitSignal}` : `code ${exitCode}`
}.`
: "\n\nProcess still running."),
data: {
status,
sessionId: params.sessionId,
exitCode: exited ? exitCode : undefined,
aggregated: scopedSession.aggregated,
name: deriveSessionName(scopedSession.command),
},
};
}
case "log": {
if (scopedSession) {
if (!scopedSession.backgrounded) {
return {
success: false,
message: `Session ${params.sessionId} is not backgrounded.`,
};
}
const { slice, totalLines, totalChars } = sliceLogLines(
scopedSession.aggregated,
params.offset,
params.limit
);
return {
success: true,
message: slice || "(no output yet)",
data: {
status: scopedSession.exited ? "completed" : "running",
sessionId: params.sessionId,
totalLines,
totalChars,
truncated: scopedSession.truncated,
name: deriveSessionName(scopedSession.command),
},
};
}
if (scopedFinished) {
const { slice, totalLines, totalChars } = sliceLogLines(
scopedFinished.aggregated,
params.offset,
params.limit
);
const status = scopedFinished.status === "completed" ? "completed" : "failed";
return {
success: true,
message: slice || "(no output recorded)",
data: {
status,
sessionId: params.sessionId,
totalLines,
totalChars,
truncated: scopedFinished.truncated,
exitCode: scopedFinished.exitCode ?? undefined,
exitSignal: scopedFinished.exitSignal ?? undefined,
name: deriveSessionName(scopedFinished.command),
},
};
}
return {
success: false,
message: `No session found for ${params.sessionId}`,
};
}
case "write": {
if (!scopedSession) {
return {
success: false,
message: `No active session found for ${params.sessionId}`,
};
}
if (!scopedSession.backgrounded) {
return {
success: false,
message: `Session ${params.sessionId} is not backgrounded.`,
};
}
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
if (!stdin || stdin.destroyed) {
return {
success: false,
message: `Session ${params.sessionId} stdin is not writable.`,
};
}
await new Promise<void>((resolve, reject) => {
stdin.write(params.data ?? "", (err) => {
if (err) reject(err);
else resolve();
});
});
if (params.eof) {
stdin.end();
}
return {
success: true,
message: `Wrote ${(params.data ?? "").length} bytes to session ${params.sessionId}${
params.eof ? " (stdin closed)" : ""
}.`,
data: {
sessionId: params.sessionId,
name: deriveSessionName(scopedSession.command),
},
};
}
case "send-keys": {
if (!scopedSession) {
return {
success: false,
message: `No active session found for ${params.sessionId}`,
};
}
if (!scopedSession.backgrounded) {
return {
success: false,
message: `Session ${params.sessionId} is not backgrounded.`,
};
}
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
if (!stdin || stdin.destroyed) {
return {
success: false,
message: `Session ${params.sessionId} stdin is not writable.`,
};
}
const { data, warnings } = encodeKeySequence({
keys: params.keys,
hex: params.hex,
literal: params.literal,
});
if (!data) {
return { success: false, message: "No key data provided." };
}
await new Promise<void>((resolve, reject) => {
stdin.write(data, (err) => {
if (err) reject(err);
else resolve();
});
});
return {
success: true,
message:
`Sent ${data.length} bytes to session ${params.sessionId}.` +
(warnings.length ? `\nWarnings:\n- ${warnings.join("\n- ")}` : ""),
data: {
sessionId: params.sessionId,
name: deriveSessionName(scopedSession.command),
},
};
}
case "submit": {
if (!scopedSession) {
return {
success: false,
message: `No active session found for ${params.sessionId}`,
};
}
if (!scopedSession.backgrounded) {
return {
success: false,
message: `Session ${params.sessionId} is not backgrounded.`,
};
}
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
if (!stdin || stdin.destroyed) {
return {
success: false,
message: `Session ${params.sessionId} stdin is not writable.`,
};
}
await new Promise<void>((resolve, reject) => {
stdin.write("\r", (err) => {
if (err) reject(err);
else resolve();
});
});
return {
success: true,
message: `Submitted session ${params.sessionId} (sent CR).`,
data: {
sessionId: params.sessionId,
name: deriveSessionName(scopedSession.command),
},
};
}
case "paste": {
if (!scopedSession) {
return {
success: false,
message: `No active session found for ${params.sessionId}`,
};
}
if (!scopedSession.backgrounded) {
return {
success: false,
message: `Session ${params.sessionId} is not backgrounded.`,
};
}
const stdin = scopedSession.stdin ?? scopedSession.child?.stdin;
if (!stdin || stdin.destroyed) {
return {
success: false,
message: `Session ${params.sessionId} stdin is not writable.`,
};
}
const payload = encodePaste(params.text ?? "", params.bracketed !== false);
if (!payload) {
return { success: false, message: "No paste text provided." };
}
await new Promise<void>((resolve, reject) => {
stdin.write(payload, (err) => {
if (err) reject(err);
else resolve();
});
});
return {
success: true,
message: `Pasted ${params.text?.length ?? 0} chars to session ${params.sessionId}.`,
data: {
sessionId: params.sessionId,
name: deriveSessionName(scopedSession.command),
},
};
}
case "kill": {
if (!scopedSession) {
return {
success: false,
message: `No active session found for ${params.sessionId}`,
};
}
if (!scopedSession.backgrounded) {
return {
success: false,
message: `Session ${params.sessionId} is not backgrounded.`,
};
}
killSession(scopedSession);
markExited(scopedSession, null, "SIGKILL", "failed");
return {
success: true,
message: `Killed session ${params.sessionId}.`,
data: { name: deriveSessionName(scopedSession.command) },
};
}
case "clear": {
if (scopedFinished) {
deleteSession(params.sessionId);
return {
success: true,
message: `Cleared session ${params.sessionId}.`,
};
}
return {
success: false,
message: `No finished session found for ${params.sessionId}`,
};
}
case "remove": {
if (scopedSession) {
killSession(scopedSession);
markExited(scopedSession, null, "SIGKILL", "failed");
return {
success: true,
message: `Removed session ${params.sessionId}.`,
data: { name: deriveSessionName(scopedSession.command) },
};
}
if (scopedFinished) {
deleteSession(params.sessionId);
return {
success: true,
message: `Removed session ${params.sessionId}.`,
};
}
return {
success: false,
message: `No session found for ${params.sessionId}`,
};
}
}
return {
success: false,
message: `Unknown action ${params.action as string}`,
};
}
// ===== Public Service Methods for External Use =====
/**
* List all running sessions
*/
listRunningSessions(): ProcessSession[] {
const scopeKey = this.scopeKey;
return listRunningSessions().filter((s) => !scopeKey || s.scopeKey === scopeKey);
}
/**
* List all finished sessions
*/
listFinishedSessions(): FinishedSession[] {
const scopeKey = this.scopeKey;
return listFinishedSessions().filter((s) => !scopeKey || s.scopeKey === scopeKey);
}
/**
* Get a specific session by ID
*/
getSession(id: string): ProcessSession | undefined {
const session = getSession(id);
if (!session) return undefined;
if (this.scopeKey && session.scopeKey !== this.scopeKey) return undefined;
return session;
}
/**
* Get a specific finished session by ID
*/
getFinishedSession(id: string): FinishedSession | undefined {
const session = getFinishedSession(id);
if (!session) return undefined;
if (this.scopeKey && session.scopeKey !== this.scopeKey) return undefined;
return session;
}
/**
* Kill a session by ID
*/
killSessionById(id: string): boolean {
const session = this.getSession(id);
if (!session) return false;
killSession(session);
markExited(session, null, "SIGKILL", "killed");
return true;
}
/**
* Get command history for a conversation
*/
getCommandHistory(conversationId: string, limit?: number): CommandHistoryEntry[] {
const history = this.commandHistory.get(conversationId) || [];
if (limit && limit > 0) {
return history.slice(-limit);
}
return history;
}
/**
* Clear command history for a conversation
*/
clearCommandHistory(conversationId: string): void {
this.commandHistory.delete(conversationId);
logger.info(`Cleared command history for conversation: ${conversationId}`);
}
/**
* Get current working directory
*/
getCurrentDirectory(_conversationId?: string): string {
return this.currentDirectory;
}
/**
* Set current working directory
*/
setCurrentDirectory(directory: string): boolean {
const validatedPath = validatePath(
directory,
this.shellConfig.allowedDirectory,
this.currentDirectory
);
if (!validatedPath) {
return false;
}
this.currentDirectory = validatedPath;
return true;
}
/**
* Get allowed directory
*/
getAllowedDirectory(): string {
return this.shellConfig.allowedDirectory;
}
/**
* Get shell configuration
*/
getShellConfig(): ShellConfig {
return { ...this.shellConfig };
}
// ===== Private Methods =====
private async handleCdCommand(command: string): Promise<CommandResult> {
const parts = command.split(/\s+/);
if (parts.length < 2) {
this.currentDirectory = this.shellConfig.allowedDirectory;
return {
success: true,
stdout: `Changed directory to: ${this.currentDirectory}`,
stderr: "",
exitCode: 0,
executedIn: this.currentDirectory,
};
}
const targetPath = parts.slice(1).join(" ");
const validatedPath = validatePath(
targetPath,
this.shellConfig.allowedDirectory,
this.currentDirectory
);
if (!validatedPath) {
return {
success: false,
stdout: "",
stderr: "Cannot navigate outside allowed directory",
exitCode: 1,
error: "Permission denied",
executedIn: this.currentDirectory,
};
}
this.currentDirectory = validatedPath;
return {
success: true,
stdout: `Changed directory to: ${this.currentDirectory}`,
stderr: "",
exitCode: 0,
executedIn: this.currentDirectory,
};
}
private async runCommandSimple(command: string): Promise<CommandResult> {
return new Promise((resolve) => {
const useShell = command.includes(">") || command.includes("<") || command.includes("|");
let cmd: string;
let args: string[];
if (useShell) {
const shell = getShellConfig();
cmd = shell.shell;
args = [...shell.args, command];
logger.info(
`Executing shell command: ${cmd} ${shell.args.join(" ")} "${command}" in ${this.currentDirectory}`
);
} else {
const parts = command.split(/\s+/);
cmd = parts[0];
args = parts.slice(1);
logger.info(`Executing command: ${cmd} ${args.join(" ")} in ${this.currentDirectory}`);
}
let stdout = "";
let stderr = "";
let timedOut = false;
const child = spawn(cmd, args, {
cwd: this.currentDirectory,
env: process.env,
shell: false,
});
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
}, 5000);
}, this.shellConfig.timeout);
if (child.stdout) {
child.stdout.on("data", (data: Buffer) => {
stdout += data.toString();
});
}
if (child.stderr) {
child.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
}
child.on("exit", (code) => {
clearTimeout(timeout);
if (timedOut) {
resolve({
success: false,
stdout,
stderr: `${stderr}\nCommand timed out`,
exitCode: code,
error: "Command execution timeout",
executedIn: this.currentDirectory,
});
return;
}
resolve({
success: code === 0,
stdout,
stderr,
exitCode: code,
executedIn: this.currentDirectory,
});
});
child.on("error", (err: Error) => {
clearTimeout(timeout);
resolve({
success: false,
stdout,
stderr: err.message,
exitCode: 1,
error: "Failed to execute command",
executedIn: this.currentDirectory,
});
});
});
}
private async runExecProcess(opts: {
command: string;
workdir: string;
env: Record<string, string>;
usePty: boolean;
warnings: string[];
maxOutput: number;
pendingMaxOutput: number;
notifyOnExit: boolean;
scopeKey?: string;
sessionKey?: string;
timeoutSec: number;
onUpdate?: (session: ProcessSession) => void;
}): Promise<{
session: ProcessSession;
startedAt: number;
promise: Promise<{
status: "completed" | "failed";
exitCode: number | null;
exitSignal: NodeJS.Signals | number | null;
durationMs: number;
aggregated: string;
timedOut: boolean;
reason?: string;
}>;
kill: () => void;
}> {
const startedAt = Date.now();
const sessionId = createSessionSlug();
let child: ChildProcessWithoutNullStreams | null = null;
let pty: PtyHandle | null = null;
let stdin: ProcessSession["stdin"] | undefined;
if (opts.usePty) {
const { shell, args: shellArgs } = getShellConfig();
try {
const ptyModule = (await import("@lydell/node-pty")) as {
spawn?: PtySpawn;
default?: { spawn?: PtySpawn };
};
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
if (!spawnPty) {
throw new Error("PTY support is unavailable (node-pty spawn not found).");
}
pty = spawnPty(shell, [...shellArgs, opts.command], {
cwd: opts.workdir,
env: opts.env,
name: process.env.TERM ?? "xterm-256color",
cols: 120,
rows: 30,
});
stdin = {
destroyed: false,
write: (data, cb) => {
try {
pty?.write(data);
cb?.(null);
} catch (err) {
// error-policy:J1 stream-write boundary; a PTY write failure is
// forwarded to the Node stream callback as cb(err) (the stream
// error channel), never swallowed.
cb?.(err as Error);
}
},
end: () => {
try {
const eof = process.platform === "win32" ? "\x1a" : "\x04";
pty?.write(eof);
} catch {
// error-policy:J6 best-effort EOF on stream teardown; a PTY that
// already closed cannot accept the EOF byte and needs no action.
}
},
};
} catch (err) {
// error-policy:J4 optional native PTY (`@lydell/node-pty`) unavailable
// → designed degrade to plain cross-spawn; the failure is surfaced to
// the caller as a warning appended to `opts.warnings`, not swallowed.
const errText = String(err);
const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY.`;
logger.warn(`exec: PTY spawn failed (${errText}); retrying without PTY.`);
opts.warnings.push(warning);
}
}
if (!pty) {
const { shell, args: shellArgs } = getShellConfig();
const proc = spawn(shell, [...shellArgs, opts.command], {
cwd: opts.workdir,
env: opts.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
}) as ChildProcessWithoutNullStreams;
child = proc;
stdin = child.stdin;
}
const session: ProcessSession = {
id: sessionId,
command: opts.command,
scopeKey: opts.scopeKey,
sessionKey: opts.sessionKey,
notifyOnExit: opts.notifyOnExit,
exitNotified: false,
child: child ?? undefined,
stdin,
pid: child?.pid ?? pty?.pid,
startedAt,
cwd: opts.workdir,
maxOutputChars: opts.maxOutput,
pendingMaxOutputChars: opts.pendingMaxOutput,
totalOutputChars: 0,
pendingStdout: [],
pendingStderr: [],
pendingStdoutChars: 0,
pendingStderrChars: 0,
aggregated: "",
tail: "",
exited: false,
exitCode: undefined,
exitSignal: undefined,
truncated: false,
backgrounded: false,
};
addSession(session);
let settled = false;
let timeoutTimer: NodeJS.Timeout | null = null;
let timeoutFinalizeTimer: NodeJS.Timeout | null = null;
let timedOut = false;
const timeoutFinalizeMs = 1000;
let resolveFn:
| ((outcome: {
status: "completed" | "failed";
exitCode: number | null;
exitSignal: NodeJS.Signals | number | null;
durationMs: number;
aggregated: string;
timedOut: boolean;
reason?: string;
}) => void)
| null = null;
const settle = (outcome: {
status: "completed" | "failed";
exitCode: number | null;
exitSignal: NodeJS.Signals | number | null;
durationMs: number;
aggregated: string;
timedOut: boolean;
reason?: string;
}) => {
if (settled) {
return;
}
settled = true;
resolveFn?.(outcome);
};
const finalizeTimeout = () => {
if (session.exited) {
return;
}
markExited(session, null, "SIGKILL", "failed");
const aggregated = session.aggregated.trim();
const reason = `Command timed out after ${opts.timeoutSec} seconds`;
settle({
status: "failed",
exitCode: null,
exitSignal: "SIGKILL",
durationMs: Date.now() - startedAt,
aggregated,
timedOut: true,
reason: aggregated ? `${aggregated}\n\n${reason}` : reason,
});
};
const onTimeout = () => {
timedOut = true;
killSession(session);
if (!timeoutFinalizeTimer) {
timeoutFinalizeTimer = setTimeout(() => {
finalizeTimeout();
}, timeoutFinalizeMs);
}
};
if (opts.timeoutSec > 0) {
timeoutTimer = setTimeout(() => {
onTimeout();
}, opts.timeoutSec * 1000);
}
const emitUpdate = () => {
if (opts.onUpdate) {
opts.onUpdate(session);
}
};
const handleStdout = (data: string) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stdout", chunk);
emitUpdate();
}
};
const handleStderr = (data: string) => {
const str = sanitizeBinaryOutput(data.toString());
for (const chunk of chunkString(str)) {
appendOutput(session, "stderr", chunk);
emitUpdate();
}
};
if (pty) {
const cursorResponse = buildCursorPositionResponse();
pty.onData((data) => {
const raw = data.toString();
const { cleaned, requests } = stripDsrRequests(raw);
if (requests > 0) {
for (let i = 0; i < requests; i += 1) {
pty.write(cursorResponse);
}
}
handleStdout(cleaned);
});
} else if (child) {
child.stdout.on("data", handleStdout);
child.stderr.on("data", handleStderr);
}
const promise = new Promise<{
status: "completed" | "failed";
exitCode: number | null;
exitSignal: NodeJS.Signals | number | null;
durationMs: number;
aggregated: string;
timedOut: boolean;
reason?: string;
}>((resolve) => {
resolveFn = resolve;
const handleExit = (code: number | null, exitSignal: NodeJS.Signals | number | null) => {
if (timeoutTimer) {
clearTimeout(timeoutTimer);
}
if (timeoutFinalizeTimer) {
clearTimeout(timeoutFinalizeTimer);
}
const durationMs = Date.now() - startedAt;
const wasSignal = exitSignal != null;
const isSuccess = code === 0 && !wasSignal && !timedOut;
const status: "completed" | "failed" = isSuccess ? "completed" : "failed";
markExited(session, code, exitSignal, status);
if (!session.child && session.stdin) {
session.stdin.destroyed = true;
}
if (settled) {
return;
}
const aggregated = session.aggregated.trim();
if (!isSuccess) {
const reason = timedOut
? `Command timed out after ${opts.timeoutSec} seconds`
: wasSignal && exitSignal
? `Command aborted by signal ${exitSignal}`
: code === null
? "Command aborted before exit code was captured"
: `Command exited with code ${code}`;
const message = aggregated ? `${aggregated}\n\n${reason}` : reason;
settle({
status: "failed",
exitCode: code ?? null,
exitSignal: exitSignal ?? null,
durationMs,
aggregated,
timedOut,
reason: message,
});
return;
}
settle({
status: "completed",
exitCode: code,
exitSignal: exitSignal ?? null,
durationMs,
aggregated,
timedOut: false,
});
};
if (pty) {
pty.onExit((event) => {
const rawSignal = event.signal ?? null;
const normalizedSignal = rawSignal === 0 ? null : rawSignal;
handleExit(event.exitCode, normalizedSignal);
});
} else if (child) {
child.once("close", (code, exitSignal) => {
handleExit(code, exitSignal);
});
child.once("error", (err) => {
if (timeoutTimer) {
clearTimeout(timeoutTimer);
}
if (timeoutFinalizeTimer) {
clearTimeout(timeoutFinalizeTimer);
}
markExited(session, null, null, "failed");
const aggregated = session.aggregated.trim();
const message = aggregated ? `${aggregated}\n\n${String(err)}` : String(err);
settle({
status: "failed",
exitCode: null,
exitSignal: null,
durationMs: Date.now() - startedAt,
aggregated,
timedOut,
reason: message,
});
});
}
});
return {
session,
startedAt,
promise,
kill: () => killSession(session),
};
}
private addToHistory(
conversationId: string | undefined,
command: string,
result: CommandResult,
fileOperations?: FileOperation[]
): void {
if (!conversationId) return;
const historyEntry: CommandHistoryEntry = {
command,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
timestamp: Date.now(),
workingDirectory: result.executedIn,
fileOperations,
};
if (!this.commandHistory.has(conversationId)) {
this.commandHistory.set(conversationId, []);
}
const history = this.commandHistory.get(conversationId);
if (!history) {
throw new Error(`No history found for conversation ${conversationId}`);
}
history.push(historyEntry);
if (history.length > this.maxHistoryPerConversation) {
history.shift();
}
}
private detectFileOperations(command: string, cwd: string): FileOperation[] | undefined {
const operations: FileOperation[] = [];
const parts = command.trim().split(/\s+/);
const cmd = parts[0].toLowerCase();
if (cmd === "touch" && parts.length > 1) {
operations.push({
type: "create" as FileOperationType,
target: this.resolvePath(parts[1], cwd),
});
} else if (cmd === "echo" && command.includes(">")) {
const match = command.match(/>\s*([^\s]+)$/);
if (match) {
operations.push({
type: "write" as FileOperationType,
target: this.resolvePath(match[1], cwd),
});
}
} else if (cmd === "mkdir" && parts.length > 1) {
operations.push({
type: "mkdir" as FileOperationType,
target: this.resolvePath(parts[1], cwd),
});
} else if (cmd === "cat" && parts.length > 1 && !command.includes(">")) {
operations.push({
type: "read" as FileOperationType,
target: this.resolvePath(parts[1], cwd),
});
} else if (cmd === "mv" && parts.length > 2) {
operations.push({
type: "move" as FileOperationType,
target: this.resolvePath(parts[1], cwd),
secondaryTarget: this.resolvePath(parts[2], cwd),
});
} else if (cmd === "cp" && parts.length > 2) {
operations.push({
type: "copy" as FileOperationType,
target: this.resolvePath(parts[1], cwd),
secondaryTarget: this.resolvePath(parts[2], cwd),
});
}
return operations.length > 0 ? operations : undefined;
}
private resolvePath(filePath: string, cwd: string): string {
if (path.isAbsolute(filePath)) {
return filePath;
}
return path.join(cwd, filePath);
}
}