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:
morluto authored and GitHub committed 2026-10-06 05:00:32 +08:00
1 parent 55e5eb090b
commit 3c84080aab
30 files changed
+502 -499

No files matched your search

+2 -4
View File
@@ -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 {
+1 -1
View File
@@ -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 -1
View File
@@ -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 () => {
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -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";
+2 -1
View File
@@ -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
View File
@@ -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;
}
};
+1 -1
View File
@@ -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[] => [
+2 -2
View File
@@ -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",
+149
View File
@@ -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;
}
};
+2 -2
View File
@@ -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. */
+180
View File
@@ -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>;
+1 -1
View File
@@ -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,
+3 -3
View File
@@ -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";
+2 -5
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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,
+2 -4
View File
@@ -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,
+2 -2
View File
@@ -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",
+2 -107
View File
@@ -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", () => {
+87 -1
View File
@@ -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 -1
View File
@@ -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;
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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,