fix(web): self-heal node-pty spawn-helper (#1978)
* fix(web): self-heal node-pty spawn-helper * chore: remove changeset * fix(web): centralize node-pty prebuild path * fix(cli): preserve update lifecycle config path --------- Co-authored-by: i-trytoohard <193449657+i-trytoohard@users.noreply.github.com>
This commit is contained in:
parent
018fefd6fb
commit
a66a087ef6
|
|
@ -600,7 +600,10 @@ describe("update command", () => {
|
|||
expect(mockSpawn.mock.calls[0]).toEqual([
|
||||
"ao",
|
||||
["stop", "--yes"],
|
||||
expect.objectContaining({ stdio: "inherit" }),
|
||||
expect.objectContaining({
|
||||
stdio: "inherit",
|
||||
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
|
||||
}),
|
||||
]);
|
||||
expect(mockSpawn.mock.calls[1][0]).toBe("pnpm");
|
||||
expect(mockSpawn.mock.calls[1][1]).toEqual(["add", "-g", "@aoagents/ao@latest"]);
|
||||
|
|
@ -609,7 +612,10 @@ describe("update command", () => {
|
|||
expect(mockSpawn.mock.calls[3]).toEqual([
|
||||
"ao",
|
||||
["start", "my-app", "--restore"],
|
||||
expect.objectContaining({ stdio: "inherit" }),
|
||||
expect.objectContaining({
|
||||
stdio: "inherit",
|
||||
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
const stopOrder = mockSpawn.mock.invocationCallOrder[0];
|
||||
|
|
@ -662,6 +668,11 @@ describe("update command", () => {
|
|||
|
||||
expect(mockSpawn.mock.calls[0][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]);
|
||||
expect(mockSpawn.mock.calls[0][2]).toEqual(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
|
||||
}),
|
||||
);
|
||||
expect(mockSpawn.mock.calls[1][0]).toBe("pnpm");
|
||||
expect(mockSpawn.mock.calls[2][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[2][1]).toEqual(["--version"]);
|
||||
|
|
@ -683,7 +694,10 @@ describe("update command", () => {
|
|||
expect(mockSpawn.mock.calls.at(-1)).toEqual([
|
||||
"ao",
|
||||
["start", "my-app", "--no-restore"],
|
||||
expect.objectContaining({ stdio: "inherit" }),
|
||||
expect.objectContaining({
|
||||
stdio: "inherit",
|
||||
env: expect.objectContaining({ AO_CONFIG_PATH: "/tmp/test-global-config.yaml" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -705,7 +719,10 @@ describe("update command", () => {
|
|||
expect(mockSpawn.mock.calls[0][0]).toBe("ao");
|
||||
expect(mockSpawn.mock.calls[0][1]).toEqual(["stop", "--yes"]);
|
||||
expect(mockSpawn.mock.calls.some((call) => call[0] === "pnpm")).toBe(false);
|
||||
const stderr = vi.mocked(console.error).mock.calls.map((c) => String(c[0])).join("\n");
|
||||
const stderr = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(stderr).toContain("AO still appears to be running after `ao stop --yes`");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
|
|
@ -34,7 +35,14 @@ function createHealthyRepo(tempRoot: string): string {
|
|||
mkdirSync(join(fakeRepo, "packages", "core", "dist"), { recursive: true });
|
||||
mkdirSync(join(fakeRepo, "packages", "cli", "dist"), { recursive: true });
|
||||
mkdirSync(join(fakeRepo, "packages", "web"), { recursive: true });
|
||||
writeFileSync(join(fakeRepo, "packages", "core", "dist", "index.js"), "export {};\n");
|
||||
writeFileSync(
|
||||
join(fakeRepo, "packages", "core", "package.json"),
|
||||
JSON.stringify({ type: "module", main: "dist/index.js" }, null, 2),
|
||||
);
|
||||
writeFileSync(
|
||||
join(fakeRepo, "packages", "core", "dist", "index.js"),
|
||||
'export function getNodePtyPrebuildsSubdir() { return process.platform + "-" + process.arch; }\n',
|
||||
);
|
||||
writeFileSync(join(fakeRepo, "packages", "cli", "dist", "index.js"), "export {};\n");
|
||||
writeFileSync(
|
||||
join(fakeRepo, "packages", "ao", "bin", "ao.js"),
|
||||
|
|
@ -61,7 +69,7 @@ function createHealthyPath(binDir: string): void {
|
|||
createFakeBinary(
|
||||
binDir,
|
||||
"node",
|
||||
'if [ "$1" = "--version" ]; then\n printf "v20.11.1\\n"\n exit 0\nfi\nexit 0',
|
||||
`if [ "$1" = "--version" ]; then\n printf "v20.11.1\\n"\n exit 0\nfi\nexec ${JSON.stringify(process.execPath)} "$@"`,
|
||||
);
|
||||
createFakeBinary(
|
||||
binDir,
|
||||
|
|
@ -252,6 +260,67 @@ exit 0`,
|
|||
expect(npmCommands).toContain("link --force");
|
||||
});
|
||||
|
||||
it("warns about and repairs a non-executable node-pty spawn-helper", () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-node-pty-helper-"));
|
||||
const fakeRepo = createHealthyRepo(tempRoot);
|
||||
const binDir = join(tempRoot, "bin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
createHealthyPath(binDir);
|
||||
|
||||
const helperPath = join(
|
||||
fakeRepo,
|
||||
"node_modules",
|
||||
"node-pty",
|
||||
"prebuilds",
|
||||
`${process.platform}-${process.arch}`,
|
||||
"spawn-helper",
|
||||
);
|
||||
mkdirSync(dirname(helperPath), { recursive: true });
|
||||
writeFileSync(join(fakeRepo, "node_modules", "node-pty", "package.json"), "{}\n");
|
||||
writeFileSync(helperPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(helperPath, 0o644);
|
||||
|
||||
const configPath = join(tempRoot, "agent-orchestrator.yaml");
|
||||
const dataDir = join(tempRoot, "data");
|
||||
const worktreeDir = join(tempRoot, "worktrees");
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
mkdirSync(worktreeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[`dataDir: ${dataDir}`, `worktreeDir: ${worktreeDir}`, "projects: {}"].join("\n"),
|
||||
);
|
||||
|
||||
const warnResult = spawnSync("bash", [scriptPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:/bin:/usr/bin`,
|
||||
AO_REPO_ROOT: fakeRepo,
|
||||
AO_CONFIG_PATH: configPath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
const fixResult = spawnSync("bash", [scriptPath, "--fix"], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:/bin:/usr/bin`,
|
||||
AO_REPO_ROOT: fakeRepo,
|
||||
AO_CONFIG_PATH: configPath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
const statMode = (statSync(helperPath).mode & 0o111) !== 0;
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
|
||||
expect(warnResult.status).toBe(0);
|
||||
expect(warnResult.stdout).toContain("WARN node-pty spawn-helper is not executable");
|
||||
expect(warnResult.stdout).toContain("posix_spawnp failed");
|
||||
expect(fixResult.status).toBe(0);
|
||||
expect(fixResult.stdout).toContain("FIXED chmod +x applied to node-pty spawn-helper");
|
||||
expect(statMode).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a healthy packaged install without source-checkout failures", () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-package-"));
|
||||
const fakeInstall = createHealthyPackageInstall(tempRoot);
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ while [ $# -gt 0 ]; do
|
|||
cat <<'EOF'
|
||||
Usage: ao doctor [--fix]
|
||||
|
||||
Checks install, PATH, binaries, service health, stale temp files, and runtime sanity.
|
||||
Checks install, PATH, binaries, service health, web terminal support, stale temp files, and runtime sanity.
|
||||
|
||||
Options:
|
||||
--fix Apply safe fixes for missing launcher links, missing support dirs, and stale temp files
|
||||
--fix Apply safe fixes for missing launcher links, missing support dirs, node-pty spawn-helper permissions, and stale temp files
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
|
|
@ -398,6 +398,136 @@ check_stale_temp_files() {
|
|||
warn "$stale_count stale temp files older than 60 minutes found under $temp_root. Fix: rerun ao doctor --fix"
|
||||
}
|
||||
|
||||
file_mode_octal() {
|
||||
case "$(uname -s 2>/dev/null || true)" in
|
||||
Darwin|FreeBSD|OpenBSD|NetBSD)
|
||||
stat -f '%Lp' "$1" 2>/dev/null || printf 'unknown'
|
||||
;;
|
||||
*)
|
||||
stat -c '%a' "$1" 2>/dev/null || printf 'unknown'
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_node_pty_spawn_helper() {
|
||||
node - "$REPO_ROOT" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const { createRequire } = require("node:module");
|
||||
const path = require("node:path");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
|
||||
const repoRoot = process.argv[2];
|
||||
|
||||
function resolvePackageJson(fromDir) {
|
||||
try {
|
||||
return createRequire(path.join(fromDir, "ao-doctor.js")).resolve("node-pty/package.json");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findPackageUp(startDir, ...segments) {
|
||||
let dir = path.resolve(startDir);
|
||||
while (true) {
|
||||
const candidate = path.resolve(dir, "node_modules", ...segments);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveNodeModulesPackage(fromDir, ...segments) {
|
||||
const packageDir = path.resolve(fromDir, "node_modules", ...segments);
|
||||
return fs.existsSync(path.resolve(packageDir, "package.json")) ? packageDir : null;
|
||||
}
|
||||
|
||||
function resolveCoreEntrypoint() {
|
||||
const sourceCoreDir = path.resolve(repoRoot, "packages", "core");
|
||||
const coreDir =
|
||||
(fs.existsSync(path.join(sourceCoreDir, "package.json")) ? sourceCoreDir : null) ??
|
||||
findPackageUp(repoRoot, "@aoagents", "ao-core") ??
|
||||
resolveNodeModulesPackage(repoRoot, "@aoagents", "ao-core");
|
||||
if (!coreDir) return null;
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(coreDir, "package.json"), "utf8"));
|
||||
const entry = pkg.exports?.["."]?.import ?? pkg.module ?? pkg.main ?? "dist/index.js";
|
||||
return path.resolve(coreDir, entry);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getNodePtyPrebuildsSubdir() {
|
||||
const coreEntrypoint = resolveCoreEntrypoint();
|
||||
if (!coreEntrypoint || !fs.existsSync(coreEntrypoint)) return null;
|
||||
|
||||
const core = await import(pathToFileURL(coreEntrypoint).href);
|
||||
return typeof core.getNodePtyPrebuildsSubdir === "function"
|
||||
? core.getNodePtyPrebuildsSubdir()
|
||||
: null;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const packageJsonPath =
|
||||
resolvePackageJson(repoRoot) ??
|
||||
(() => {
|
||||
const directNodePtyDir = findPackageUp(repoRoot, "node-pty");
|
||||
if (directNodePtyDir) return path.join(directNodePtyDir, "package.json");
|
||||
|
||||
const sourceWebDir = path.resolve(repoRoot, "packages", "web");
|
||||
const webDir =
|
||||
(fs.existsSync(path.join(sourceWebDir, "package.json")) ? sourceWebDir : null) ??
|
||||
findPackageUp(repoRoot, "@aoagents", "ao-web") ??
|
||||
resolveNodeModulesPackage(repoRoot, "@aoagents", "ao-web");
|
||||
if (!webDir) return null;
|
||||
|
||||
const webNodePtyDir =
|
||||
resolveNodeModulesPackage(webDir, "node-pty") ?? findPackageUp(webDir, "node-pty");
|
||||
return webNodePtyDir ? path.join(webNodePtyDir, "package.json") : null;
|
||||
})();
|
||||
|
||||
const prebuildsSubdir = await getNodePtyPrebuildsSubdir();
|
||||
if (!packageJsonPath || !prebuildsSubdir) process.exit(0);
|
||||
|
||||
console.log(path.join(path.dirname(packageJsonPath), "prebuilds", prebuildsSubdir, "spawn-helper"));
|
||||
})().catch(() => process.exit(0));
|
||||
NODE
|
||||
}
|
||||
|
||||
check_node_pty_spawn_helper() {
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
warn "node-pty spawn-helper check skipped because node is unavailable"
|
||||
return
|
||||
fi
|
||||
|
||||
local helper_path mode
|
||||
helper_path="$(resolve_node_pty_spawn_helper 2>/dev/null || true)"
|
||||
if [ -z "$helper_path" ] || [ ! -f "$helper_path" ]; then
|
||||
pass "node-pty spawn-helper check skipped because no helper was found for this platform"
|
||||
return
|
||||
fi
|
||||
|
||||
mode="$(file_mode_octal "$helper_path")"
|
||||
if [ -x "$helper_path" ]; then
|
||||
pass "node-pty spawn-helper is executable at $helper_path (mode 0o$mode)"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$FIX_MODE" = true ]; then
|
||||
if chmod 755 "$helper_path"; then
|
||||
fixed "chmod +x applied to node-pty spawn-helper at $helper_path (was 0o$mode)"
|
||||
return
|
||||
fi
|
||||
warn "node-pty spawn-helper is not executable at $helper_path (mode 0o$mode), and chmod failed. Web dashboard terminals can fail with posix_spawnp failed. Fix: chmod +x $helper_path"
|
||||
return
|
||||
fi
|
||||
|
||||
warn "node-pty spawn-helper is not executable at $helper_path (mode 0o$mode). Web dashboard terminals can fail with posix_spawnp failed. Fix: run ao doctor --fix or chmod +x $helper_path. See ao#1770."
|
||||
}
|
||||
|
||||
printf 'Agent Orchestrator Doctor\n\n'
|
||||
|
||||
check_node
|
||||
|
|
@ -409,6 +539,7 @@ check_gh
|
|||
check_config_dirs
|
||||
check_stale_temp_files
|
||||
check_install_layout
|
||||
check_node_pty_spawn_helper
|
||||
check_runtime_sanity
|
||||
|
||||
printf '\nResults: %s PASS, %s WARN, %s FAIL, %s FIXED\n' "$PASS_COUNT" "$WARN_COUNT" "$FAIL_COUNT" "$FIX_COUNT"
|
||||
|
|
|
|||
|
|
@ -147,12 +147,14 @@ async function handleCheck(): Promise<void> {
|
|||
*/
|
||||
interface UpdateLifecyclePlan {
|
||||
runningBeforeUpdate: boolean;
|
||||
configPath?: string;
|
||||
primaryProjectId?: string;
|
||||
activeSessions: Session[];
|
||||
}
|
||||
|
||||
async function getUpdateLifecyclePlan(): Promise<UpdateLifecyclePlan> {
|
||||
let sessions: Session[];
|
||||
let configPath: string | undefined;
|
||||
let primaryProjectId: string | undefined;
|
||||
let runningBeforeUpdate = false;
|
||||
try {
|
||||
|
|
@ -168,6 +170,7 @@ async function getUpdateLifecyclePlan(): Promise<UpdateLifecyclePlan> {
|
|||
const running = await getRunning();
|
||||
if (running && running.projects.length > 0) {
|
||||
runningBeforeUpdate = true;
|
||||
configPath = running.configPath;
|
||||
primaryProjectId = running.projects[0];
|
||||
// running.configPath could be local-wrapped (a project's
|
||||
// agent-orchestrator.yaml) OR the canonical global path. loadConfig
|
||||
|
|
@ -184,15 +187,16 @@ async function getUpdateLifecyclePlan(): Promise<UpdateLifecyclePlan> {
|
|||
// sessions to `killed`, so terminal statuses don't block the update.
|
||||
const globalPath = getGlobalConfigPath();
|
||||
if (!existsSync(globalPath)) {
|
||||
return { runningBeforeUpdate, primaryProjectId, activeSessions: [] };
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
|
||||
}
|
||||
const globalConfig = loadGlobalConfig(globalPath);
|
||||
if (!globalConfig || Object.keys(globalConfig.projects).length === 0) {
|
||||
return { runningBeforeUpdate, primaryProjectId, activeSessions: [] };
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
|
||||
}
|
||||
if (!isCanonicalGlobalConfigPath(globalPath)) {
|
||||
return { runningBeforeUpdate, primaryProjectId, activeSessions: [] };
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
|
||||
}
|
||||
configPath = globalPath;
|
||||
const config = loadConfig(globalPath);
|
||||
primaryProjectId = Object.keys(config.projects)[0];
|
||||
const sm = await getSessionManager(config);
|
||||
|
|
@ -204,11 +208,11 @@ async function getUpdateLifecyclePlan(): Promise<UpdateLifecyclePlan> {
|
|||
console.error(
|
||||
chalk.yellow("⚠ Could not check for active sessions before updating. Proceeding anyway."),
|
||||
);
|
||||
return { runningBeforeUpdate, primaryProjectId, activeSessions: [] };
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: [] };
|
||||
}
|
||||
|
||||
const active = sessions.filter((s) => ACTIVE_SESSION_STATUSES.has(s.status));
|
||||
return { runningBeforeUpdate, primaryProjectId, activeSessions: active };
|
||||
return { runningBeforeUpdate, configPath, primaryProjectId, activeSessions: active };
|
||||
}
|
||||
|
||||
async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise<boolean> {
|
||||
|
|
@ -232,7 +236,9 @@ async function pauseAoForUpdate(plan: UpdateLifecyclePlan): Promise<boolean> {
|
|||
console.log(chalk.dim("\nAO is running; it will be restarted after the update."));
|
||||
}
|
||||
|
||||
const stopExit = await runAoLifecycleCommand(["stop", "--yes"]);
|
||||
const stopExit = await runAoLifecycleCommand(["stop", "--yes"], {
|
||||
configPath: plan.configPath,
|
||||
});
|
||||
if (stopExit !== 0) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
|
|
@ -288,7 +294,7 @@ async function restartAoAfterUpdate(
|
|||
args.push(opts.restore ? "--restore" : "--no-restore");
|
||||
|
||||
console.log(chalk.dim(`\nRestarting AO: ao ${args.join(" ")}`));
|
||||
const exitCode = await runAoLifecycleCommand(args);
|
||||
const exitCode = await runAoLifecycleCommand(args, { configPath: plan.configPath });
|
||||
if (exitCode !== 0) {
|
||||
recordActivityEvent({
|
||||
source: "cli",
|
||||
|
|
@ -307,12 +313,16 @@ async function restartAoAfterUpdate(
|
|||
}
|
||||
}
|
||||
|
||||
function runAoLifecycleCommand(args: string[]): Promise<number> {
|
||||
function runAoLifecycleCommand(
|
||||
args: string[],
|
||||
opts: { configPath?: string } = {},
|
||||
): Promise<number> {
|
||||
return new Promise<number>((resolveExit) => {
|
||||
const child = spawn("ao", args, {
|
||||
stdio: "inherit",
|
||||
shell: isWindows(),
|
||||
windowsHide: true,
|
||||
env: opts.configPath ? { ...process.env, AO_CONFIG_PATH: opts.configPath } : process.env,
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
console.error(chalk.yellow(`Could not run ao ${args.join(" ")}: ${error.message}`));
|
||||
|
|
|
|||
|
|
@ -44,6 +44,14 @@ describe("platform adapter", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("getNodePtyPrebuildsSubdir", () => {
|
||||
it("centralizes node-pty prebuild platform/arch naming", async () => {
|
||||
setPlatform("darwin");
|
||||
const mod = await import("../platform.js");
|
||||
expect(mod.getNodePtyPrebuildsSubdir()).toBe(`darwin-${process.arch}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShell", () => {
|
||||
it("always returns /bin/sh on unix (ignores $SHELL)", async () => {
|
||||
setPlatform("linux");
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ export {
|
|||
isMac,
|
||||
isLinux,
|
||||
getDefaultRuntime,
|
||||
getNodePtyPrebuildsSubdir,
|
||||
getShell,
|
||||
killProcessTree,
|
||||
findPidByPort,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ export function getDefaultRuntime(): "tmux" | "process" {
|
|||
return isWindows() ? "process" : "tmux";
|
||||
}
|
||||
|
||||
export function getNodePtyPrebuildsSubdir(): string {
|
||||
return `${process.platform}-${process.arch}`;
|
||||
}
|
||||
|
||||
// -- Shell resolution --
|
||||
|
||||
interface ShellInfo {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Socket } from "node:net";
|
||||
import { WebSocket } from "ws";
|
||||
import { appendDashboardNotification, type OrchestratorEvent } from "@aoagents/ao-core";
|
||||
import { appendDashboardNotification, isWindows, type OrchestratorEvent } from "@aoagents/ao-core";
|
||||
import type { SessionBroadcaster as SessionBroadcasterType } from "../mux-websocket";
|
||||
|
||||
// vi.mock factories run before module-level statements. Hoist the mock
|
||||
|
|
@ -966,6 +966,41 @@ describe("TerminalManager.open — tmux target args (regression for #1714)", ()
|
|||
const [, args] = mockPtySpawn.mock.calls[0];
|
||||
expect(args).toEqual(["attach-session", "-t", "=ao-177"]);
|
||||
});
|
||||
|
||||
it("repairs node-pty spawn-helper when applicable and retries once after posix_spawnp failure", () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "ao-mux-spawn-helper-"));
|
||||
const helperPath = join(tempRoot, "spawn-helper");
|
||||
writeFileSync(helperPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(helperPath, 0o644);
|
||||
process.env.AO_NODE_PTY_SPAWN_HELPER_PATH = helperPath;
|
||||
|
||||
const pty = {
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
|
||||
mockPtySpawn
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("posix_spawnp failed.");
|
||||
})
|
||||
.mockImplementationOnce(() => pty);
|
||||
|
||||
try {
|
||||
const mgr = new TerminalManager("/usr/bin/tmux");
|
||||
mgr.open("ao-177");
|
||||
|
||||
expect(mockPtySpawn).toHaveBeenCalledTimes(2);
|
||||
if (!isWindows()) {
|
||||
expect((statSync(helperPath).mode & 0o111) !== 0).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
delete process.env.AO_NODE_PTY_SPAWN_HELPER_PATH;
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalManager.open — re-attach skipped when tmux session is gone (regression for #1756)", () => {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,15 @@
|
|||
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { spawn } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { type Socket, connect as netConnect } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
DEFAULT_DASHBOARD_NOTIFICATION_LIMIT,
|
||||
getEnvDefaults,
|
||||
getDashboardNotificationStorePath,
|
||||
getNodePtyPrebuildsSubdir,
|
||||
isWindows,
|
||||
loadConfig,
|
||||
normalizeDashboardNotificationLimit,
|
||||
|
|
@ -380,8 +384,38 @@ export class NotificationBroadcaster {
|
|||
// node-pty is an optionalDependency — load dynamically
|
||||
/* eslint-disable @typescript-eslint/consistent-type-imports -- node-pty is optional; static import would crash if missing */
|
||||
type IPty = import("node-pty").IPty;
|
||||
let ptySpawn: typeof import("node-pty").spawn | undefined;
|
||||
type PtySpawn = typeof import("node-pty").spawn;
|
||||
type PtySpawnOptions = Parameters<PtySpawn>[2];
|
||||
let ptySpawn: PtySpawn | undefined;
|
||||
/* eslint-enable @typescript-eslint/consistent-type-imports */
|
||||
const nodePtyRequire = createRequire(import.meta.url);
|
||||
|
||||
export function resolveNodePtySpawnHelperPath(): string | null {
|
||||
const override = process.env.AO_NODE_PTY_SPAWN_HELPER_PATH;
|
||||
if (override) return override;
|
||||
|
||||
try {
|
||||
const packageJsonPath = nodePtyRequire.resolve("node-pty/package.json");
|
||||
return join(dirname(packageJsonPath), "prebuilds", getNodePtyPrebuildsSubdir(), "spawn-helper");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPosixSpawnpFailure(err: unknown): boolean {
|
||||
return err instanceof Error && err.message.includes("posix_spawnp");
|
||||
}
|
||||
|
||||
function repairNodePtySpawnHelper(): string | null {
|
||||
if (isWindows()) return null;
|
||||
|
||||
const spawnHelperPath = resolveNodePtySpawnHelperPath();
|
||||
if (!spawnHelperPath || !fs.existsSync(spawnHelperPath)) return null;
|
||||
|
||||
fs.chmodSync(spawnHelperPath, 0o755);
|
||||
return spawnHelperPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const nodePty = await import("node-pty");
|
||||
ptySpawn = nodePty.spawn;
|
||||
|
|
@ -432,6 +466,7 @@ const REATTACH_RESET_GRACE_MS = 5_000;
|
|||
export class TerminalManager {
|
||||
private terminals = new Map<string, ManagedTerminal>();
|
||||
private TMUX: string;
|
||||
private spawnHelperRepairAttempted = false;
|
||||
|
||||
constructor(tmuxPath?: string) {
|
||||
const resolved = tmuxPath ?? findTmux();
|
||||
|
|
@ -445,6 +480,41 @@ export class TerminalManager {
|
|||
return projectId ? `${projectId}:${id}` : id;
|
||||
}
|
||||
|
||||
private spawnTmuxPty(args: string[], options: PtySpawnOptions): IPty {
|
||||
if (!ptySpawn) {
|
||||
throw new Error("node-pty not available");
|
||||
}
|
||||
|
||||
try {
|
||||
return ptySpawn(this.TMUX, args, options);
|
||||
} catch (err) {
|
||||
if (this.spawnHelperRepairAttempted || !isPosixSpawnpFailure(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.spawnHelperRepairAttempted = true;
|
||||
try {
|
||||
const repairedPath = repairNodePtySpawnHelper();
|
||||
if (repairedPath) {
|
||||
console.warn(
|
||||
`[MuxServer] node-pty posix_spawnp failed; set executable bit on ${repairedPath} and retrying once.`,
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"[MuxServer] node-pty posix_spawnp failed; spawn-helper was not found, retrying once.",
|
||||
);
|
||||
}
|
||||
} catch (repairErr) {
|
||||
const message = repairErr instanceof Error ? repairErr.message : String(repairErr);
|
||||
console.warn(
|
||||
`[MuxServer] node-pty posix_spawnp failed; chmod spawn-helper failed (${message}), retrying once.`,
|
||||
);
|
||||
}
|
||||
|
||||
return ptySpawn(this.TMUX, args, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open/attach to a terminal. If already open, just return.
|
||||
* If has subscribers but PTY crashed, re-attach.
|
||||
|
|
@ -515,14 +585,10 @@ export class TerminalManager {
|
|||
TMPDIR: platformDefaults.TMPDIR,
|
||||
};
|
||||
|
||||
if (!ptySpawn) {
|
||||
throw new Error("node-pty not available");
|
||||
}
|
||||
|
||||
// Spawn PTY — use `=`-prefixed exact-match target so we never attach to
|
||||
// a session whose name happens to be a prefix of the requested id.
|
||||
const exactTmuxTarget = `=${tmuxSessionId}`;
|
||||
const pty = ptySpawn(this.TMUX, ["attach-session", "-t", exactTmuxTarget], {
|
||||
const pty = this.spawnTmuxPty(["attach-session", "-t", exactTmuxTarget], {
|
||||
name: "xterm-256color",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
|
|
|
|||
Loading…
Reference in New Issue