diff --git a/src/daemon/inspect.test.ts b/src/daemon/inspect.test.ts new file mode 100644 index 000000000..0e1f87938 --- /dev/null +++ b/src/daemon/inspect.test.ts @@ -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, + }, + ]); + }); +}); diff --git a/src/daemon/inspect.ts b/src/daemon/inspect.ts index 5cb6ea1cb..29ac8094c 100644 --- a/src/daemon/inspect.ts +++ b/src/daemon/inspect.ts @@ -152,19 +152,26 @@ async function readUtf8File(filePath: string): Promise { } } -async function scanLaunchdDir(params: { - dir: string; - scope: "user" | "system"; -}): Promise { - 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 { + 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 { + 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 { 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; diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index 102d547c7..c92065b58 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -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 { const argv1 = process.argv[1]; if (!argv1) { diff --git a/src/daemon/runtime-binary.ts b/src/daemon/runtime-binary.ts new file mode 100644 index 000000000..95f7ea107 --- /dev/null +++ b/src/daemon/runtime-binary.ts @@ -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"; +} diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index 77a8486a7..09e766065 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -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; } diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index 8885776ac..53d362397 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -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, + }); }); } diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index d8a55b576..4990a28b4 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -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, + }); }); } diff --git a/src/infra/pairing-pending.ts b/src/infra/pairing-pending.ts new file mode 100644 index 000000000..a6b40b773 --- /dev/null +++ b/src/infra/pairing-pending.ts @@ -0,0 +1,27 @@ +type PendingState = { + pendingById: Record; +}; + +export async function rejectPendingPairingRequest< + TPending, + TState extends PendingState, + TIdKey extends string, +>(params: { + requestId: string; + idKey: TIdKey; + loadState: () => Promise; + persistState: (state: TState) => Promise; + getId: (pending: TPending) => string; +}): Promise<({ requestId: string } & Record) | 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; +} diff --git a/src/infra/ports-inspect.ts b/src/infra/ports-inspect.ts index 4cd86c1b6..d6c172a7b 100644 --- a/src/infra/ports-inspect.ts +++ b/src/infra/ports-inspect.ts @@ -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 { try { - await new Promise((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") { diff --git a/src/infra/ports-probe.ts b/src/infra/ports-probe.ts new file mode 100644 index 000000000..9d971ea94 --- /dev/null +++ b/src/infra/ports-probe.ts @@ -0,0 +1,24 @@ +import net from "node:net"; + +export async function tryListenOnPort(params: { + port: number; + host?: string; + exclusive?: boolean; +}): Promise { + 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((resolve, reject) => { + const tester = net + .createServer() + .once("error", (err) => reject(err)) + .once("listening", () => { + tester.close(() => resolve()); + }) + .listen(listenOptions); + }); +} diff --git a/src/infra/ports.ts b/src/infra/ports.ts index cd8c21eaa..58f0cf7d7 100644 --- a/src/infra/ports.ts +++ b/src/infra/ports.ts @@ -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 { // Detect EADDRINUSE early with a friendly message. try { - await new Promise((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); diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index 758ffa596..eb0b52b30 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -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; }); }