Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 75 additions & 3 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ import type {
SyncProjectSwitchResultPayload,
} from "../../desktop/src/shared/types/sync";
import { MACOS_VM_PHASES } from "../../desktop/src/shared/types/macosVm";
import type { AdeServiceCommand } from "./serviceManager/common";
import {
isCurrentProcessDescendantOfPid,
type AdeServiceCommand,
} from "./serviceManager/common";
import { normalizeAdeRuntimeRole, resolveAdeDefaultRole } from "./runtimeRoles";
import type { AdeRuntime } from "./bootstrap";
import { reseedBundledAdeSkillsForCli } from "./bootstrap";
Expand Down Expand Up @@ -12542,6 +12545,35 @@ function isRuntimeShutdownCloseError(error: unknown): boolean {
return message.includes("socket closed") || message.includes("runtime endpoint closed");
}

function shouldAllowRuntimeSelfShutdown(env: NodeJS.ProcessEnv = process.env): boolean {
return env.ADE_ALLOW_RUNTIME_SERVICE_SELF_MUTATION === "1";
}

class RuntimeSelfShutdownBlockedError extends Error {}

function runtimeSelfShutdownBlock(
runtimeInfo: MachineRuntimeInfo,
action: "repair" | "stop",
): string | null {
const runtimePid = runtimeInfo.pid;
if (!runtimePid || shouldAllowRuntimeSelfShutdown()) return null;
if (!isCurrentProcessDescendantOfPid({ targetPid: runtimePid })) return null;
const verb = action === "repair" ? "repair/restart" : "stop";
return (
`Refusing to ${verb} ADE runtime from a command running inside that runtime ` +
`(pid ${runtimePid}). Run this from an external terminal, or set ` +
"ADE_ALLOW_RUNTIME_SERVICE_SELF_MUTATION=1 if you intentionally want to tear down active ADE sessions."
);
}

function runtimeSelfShutdownBlockedError(
runtimeInfo: MachineRuntimeInfo,
action: "repair" | "stop",
): RuntimeSelfShutdownBlockedError | null {
const message = runtimeSelfShutdownBlock(runtimeInfo, action);
return message ? new RuntimeSelfShutdownBlockedError(message) : null;
}

