refactor(app): split Setup and ProcessOwnership modules (#628)
* refactor(domain): consolidate canonical ordering and digest helpers (#624) * refactor(domain): consolidate canonical ordering and digest helpers Single owner for code-point ordering (domain/canonicalOrdering.ts) and canonical JSON digests (domain/comparisonSemantics.ts). Delete all local compareCodePoints/compareText/digestCanonical duplicates and migrate every importer. No re-export shims. * fix(ci): allow slower real browser startup * chore(config): remove dead eslint, dedupe scripts, cacheable turbo tasks Delete eslint.config.js (oxlint is the linter), drop lint:dead alias, pin smol-toml, fold jscpd/scan:todos into check:pr, make test:run cacheable, align oxlint ignores. * fix(config): hash all test inputs for cached runs * docs: fix architecture diagram and pretty-print tool catalog Drop dangling REPLAY edges, add dotnet/reference/error/evaluation nodes. Emit 2-space JSON from the catalog generator and regenerate. * test: extract shared fixtures, drop knip carve-outs Dedupe android/apple session tests, bundler adapter headers, and reconstruction helpers into tests/support fixtures. Delete unused type aliases and the knip ignoreIssues block. Lower jscpd threshold 2.7 to 2.5. * refactor(app): split Setup and ProcessOwnership modules Extract SetupTypes/SetupHost from Setup.ts and move observation helpers into ProcessOwnershipObservation/ProcessTree. Migrate all importers including CLI commands. No re-export shims.
This commit is contained in:
1 parent
55e5eb090b
commit
3c84080aab
30 files changed
+502
-499
No files matched your search
@@ -26,10 +26,8 @@ import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import type { SnapshotResult } from "./FilesystemSnapshot.js";
|
||||
import { snapshotRoots } from "./FilesystemSnapshot.js";
|
||||
import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js";
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
observeOwnedProcessGroup,
|
||||
} from "../process/ProcessOwnership.js";
|
||||
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
|
||||
import { observeOwnedProcessGroup } from "../process/ProcessOwnershipObservation.js";
|
||||
import { ProcessCaptureError } from "./ProcessCaptureError.js";
|
||||
import { assertNotCancelled } from "./ProcessScenarioRuntimeValidation.js";
|
||||
import {
|
||||
|
||||
@@ -25,7 +25,7 @@ import { snapshotRoots } from "./FilesystemSnapshot.js";
|
||||
import { TerminalRenderer } from "./TerminalRenderer.js";
|
||||
import type { ProcessTimer } from "./ProcessTimer.js";
|
||||
import { normalizeProcessText } from "./ProcessNormalization.js";
|
||||
import { selectCapturedProcessGroupIds } from "../process/ProcessOwnership.js";
|
||||
import { selectCapturedProcessGroupIds } from "../process/ProcessOwnershipProcessTree.js";
|
||||
import {
|
||||
awaitTerminalExit,
|
||||
buildCaptureResult,
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { FakeSetupHost, options } from "./Setup.fixture.js";
|
||||
import { runSetup, setupRegistrationCommand } from "./Setup.js";
|
||||
import { runSetup } from "./Setup.js";
|
||||
import { setupRegistrationCommand } from "./SetupHost.js";
|
||||
|
||||
describe("setup workflow", () => {
|
||||
it("omits an aligned managed skill from an otherwise empty plan", async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { detectClients } from "./Setup.js";
|
||||
import { detectClients } from "./SetupHost.js";
|
||||
|
||||
describe("setup client discovery", () => {
|
||||
let temporaryHome: string | undefined;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
type ClientConfigurationInspection,
|
||||
type ClientConfigurationResult,
|
||||
type SetupClient,
|
||||
type SetupHost,
|
||||
type SetupOptions,
|
||||
type SetupHopperInstallResult,
|
||||
type SetupProviderEnvironment,
|
||||
} from "./Setup.js";
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type { SetupHopperInstallResult } from "./SetupInstallFailure.js";
|
||||
import type { DoctorCheck, DoctorReport, DoctorScope } from "./Doctor.js";
|
||||
import type { LinuxDistribution } from "./LinuxHopper.js";
|
||||
import { PRODUCT_IDENTITY, SDK_IDENTITY } from "../identity.js";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { runSetup, type SetupProgressEvent } from "./Setup.js";
|
||||
import { runSetup } from "./Setup.js";
|
||||
import type { SetupProgressEvent } from "./SetupTypes.js";
|
||||
import { FakeSetupHost, options } from "./Setup.fixture.js";
|
||||
|
||||
describe("setup workflow", () => {
|
||||
|
||||
+21
-341
@@ -1,235 +1,27 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { supportsNodeVersion } from "../domain/runtimeVersion.js";
|
||||
import {
|
||||
runDoctor,
|
||||
systemDoctorHost,
|
||||
type DoctorHost,
|
||||
type DoctorReport,
|
||||
type DoctorScope,
|
||||
} from "./Doctor.js";
|
||||
import {
|
||||
installLinuxHopper,
|
||||
readLinuxDistribution,
|
||||
type LinuxDistribution,
|
||||
} from "./LinuxHopper.js";
|
||||
import { installMacHopper } from "./MacHopper.js";
|
||||
import type {
|
||||
ClientConfigurationResult,
|
||||
SetupAction,
|
||||
SetupClientState,
|
||||
SetupConfirmation,
|
||||
SetupConfirmationDecision,
|
||||
SetupDoctorSummary,
|
||||
SetupHost,
|
||||
SetupOptions,
|
||||
SetupProgressEvent,
|
||||
SetupProviderEnvironment,
|
||||
SetupResult,
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type { DoctorReport, DoctorScope } from "./Doctor.js";
|
||||
import { configureDetectedClients } from "./SetupClients.js";
|
||||
import { supportedClients, type SetupClient } from "./SupportedClients.js";
|
||||
export type { SetupClient } from "./SupportedClients.js";
|
||||
import {
|
||||
canonicalSkillNeedsInstall,
|
||||
installCanonicalSkill,
|
||||
} from "./SetupSkill.js";
|
||||
import { discoverSetupState, planSetupActions } from "./SetupPlan.js";
|
||||
import {
|
||||
clientConfigurationAligned,
|
||||
configureClientConfiguration,
|
||||
configureJsonClient,
|
||||
configureTomlClient,
|
||||
inspectClientConfiguration,
|
||||
} from "./SetupClientConfiguration.js";
|
||||
export { configureJsonClient, configureTomlClient };
|
||||
import {
|
||||
setupInstallFailure,
|
||||
type SetupFailureCode,
|
||||
type SetupHopperInstallResult,
|
||||
} from "./SetupInstallFailure.js";
|
||||
import { providerRegistrationEnvironment } from "./SetupRegistrationEnvironment.js";
|
||||
export type { SetupHopperInstallResult } from "./SetupInstallFailure.js";
|
||||
|
||||
/** Exact non-secret provider variables propagated into managed registrations. */
|
||||
export type SetupProviderEnvironment = Readonly<Record<string, string>>;
|
||||
|
||||
export { canonicalSkillNeedsInstall, installCanonicalSkill };
|
||||
|
||||
/** Resolve the executable and arguments used in a managed MCP registration. */
|
||||
export const setupRegistrationCommand = (
|
||||
platform: NodeJS.Platform,
|
||||
useNpmRunner: boolean = process.env.npm_command === "exec",
|
||||
): readonly string[] =>
|
||||
useNpmRunner
|
||||
? PRODUCT_IDENTITY.mcpCommand.split(" ")
|
||||
: platform === "win32"
|
||||
? [
|
||||
process.execPath,
|
||||
resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary),
|
||||
"mcp",
|
||||
]
|
||||
: [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
|
||||
|
||||
/** Result of one backup/write/readback transaction. */
|
||||
export type ClientConfigurationResult =
|
||||
| {
|
||||
readonly status: "unchanged" | "configured" | "skipped";
|
||||
readonly backupPath?: string;
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason: "path" | "backup" | "write" | "readback";
|
||||
};
|
||||
|
||||
/** Read-only state used to block unsafe setup plans before any mutation. */
|
||||
export type ClientConfigurationInspection =
|
||||
| { readonly status: "already_current" }
|
||||
| {
|
||||
readonly status: "create" | "update";
|
||||
readonly backupPath?: string;
|
||||
}
|
||||
| {
|
||||
readonly status: "invalid";
|
||||
readonly remediation: string;
|
||||
};
|
||||
/** Effects required by the idempotent, non-blocking setup workflow. */
|
||||
export interface SetupHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly nodeVersion: string;
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
linuxDistribution(): Promise<LinuxDistribution | undefined>;
|
||||
hopperPath(): Promise<string | undefined>;
|
||||
installHopper(replaceExisting: boolean): Promise<SetupHopperInstallResult>;
|
||||
providerEnvironment?(): Promise<SetupProviderEnvironment>;
|
||||
detectedClients(): Promise<readonly SetupClient[]>;
|
||||
supportedClients?(): Promise<readonly SetupClient[]>;
|
||||
configureClient(
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
): Promise<ClientConfigurationResult>;
|
||||
clientNeedsConfigure(
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
): Promise<boolean>;
|
||||
inspectClientConfiguration?(
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
): Promise<ClientConfigurationInspection>;
|
||||
skillNeedsInstall(): Promise<boolean>;
|
||||
installSkill(): Promise<"installed" | "unchanged" | "failed">;
|
||||
doctor(scope?: DoctorScope): Promise<Awaited<ReturnType<typeof runDoctor>>>;
|
||||
}
|
||||
/** Structured setup outcome carrying remediation instead of prompting. */
|
||||
export interface SetupResult {
|
||||
readonly status:
|
||||
| "planned"
|
||||
| "cancelled"
|
||||
| "needs_confirmation"
|
||||
| "ready"
|
||||
| "needs_human";
|
||||
readonly plannedActions: readonly SetupAction[];
|
||||
readonly appliedActions: readonly string[];
|
||||
readonly clients: Readonly<Record<string, ClientConfigurationResult>>;
|
||||
readonly doctor: SetupDoctorSummary;
|
||||
/** Supported-client discovery state captured before setup mutations. */
|
||||
readonly clientStates: readonly SetupClientState[];
|
||||
readonly remediation?: string;
|
||||
readonly code?: SetupFailureCode;
|
||||
}
|
||||
|
||||
/** Whether the requested setup actions still need approval or human repair. */
|
||||
export const isSetupFailure = (result: SetupResult): boolean => {
|
||||
const status = result.status;
|
||||
switch (status) {
|
||||
case "planned":
|
||||
case "cancelled":
|
||||
case "ready":
|
||||
return false;
|
||||
case "needs_confirmation":
|
||||
case "needs_human":
|
||||
return true;
|
||||
default: {
|
||||
const exhaustive: never = status;
|
||||
throw new TypeError(
|
||||
`Unhandled setup result status: ${String(exhaustive)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Relevant health and identity evidence for the selected setup scope. */
|
||||
export interface SetupDoctorSummary {
|
||||
readonly healthy: boolean;
|
||||
readonly environment_healthy: boolean;
|
||||
readonly scope: DoctorReport["scope"];
|
||||
readonly hopperPath?: string;
|
||||
readonly availableProviders: readonly string[];
|
||||
readonly providerInspections?: DoctorReport["providerInspections"];
|
||||
readonly identity?: Pick<
|
||||
NonNullable<DoctorReport["identity"]>,
|
||||
"skill" | "registrations"
|
||||
>;
|
||||
}
|
||||
|
||||
/** Read-only state for one supported client location. */
|
||||
export interface SetupClientState {
|
||||
readonly client: SetupClient;
|
||||
readonly detected: boolean;
|
||||
readonly configured: boolean;
|
||||
readonly status: "configured" | "missing" | "needs_configuration" | "invalid";
|
||||
}
|
||||
|
||||
/** One concrete setup mutation disclosed before approval. */
|
||||
export interface SetupAction {
|
||||
readonly id: string;
|
||||
readonly kind: "install_hopper" | "configure_client" | "install_skill";
|
||||
readonly label: string;
|
||||
readonly target: string;
|
||||
readonly detail: string;
|
||||
readonly external: boolean;
|
||||
readonly operation: "create" | "update" | "install";
|
||||
readonly backupPath?: string;
|
||||
readonly networkOrigins?: readonly string[];
|
||||
readonly commands?: readonly string[];
|
||||
readonly integrity?: string;
|
||||
}
|
||||
|
||||
/** Explicit authorization supplied by an interactive or unattended CLI adapter. */
|
||||
export interface SetupOptions {
|
||||
readonly approved: boolean;
|
||||
readonly installHopper: boolean;
|
||||
readonly structured: boolean;
|
||||
readonly proposeHopper?: boolean;
|
||||
readonly clientIds?: readonly string[];
|
||||
readonly installSkill?: boolean;
|
||||
readonly readinessScope?: DoctorScope;
|
||||
readonly dryRun?: boolean;
|
||||
readonly allDetectedClients?: boolean;
|
||||
readonly onProgress?: (event: SetupProgressEvent) => void;
|
||||
}
|
||||
|
||||
/** One settled or active setup operation projected to the interactive adapter. */
|
||||
export interface SetupProgressEvent {
|
||||
readonly actionId: string;
|
||||
readonly label: string;
|
||||
readonly state: "started" | "completed" | "warning" | "failed";
|
||||
readonly detail?: string;
|
||||
}
|
||||
|
||||
/** Interactive selection and final consent returned by the CLI adapter. */
|
||||
export interface SetupConfirmationDecision {
|
||||
readonly approved: boolean;
|
||||
readonly selectedActionIds: readonly string[];
|
||||
readonly cancelled?: boolean;
|
||||
}
|
||||
|
||||
/** Read-only context shown while selecting clients or reviewing exact actions. */
|
||||
export interface SetupConfirmationContext {
|
||||
readonly stage: "select" | "confirm";
|
||||
readonly clientStates: readonly SetupClientState[];
|
||||
readonly selectedClientIds: readonly string[];
|
||||
readonly clientSelectionAllowed: boolean;
|
||||
}
|
||||
|
||||
/** Adapter-owned selection and confirmation for the setup workflow. */
|
||||
export type SetupConfirmation = (
|
||||
actions: readonly SetupAction[],
|
||||
context?: SetupConfirmationContext,
|
||||
) => Promise<boolean | SetupConfirmationDecision>;
|
||||
filterClientsNeedingConfigure,
|
||||
hostRemediation,
|
||||
initialProviderEnvironment,
|
||||
setupRegistrationCommand,
|
||||
systemSetupHost,
|
||||
} from "./SetupHost.js";
|
||||
|
||||
/** Discover, approve, and apply setup actions idempotently. */
|
||||
export const runSetup = async (
|
||||
@@ -581,28 +373,6 @@ const setupFailure = async (
|
||||
const emitProgress = (options: SetupOptions, event: SetupProgressEvent): void =>
|
||||
options.onProgress?.(event);
|
||||
|
||||
const filterClientsNeedingConfigure = async (
|
||||
host: SetupHost,
|
||||
detectedClients: readonly SetupClient[],
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
): Promise<readonly SetupClient[]> => {
|
||||
const needs = await Promise.all(
|
||||
detectedClients.map((client) =>
|
||||
host.clientNeedsConfigure(client, providerEnvironment, command),
|
||||
),
|
||||
);
|
||||
return detectedClients.filter((_, index) => needs[index]);
|
||||
};
|
||||
|
||||
const initialProviderEnvironment = async (
|
||||
host: SetupHost,
|
||||
hopperPath: string | undefined,
|
||||
): Promise<SetupProviderEnvironment> => ({
|
||||
...(await host.providerEnvironment?.()),
|
||||
...(hopperPath === undefined ? {} : { HOPPER_LAUNCHER_PATH: hopperPath }),
|
||||
});
|
||||
|
||||
const finalSetupRemediation = (
|
||||
platform: NodeJS.Platform,
|
||||
installedHopper: boolean,
|
||||
@@ -679,93 +449,3 @@ const summarizeDoctor = (report: DoctorReport): SetupDoctorSummary => ({
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const hostRemediation = async (
|
||||
host: SetupHost,
|
||||
installHopper: boolean,
|
||||
): Promise<string | undefined> => {
|
||||
if (!supportsNodeVersion(host.nodeVersion))
|
||||
return "Install Node.js 22.19+ or 24.11+ and rerun setup.";
|
||||
if (!installHopper) return undefined;
|
||||
if (host.platform !== "darwin" && host.platform !== "linux")
|
||||
return "REA supports Hopper on macOS and selected 64-bit Linux distributions.";
|
||||
if (host.platform === "darwin") {
|
||||
const version = await host.macosVersion();
|
||||
return version === undefined || major(version) < 12
|
||||
? "Upgrade to macOS 12 or newer."
|
||||
: undefined;
|
||||
}
|
||||
if ((await host.linuxDistribution())?.supported === true) return undefined;
|
||||
return "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux; configure an existing supported provider instead.";
|
||||
};
|
||||
|
||||
/** Production setup effects for Hopper, agent configuration, and the canonical skill directory. */
|
||||
export const systemSetupHost = (
|
||||
doctorHost: DoctorHost = systemDoctorHost(),
|
||||
): SetupHost => {
|
||||
const platform = doctorHost.platform;
|
||||
return {
|
||||
platform,
|
||||
nodeVersion: process.versions.node,
|
||||
macosVersion: () => doctorHost.macosVersion(),
|
||||
linuxDistribution: readLinuxDistribution,
|
||||
hopperPath: async () => (await runDoctor(undefined, doctorHost)).hopperPath,
|
||||
providerEnvironment: async () => {
|
||||
const diagnosis = await runDoctor(undefined, doctorHost);
|
||||
return {
|
||||
...providerRegistrationEnvironment(diagnosis.providerInspections ?? []),
|
||||
...(diagnosis.hopperPath === undefined
|
||||
? {}
|
||||
: { HOPPER_LAUNCHER_PATH: diagnosis.hopperPath }),
|
||||
};
|
||||
},
|
||||
installHopper: async (replaceExisting) => {
|
||||
const result =
|
||||
platform === "linux"
|
||||
? await installLinuxHopper()
|
||||
: await installMacHopper({ replaceExisting });
|
||||
if (result.status === "installed") return result;
|
||||
return setupInstallFailure(result.reason);
|
||||
},
|
||||
detectedClients: () => detectClients(homedir()),
|
||||
supportedClients: () => Promise.resolve(supportedClients(homedir())),
|
||||
configureClient: (client, providerEnvironment, command) =>
|
||||
client.format === "unsupported"
|
||||
? Promise.resolve({ status: "skipped" })
|
||||
: configureClientConfiguration(client, providerEnvironment, command),
|
||||
clientNeedsConfigure: (client, providerEnvironment, command) =>
|
||||
clientConfigurationAligned(client, providerEnvironment, command).then(
|
||||
(aligned) => !aligned,
|
||||
),
|
||||
inspectClientConfiguration: inspectClientConfiguration,
|
||||
skillNeedsInstall: () => canonicalSkillNeedsInstall(homedir()),
|
||||
installSkill: () => installCanonicalSkill(homedir()),
|
||||
doctor: (scope) => runDoctor(undefined, doctorHost, scope),
|
||||
};
|
||||
};
|
||||
|
||||
/** Detect supported agents from their config files or stable installation markers. */
|
||||
export const detectClients = async (
|
||||
home: string,
|
||||
): Promise<readonly SetupClient[]> => {
|
||||
const detected: SetupClient[] = [];
|
||||
for (const candidate of supportedClients(home)) {
|
||||
const [hasConfig, hasMarker] = await Promise.all([
|
||||
exists(candidate.configPath),
|
||||
candidate.markerPath === undefined ? false : exists(candidate.markerPath),
|
||||
]);
|
||||
if (hasConfig || hasMarker) detected.push(candidate);
|
||||
}
|
||||
return detected;
|
||||
};
|
||||
|
||||
const major = (version: string): number =>
|
||||
Number.parseInt(version.split(".")[0] ?? "0", 10);
|
||||
const exists = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
ClientConfigurationInspection,
|
||||
ClientConfigurationResult,
|
||||
SetupProviderEnvironment,
|
||||
} from "./Setup.js";
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
|
||||
const defaultCommand = (): readonly string[] => [
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
ClientConfigurationResult,
|
||||
SetupClient,
|
||||
SetupHost,
|
||||
SetupProviderEnvironment,
|
||||
SetupProgressEvent,
|
||||
} from "./Setup.js";
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
|
||||
const failedConfigurationMessage = (
|
||||
reason: "path" | "backup" | "write" | "readback",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { supportsNodeVersion } from "../domain/runtimeVersion.js";
|
||||
import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js";
|
||||
import { installLinuxHopper, readLinuxDistribution } from "./LinuxHopper.js";
|
||||
import { installMacHopper } from "./MacHopper.js";
|
||||
import { supportedClients, type SetupClient } from "./SupportedClients.js";
|
||||
import {
|
||||
canonicalSkillNeedsInstall,
|
||||
installCanonicalSkill,
|
||||
} from "./SetupSkill.js";
|
||||
import {
|
||||
clientConfigurationAligned,
|
||||
configureClientConfiguration,
|
||||
inspectClientConfiguration,
|
||||
} from "./SetupClientConfiguration.js";
|
||||
import { setupInstallFailure } from "./SetupInstallFailure.js";
|
||||
import { providerRegistrationEnvironment } from "./SetupRegistrationEnvironment.js";
|
||||
import type { SetupHost, SetupProviderEnvironment } from "./SetupTypes.js";
|
||||
|
||||
/** Resolve the executable and arguments used in a managed MCP registration. */
|
||||
export const setupRegistrationCommand = (
|
||||
platform: NodeJS.Platform,
|
||||
useNpmRunner: boolean = process.env.npm_command === "exec",
|
||||
): readonly string[] =>
|
||||
useNpmRunner
|
||||
? PRODUCT_IDENTITY.mcpCommand.split(" ")
|
||||
: platform === "win32"
|
||||
? [
|
||||
process.execPath,
|
||||
resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary),
|
||||
"mcp",
|
||||
]
|
||||
: [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
|
||||
|
||||
export const filterClientsNeedingConfigure = async (
|
||||
host: SetupHost,
|
||||
detectedClients: readonly SetupClient[],
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
): Promise<readonly SetupClient[]> => {
|
||||
const needs = await Promise.all(
|
||||
detectedClients.map((client) =>
|
||||
host.clientNeedsConfigure(client, providerEnvironment, command),
|
||||
),
|
||||
);
|
||||
return detectedClients.filter((_, index) => needs[index]);
|
||||
};
|
||||
|
||||
export const initialProviderEnvironment = async (
|
||||
host: SetupHost,
|
||||
hopperPath: string | undefined,
|
||||
): Promise<SetupProviderEnvironment> => ({
|
||||
...(await host.providerEnvironment?.()),
|
||||
...(hopperPath === undefined ? {} : { HOPPER_LAUNCHER_PATH: hopperPath }),
|
||||
});
|
||||
|
||||
export const hostRemediation = async (
|
||||
host: SetupHost,
|
||||
installHopper: boolean,
|
||||
): Promise<string | undefined> => {
|
||||
if (!supportsNodeVersion(host.nodeVersion))
|
||||
return "Install Node.js 22.19+ or 24.11+ and rerun setup.";
|
||||
if (!installHopper) return undefined;
|
||||
if (host.platform !== "darwin" && host.platform !== "linux")
|
||||
return "REA supports Hopper on macOS and selected 64-bit Linux distributions.";
|
||||
if (host.platform === "darwin") {
|
||||
const version = await host.macosVersion();
|
||||
return version === undefined || major(version) < 12
|
||||
? "Upgrade to macOS 12 or newer."
|
||||
: undefined;
|
||||
}
|
||||
if ((await host.linuxDistribution())?.supported === true) return undefined;
|
||||
return "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux; configure an existing supported provider instead.";
|
||||
};
|
||||
|
||||
/** Production setup effects for Hopper, agent configuration, and the canonical skill directory. */
|
||||
export const systemSetupHost = (
|
||||
doctorHost: DoctorHost = systemDoctorHost(),
|
||||
): SetupHost => {
|
||||
const platform = doctorHost.platform;
|
||||
return {
|
||||
platform,
|
||||
nodeVersion: process.versions.node,
|
||||
macosVersion: () => doctorHost.macosVersion(),
|
||||
linuxDistribution: readLinuxDistribution,
|
||||
hopperPath: async () => (await runDoctor(undefined, doctorHost)).hopperPath,
|
||||
providerEnvironment: async () => {
|
||||
const diagnosis = await runDoctor(undefined, doctorHost);
|
||||
return {
|
||||
...providerRegistrationEnvironment(diagnosis.providerInspections ?? []),
|
||||
...(diagnosis.hopperPath === undefined
|
||||
? {}
|
||||
: { HOPPER_LAUNCHER_PATH: diagnosis.hopperPath }),
|
||||
};
|
||||
},
|
||||
installHopper: async (replaceExisting) => {
|
||||
const result =
|
||||
platform === "linux"
|
||||
? await installLinuxHopper()
|
||||
: await installMacHopper({ replaceExisting });
|
||||
if (result.status === "installed") return result;
|
||||
return setupInstallFailure(result.reason);
|
||||
},
|
||||
detectedClients: () => detectClients(homedir()),
|
||||
supportedClients: () => Promise.resolve(supportedClients(homedir())),
|
||||
configureClient: (client, providerEnvironment, command) =>
|
||||
client.format === "unsupported"
|
||||
? Promise.resolve({ status: "skipped" })
|
||||
: configureClientConfiguration(client, providerEnvironment, command),
|
||||
clientNeedsConfigure: (client, providerEnvironment, command) =>
|
||||
clientConfigurationAligned(client, providerEnvironment, command).then(
|
||||
(aligned) => !aligned,
|
||||
),
|
||||
inspectClientConfiguration: inspectClientConfiguration,
|
||||
skillNeedsInstall: () => canonicalSkillNeedsInstall(homedir()),
|
||||
installSkill: () => installCanonicalSkill(homedir()),
|
||||
doctor: (scope) => runDoctor(undefined, doctorHost, scope),
|
||||
};
|
||||
};
|
||||
|
||||
/** Detect supported agents from their config files or stable installation markers. */
|
||||
export const detectClients = async (
|
||||
home: string,
|
||||
): Promise<readonly SetupClient[]> => {
|
||||
const detected: SetupClient[] = [];
|
||||
for (const candidate of supportedClients(home)) {
|
||||
const [hasConfig, hasMarker] = await Promise.all([
|
||||
exists(candidate.configPath),
|
||||
candidate.markerPath === undefined ? false : exists(candidate.markerPath),
|
||||
]);
|
||||
if (hasConfig || hasMarker) detected.push(candidate);
|
||||
}
|
||||
return detected;
|
||||
};
|
||||
|
||||
const major = (version: string): number =>
|
||||
Number.parseInt(version.split(".")[0] ?? "0", 10);
|
||||
const exists = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
import { macHopperInstallDisclosure } from "./MacHopper.js";
|
||||
import type {
|
||||
SetupAction,
|
||||
SetupClient,
|
||||
SetupClientState,
|
||||
SetupHost,
|
||||
SetupProviderEnvironment,
|
||||
} from "./Setup.js";
|
||||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type { DoctorScope } from "./Doctor.js";
|
||||
|
||||
/** Read-only setup facts shared by client selection and preflight. */
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js";
|
||||
import type { LinuxDistribution } from "./LinuxHopper.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
import type {
|
||||
SetupFailureCode,
|
||||
SetupHopperInstallResult,
|
||||
} from "./SetupInstallFailure.js";
|
||||
|
||||
/** Exact non-secret provider variables propagated into managed registrations. */
|
||||
export type SetupProviderEnvironment = Readonly<Record<string, string>>;
|
||||
|
||||
/** Result of one backup/write/readback transaction. */
|
||||
export type ClientConfigurationResult =
|
||||
| {
|
||||
readonly status: "unchanged" | "configured" | "skipped";
|
||||
readonly backupPath?: string;
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason: "path" | "backup" | "write" | "readback";
|
||||
};
|
||||
|
||||
/** Read-only state used to block unsafe setup plans before any mutation. */
|
||||
export type ClientConfigurationInspection =
|
||||
| { readonly status: "already_current" }
|
||||
| {
|
||||
readonly status: "create" | "update";
|
||||
readonly backupPath?: string;
|
||||
}
|
||||
| {
|
||||
readonly status: "invalid";
|
||||
readonly remediation: string;
|
||||
};
|
||||
/** Effects required by the idempotent, non-blocking setup workflow. */
|
||||
export interface SetupHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly nodeVersion: string;
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
linuxDistribution(): Promise<LinuxDistribution | undefined>;
|
||||
hopperPath(): Promise<string | undefined>;
|
||||
installHopper(replaceExisting: boolean): Promise<SetupHopperInstallResult>;
|
||||
providerEnvironment?(): Promise<SetupProviderEnvironment>;
|
||||
detectedClients(): Promise<readonly SetupClient[]>;
|
||||
supportedClients?(): Promise<readonly SetupClient[]>;
|
||||
configureClient(
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
): Promise<ClientConfigurationResult>;
|
||||
clientNeedsConfigure(
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
): Promise<boolean>;
|
||||
inspectClientConfiguration?(
|
||||
client: SetupClient,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
): Promise<ClientConfigurationInspection>;
|
||||
skillNeedsInstall(): Promise<boolean>;
|
||||
installSkill(): Promise<"installed" | "unchanged" | "failed">;
|
||||
doctor(scope?: DoctorScope): Promise<Awaited<ReturnType<typeof runDoctor>>>;
|
||||
}
|
||||
/** Structured setup outcome carrying remediation instead of prompting. */
|
||||
export interface SetupResult {
|
||||
readonly status:
|
||||
| "planned"
|
||||
| "cancelled"
|
||||
| "needs_confirmation"
|
||||
| "ready"
|
||||
| "needs_human";
|
||||
readonly plannedActions: readonly SetupAction[];
|
||||
readonly appliedActions: readonly string[];
|
||||
readonly clients: Readonly<Record<string, ClientConfigurationResult>>;
|
||||
readonly doctor: SetupDoctorSummary;
|
||||
/** Supported-client discovery state captured before setup mutations. */
|
||||
readonly clientStates: readonly SetupClientState[];
|
||||
readonly remediation?: string;
|
||||
readonly code?: SetupFailureCode;
|
||||
}
|
||||
|
||||
/** Whether the requested setup actions still need approval or human repair. */
|
||||
export const isSetupFailure = (result: SetupResult): boolean => {
|
||||
const status = result.status;
|
||||
switch (status) {
|
||||
case "planned":
|
||||
case "cancelled":
|
||||
case "ready":
|
||||
return false;
|
||||
case "needs_confirmation":
|
||||
case "needs_human":
|
||||
return true;
|
||||
default: {
|
||||
const exhaustive: never = status;
|
||||
throw new TypeError(
|
||||
`Unhandled setup result status: ${String(exhaustive)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Relevant health and identity evidence for the selected setup scope. */
|
||||
export interface SetupDoctorSummary {
|
||||
readonly healthy: boolean;
|
||||
readonly environment_healthy: boolean;
|
||||
readonly scope: DoctorReport["scope"];
|
||||
readonly hopperPath?: string;
|
||||
readonly availableProviders: readonly string[];
|
||||
readonly providerInspections?: DoctorReport["providerInspections"];
|
||||
readonly identity?: Pick<
|
||||
NonNullable<DoctorReport["identity"]>,
|
||||
"skill" | "registrations"
|
||||
>;
|
||||
}
|
||||
|
||||
/** Read-only state for one supported client location. */
|
||||
export interface SetupClientState {
|
||||
readonly client: SetupClient;
|
||||
readonly detected: boolean;
|
||||
readonly configured: boolean;
|
||||
readonly status: "configured" | "missing" | "needs_configuration" | "invalid";
|
||||
}
|
||||
|
||||
/** One concrete setup mutation disclosed before approval. */
|
||||
export interface SetupAction {
|
||||
readonly id: string;
|
||||
readonly kind: "install_hopper" | "configure_client" | "install_skill";
|
||||
readonly label: string;
|
||||
readonly target: string;
|
||||
readonly detail: string;
|
||||
readonly external: boolean;
|
||||
readonly operation: "create" | "update" | "install";
|
||||
readonly backupPath?: string;
|
||||
readonly networkOrigins?: readonly string[];
|
||||
readonly commands?: readonly string[];
|
||||
readonly integrity?: string;
|
||||
}
|
||||
|
||||
/** Explicit authorization supplied by an interactive or unattended CLI adapter. */
|
||||
export interface SetupOptions {
|
||||
readonly approved: boolean;
|
||||
readonly installHopper: boolean;
|
||||
readonly structured: boolean;
|
||||
readonly proposeHopper?: boolean;
|
||||
readonly clientIds?: readonly string[];
|
||||
readonly installSkill?: boolean;
|
||||
readonly readinessScope?: DoctorScope;
|
||||
readonly dryRun?: boolean;
|
||||
readonly allDetectedClients?: boolean;
|
||||
readonly onProgress?: (event: SetupProgressEvent) => void;
|
||||
}
|
||||
|
||||
/** One settled or active setup operation projected to the interactive adapter. */
|
||||
export interface SetupProgressEvent {
|
||||
readonly actionId: string;
|
||||
readonly label: string;
|
||||
readonly state: "started" | "completed" | "warning" | "failed";
|
||||
readonly detail?: string;
|
||||
}
|
||||
|
||||
/** Interactive selection and final consent returned by the CLI adapter. */
|
||||
export interface SetupConfirmationDecision {
|
||||
readonly approved: boolean;
|
||||
readonly selectedActionIds: readonly string[];
|
||||
readonly cancelled?: boolean;
|
||||
}
|
||||
|
||||
/** Read-only context shown while selecting clients or reviewing exact actions. */
|
||||
export interface SetupConfirmationContext {
|
||||
readonly stage: "select" | "confirm";
|
||||
readonly clientStates: readonly SetupClientState[];
|
||||
readonly selectedClientIds: readonly string[];
|
||||
readonly clientSelectionAllowed: boolean;
|
||||
}
|
||||
|
||||
/** Adapter-owned selection and confirmation for the setup workflow. */
|
||||
export type SetupConfirmation = (
|
||||
actions: readonly SetupAction[],
|
||||
context?: SetupConfirmationContext,
|
||||
) => Promise<boolean | SetupConfirmationDecision>;
|
||||
@@ -7,7 +7,7 @@ import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.j
|
||||
import { readClientRegistrationStatuses } from "./ClientRegistrationStatus.js";
|
||||
import { parseClientConfiguration } from "./ClientConfigurationDocument.js";
|
||||
import { supportedClients } from "./SupportedClients.js";
|
||||
import type { SetupAction } from "./Setup.js";
|
||||
import type { SetupAction } from "./SetupTypes.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
|
||||
/** Existing REA integrations selected for maintenance, without discovering new targets. */
|
||||
|
||||
@@ -24,13 +24,13 @@ import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
cleanupWindowsProcessTree,
|
||||
observeOwnedProcessLineage,
|
||||
selectCapturedProcessGroupIds,
|
||||
verifyNoTokenOwnedProcesses,
|
||||
type OwnedProcessGroup,
|
||||
type ProcessCleanupResult,
|
||||
type ProcessLineageObservation,
|
||||
} from "../process/ProcessOwnership.js";
|
||||
import { observeOwnedProcessLineage } from "../process/ProcessOwnershipObservation.js";
|
||||
import { selectCapturedProcessGroupIds } from "../process/ProcessOwnershipProcessTree.js";
|
||||
import {
|
||||
runElectronActions,
|
||||
readApplicationState,
|
||||
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
runDoctor,
|
||||
type DoctorScope,
|
||||
} from "../application/Doctor.js";
|
||||
import { runSetup } from "../application/Setup.js";
|
||||
import { systemSetupHost } from "../application/SetupHost.js";
|
||||
import {
|
||||
runSetup,
|
||||
isSetupFailure,
|
||||
systemSetupHost,
|
||||
type SetupOptions,
|
||||
} from "../application/Setup.js";
|
||||
} from "../application/SetupTypes.js";
|
||||
import { isUninstallFailure, runUninstall } from "../application/Uninstall.js";
|
||||
import { isUpdateFailure, runUpdate } from "../application/Update.js";
|
||||
import { systemUpdateHost } from "../application/UpdateRuntime.js";
|
||||
|
||||
@@ -8,11 +8,8 @@ import {
|
||||
|
||||
import { isCliOperationFailure, logCliCommand } from "./cliLogging.js";
|
||||
import { silentLogger } from "./logger.js";
|
||||
import {
|
||||
isSetupFailure,
|
||||
runSetup,
|
||||
type SetupResult,
|
||||
} from "./application/Setup.js";
|
||||
import { runSetup } from "./application/Setup.js";
|
||||
import { isSetupFailure, type SetupResult } from "./application/SetupTypes.js";
|
||||
import {
|
||||
FakeSetupHost,
|
||||
options as setupOptions,
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import type {
|
||||
SetupClientState,
|
||||
SetupProgressEvent,
|
||||
SetupResult,
|
||||
} from "./application/Setup.js";
|
||||
} from "./application/SetupTypes.js";
|
||||
import { SUPPORTED_CLIENT_DEFINITIONS } from "./application/SupportedClients.js";
|
||||
|
||||
const clientDisplayNames: ReadonlyMap<string, string> = new Map(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
observeOwnedProcessLineage,
|
||||
verifyNoTokenOwnedProcesses,
|
||||
type ProcessOwnershipHost,
|
||||
} from "./ProcessOwnership.js";
|
||||
import { observeOwnedProcessLineage } from "./ProcessOwnershipObservation.js";
|
||||
const ownership = {
|
||||
runId: "run-token",
|
||||
leaderPid: 100,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
observeOwnedProcessLineage,
|
||||
type ProcessOwnershipHost,
|
||||
} from "./ProcessOwnership.js";
|
||||
import { type ProcessOwnershipHost } from "./ProcessOwnership.js";
|
||||
import { observeOwnedProcessLineage } from "./ProcessOwnershipObservation.js";
|
||||
const ownership = {
|
||||
runId: "run-token",
|
||||
leaderPid: 100,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
parseProcessEnvironment,
|
||||
selectCapturedProcessGroupIds,
|
||||
type ProcessOwnershipHost,
|
||||
} from "./ProcessOwnership.js";
|
||||
import { parseProcessEnvironment } from "./ProcessOwnershipObservation.js";
|
||||
import { selectCapturedProcessGroupIds } from "./ProcessOwnershipProcessTree.js";
|
||||
import { matchesOwnedProcessCommand } from "./ProcessCommandIdentity.js";
|
||||
const ownership = {
|
||||
runId: "run-token",
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import {
|
||||
observeOwnedProcessGroupWithHost,
|
||||
observeOwnedProcessLineageWithHost,
|
||||
createSystemProcessOwnershipHost,
|
||||
errorMessage,
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
|
||||
import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js";
|
||||
import { execFileOutput } from "./ExecFileOutput.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -127,86 +125,6 @@ export const cleanupWindowsProcessTree = async (
|
||||
}
|
||||
};
|
||||
|
||||
/** Select the PTY root group and groups led by an observed captured process. */
|
||||
export const selectCapturedProcessGroupIds = (
|
||||
rootPid: number,
|
||||
samples: readonly {
|
||||
readonly pid: number;
|
||||
readonly process_group_id: number | null;
|
||||
}[],
|
||||
): readonly number[] => {
|
||||
const processGroupIds = new Set<number>([rootPid]);
|
||||
for (const sample of samples) {
|
||||
// A sampled member may transiently inherit an unrelated group. POSIX group
|
||||
// leaders have pid === pgid, so only that observation establishes that the
|
||||
// group leader itself belonged to the captured tree. Token checks below
|
||||
// remain the final authority immediately before observation or signaling.
|
||||
if (sample.pid === sample.process_group_id) processGroupIds.add(sample.pid);
|
||||
}
|
||||
return [...processGroupIds];
|
||||
};
|
||||
|
||||
/** Parse the NUL-delimited Linux process environment without nameless keys. */
|
||||
export const parseProcessEnvironment = (
|
||||
value: string,
|
||||
): Readonly<Record<string, string>> =>
|
||||
Object.fromEntries(
|
||||
value
|
||||
.split("\0")
|
||||
.filter((entry) => entry.indexOf("=") > 0)
|
||||
.map((entry) => {
|
||||
const separator = entry.indexOf("=");
|
||||
return [entry.slice(0, separator), entry.slice(separator + 1)];
|
||||
}),
|
||||
);
|
||||
|
||||
/** Create the operating-system process inspector for an explicit host context. */
|
||||
export const createSystemProcessOwnershipHost = (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
hostEnvironment: NodeJS.ProcessEnv = process.env,
|
||||
): ProcessOwnershipHost => ({
|
||||
platform,
|
||||
async listProcesses() {
|
||||
if (platform === "win32") return [];
|
||||
const { stdout } = await execFileOutput(
|
||||
"ps",
|
||||
["-axo", "pid=,ppid=,pgid=,stat=,command="],
|
||||
{ env: hostEnvironment },
|
||||
);
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)/u.exec(line))
|
||||
.filter((match): match is RegExpExecArray => match !== null)
|
||||
.map((match) => ({
|
||||
pid: Number(match[1]),
|
||||
parentPid: Number(match[2]),
|
||||
processGroupId: Number(match[3]),
|
||||
state: match[4] ?? "",
|
||||
command: match[5] ?? "",
|
||||
}));
|
||||
},
|
||||
async environment(pid) {
|
||||
if (platform === "linux")
|
||||
return parseProcessEnvironment(
|
||||
await readFile(`/proc/${pid}/environ`, "utf8"),
|
||||
);
|
||||
const { stdout } = await execFileOutput("ps", ["eww", "-p", String(pid)], {
|
||||
env: hostEnvironment,
|
||||
});
|
||||
const observedEnvironment: Record<string, string> = {};
|
||||
for (const match of stdout.matchAll(
|
||||
/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=([^\s]*)/gu,
|
||||
)) {
|
||||
const name = match[1];
|
||||
if (name !== undefined) observedEnvironment[name] = match[2] ?? "";
|
||||
}
|
||||
return observedEnvironment;
|
||||
},
|
||||
signalGroup(processGroupId, signal) {
|
||||
process.kill(-processGroupId, signal);
|
||||
},
|
||||
});
|
||||
|
||||
const systemHost = createSystemProcessOwnershipHost();
|
||||
|
||||
const systemWindowsProcessTreeHost: WindowsProcessTreeHost = {
|
||||
@@ -519,26 +437,3 @@ const scanTokenOwnedProcesses = async (
|
||||
}
|
||||
return { owned, failures };
|
||||
};
|
||||
|
||||
const errorMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
/** Observe one group without signaling it, failing closed on identity doubt. */
|
||||
export const observeOwnedProcessGroup = async (
|
||||
ownership: OwnedProcessGroup,
|
||||
host: ProcessOwnershipHost = systemHost,
|
||||
): Promise<ProcessGroupObservation> =>
|
||||
observeOwnedProcessGroupWithHost(ownership, host);
|
||||
|
||||
/**
|
||||
* Record the live launcher and descendant lineage after run-token validation.
|
||||
*
|
||||
* The observation is intentionally point-in-time. A verified empty descendant
|
||||
* list means no descendants were live during this observation, not that the
|
||||
* run never created a short-lived child.
|
||||
*/
|
||||
export const observeOwnedProcessLineage = async (
|
||||
ownership: OwnedProcessGroup,
|
||||
host: ProcessOwnershipHost = systemHost,
|
||||
): Promise<ProcessLineageObservation> =>
|
||||
observeOwnedProcessLineageWithHost(ownership, host);
|
||||
@@ -2,12 +2,14 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
cleanupWindowsProcessTree,
|
||||
createSystemProcessOwnershipHost,
|
||||
observeOwnedProcessGroup,
|
||||
observeOwnedProcessLineage,
|
||||
type ProcessOwnershipHost,
|
||||
type WindowsProcessTreeHost,
|
||||
} from "./ProcessOwnership.js";
|
||||
import {
|
||||
createSystemProcessOwnershipHost,
|
||||
observeOwnedProcessGroup,
|
||||
observeOwnedProcessLineage,
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
import { host, ownership } from "./ProcessOwnership.fixture.js";
|
||||
|
||||
describe("owned process-group cleanup validation: ownership and lineage", () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js";
|
||||
import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js";
|
||||
import { execFileOutput } from "./ExecFileOutput.js";
|
||||
import type {
|
||||
OwnedProcessGroup,
|
||||
ProcessGroupObservation,
|
||||
@@ -127,6 +130,89 @@ const unavailableLineage = (
|
||||
reason,
|
||||
});
|
||||
|
||||
/** Parse the NUL-delimited Linux process environment without nameless keys. */
|
||||
export const parseProcessEnvironment = (
|
||||
value: string,
|
||||
): Readonly<Record<string, string>> =>
|
||||
Object.fromEntries(
|
||||
value
|
||||
.split("\0")
|
||||
.filter((entry) => entry.indexOf("=") > 0)
|
||||
.map((entry) => {
|
||||
const separator = entry.indexOf("=");
|
||||
return [entry.slice(0, separator), entry.slice(separator + 1)];
|
||||
}),
|
||||
);
|
||||
|
||||
/** Create the operating-system process inspector for an explicit host context. */
|
||||
export const createSystemProcessOwnershipHost = (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
hostEnvironment: NodeJS.ProcessEnv = process.env,
|
||||
): ProcessOwnershipHost => ({
|
||||
platform,
|
||||
async listProcesses() {
|
||||
if (platform === "win32") return [];
|
||||
const { stdout } = await execFileOutput(
|
||||
"ps",
|
||||
["-axo", "pid=,ppid=,pgid=,stat=,command="],
|
||||
{ env: hostEnvironment },
|
||||
);
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)/u.exec(line))
|
||||
.filter((match): match is RegExpExecArray => match !== null)
|
||||
.map((match) => ({
|
||||
pid: Number(match[1]),
|
||||
parentPid: Number(match[2]),
|
||||
processGroupId: Number(match[3]),
|
||||
state: match[4] ?? "",
|
||||
command: match[5] ?? "",
|
||||
}));
|
||||
},
|
||||
async environment(pid) {
|
||||
if (platform === "linux")
|
||||
return parseProcessEnvironment(
|
||||
await readFile(`/proc/${pid}/environ`, "utf8"),
|
||||
);
|
||||
const { stdout } = await execFileOutput("ps", ["eww", "-p", String(pid)], {
|
||||
env: hostEnvironment,
|
||||
});
|
||||
const observedEnvironment: Record<string, string> = {};
|
||||
for (const match of stdout.matchAll(
|
||||
/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=([^\s]*)/gu,
|
||||
)) {
|
||||
const name = match[1];
|
||||
if (name !== undefined) observedEnvironment[name] = match[2] ?? "";
|
||||
}
|
||||
return observedEnvironment;
|
||||
},
|
||||
signalGroup(processGroupId, signal) {
|
||||
process.kill(-processGroupId, signal);
|
||||
},
|
||||
});
|
||||
|
||||
const systemHost = createSystemProcessOwnershipHost();
|
||||
|
||||
/** Observe one group without signaling it, failing closed on identity doubt. */
|
||||
export const observeOwnedProcessGroup = async (
|
||||
ownership: OwnedProcessGroup,
|
||||
host: ProcessOwnershipHost = systemHost,
|
||||
): Promise<ProcessGroupObservation> =>
|
||||
observeOwnedProcessGroupWithHost(ownership, host);
|
||||
|
||||
/**
|
||||
* Record the live launcher and descendant lineage after run-token validation.
|
||||
*
|
||||
* The observation is intentionally point-in-time. A verified empty descendant
|
||||
* list means no descendants were live during this observation, not that the
|
||||
* run never created a short-lived child.
|
||||
*/
|
||||
export const observeOwnedProcessLineage = async (
|
||||
ownership: OwnedProcessGroup,
|
||||
host: ProcessOwnershipHost = systemHost,
|
||||
): Promise<ProcessLineageObservation> =>
|
||||
observeOwnedProcessLineageWithHost(ownership, host);
|
||||
|
||||
const processIsGone = async (
|
||||
host: ProcessOwnershipHost,
|
||||
pid: number,
|
||||
@@ -140,5 +226,5 @@ const processIsGone = async (
|
||||
}
|
||||
};
|
||||
|
||||
const errorMessage = (cause: unknown): string =>
|
||||
export const errorMessage = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
@@ -28,3 +28,22 @@ export const descendantsOf = (
|
||||
}
|
||||
return descendants;
|
||||
};
|
||||
|
||||
/** Select the PTY root group and groups led by an observed captured process. */
|
||||
export const selectCapturedProcessGroupIds = (
|
||||
rootPid: number,
|
||||
samples: readonly {
|
||||
readonly pid: number;
|
||||
readonly process_group_id: number | null;
|
||||
}[],
|
||||
): readonly number[] => {
|
||||
const processGroupIds = new Set<number>([rootPid]);
|
||||
for (const sample of samples) {
|
||||
// A sampled member may transiently inherit an unrelated group. POSIX group
|
||||
// leaders have pid === pgid, so only that observation establishes that the
|
||||
// group leader itself belonged to the captured tree. Token checks below
|
||||
// remain the final authority immediately before observation or signaling.
|
||||
if (sample.pid === sample.process_group_id) processGroupIds.add(sample.pid);
|
||||
}
|
||||
return [...processGroupIds];
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ProcessLineageObservation } from "./ProcessOwnership.js";
|
||||
import { observeOwnedProcessLineage } from "./ProcessOwnership.js";
|
||||
import { observeOwnedProcessLineage } from "./ProcessOwnershipObservation.js";
|
||||
import type { ProviderProcessLaunch } from "./ProviderProcess.js";
|
||||
|
||||
/** Retain the latest truthful process-lineage observation for one client. */
|
||||
|
||||
@@ -14,10 +14,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { supportedClients } from "../../../src/application/SupportedClients.js";
|
||||
|
||||
import {
|
||||
configureTomlClient,
|
||||
detectClients,
|
||||
} from "../../../src/application/Setup.js";
|
||||
import { configureTomlClient } from "../../../src/application/SetupClientConfiguration.js";
|
||||
import { detectClients } from "../../../src/application/SetupHost.js";
|
||||
import {
|
||||
runUninstall,
|
||||
systemUninstallHost,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { resolveClientConfigTransactionPath } from "../../../src/application/ClientConfigPath.js";
|
||||
import { configureJsonClient } from "../../../src/application/Setup.js";
|
||||
import { configureJsonClient } from "../../../src/application/SetupClientConfiguration.js";
|
||||
import { PRODUCT_IDENTITY } from "../../../src/identity.js";
|
||||
|
||||
const pinnedPackage = PRODUCT_IDENTITY.registrationPackageSpecifier;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
canonicalSkillNeedsInstall,
|
||||
installCanonicalSkill,
|
||||
} from "../../../src/application/Setup.js";
|
||||
} from "../../../src/application/SetupSkill.js";
|
||||
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
|
||||
|
||||
describe("canonical skill transaction", () => {
|
||||
|
||||
@@ -7,10 +7,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PendingOperations } from "../../../src/process/PendingOperations.js";
|
||||
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
|
||||
import { ProviderStartupDeadline } from "../../../src/process/ProviderDeadline.js";
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
observeOwnedProcessLineage,
|
||||
} from "../../../src/process/ProcessOwnership.js";
|
||||
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
|
||||
import { observeOwnedProcessLineage } from "../../../src/process/ProcessOwnershipObservation.js";
|
||||
import {
|
||||
ProviderProcessSupervisor,
|
||||
spawnOwnedProviderProcess,
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
ArtifactOperationError,
|
||||
projectAnalysisError,
|
||||
} from "../../src/domain/errors.js";
|
||||
import { isSetupFailure, runSetup } from "../../src/application/Setup.js";
|
||||
import { runSetup } from "../../src/application/Setup.js";
|
||||
import { isSetupFailure } from "../../src/application/SetupTypes.js";
|
||||
import {
|
||||
FakeSetupHost,
|
||||
options as setupOptions,
|
||||
|
||||
Reference in new issue
Block a user