From c6de1b1f7d1f7ad46cf9d3e6fbd6a130f8f5ec94 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Jan 2026 01:25:37 +0100 Subject: [PATCH] feat: add --dev/--profile CLI profiles --- package.json | 12 +-- src/agents/workspace.ts | 13 +++- src/browser/config.test.ts | 18 +++++ src/browser/config.ts | 27 ++++++- src/browser/profiles-service.ts | 6 +- src/browser/profiles.test.ts | 11 +++ src/browser/profiles.ts | 21 +++++- src/cli/gateway-cli.coverage.test.ts | 44 +++++++++++ src/cli/gateway-cli.ts | 4 +- src/cli/profile.test.ts | 99 +++++++++++++++++++++++++ src/cli/profile.ts | 107 +++++++++++++++++++++++++++ src/cli/program.ts | 17 ++++- src/cli/run-main.ts | 48 ++++++++++++ src/commands/onboard-providers.ts | 2 +- src/config/port-defaults.ts | 49 ++++++++++++ src/entry.ts | 20 +++++ src/gateway/server.ts | 20 ++++- src/utils.ts | 13 +++- src/web/session.ts | 10 ++- 19 files changed, 516 insertions(+), 25 deletions(-) create mode 100644 src/cli/profile.test.ts create mode 100644 src/cli/profile.ts create mode 100644 src/cli/run-main.ts create mode 100644 src/config/port-defaults.ts create mode 100644 src/entry.ts diff --git a/package.json b/package.json index cdbaae71d..00fecdd21 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "dist/index.js", "bin": { - "clawdbot": "dist/index.js" + "clawdbot": "dist/entry.js" }, "files": [ "dist/agents/**", @@ -36,7 +36,7 @@ "LICENSE" ], "scripts": { - "dev": "tsx src/index.ts", + "dev": "tsx src/entry.ts", "docs:list": "tsx scripts/docs-list.ts", "docs:dev": "cd docs && mint dev", "docs:build": "cd docs && pnpm dlx mint broken-links", @@ -45,10 +45,10 @@ "ui:install": "pnpm -C ui install", "ui:dev": "pnpm -C ui dev", "ui:build": "pnpm -C ui build", - "start": "tsx src/index.ts", - "clawdbot": "tsx src/index.ts", - "gateway:watch": "tsx watch --clear-screen=false --include 'src/**/*.ts' src/index.ts gateway --force", - "clawdbot:rpc": "tsx src/index.ts agent --mode rpc --json", + "start": "tsx src/entry.ts", + "clawdbot": "tsx src/entry.ts", + "gateway:watch": "tsx watch --clear-screen=false --include 'src/**/*.ts' src/entry.ts gateway --force", + "clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json", "lint": "biome check src test && oxlint --type-aware src test --ignore-pattern src/canvas-host/a2ui/a2ui.bundle.js", "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", "lint:fix": "biome check --write --unsafe src && biome format --write src", diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 93be63d7d..ca9ecfe72 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -5,7 +5,18 @@ import { fileURLToPath } from "node:url"; import { resolveUserPath } from "../utils.js"; -export const DEFAULT_AGENT_WORKSPACE_DIR = path.join(os.homedir(), "clawd"); +export function resolveDefaultAgentWorkspaceDir( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir, +): string { + const profile = env.CLAWDBOT_PROFILE?.trim(); + if (profile && profile.toLowerCase() !== "default") { + return path.join(homedir(), `clawd-${profile}`); + } + return path.join(homedir(), "clawd"); +} + +export const DEFAULT_AGENT_WORKSPACE_DIR = resolveDefaultAgentWorkspaceDir(); export const DEFAULT_AGENTS_FILENAME = "AGENTS.md"; export const DEFAULT_SOUL_FILENAME = "SOUL.md"; export const DEFAULT_TOOLS_FILENAME = "TOOLS.md"; diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index a6663dff3..d5a96f130 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -19,6 +19,24 @@ describe("browser config", () => { expect(profile?.cdpIsLoopback).toBe(true); }); + it("derives default ports from CLAWDBOT_GATEWAY_PORT when unset", () => { + const prev = process.env.CLAWDBOT_GATEWAY_PORT; + process.env.CLAWDBOT_GATEWAY_PORT = "19001"; + try { + const resolved = resolveBrowserConfig(undefined); + expect(resolved.controlPort).toBe(19003); + const profile = resolveProfile(resolved, resolved.defaultProfile); + expect(profile?.cdpPort).toBe(19012); + expect(profile?.cdpUrl).toBe("http://127.0.0.1:19012"); + } finally { + if (prev === undefined) { + delete process.env.CLAWDBOT_GATEWAY_PORT; + } else { + process.env.CLAWDBOT_GATEWAY_PORT = prev; + } + } + }); + it("normalizes hex colors", () => { const resolved = resolveBrowserConfig({ controlUrl: "http://localhost:18791", diff --git a/src/browser/config.ts b/src/browser/config.ts index 26a427f5d..40c8165a7 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -1,4 +1,8 @@ import type { BrowserConfig, BrowserProfileConfig } from "../config/config.js"; +import { + deriveDefaultBrowserCdpPortRange, + deriveDefaultBrowserControlPort, +} from "../config/port-defaults.js"; import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_CONTROL_URL, @@ -89,11 +93,12 @@ function ensureDefaultProfile( profiles: Record | undefined, defaultColor: string, legacyCdpPort?: number, + derivedDefaultCdpPort?: number, ): Record { const result = { ...profiles }; if (!result[DEFAULT_CLAWD_BROWSER_PROFILE_NAME]) { result[DEFAULT_CLAWD_BROWSER_PROFILE_NAME] = { - cdpPort: legacyCdpPort ?? CDP_PORT_RANGE_START, + cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START, color: defaultColor, }; } @@ -103,13 +108,30 @@ export function resolveBrowserConfig( cfg: BrowserConfig | undefined, ): ResolvedBrowserConfig { const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED; + const envControlUrl = process.env.CLAWDBOT_BROWSER_CONTROL_URL?.trim(); + const derivedControlPort = (() => { + const raw = process.env.CLAWDBOT_GATEWAY_PORT?.trim(); + if (!raw) return null; + const gatewayPort = Number.parseInt(raw, 10); + if (!Number.isFinite(gatewayPort) || gatewayPort <= 0) return null; + return deriveDefaultBrowserControlPort(gatewayPort); + })(); + const derivedControlUrl = derivedControlPort + ? `http://127.0.0.1:${derivedControlPort}` + : null; + const controlInfo = parseHttpUrl( - cfg?.controlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL, + cfg?.controlUrl ?? + envControlUrl ?? + derivedControlUrl ?? + DEFAULT_CLAWD_BROWSER_CONTROL_URL, "browser.controlUrl", ); const controlPort = controlInfo.port; const defaultColor = normalizeHexColor(cfg?.color); + const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort); + const rawCdpUrl = (cfg?.cdpUrl ?? "").trim(); let cdpInfo: | { @@ -149,6 +171,7 @@ export function resolveBrowserConfig( cfg?.profiles, defaultColor, legacyCdpPort, + derivedCdpRange.start, ); const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http"; diff --git a/src/browser/profiles-service.ts b/src/browser/profiles-service.ts index b0dd6da6b..55d718789 100644 --- a/src/browser/profiles-service.ts +++ b/src/browser/profiles-service.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { BrowserProfileConfig, ClawdbotConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; +import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; import { resolveClawdUserDataDir } from "./chrome.js"; import { parseHttpUrl, resolveProfile } from "./config.js"; import { @@ -79,7 +80,10 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { profileConfig = { cdpUrl: parsed.normalized, color: profileColor }; } else { const usedPorts = getUsedPorts(resolvedProfiles); - const cdpPort = allocateCdpPort(usedPorts); + const range = deriveDefaultBrowserCdpPortRange( + state.resolved.controlPort, + ); + const cdpPort = allocateCdpPort(usedPorts, range); if (cdpPort === null) { throw new Error("no available CDP ports in range"); } diff --git a/src/browser/profiles.test.ts b/src/browser/profiles.test.ts index bc6a5e5ef..328cc50d1 100644 --- a/src/browser/profiles.test.ts +++ b/src/browser/profiles.test.ts @@ -64,6 +64,17 @@ describe("port allocation", () => { expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START); }); + it("allocates within an explicit range", () => { + const usedPorts = new Set(); + expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe( + 20000, + ); + usedPorts.add(20000); + expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe( + 20001, + ); + }); + it("skips used ports and returns next available", () => { const usedPorts = new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 1]); expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 2); diff --git a/src/browser/profiles.ts b/src/browser/profiles.ts index 120d62889..d30166917 100644 --- a/src/browser/profiles.ts +++ b/src/browser/profiles.ts @@ -1,8 +1,9 @@ /** * CDP port allocation for browser profiles. * - * Port range: 18800-18899 (100 profiles max) + * Default port range: 18800-18899 (100 profiles max) * Ports are allocated once at profile creation and persisted in config. + * Multi-instance: callers may pass an explicit range to avoid collisions. * * Reserved ports (do not use for CDP): * 18789 - Gateway WebSocket @@ -21,8 +22,22 @@ export function isValidProfileName(name: string): boolean { return PROFILE_NAME_REGEX.test(name); } -export function allocateCdpPort(usedPorts: Set): number | null { - for (let port = CDP_PORT_RANGE_START; port <= CDP_PORT_RANGE_END; port++) { +export function allocateCdpPort( + usedPorts: Set, + range?: { start: number; end: number }, +): number | null { + const start = range?.start ?? CDP_PORT_RANGE_START; + const end = range?.end ?? CDP_PORT_RANGE_END; + if ( + !Number.isFinite(start) || + !Number.isFinite(end) || + start <= 0 || + end <= 0 + ) { + return null; + } + if (start > end) return null; + for (let port = start; port <= end; port++) { if (!usedPorts.has(port)) return port; } return null; diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index 7cdd83ed6..4d46e70dd 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -24,6 +24,28 @@ const defaultRuntime = { }, }; +async function withEnvOverride( + overrides: Record, + fn: () => Promise, +): Promise { + const saved: Record = {}; + for (const key of Object.keys(overrides)) { + saved[key] = process.env[key]; + if (overrides[key] === undefined) delete process.env[key]; + else process.env[key] = overrides[key]; + } + vi.resetModules(); + try { + return await fn(); + } finally { + for (const key of Object.keys(saved)) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + vi.resetModules(); + } +} + vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGateway(opts), randomIdempotencyKey: () => randomIdempotencyKey(), @@ -205,4 +227,26 @@ describe("gateway-cli coverage", () => { process.removeListener("SIGINT", listener); } }); + + it("uses env/config port when --port is omitted", async () => { + await withEnvOverride({ CLAWDBOT_GATEWAY_PORT: "19001" }, async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + startGatewayServer.mockClear(); + + const { registerGatewayCli } = await import("./gateway-cli.js"); + const program = new Command(); + program.exitOverride(); + registerGatewayCli(program); + + startGatewayServer.mockRejectedValueOnce(new Error("nope")); + await expect( + program.parseAsync(["gateway", "--allow-unconfigured"], { + from: "user", + }), + ).rejects.toThrow("__exit__:1"); + + expect(startGatewayServer).toHaveBeenCalledWith(19001, expect.anything()); + }); + }); }); diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 8c1af3911..85dff78fd 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -153,7 +153,7 @@ export function registerGatewayCli(program: Command) { program .command("gateway-daemon") .description("Run the WebSocket Gateway as a long-lived daemon") - .option("--port ", "Port for the gateway WebSocket", "18789") + .option("--port ", "Port for the gateway WebSocket") .option( "--bind ", 'Bind mode ("loopback"|"tailnet"|"lan"|"auto"). Defaults to config gateway.bind (or loopback).', @@ -298,7 +298,7 @@ export function registerGatewayCli(program: Command) { const gateway = program .command("gateway") .description("Run the WebSocket Gateway") - .option("--port ", "Port for the gateway WebSocket", "18789") + .option("--port ", "Port for the gateway WebSocket") .option( "--bind ", 'Bind mode ("loopback"|"tailnet"|"lan"|"auto"). Defaults to config gateway.bind (or loopback).', diff --git a/src/cli/profile.test.ts b/src/cli/profile.test.ts new file mode 100644 index 000000000..09ca2dde8 --- /dev/null +++ b/src/cli/profile.test.ts @@ -0,0 +1,99 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js"; + +describe("parseCliProfileArgs", () => { + it("strips --dev anywhere in argv", () => { + const res = parseCliProfileArgs([ + "node", + "clawdbot", + "gateway", + "--dev", + "--allow-unconfigured", + ]); + if (!res.ok) throw new Error(res.error); + expect(res.profile).toBe("dev"); + expect(res.argv).toEqual([ + "node", + "clawdbot", + "gateway", + "--allow-unconfigured", + ]); + }); + + it("parses --profile value and strips it", () => { + const res = parseCliProfileArgs([ + "node", + "clawdbot", + "--profile", + "work", + "status", + ]); + if (!res.ok) throw new Error(res.error); + expect(res.profile).toBe("work"); + expect(res.argv).toEqual(["node", "clawdbot", "status"]); + }); + + it("rejects missing profile value", () => { + const res = parseCliProfileArgs(["node", "clawdbot", "--profile"]); + expect(res.ok).toBe(false); + }); + + it("rejects combining --dev with --profile (dev first)", () => { + const res = parseCliProfileArgs([ + "node", + "clawdbot", + "--dev", + "--profile", + "work", + "status", + ]); + expect(res.ok).toBe(false); + }); + + it("rejects combining --dev with --profile (profile first)", () => { + const res = parseCliProfileArgs([ + "node", + "clawdbot", + "--profile", + "work", + "--dev", + "status", + ]); + expect(res.ok).toBe(false); + }); +}); + +describe("applyCliProfileEnv", () => { + it("fills env defaults for dev profile", () => { + const env: Record = {}; + applyCliProfileEnv({ + profile: "dev", + env, + homedir: () => "/home/peter", + }); + expect(env.CLAWDBOT_PROFILE).toBe("dev"); + expect(env.CLAWDBOT_STATE_DIR).toBe("/home/peter/.clawdbot-dev"); + expect(env.CLAWDBOT_CONFIG_PATH).toBe( + path.join("/home/peter/.clawdbot-dev", "clawdbot.json"), + ); + expect(env.CLAWDBOT_GATEWAY_PORT).toBe("19001"); + }); + + it("does not override explicit env values", () => { + const env: Record = { + CLAWDBOT_STATE_DIR: "/custom", + CLAWDBOT_GATEWAY_PORT: "19099", + }; + applyCliProfileEnv({ + profile: "dev", + env, + homedir: () => "/home/peter", + }); + expect(env.CLAWDBOT_STATE_DIR).toBe("/custom"); + expect(env.CLAWDBOT_GATEWAY_PORT).toBe("19099"); + expect(env.CLAWDBOT_CONFIG_PATH).toBe( + path.join("/custom", "clawdbot.json"), + ); + }); +}); diff --git a/src/cli/profile.ts b/src/cli/profile.ts new file mode 100644 index 000000000..a0725caae --- /dev/null +++ b/src/cli/profile.ts @@ -0,0 +1,107 @@ +import os from "node:os"; +import path from "node:path"; + +export type CliProfileParseResult = + | { ok: true; profile: string | null; argv: string[] } + | { ok: false; error: string }; + +function takeValue( + raw: string, + next: string | undefined, +): { + value: string | null; + consumedNext: boolean; +} { + if (raw.includes("=")) { + const [, value] = raw.split("=", 2); + const trimmed = (value ?? "").trim(); + return { value: trimmed || null, consumedNext: false }; + } + const trimmed = (next ?? "").trim(); + return { value: trimmed || null, consumedNext: Boolean(next) }; +} + +function isValidProfileName(value: string): boolean { + if (!value) return false; + // Keep it path-safe + shell-friendly. + return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(value); +} + +export function parseCliProfileArgs(argv: string[]): CliProfileParseResult { + if (argv.length < 2) return { ok: true, profile: null, argv }; + + const out: string[] = argv.slice(0, 2); + let profile: string | null = null; + let sawDev = false; + + const args = argv.slice(2); + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === undefined) continue; + + if (arg === "--dev") { + if (profile && profile !== "dev") { + return { ok: false, error: "Cannot combine --dev with --profile" }; + } + sawDev = true; + profile = "dev"; + continue; + } + + if (arg === "--profile" || arg.startsWith("--profile=")) { + if (sawDev) { + return { ok: false, error: "Cannot combine --dev with --profile" }; + } + const next = args[i + 1]; + const { value, consumedNext } = takeValue(arg, next); + if (consumedNext) i += 1; + if (!value) return { ok: false, error: "--profile requires a value" }; + if (!isValidProfileName(value)) { + return { + ok: false, + error: 'Invalid --profile (use letters, numbers, "_", "-" only)', + }; + } + profile = value; + continue; + } + + out.push(arg); + } + + return { ok: true, profile, argv: out }; +} + +function resolveProfileStateDir( + profile: string, + homedir: () => string, +): string { + const suffix = profile.toLowerCase() === "default" ? "" : `-${profile}`; + return path.join(homedir(), `.clawdbot${suffix}`); +} + +export function applyCliProfileEnv(params: { + profile: string; + env?: Record; + homedir?: () => string; +}) { + const env = params.env ?? (process.env as Record); + const homedir = params.homedir ?? os.homedir; + const profile = params.profile.trim(); + if (!profile) return; + + // Convenience only: fill defaults, never override explicit env values. + env.CLAWDBOT_PROFILE = profile; + + const stateDir = + env.CLAWDBOT_STATE_DIR?.trim() || resolveProfileStateDir(profile, homedir); + if (!env.CLAWDBOT_STATE_DIR?.trim()) env.CLAWDBOT_STATE_DIR = stateDir; + + if (!env.CLAWDBOT_CONFIG_PATH?.trim()) { + env.CLAWDBOT_CONFIG_PATH = path.join(stateDir, "clawdbot.json"); + } + + if (profile === "dev" && !env.CLAWDBOT_GATEWAY_PORT?.trim()) { + env.CLAWDBOT_GATEWAY_PORT = "19001"; + } +} diff --git a/src/cli/program.ts b/src/cli/program.ts index 12bca7579..c645483a1 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -35,7 +35,18 @@ export function buildProgram() { const TAGLINE = "Send, receive, and auto-reply on WhatsApp (web) and Telegram (bot)."; - program.name("clawdbot").description("").version(PROGRAM_VERSION); + program + .name("clawdbot") + .description("") + .version(PROGRAM_VERSION) + .option( + "--dev", + "Dev profile: isolate config/state under ~/.clawdbot-dev and default gateway port 19001", + ) + .option( + "--profile ", + "Use a named profile (isolates CLAWDBOT_STATE_DIR/CLAWDBOT_CONFIG_PATH under ~/.clawdbot-)", + ); const formatIntroLine = (version: string, rich = true) => { const base = `📡 clawdbot ${version} — ${TAGLINE}`; @@ -96,6 +107,10 @@ export function buildProgram() { "Send via your web session and print JSON result.", ], ["clawdbot gateway --port 18789", "Run the WebSocket Gateway locally."], + [ + "clawdbot --dev gateway", + "Run a dev Gateway (isolated state/config) on ws://127.0.0.1:19001.", + ], [ "clawdbot gateway --force", "Kill anything bound to the default gateway port, then start it.", diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts new file mode 100644 index 000000000..cd2fc8247 --- /dev/null +++ b/src/cli/run-main.ts @@ -0,0 +1,48 @@ +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { loadDotEnv } from "../infra/dotenv.js"; +import { normalizeEnv } from "../infra/env.js"; +import { isMainModule } from "../infra/is-main.js"; +import { ensureClawdbotCliOnPath } from "../infra/path-env.js"; +import { assertSupportedRuntime } from "../infra/runtime-guard.js"; +import { enableConsoleCapture } from "../logging.js"; + +export async function runCli(argv: string[] = process.argv) { + loadDotEnv({ quiet: true }); + normalizeEnv(); + ensureClawdbotCliOnPath(); + + // Capture all console output into structured logs while keeping stdout/stderr behavior. + enableConsoleCapture(); + + // Enforce the minimum supported runtime before doing any work. + assertSupportedRuntime(); + + const { buildProgram } = await import("./program.js"); + const program = buildProgram(); + + // Global error handlers to prevent silent crashes from unhandled rejections/exceptions. + // These log the error and exit gracefully instead of crashing without trace. + process.on("unhandledRejection", (reason, _promise) => { + console.error( + "[clawdbot] Unhandled promise rejection:", + reason instanceof Error ? (reason.stack ?? reason.message) : reason, + ); + process.exit(1); + }); + + process.on("uncaughtException", (error) => { + console.error( + "[clawdbot] Uncaught exception:", + error.stack ?? error.message, + ); + process.exit(1); + }); + + await program.parseAsync(argv); +} + +export function isCliMainModule(): boolean { + return isMainModule({ currentFile: fileURLToPath(import.meta.url) }); +} diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 05040858a..93797b732 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -330,7 +330,7 @@ export async function setupProviders( await prompter.note( [ "Scan the QR with WhatsApp on your phone.", - "Credentials are stored under ~/.clawdbot/credentials/ for future runs.", + `Credentials are stored under ${resolveWebAuthDir()}/ for future runs.`, ].join("\n"), "WhatsApp linking", ); diff --git a/src/config/port-defaults.ts b/src/config/port-defaults.ts new file mode 100644 index 000000000..a6fd3dd8a --- /dev/null +++ b/src/config/port-defaults.ts @@ -0,0 +1,49 @@ +export type PortRange = { start: number; end: number }; + +function isValidPort(port: number): boolean { + return Number.isFinite(port) && port > 0 && port <= 65535; +} + +function clampPort(port: number, fallback: number): number { + return isValidPort(port) ? port : fallback; +} + +function derivePort(base: number, offset: number, fallback: number): number { + return clampPort(base + offset, fallback); +} + +export const DEFAULT_BRIDGE_PORT = 18790; +export const DEFAULT_BROWSER_CONTROL_PORT = 18791; +export const DEFAULT_CANVAS_HOST_PORT = 18793; +export const DEFAULT_BROWSER_CDP_PORT_RANGE_START = 18800; +export const DEFAULT_BROWSER_CDP_PORT_RANGE_END = 18899; + +export function deriveDefaultBridgePort(gatewayPort: number): number { + return derivePort(gatewayPort, 1, DEFAULT_BRIDGE_PORT); +} + +export function deriveDefaultBrowserControlPort(gatewayPort: number): number { + return derivePort(gatewayPort, 2, DEFAULT_BROWSER_CONTROL_PORT); +} + +export function deriveDefaultCanvasHostPort(gatewayPort: number): number { + return derivePort(gatewayPort, 4, DEFAULT_CANVAS_HOST_PORT); +} + +export function deriveDefaultBrowserCdpPortRange( + browserControlPort: number, +): PortRange { + const start = derivePort( + browserControlPort, + 9, + DEFAULT_BROWSER_CDP_PORT_RANGE_START, + ); + const end = clampPort( + start + + (DEFAULT_BROWSER_CDP_PORT_RANGE_END - + DEFAULT_BROWSER_CDP_PORT_RANGE_START), + DEFAULT_BROWSER_CDP_PORT_RANGE_END, + ); + if (end < start) return { start, end: start }; + return { start, end }; +} diff --git a/src/entry.ts b/src/entry.ts new file mode 100644 index 000000000..6aa98c7f5 --- /dev/null +++ b/src/entry.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import process from "node:process"; + +import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; + +const parsed = parseCliProfileArgs(process.argv); +if (!parsed.ok) { + // Keep it simple; Commander will handle rich help/errors after we strip flags. + console.error(`[clawdbot] ${parsed.error}`); + process.exit(2); +} + +if (parsed.profile) { + applyCliProfileEnv({ profile: parsed.profile }); + // Keep Commander and ad-hoc argv checks consistent. + process.argv = parsed.argv; +} + +const { runCli } = await import("./cli/run-main.js"); +await runCli(process.argv); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 080b76469..58afa2259 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -28,6 +28,10 @@ import { STATE_DIR_CLAWDBOT, writeConfigFile, } from "../config/config.js"; +import { + deriveDefaultBridgePort, + deriveDefaultCanvasHostPort, +} from "../config/port-defaults.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js"; import { appendCronRunLog, resolveCronRunLogPath } from "../cron/run-log.js"; @@ -355,6 +359,9 @@ export async function startGatewayServer( port = 18789, opts: GatewayServerOptions = {}, ): Promise { + // Ensure all default port derivations (browser/bridge/canvas) see the actual runtime port. + process.env.CLAWDBOT_GATEWAY_PORT = String(port); + const configSnapshot = await readConfigFileSnapshot(); if (configSnapshot.legacyIssues.length > 0) { if (isNixMode) { @@ -819,9 +826,11 @@ export async function startGatewayServer( } if (process.env.CLAWDBOT_BRIDGE_PORT !== undefined) { const parsed = Number.parseInt(process.env.CLAWDBOT_BRIDGE_PORT, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 18790; + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : deriveDefaultBridgePort(port); } - return 18790; + return deriveDefaultBridgePort(port); })(); const bridgeHost = (() => { @@ -848,9 +857,14 @@ export async function startGatewayServer( })(); const canvasHostPort = (() => { + if (process.env.CLAWDBOT_CANVAS_HOST_PORT !== undefined) { + const parsed = Number.parseInt(process.env.CLAWDBOT_CANVAS_HOST_PORT, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + return deriveDefaultCanvasHostPort(port); + } const configured = cfgAtStart.canvasHost?.port; if (typeof configured === "number" && configured > 0) return configured; - return 18793; + return deriveDefaultCanvasHostPort(port); })(); if (canvasHostEnabled && bridgeEnabled && bridgeHost) { diff --git a/src/utils.ts b/src/utils.ts index ad4001bc0..589b5b4c0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -104,6 +104,15 @@ export function resolveUserPath(input: string): string { return path.resolve(trimmed); } +export function resolveConfigDir( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir, +): string { + const override = env.CLAWDBOT_STATE_DIR?.trim(); + if (override) return resolveUserPath(override); + return path.join(homedir(), ".clawdbot"); +} + export function resolveHomeDir(): string | undefined { const envHome = process.env.HOME?.trim(); if (envHome) return envHome; @@ -133,5 +142,5 @@ export function shortenHomeInString(input: string): string { return input.split(home).join("~"); } -// Fixed configuration root; legacy ~/.clawdbot is no longer used. -export const CONFIG_DIR = path.join(os.homedir(), ".clawdbot"); +// Configuration root; can be overridden via CLAWDBOT_STATE_DIR. +export const CONFIG_DIR = resolveConfigDir(); diff --git a/src/web/session.ts b/src/web/session.ts index 310dd0d62..6b69aa2cf 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -1,7 +1,6 @@ import { randomUUID } from "node:crypto"; import fsSync from "node:fs"; import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { DisconnectReason, @@ -17,11 +16,16 @@ import { danger, info, success } from "../globals.js"; import { getChildLogger, toPinoLikeLogger } from "../logging.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import type { Provider } from "../utils.js"; -import { CONFIG_DIR, ensureDir, jidToE164 } from "../utils.js"; +import { + CONFIG_DIR, + ensureDir, + jidToE164, + resolveConfigDir, +} from "../utils.js"; import { VERSION } from "../version.js"; export function resolveWebAuthDir() { - return path.join(os.homedir(), ".clawdbot", "credentials"); + return path.join(resolveConfigDir(), "credentials"); } function resolveWebCredsPath() {