function shouldRepairMachineRuntimeServiceBeforeSpawn(
socketPath: string,
socketPathOverride?: string | null,
Expand Down Expand Up @@ -12584,14 +12616,17 @@ async function repairMachineRuntimeServiceConnection(args: {
{ enforceBuildCompatibility: args.enforceBuildCompatibility },
);
if (mismatch) {
const selfShutdownBlock = runtimeSelfShutdownBlockedError(runtimeInfo, "repair");
if (selfShutdownBlock) throw selfShutdownBlock;
uninstallRuntimeService();
client.close();
return null;
}
const repaired = client;
client = null;
return repaired;
} catch {
} catch (error) {
if (error instanceof RuntimeSelfShutdownBlockedError) throw error;
return null;
} finally {
try {
Expand Down Expand Up @@ -12691,6 +12726,11 @@ async function connectMachineRuntimeDaemon(
`ADE runtime ${mismatch}.`,
);
}
const selfShutdownBlock = runtimeSelfShutdownBlockedError(runtimeInfo, "repair");
if (selfShutdownBlock) {
client.close();
throw selfShutdownBlock;
}
await shutdownMachineRuntimeDaemon(client);
const repaired = await repairServiceConnection();
if (repaired) return repaired;
Expand All @@ -12716,6 +12756,11 @@ async function connectMachineRuntimeDaemon(
{ enforceBuildCompatibility },
);
if (restartedMismatch) {
const selfShutdownBlock = runtimeSelfShutdownBlockedError(restartedInfo, "repair");
if (selfShutdownBlock) {
restarted.close();
throw selfShutdownBlock;
}
await shutdownMachineRuntimeDaemon(restarted);
throw new Error(
`ADE runtime ${restartedMismatch}.`,
Expand All @@ -12725,6 +12770,7 @@ async function connectMachineRuntimeDaemon(
}
return client;
} catch (firstError) {
if (firstError instanceof RuntimeSelfShutdownBlockedError) throw firstError;
if (!allowSpawn) throw firstError;
const repaired = await repairServiceConnection();
if (repaired) return repaired;
Expand All @@ -12747,13 +12793,19 @@ async function connectMachineRuntimeDaemon(
{ enforceBuildCompatibility },
);
if (mismatch) {
const selfShutdownBlock = runtimeSelfShutdownBlockedError(runtimeInfo, "repair");
if (selfShutdownBlock) {
client.close();
throw selfShutdownBlock;
}
await shutdownMachineRuntimeDaemon(client);
throw new Error(
`ADE runtime ${mismatch}.`,
);
}
return client;
} catch (secondError) {
if (secondError instanceof RuntimeSelfShutdownBlockedError) throw secondError;
const firstMessage =
firstError instanceof Error ? firstError.message : String(firstError);
const secondMessage =
Expand Down Expand Up @@ -12847,7 +12899,18 @@ async function runRuntimeCommand(
"ADE runtime endpoint",
);
try {
await initializeMachineRuntimeDaemon(client, options).catch(() => null);
const runtimeInfo = await initializeMachineRuntimeDaemon(client, options).catch(() => null);
if (runtimeInfo) {
const selfShutdownBlock = runtimeSelfShutdownBlock(runtimeInfo, "stop");
if (selfShutdownBlock) {
return {
ok: false,
running: true,
socketPath,
message: selfShutdownBlock,
};
}
}
await shutdownMachineRuntimeDaemon(client);
} finally {
client.close();
Comment thread
arul28 marked this conversation as resolved.
Expand Down Expand Up @@ -12954,6 +13017,15 @@ async function runBrainCommand(
if (sub === "restart") {
const { installRuntimeService, uninstallRuntimeService } = await import("./serviceManager");
const stopped = uninstallRuntimeService();
if (!stopped.ok) {
return {
ok: false,
action: "restart",
stopped,
started: null,
message: stopped.message,
};
}
const started = await withAdeDefaultRole("cto", () => installRuntimeService());
return {
ok: stopped.ok && started.ok,
Comment thread
arul28 marked this conversation as resolved.
Expand Down
100 changes: 100 additions & 0 deletions apps/ade-cli/src/serviceManager/common.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
ADE_RUNTIME_SERVICE_NAME,
isCurrentProcessDescendantOfPid,
isStaleChannelServeCommandLine,
renderCommand,
renderWindowsCommand,
Expand All @@ -19,6 +20,7 @@ import {
launchAgentPath,
parseLaunchdPrintPid,
renderLaunchdPlist,
uninstallLaunchdService,
} from "./installLaunchd";
import { installSystemdService, renderSystemdEnvironment, renderSystemdUnit, servicePath as systemdServicePath } from "./installSystemd";
import {
Expand Down Expand Up @@ -213,6 +215,34 @@ describe("resolveAdeServeCommand", () => {
});
});

describe("isCurrentProcessDescendantOfPid", () => {
it("detects when the current process descends from the target pid", () => {
const parentPid = (pid: number) => ({
400: 300,
300: 100,
100: 1,
})[pid] ?? null;

expect(isCurrentProcessDescendantOfPid({
targetPid: 100,
currentPid: 400,
parentPid,
})).toBe(true);
});

it("returns false for unrelated process trees and parent cycles", () => {
expect(isCurrentProcessDescendantOfPid({
targetPid: 100,
currentPid: 400,
parentPid: (pid) => ({
400: 300,
300: 200,
200: 300,
})[pid] ?? null,
})).toBe(false);
});
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

describe("isStaleChannelServeCommandLine", () => {
const cliScriptPath = "/Applications/ADE Beta.app/Contents/Resources/ade-cli/cli.cjs";
const primarySocketPath = "/Users/example/.ade-beta/sock/ade.sock";
Expand Down Expand Up @@ -504,6 +534,7 @@ describe("launchd service install", () => {
spawnSync,
homeDir,
env: { ...process.env, ADE_HOME: adeHome },
parentPid: () => null,
terminateDeps: {
kill: (pid, signal) => killed.push({ pid, signal }),
pidAlive: () => false,
Expand All @@ -517,6 +548,75 @@ describe("launchd service install", () => {
]);
});

it("refuses to restart a launch agent from a descendant of the loaded service", () => {
const homeDir = makeTempHome("ade-launchd-self-install-");
const servicePath = launchAgentPath(homeDir);
const calls: Array<{ command: string; args: string[] }> = [];
const spawnSync = spawnSequence(calls, [
{ status: 0, stdout: "state = running\npid = 100\n", stderr: "" },
]);
const parentPid = (pid: number) => ({
400: 300,
300: 100,
100: 1,
})[pid] ?? null;

const result = installLaunchdService({
command: serviceCommand,
spawnSync,
homeDir,
currentPid: 400,
parentPid,
});

expect(result).toMatchObject({
ok: false,
serviceName: ADE_RUNTIME_SERVICE_NAME,
action: "install",
path: servicePath,
});
expect(result.message).toContain("Refusing to restart ADE brain");
expect(calls).toEqual([
{ command: "launchctl", args: ["print", currentLaunchdDomain()] },
]);
expect(fs.existsSync(servicePath)).toBe(false);
});

it("refuses to uninstall a launch agent from a descendant of the loaded service", () => {
const homeDir = makeTempHome("ade-launchd-self-uninstall-");
const servicePath = launchAgentPath(homeDir);
fs.mkdirSync(path.dirname(servicePath), { recursive: true });
fs.writeFileSync(servicePath, renderLaunchdPlist(serviceCommand, homeDir), "utf8");
const calls: Array<{ command: string; args: string[] }> = [];
const spawnSync = spawnSequence(calls, [
{ status: 0, stdout: "state = running\npid = 100\n", stderr: "" },
]);
const parentPid = (pid: number) => ({
400: 300,
300: 100,
100: 1,
})[pid] ?? null;

const result = uninstallLaunchdService({
spawnSync,
homeDir,
currentPid: 400,
parentPid,
});

expect(result).toMatchObject({
ok: false,
serviceName: ADE_RUNTIME_SERVICE_NAME,
action: "uninstall",
path: servicePath,
});
expect(result.message).toContain("Refusing to stop ADE brain");
expect(calls).toEqual([
{ command: "launchctl", args: ["print", currentLaunchdDomain()] },
]);
expect(fs.existsSync(servicePath)).toBe(true);
});

it("surfaces launchctl load failures", () => {
const homeDir = makeTempHome("ade-launchd-fail-");
const calls: Array<{ command: string; args: string[] }> = [];
Expand Down
40 changes: 39 additions & 1 deletion apps/ade-cli/src/serviceManager/common.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from "node:fs";
import { createHash } from "node:crypto";
import path from "node:path";
import type { SpawnSyncOptions } from "node:child_process";
import { spawnSync, type SpawnSyncOptions } from "node:child_process";

export type ServiceManagerResult = {
ok: boolean;
Expand Down Expand Up @@ -50,6 +50,44 @@ export type ServiceManagerSpawnSync = (
options?: SpawnSyncOptions,
) => ServiceManagerProcessResult;

function processOutputText(result: ServiceManagerProcessResult): string {
if (typeof result.stdout === "string") return result.stdout.trim();
if (Buffer.isBuffer(result.stdout)) return result.stdout.toString("utf8").trim();
return "";
}

function readParentPid(
run: ServiceManagerSpawnSync,
pid: number,
): number | null {
const result = run("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
if (result.status !== 0) return null;
const parentPid = Number.parseInt(processOutputText(result), 10);
return Number.isFinite(parentPid) && parentPid > 0 ? parentPid : null;
}

export function isCurrentProcessDescendantOfPid(args: {
targetPid: number;
run?: ServiceManagerSpawnSync;
currentPid?: number;
parentPid?: (pid: number) => number | null;
}): boolean {
const targetPid = Math.floor(args.targetPid);
if (!Number.isFinite(targetPid) || targetPid <= 0) return false;
const run = args.run ?? spawnSync;
const readPid = args.parentPid ?? ((pid) => readParentPid(run, pid));
const seen = new Set<number>();
let cursor = Math.floor(args.currentPid ?? process.pid);
while (Number.isFinite(cursor) && cursor > 0 && !seen.has(cursor)) {
if (cursor === targetPid) return true;
seen.add(cursor);
const next = readPid(cursor);
if (!next || next === cursor) return false;
cursor = next;
}
return false;
}

const RUNTIME_ENV_PASSTHROUGH = [
"NODE_PATH",
"ADE_HOME",
Expand Down
Loading