From 85e94d5968d29cee4a6da8646b2110c95c5ad3fd Mon Sep 17 00:00:00 2001 From: Arc Date: Sat, 21 Mar 2026 07:04:17 +0000 Subject: [PATCH] feat: add generalized platform abstractions Introduce src/infra/platform.ts with centralized platform types, detection helpers, and common utilities (SupportedPlatform type guard, boolean platform flags, fd-link paths, procfs helpers, headless detection, platform registry pattern). Migrate daemon/service.ts, daemon/inspect.ts, infra/fs-safe.ts, infra/gateway-processes.ts, and agents/shell-utils.ts to use the new abstractions. Add comprehensive test suite in platform.test.ts (22 tests). Co-Authored-By: Claude Opus 4.6 --- README.md | 18 ++-- docs/cockpit/FAST-TODO.md | 2 +- src/agents/shell-utils.ts | 7 +- src/daemon/inspect.ts | 2 +- src/daemon/service.ts | 8 +- src/infra/fs-safe.ts | 14 +-- src/infra/gateway-processes.ts | 6 +- src/infra/platform.test.ts | 167 +++++++++++++++++++++++++++++++++ src/infra/platform.ts | 130 +++++++++++++++++++++++++ 9 files changed, 326 insertions(+), 28 deletions(-) create mode 100644 src/infra/platform.test.ts create mode 100644 src/infra/platform.ts diff --git a/README.md b/README.md index a04a13293b..1ba5a65d2f 100644 --- a/README.md +++ b/README.md @@ -40,21 +40,21 @@ openclaw onboard Arc has one runtime with two surfaces: -| Surface | Role | -| --- | --- | +| Surface | Role | +| ------------------- | ------------------------------------------------------ | | **Swift macOS app** | Flagship review workstation — diffs, queues, decisions | -| **VPS TUI** | Fast remote operator console — queue, inspect, unblock | +| **VPS TUI** | Fast remote operator console — queue, inspect, unblock | ### The Layer Model Arc only makes sense if the layers stay clean: -| Layer | Role | -| --- | --- | -| **Arc** | product, workflow, workstation, project cockpit | -| **OpenClaw** | runtime, gateway, worktrees, worker lifecycle, durable state | -| **Claude + Codex** | worker engines that do the coding work | -| **Obsidian** | planning, notes, specs, architecture, project memory | +| Layer | Role | +| ------------------ | ------------------------------------------------------------ | +| **Arc** | product, workflow, workstation, project cockpit | +| **OpenClaw** | runtime, gateway, worktrees, worker lifecycle, durable state | +| **Claude + Codex** | worker engines that do the coding work | +| **Obsidian** | planning, notes, specs, architecture, project memory | Obsidian should hold thinking. Arc should hold execution. diff --git a/docs/cockpit/FAST-TODO.md b/docs/cockpit/FAST-TODO.md index 21ce5983cb..f6d3e363e5 100644 --- a/docs/cockpit/FAST-TODO.md +++ b/docs/cockpit/FAST-TODO.md @@ -43,7 +43,7 @@ Arc becomes the default daily surface when these are all done: - [ ] broad product polish for strangers - [ ] hosted-first architecture -- [ ] generalized platform abstractions +- [x] generalized platform abstractions - [ ] multi-user shared cockpit state - [ ] advanced memory / retrieval work - [ ] full-editor ambitions before the review workstation is strong diff --git a/src/agents/shell-utils.ts b/src/agents/shell-utils.ts index a4a5dbc115..97db19b33f 100644 --- a/src/agents/shell-utils.ts +++ b/src/agents/shell-utils.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +import { isWindows } from "../infra/platform.js"; export function resolvePowerShellPath(): string { // Prefer PowerShell 7 when available; PS 5.1 lacks "&&" support. @@ -40,7 +41,7 @@ export function resolvePowerShellPath(): string { } export function getShellConfig(): { shell: string; args: string[] } { - if (process.platform === "win32") { + if (isWindows) { // Use PowerShell instead of cmd.exe on Windows. // Problem: Many Windows system utilities (ipconfig, systeminfo, etc.) write // directly to the console via WriteConsole API, bypassing stdout pipes. @@ -107,7 +108,7 @@ export function detectRuntimeShell(): string | undefined { } } - if (process.platform === "win32") { + if (isWindows) { if (process.env.POWERSHELL_DISTRIBUTION_CHANNEL) { return "pwsh"; } @@ -168,7 +169,7 @@ export function sanitizeBinaryOutput(text: string): string { } export function killProcessTree(pid: number): void { - if (process.platform === "win32") { + if (isWindows) { try { spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore", diff --git a/src/daemon/inspect.ts b/src/daemon/inspect.ts index c3025ae8b8..932d468cdb 100644 --- a/src/daemon/inspect.ts +++ b/src/daemon/inspect.ts @@ -11,7 +11,7 @@ import { resolveHomeDir } from "./paths.js"; import { execSchtasks } from "./schtasks-exec.js"; export type ExtraGatewayService = { - platform: "darwin" | "linux" | "win32"; + platform: import("../infra/platform.js").SupportedPlatform; label: string; detail: string; scope: "user" | "system"; diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 8083ce4b5e..a6e11493ab 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -1,3 +1,5 @@ +import type { PlatformRegistry, SupportedPlatform } from "../infra/platform.js"; +import { isSupportedPlatform } from "../infra/platform.js"; import { installLaunchAgent, isLaunchAgentLoaded, @@ -91,9 +93,9 @@ export function describeGatewayServiceRestart( }; } -type SupportedGatewayServicePlatform = "darwin" | "linux" | "win32"; +type SupportedGatewayServicePlatform = SupportedPlatform; -const GATEWAY_SERVICE_REGISTRY: Record = { +const GATEWAY_SERVICE_REGISTRY: PlatformRegistry = { darwin: { label: "LaunchAgent", loadedText: "loaded", @@ -135,7 +137,7 @@ const GATEWAY_SERVICE_REGISTRY: Record { - if (process.platform === "win32") { + if (isWindows) { await writeFileWithinRootLegacy(params); return; } @@ -608,7 +604,7 @@ export async function copyFileWithinRoot(params: { } try { - if (process.platform === "win32") { + if (isWindows) { await copyFileWithinRootLegacy(params, source); return; } diff --git a/src/infra/gateway-processes.ts b/src/infra/gateway-processes.ts index 340b54a259..33e2e677f7 100644 --- a/src/infra/gateway-processes.ts +++ b/src/infra/gateway-processes.ts @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import fsSync from "node:fs"; import { parseCmdScriptCommandLine } from "../daemon/cmd-argv.js"; import { isGatewayArgv, parseProcCmdline } from "./gateway-process-argv.js"; +import { procCmdlinePath } from "./platform.js"; import { findGatewayPidsOnPortSync as findUnixGatewayPidsOnPortSync } from "./restart-stale-pids.js"; const WINDOWS_GATEWAY_DISCOVERY_TIMEOUT_MS = 5_000; @@ -111,9 +112,10 @@ function readWindowsListeningPidsOnPortSync(port: number): number[] { } export function readGatewayProcessArgsSync(pid: number): string[] | null { - if (process.platform === "linux") { + const cmdlinePath = procCmdlinePath(pid); + if (cmdlinePath) { try { - return parseProcCmdline(fsSync.readFileSync(`/proc/${pid}/cmdline`, "utf8")); + return parseProcCmdline(fsSync.readFileSync(cmdlinePath, "utf8")); } catch { return null; } diff --git a/src/infra/platform.test.ts b/src/infra/platform.test.ts new file mode 100644 index 0000000000..be930646cb --- /dev/null +++ b/src/infra/platform.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import { + currentPlatform, + fdLinkPaths, + isArmHost, + isHeadless, + isMacOS, + isLinux, + isWindows, + isSupportedPlatform, + platformLabel, + procCmdlinePath, + resolvePlatformEntry, + supportsNoFollow, + type PlatformRegistry, + type SupportedPlatform, +} from "./platform.js"; + +describe("isSupportedPlatform", () => { + it("accepts darwin, linux, and win32", () => { + expect(isSupportedPlatform("darwin")).toBe(true); + expect(isSupportedPlatform("linux")).toBe(true); + expect(isSupportedPlatform("win32")).toBe(true); + }); + + it("rejects other platforms", () => { + expect(isSupportedPlatform("freebsd")).toBe(false); + expect(isSupportedPlatform("sunos")).toBe(false); + expect(isSupportedPlatform("aix")).toBe(false); + expect(isSupportedPlatform("android")).toBe(false); + }); +}); + +describe("currentPlatform", () => { + it("returns current process platform when supported", () => { + const platform = currentPlatform(); + expect(["darwin", "linux", "win32"]).toContain(platform); + expect(platform).toBe(process.platform); + }); +}); + +describe("boolean helpers", () => { + it("exactly one of isMacOS, isLinux, isWindows is true", () => { + const trueCount = [isMacOS, isLinux, isWindows].filter(Boolean).length; + expect(trueCount).toBe(1); + }); + + it("matches process.platform", () => { + if (process.platform === "darwin") { + expect(isMacOS).toBe(true); + } + if (process.platform === "linux") { + expect(isLinux).toBe(true); + } + if (process.platform === "win32") { + expect(isWindows).toBe(true); + } + }); +}); + +describe("procCmdlinePath", () => { + it("returns /proc path on linux", () => { + if (process.platform === "linux") { + expect(procCmdlinePath(123)).toBe("/proc/123/cmdline"); + } + }); + + it("returns null on non-linux platforms", () => { + if (process.platform !== "linux") { + expect(procCmdlinePath(123)).toBeNull(); + } + }); +}); + +describe("fdLinkPaths", () => { + it("returns at least one path on unix platforms", () => { + if (process.platform !== "win32") { + const paths = fdLinkPaths(5); + expect(paths.length).toBeGreaterThan(0); + expect(paths.every((p) => p.includes("5"))).toBe(true); + } + }); + + it("returns linux-specific paths on linux", () => { + if (process.platform === "linux") { + const paths = fdLinkPaths(7); + expect(paths).toEqual(["/proc/self/fd/7", "/dev/fd/7"]); + } + }); + + it("returns darwin-specific path on darwin", () => { + if (process.platform === "darwin") { + expect(fdLinkPaths(7)).toEqual(["/dev/fd/7"]); + } + }); + + it("returns empty on windows", () => { + if (process.platform === "win32") { + expect(fdLinkPaths(7)).toEqual([]); + } + }); +}); + +describe("supportsNoFollow", () => { + it("is false only on windows", () => { + expect(supportsNoFollow).toBe(process.platform !== "win32"); + }); +}); + +describe("platformLabel", () => { + it("returns human-friendly names", () => { + expect(platformLabel("darwin")).toBe("macOS"); + expect(platformLabel("linux")).toBe("Linux"); + expect(platformLabel("win32")).toBe("Windows"); + }); +}); + +describe("PlatformRegistry / resolvePlatformEntry", () => { + it("resolves the current platform entry", () => { + const registry: PlatformRegistry = { + darwin: "mac-value", + linux: "linux-value", + win32: "win-value", + }; + const result = resolvePlatformEntry(registry); + const expected = registry[process.platform as SupportedPlatform]; + expect(result).toBe(expected); + }); +}); + +describe("isHeadless", () => { + it("returns false for darwin regardless of env", () => { + expect(isHeadless({}, "darwin")).toBe(false); + }); + + it("returns false for win32 regardless of env", () => { + expect(isHeadless({}, "win32")).toBe(false); + }); + + it("returns false on linux with DISPLAY set", () => { + expect(isHeadless({ DISPLAY: ":0" }, "linux")).toBe(false); + }); + + it("returns false on linux with WAYLAND_DISPLAY set", () => { + expect(isHeadless({ WAYLAND_DISPLAY: "wayland-0" }, "linux")).toBe(false); + }); + + it("returns true on linux SSH without display", () => { + expect(isHeadless({ SSH_CLIENT: "1.2.3.4 12345 22" }, "linux")).toBe(true); + }); + + it("returns true on linux with no display or SSH", () => { + expect(isHeadless({}, "linux")).toBe(true); + }); +}); + +describe("isArmHost", () => { + it("detects arm architectures", () => { + expect(isArmHost("arm")).toBe(true); + expect(isArmHost("arm64")).toBe(true); + }); + + it("rejects non-arm architectures", () => { + expect(isArmHost("x64")).toBe(false); + expect(isArmHost("ia32")).toBe(false); + }); +}); diff --git a/src/infra/platform.ts b/src/infra/platform.ts new file mode 100644 index 0000000000..74be22e822 --- /dev/null +++ b/src/infra/platform.ts @@ -0,0 +1,130 @@ +import os from "node:os"; + +/** + * The three desktop platforms OpenClaw targets at runtime. + * Narrower than NodeJS.Platform so callers can exhaustively switch + * without handling hypothetical platforms. + */ +export type SupportedPlatform = "darwin" | "linux" | "win32"; + +/** + * Type guard that narrows a NodeJS.Platform to SupportedPlatform. + */ +export function isSupportedPlatform(platform: NodeJS.Platform): platform is SupportedPlatform { + return platform === "darwin" || platform === "linux" || platform === "win32"; +} + +/** + * Returns the current process platform as SupportedPlatform, or throws + * if running on an unsupported platform. + */ +export function currentPlatform(): SupportedPlatform { + if (isSupportedPlatform(process.platform)) { + return process.platform; + } + throw new Error(`Unsupported platform: ${process.platform}`); +} + +/** + * Convenience boolean helpers. These read `process.platform` once + * and are cheaper than string comparison at hot call sites. + * + * NOTE: These are module-level constants evaluated at import time. + * Do not use in code paths where tests mock `process.platform` at + * runtime — use `process.platform === "..."` checks directly there. + */ +export const isMacOS = process.platform === "darwin"; +export const isLinux = process.platform === "linux"; +export const isWindows = process.platform === "win32"; + +/** + * Platform-specific path to read a process's command line given its PID. + * Returns null when the platform does not expose procfs-style command line files. + */ +export function procCmdlinePath(pid: number): string | null { + if (process.platform === "linux") { + return `/proc/${pid}/cmdline`; + } + return null; +} + +/** + * Platform-specific file descriptor link paths used to resolve + * the real path of an already-opened file handle. + * Returns candidates in priority order; empty on Windows. + */ +export function fdLinkPaths(fd: number): string[] { + if (process.platform === "linux") { + return [`/proc/self/fd/${fd}`, `/dev/fd/${fd}`]; + } + if (process.platform === "darwin") { + return [`/dev/fd/${fd}`]; + } + return []; +} + +/** + * Whether the platform supports O_NOFOLLOW on file open. + * Windows does not expose this flag. + */ +export const supportsNoFollow = process.platform !== "win32"; + +/** + * Human-friendly platform noun for UI/log output. + */ +export function platformLabel(platform: SupportedPlatform = currentPlatform()): string { + switch (platform) { + case "darwin": + return "macOS"; + case "linux": + return "Linux"; + case "win32": + return "Windows"; + } +} + +/** + * Shared type for a registry keyed by supported platform. + * Mirrors the pattern established in daemon/service.ts. + */ +export type PlatformRegistry = Record; + +/** + * Look up the current platform in a PlatformRegistry. + * Throws if the runtime platform is unsupported. + */ +export function resolvePlatformEntry(registry: PlatformRegistry): T { + return registry[currentPlatform()]; +} + +/** + * Returns true when the process appears to be running in a headless + * environment (SSH session with no display forwarding, or a container + * with no DISPLAY / WAYLAND_DISPLAY). + * On Windows and macOS the desktop is assumed always present. + */ +export function isHeadless( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): boolean { + if (platform === "win32" || platform === "darwin") { + return false; + } + const hasDisplay = Boolean(env.DISPLAY || env.WAYLAND_DISPLAY); + if (hasDisplay) { + return false; + } + const isSsh = Boolean(env.SSH_CLIENT) || Boolean(env.SSH_TTY) || Boolean(env.SSH_CONNECTION); + if (isSsh) { + return true; + } + // No display and not SSH — could be a container or headless server. + return true; +} + +/** + * True when running on an ARM host (arm or arm64). + */ +export function isArmHost(arch: string = os.arch()): boolean { + return arch === "arm" || arch === "arm64"; +}