refactor(daemon): share runtime and service probe helpers
This commit is contained in:
87
src/daemon/inspect.test.ts
Normal file
87
src/daemon/inspect.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { findExtraGatewayServices } from "./inspect.js";
|
||||
|
||||
const { execSchtasksMock } = vi.hoisted(() => ({
|
||||
execSchtasksMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./schtasks-exec.js", () => ({
|
||||
execSchtasks: (...args: unknown[]) => execSchtasksMock(...args),
|
||||
}));
|
||||
|
||||
describe("findExtraGatewayServices (win32)", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: "win32",
|
||||
});
|
||||
execSchtasksMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: originalPlatform,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips schtasks queries unless deep mode is enabled", async () => {
|
||||
const result = await findExtraGatewayServices({});
|
||||
expect(result).toEqual([]);
|
||||
expect(execSchtasksMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns empty results when schtasks query fails", async () => {
|
||||
execSchtasksMock.mockResolvedValueOnce({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "error",
|
||||
});
|
||||
|
||||
const result = await findExtraGatewayServices({}, { deep: true });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("collects only non-openclaw marker tasks from schtasks output", async () => {
|
||||
execSchtasksMock.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
stdout: [
|
||||
"TaskName: OpenClaw Gateway",
|
||||
"Task To Run: C:\\Program Files\\OpenClaw\\openclaw.exe gateway run",
|
||||
"",
|
||||
"TaskName: Clawdbot Legacy",
|
||||
"Task To Run: C:\\clawdbot\\clawdbot.exe run",
|
||||
"",
|
||||
"TaskName: Other Task",
|
||||
"Task To Run: C:\\tools\\helper.exe",
|
||||
"",
|
||||
"TaskName: MoltBot Legacy",
|
||||
"Task To Run: C:\\moltbot\\moltbot.exe run",
|
||||
"",
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const result = await findExtraGatewayServices({}, { deep: true });
|
||||
expect(result).toEqual([
|
||||
{
|
||||
platform: "win32",
|
||||
label: "Clawdbot Legacy",
|
||||
detail: "task: Clawdbot Legacy, run: C:\\clawdbot\\clawdbot.exe run",
|
||||
scope: "system",
|
||||
marker: "clawdbot",
|
||||
legacy: true,
|
||||
},
|
||||
{
|
||||
platform: "win32",
|
||||
label: "MoltBot Legacy",
|
||||
detail: "task: MoltBot Legacy, run: C:\\moltbot\\moltbot.exe run",
|
||||
scope: "system",
|
||||
marker: "moltbot",
|
||||
legacy: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -152,19 +152,26 @@ async function readUtf8File(filePath: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function scanLaunchdDir(params: {
|
||||
dir: string;
|
||||
scope: "user" | "system";
|
||||
}): Promise<ExtraGatewayService[]> {
|
||||
const results: ExtraGatewayService[] = [];
|
||||
const entries = await readDirEntries(params.dir);
|
||||
type ServiceFileEntry = {
|
||||
entry: string;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
contents: string;
|
||||
};
|
||||
|
||||
async function collectServiceFiles(params: {
|
||||
dir: string;
|
||||
extension: string;
|
||||
isIgnoredName: (name: string) => boolean;
|
||||
}): Promise<ServiceFileEntry[]> {
|
||||
const out: ServiceFileEntry[] = [];
|
||||
const entries = await readDirEntries(params.dir);
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith(".plist")) {
|
||||
if (!entry.endsWith(params.extension)) {
|
||||
continue;
|
||||
}
|
||||
const labelFromName = entry.replace(/\.plist$/, "");
|
||||
if (isIgnoredLaunchdLabel(labelFromName)) {
|
||||
const name = entry.slice(0, -params.extension.length);
|
||||
if (params.isIgnoredName(name)) {
|
||||
continue;
|
||||
}
|
||||
const fullPath = path.join(params.dir, entry);
|
||||
@@ -172,6 +179,23 @@ async function scanLaunchdDir(params: {
|
||||
if (contents === null) {
|
||||
continue;
|
||||
}
|
||||
out.push({ entry, name, fullPath, contents });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function scanLaunchdDir(params: {
|
||||
dir: string;
|
||||
scope: "user" | "system";
|
||||
}): Promise<ExtraGatewayService[]> {
|
||||
const results: ExtraGatewayService[] = [];
|
||||
const candidates = await collectServiceFiles({
|
||||
dir: params.dir,
|
||||
extension: ".plist",
|
||||
isIgnoredName: isIgnoredLaunchdLabel,
|
||||
});
|
||||
|
||||
for (const { name: labelFromName, fullPath, contents } of candidates) {
|
||||
const marker = detectMarker(contents);
|
||||
const label = tryExtractPlistLabel(contents) ?? labelFromName;
|
||||
if (!marker) {
|
||||
@@ -213,21 +237,13 @@ async function scanSystemdDir(params: {
|
||||
scope: "user" | "system";
|
||||
}): Promise<ExtraGatewayService[]> {
|
||||
const results: ExtraGatewayService[] = [];
|
||||
const entries = await readDirEntries(params.dir);
|
||||
const candidates = await collectServiceFiles({
|
||||
dir: params.dir,
|
||||
extension: ".service",
|
||||
isIgnoredName: isIgnoredSystemdName,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith(".service")) {
|
||||
continue;
|
||||
}
|
||||
const name = entry.replace(/\.service$/, "");
|
||||
if (isIgnoredSystemdName(name)) {
|
||||
continue;
|
||||
}
|
||||
const fullPath = path.join(params.dir, entry);
|
||||
const contents = await readUtf8File(fullPath);
|
||||
if (contents === null) {
|
||||
continue;
|
||||
}
|
||||
for (const { entry, name, fullPath, contents } of candidates) {
|
||||
const marker = detectMarker(contents);
|
||||
if (!marker) {
|
||||
continue;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isBunRuntime, isNodeRuntime } from "./runtime-binary.js";
|
||||
|
||||
type GatewayProgramArgs = {
|
||||
programArguments: string[];
|
||||
@@ -8,16 +9,6 @@ type GatewayProgramArgs = {
|
||||
|
||||
type GatewayRuntimePreference = "auto" | "node" | "bun";
|
||||
|
||||
function isNodeRuntime(execPath: string): boolean {
|
||||
const base = path.basename(execPath).toLowerCase();
|
||||
return base === "node" || base === "node.exe";
|
||||
}
|
||||
|
||||
function isBunRuntime(execPath: string): boolean {
|
||||
const base = path.basename(execPath).toLowerCase();
|
||||
return base === "bun" || base === "bun.exe";
|
||||
}
|
||||
|
||||
async function resolveCliEntrypointPathForService(): Promise<string> {
|
||||
const argv1 = process.argv[1];
|
||||
if (!argv1) {
|
||||
|
||||
11
src/daemon/runtime-binary.ts
Normal file
11
src/daemon/runtime-binary.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import path from "node:path";
|
||||
|
||||
export function isNodeRuntime(execPath: string): boolean {
|
||||
const base = path.basename(execPath).toLowerCase();
|
||||
return base === "node" || base === "node.exe";
|
||||
}
|
||||
|
||||
export function isBunRuntime(execPath: string): boolean {
|
||||
const base = path.basename(execPath).toLowerCase();
|
||||
return base === "bun" || base === "bun.exe";
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveLaunchAgentPlistPath } from "./launchd.js";
|
||||
import { isBunRuntime, isNodeRuntime } from "./runtime-binary.js";
|
||||
import {
|
||||
isSystemNodePath,
|
||||
isVersionManagedNodePath,
|
||||
@@ -224,16 +225,6 @@ function auditGatewayToken(
|
||||
});
|
||||
}
|
||||
|
||||
function isNodeRuntime(execPath: string): boolean {
|
||||
const base = path.basename(execPath).toLowerCase();
|
||||
return base === "node" || base === "node.exe";
|
||||
}
|
||||
|
||||
function isBunRuntime(execPath: string): boolean {
|
||||
const base = path.basename(execPath).toLowerCase();
|
||||
return base === "bun" || base === "bun.exe";
|
||||
}
|
||||
|
||||
function getPathModule(platform: NodeJS.Platform) {
|
||||
return platform === "win32" ? path.win32 : path.posix;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
resolvePairingPaths,
|
||||
writeJsonAtomic,
|
||||
} from "./pairing-files.js";
|
||||
import { rejectPendingPairingRequest } from "./pairing-pending.js";
|
||||
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
|
||||
|
||||
export type DevicePairingPendingRequest = {
|
||||
@@ -382,14 +383,13 @@ export async function rejectDevicePairing(
|
||||
baseDir?: string,
|
||||
): Promise<{ requestId: string; deviceId: string } | null> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = state.pendingById[requestId];
|
||||
if (!pending) {
|
||||
return null;
|
||||
}
|
||||
delete state.pendingById[requestId];
|
||||
await persistState(state, baseDir);
|
||||
return { requestId, deviceId: pending.deviceId };
|
||||
return await rejectPendingPairingRequest({
|
||||
requestId,
|
||||
idKey: "deviceId",
|
||||
loadState: () => loadState(baseDir),
|
||||
persistState: (state) => persistState(state, baseDir),
|
||||
getId: (pending) => pending.deviceId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
upsertPendingPairingRequest,
|
||||
writeJsonAtomic,
|
||||
} from "./pairing-files.js";
|
||||
import { rejectPendingPairingRequest } from "./pairing-pending.js";
|
||||
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
|
||||
|
||||
export type NodePairingPendingRequest = {
|
||||
@@ -194,14 +195,13 @@ export async function rejectNodePairing(
|
||||
baseDir?: string,
|
||||
): Promise<{ requestId: string; nodeId: string } | null> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(baseDir);
|
||||
const pending = state.pendingById[requestId];
|
||||
if (!pending) {
|
||||
return null;
|
||||
}
|
||||
delete state.pendingById[requestId];
|
||||
await persistState(state, baseDir);
|
||||
return { requestId, nodeId: pending.nodeId };
|
||||
return await rejectPendingPairingRequest({
|
||||
requestId,
|
||||
idKey: "nodeId",
|
||||
loadState: () => loadState(baseDir),
|
||||
persistState: (state) => persistState(state, baseDir),
|
||||
getId: (pending) => pending.nodeId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
27
src/infra/pairing-pending.ts
Normal file
27
src/infra/pairing-pending.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
type PendingState<TPending> = {
|
||||
pendingById: Record<string, TPending>;
|
||||
};
|
||||
|
||||
export async function rejectPendingPairingRequest<
|
||||
TPending,
|
||||
TState extends PendingState<TPending>,
|
||||
TIdKey extends string,
|
||||
>(params: {
|
||||
requestId: string;
|
||||
idKey: TIdKey;
|
||||
loadState: () => Promise<TState>;
|
||||
persistState: (state: TState) => Promise<void>;
|
||||
getId: (pending: TPending) => string;
|
||||
}): Promise<({ requestId: string } & Record<TIdKey, string>) | null> {
|
||||
const state = await params.loadState();
|
||||
const pending = state.pendingById[params.requestId];
|
||||
if (!pending) {
|
||||
return null;
|
||||
}
|
||||
delete state.pendingById[params.requestId];
|
||||
await params.persistState(state);
|
||||
return {
|
||||
requestId: params.requestId,
|
||||
[params.idKey]: params.getId(pending),
|
||||
} as { requestId: string } & Record<TIdKey, string>;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import net from "node:net";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { isErrno } from "./errors.js";
|
||||
import { buildPortHints } from "./ports-format.js";
|
||||
import { resolveLsofCommand } from "./ports-lsof.js";
|
||||
import { tryListenOnPort } from "./ports-probe.js";
|
||||
import type { PortListener, PortUsage, PortUsageStatus } from "./ports-types.js";
|
||||
|
||||
type CommandResult = {
|
||||
@@ -227,15 +227,7 @@ async function readWindowsListeners(
|
||||
|
||||
async function tryListenOnHost(port: number, host: string): Promise<PortUsageStatus | "skip"> {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tester = net
|
||||
.createServer()
|
||||
.once("error", (err) => reject(err))
|
||||
.once("listening", () => {
|
||||
tester.close(() => resolve());
|
||||
})
|
||||
.listen({ port, host, exclusive: true });
|
||||
});
|
||||
await tryListenOnPort({ port, host, exclusive: true });
|
||||
return "free";
|
||||
} catch (err) {
|
||||
if (isErrno(err) && err.code === "EADDRINUSE") {
|
||||
|
||||
24
src/infra/ports-probe.ts
Normal file
24
src/infra/ports-probe.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import net from "node:net";
|
||||
|
||||
export async function tryListenOnPort(params: {
|
||||
port: number;
|
||||
host?: string;
|
||||
exclusive?: boolean;
|
||||
}): Promise<void> {
|
||||
const listenOptions: net.ListenOptions = { port: params.port };
|
||||
if (params.host) {
|
||||
listenOptions.host = params.host;
|
||||
}
|
||||
if (typeof params.exclusive === "boolean") {
|
||||
listenOptions.exclusive = params.exclusive;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tester = net
|
||||
.createServer()
|
||||
.once("error", (err) => reject(err))
|
||||
.once("listening", () => {
|
||||
tester.close(() => resolve());
|
||||
})
|
||||
.listen(listenOptions);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import net from "node:net";
|
||||
import { danger, info, shouldLogVerbose, warn } from "../globals.js";
|
||||
import { logDebug } from "../logger.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -6,6 +5,7 @@ import { defaultRuntime } from "../runtime.js";
|
||||
import { isErrno } from "./errors.js";
|
||||
import { formatPortDiagnostics } from "./ports-format.js";
|
||||
import { inspectPortUsage } from "./ports-inspect.js";
|
||||
import { tryListenOnPort } from "./ports-probe.js";
|
||||
import type { PortListener, PortListenerKind, PortUsage, PortUsageStatus } from "./ports-types.js";
|
||||
|
||||
class PortInUseError extends Error {
|
||||
@@ -31,15 +31,7 @@ export async function describePortOwner(port: number): Promise<string | undefine
|
||||
export async function ensurePortAvailable(port: number): Promise<void> {
|
||||
// Detect EADDRINUSE early with a friendly message.
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tester = net
|
||||
.createServer()
|
||||
.once("error", (err) => reject(err))
|
||||
.once("listening", () => {
|
||||
tester.close(() => resolve());
|
||||
})
|
||||
.listen(port);
|
||||
});
|
||||
await tryListenOnPort({ port });
|
||||
} catch (err) {
|
||||
if (isErrno(err) && err.code === "EADDRINUSE") {
|
||||
throw new PortInUseError(port);
|
||||
|
||||
@@ -376,9 +376,14 @@ type AllowFromStoreEntryUpdateParams = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
type ChannelAllowFromStoreEntryMutation = (
|
||||
current: string[],
|
||||
normalized: string,
|
||||
) => string[] | null;
|
||||
|
||||
async function updateChannelAllowFromStore(
|
||||
params: {
|
||||
apply: (current: string[], normalized: string) => string[] | null;
|
||||
apply: ChannelAllowFromStoreEntryMutation;
|
||||
} & AllowFromStoreEntryUpdateParams,
|
||||
): Promise<{ changed: boolean; allowFrom: string[] }> {
|
||||
return await updateAllowFromStoreEntry({
|
||||
@@ -390,38 +395,36 @@ async function updateChannelAllowFromStore(
|
||||
});
|
||||
}
|
||||
|
||||
export async function addChannelAllowFromStoreEntry(params: {
|
||||
channel: PairingChannel;
|
||||
entry: string | number;
|
||||
accountId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ changed: boolean; allowFrom: string[] }> {
|
||||
async function mutateChannelAllowFromStoreEntry(
|
||||
params: AllowFromStoreEntryUpdateParams,
|
||||
apply: ChannelAllowFromStoreEntryMutation,
|
||||
): Promise<{ changed: boolean; allowFrom: string[] }> {
|
||||
return await updateChannelAllowFromStore({
|
||||
...params,
|
||||
apply: (current, normalized) => {
|
||||
if (current.includes(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return [...current, normalized];
|
||||
},
|
||||
apply,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeChannelAllowFromStoreEntry(params: {
|
||||
channel: PairingChannel;
|
||||
entry: string | number;
|
||||
accountId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ changed: boolean; allowFrom: string[] }> {
|
||||
return await updateChannelAllowFromStore({
|
||||
...params,
|
||||
apply: (current, normalized) => {
|
||||
const next = current.filter((entry) => entry !== normalized);
|
||||
if (next.length === current.length) {
|
||||
return null;
|
||||
}
|
||||
return next;
|
||||
},
|
||||
export async function addChannelAllowFromStoreEntry(
|
||||
params: AllowFromStoreEntryUpdateParams,
|
||||
): Promise<{ changed: boolean; allowFrom: string[] }> {
|
||||
return await mutateChannelAllowFromStoreEntry(params, (current, normalized) => {
|
||||
if (current.includes(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return [...current, normalized];
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeChannelAllowFromStoreEntry(
|
||||
params: AllowFromStoreEntryUpdateParams,
|
||||
): Promise<{ changed: boolean; allowFrom: string[] }> {
|
||||
return await mutateChannelAllowFromStoreEntry(params, (current, normalized) => {
|
||||
const next = current.filter((entry) => entry !== normalized);
|
||||
if (next.length === current.length) {
|
||||
return null;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user