feat: support configurable gateway port

This commit is contained in:
Peter Steinberger
2026-01-03 12:00:17 +01:00
parent 7199813969
commit f47c7ac369
23 changed files with 172 additions and 46 deletions

View File

@@ -2,6 +2,9 @@
## Unreleased ## Unreleased
### Features
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
### Fixes ### Fixes
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm. - Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.

View File

@@ -106,4 +106,20 @@ enum ClawdisConfigFile {
return remote["password"] as? String return remote["password"] as? String
} }
static func gatewayPort() -> Int? {
let root = self.loadDict()
guard let gateway = root["gateway"] as? [String: Any] else { return nil }
if let port = gateway["port"] as? Int, port > 0 { return port }
if let number = gateway["port"] as? NSNumber, number.intValue > 0 {
return number.intValue
}
if let raw = gateway["port"] as? String,
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)),
parsed > 0
{
return parsed
}
return nil
}
} }

View File

@@ -185,7 +185,7 @@ final class ControlChannel {
"Reason: \(reason)" "Reason: \(reason)"
} }
// Common misfire: we connected to localhost:18789 but the port is occupied // Common misfire: we connected to the configured localhost port but it is occupied
// by some other process (e.g. a local dev gateway or a stuck SSH forward). // by some other process (e.g. a local dev gateway or a stuck SSH forward).
// The gateway handshake returns something we can't parse, which currently // The gateway handshake returns something we can't parse, which currently
// surfaces as "hello failed (unexpected response)". Give the user a pointer // surfaces as "hello failed (unexpected response)". Give the user a pointer

View File

@@ -306,7 +306,7 @@ struct DebugSettings: View {
} }
if self.portReports.isEmpty, !self.portCheckInFlight { if self.portReports.isEmpty, !self.portCheckInFlight {
Text("Check which process owns 18789 and suggest fixes.") Text("Check which process owns \(GatewayEnvironment.gatewayPort()) and suggest fixes.")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
@@ -946,7 +946,7 @@ extension DebugSettings {
view.portCheckInFlight = true view.portCheckInFlight = true
view.portReports = [ view.portReports = [
DebugActions.PortReport( DebugActions.PortReport(
port: 18789, port: GatewayEnvironment.gatewayPort(),
expected: "Gateway websocket (node/tsx)", expected: "Gateway websocket (node/tsx)",
status: .missing("Missing"), status: .missing("Missing"),
listeners: []), listeners: []),

View File

@@ -72,6 +72,13 @@ enum GatewayEnvironment {
} }
static func gatewayPort() -> Int { static func gatewayPort() -> Int {
if let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PORT"] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if let parsed = Int(trimmed), parsed > 0 { return parsed }
}
if let configPort = ClawdisConfigFile.gatewayPort(), configPort > 0 {
return configPort
}
let stored = UserDefaults.standard.integer(forKey: "gatewayPort") let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
return stored > 0 ? stored : 18789 return stored > 0 ? stored : 18789
} }

View File

@@ -188,7 +188,8 @@ final class HealthStore {
if let error = self.lastError, !error.isEmpty { if let error = self.lastError, !error.isEmpty {
let lower = error.lowercased() let lower = error.lowercased()
if lower.contains("connection refused") { if lower.contains("connection refused") {
return "The gateway control port (127.0.0.1:18789) isnt listening — restart Clawdis to bring it back." let port = GatewayEnvironment.gatewayPort()
return "The gateway control port (127.0.0.1:\(port)) isnt listening — restart Clawdis to bring it back."
} }
if lower.contains("timeout") { if lower.contains("timeout") {
return "Timed out waiting for the control server; the gateway may be crashed or still starting." return "Timed out waiting for the control server; the gateway may be crashed or still starting."

View File

@@ -24,7 +24,7 @@ extension OnboardingView {
discoveryModel: discovery) discoveryModel: discovery)
view.needsBootstrap = true view.needsBootstrap = true
view.localGatewayProbe = LocalGatewayProbe( view.localGatewayProbe = LocalGatewayProbe(
port: 18789, port: GatewayEnvironment.gatewayPort(),
pid: 123, pid: 123,
command: "clawdis-gateway", command: "clawdis-gateway",
expected: true) expected: true)

View File

@@ -42,7 +42,7 @@ actor PortGuardian {
self.logger.info("port sweep skipped (mode=unconfigured)") self.logger.info("port sweep skipped (mode=unconfigured)")
return return
} }
let ports = [18789] let ports = [GatewayEnvironment.gatewayPort()]
for port in ports { for port in ports {
let listeners = await self.listeners(on: port) let listeners = await self.listeners(on: port)
guard !listeners.isEmpty else { continue } guard !listeners.isEmpty else { continue }
@@ -148,7 +148,7 @@ actor PortGuardian {
if mode == .unconfigured { if mode == .unconfigured {
return [] return []
} }
let ports = [18789] let ports = [GatewayEnvironment.gatewayPort()]
var reports: [PortReport] = [] var reports: [PortReport] = []
for port in ports { for port in ports {
@@ -279,7 +279,8 @@ actor PortGuardian {
return .init(port: port, expected: expectedDesc, status: .missing(text), listeners: []) return .init(port: port, expected: expectedDesc, status: .missing(text), listeners: [])
} }
let tunnelUnhealthy = mode == .remote && port == 18789 && tunnelHealthy == false let tunnelUnhealthy =
mode == .remote && port == GatewayEnvironment.gatewayPort() && tunnelHealthy == false
let reportListeners = listeners.map { listener in let reportListeners = listeners.map { listener in
var expected = okPredicate(listener) var expected = okPredicate(listener)
if tunnelUnhealthy, expected { expected = false } if tunnelUnhealthy, expected { expected = false }
@@ -347,7 +348,7 @@ actor PortGuardian {
switch mode { switch mode {
case .remote: case .remote:
// Remote mode expects an SSH tunnel for the gateway WebSocket port. // Remote mode expects an SSH tunnel for the gateway WebSocket port.
if port == 18789 { return cmd.contains("ssh") } if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
return false return false
case .local: case .local:
return expectedCommands.contains { cmd.contains($0) } return expectedCommands.contains { cmd.contains($0) }
@@ -361,7 +362,7 @@ actor PortGuardian {
mode: AppState.ConnectionMode, mode: AppState.ConnectionMode,
listeners: [Listener]) async -> Bool? listeners: [Listener]) async -> Bool?
{ {
guard mode == .remote, port == 18789, !listeners.isEmpty else { return nil } guard mode == .remote, port == GatewayEnvironment.gatewayPort(), !listeners.isEmpty else { return nil }
let hasSsh = listeners.contains { $0.command.lowercased().contains("ssh") } let hasSsh = listeners.contains { $0.command.lowercased().contains("ssh") }
guard hasSsh else { return nil } guard hasSsh else { return nil }
return await self.probeGatewayHealth(port: port) return await self.probeGatewayHealth(port: port)

View File

@@ -38,7 +38,7 @@ actor RemoteTunnelManager {
} }
/// Ensure an SSH tunnel is running for the gateway control port. /// Ensure an SSH tunnel is running for the gateway control port.
/// Returns the local forwarded port (usually 18789). /// Returns the local forwarded port (usually the configured gateway port).
func ensureControlTunnel() async throws -> UInt16 { func ensureControlTunnel() async throws -> UInt16 {
let settings = CommandResolver.connectionSettings() let settings = CommandResolver.connectionSettings()
guard settings.mode == .remote else { guard settings.mode == .remote else {

View File

@@ -1,7 +1,11 @@
import fs from "node:fs"; import fs from "node:fs";
import type { Command } from "commander"; import type { Command } from "commander";
import { CONFIG_PATH_CLAWDIS, loadConfig } from "../config/config.js"; import {
CONFIG_PATH_CLAWDIS,
loadConfig,
resolveGatewayPort,
} from "../config/config.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { startGatewayServer } from "../gateway/server.js"; import { startGatewayServer } from "../gateway/server.js";
import { import {
@@ -27,6 +31,13 @@ const gatewayLog = createSubsystemLogger("gateway");
type GatewayRunSignalAction = "stop" | "restart"; type GatewayRunSignalAction = "stop" | "restart";
function parsePort(raw: unknown): number | null {
if (raw === undefined || raw === null) return null;
const parsed = Number.parseInt(String(raw), 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
}
async function runGatewayLoop(params: { async function runGatewayLoop(params: {
start: () => Promise<Awaited<ReturnType<typeof startGatewayServer>>>; start: () => Promise<Awaited<ReturnType<typeof startGatewayServer>>>;
runtime: typeof defaultRuntime; runtime: typeof defaultRuntime;
@@ -186,8 +197,15 @@ export function registerGatewayCli(program: Command) {
} }
setGatewayWsLogStyle(wsLogStyle); setGatewayWsLogStyle(wsLogStyle);
const port = Number.parseInt(String(opts.port ?? "18789"), 10); const cfg = loadConfig();
if (Number.isNaN(port) || port <= 0) { const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
return;
}
const port = portOverride ?? resolveGatewayPort(cfg);
if (!Number.isFinite(port) || port <= 0) {
defaultRuntime.error("Invalid port"); defaultRuntime.error("Invalid port");
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
@@ -219,7 +237,6 @@ export function registerGatewayCli(program: Command) {
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
const cfg = loadConfig();
const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback"); const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback");
const bind = const bind =
bindRaw === "loopback" || bindRaw === "loopback" ||
@@ -335,8 +352,14 @@ export function registerGatewayCli(program: Command) {
} }
setGatewayWsLogStyle(wsLogStyle); setGatewayWsLogStyle(wsLogStyle);
const port = Number.parseInt(String(opts.port ?? "18789"), 10); const cfg = loadConfig();
if (Number.isNaN(port) || port <= 0) { const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
defaultRuntime.error("Invalid port");
defaultRuntime.exit(1);
}
const port = portOverride ?? resolveGatewayPort(cfg);
if (!Number.isFinite(port) || port <= 0) {
defaultRuntime.error("Invalid port"); defaultRuntime.error("Invalid port");
defaultRuntime.exit(1); defaultRuntime.exit(1);
} }
@@ -400,7 +423,6 @@ export function registerGatewayCli(program: Command) {
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
const cfg = loadConfig();
const configExists = fs.existsSync(CONFIG_PATH_CLAWDIS); const configExists = fs.existsSync(CONFIG_PATH_CLAWDIS);
const mode = cfg.gateway?.mode; const mode = cfg.gateway?.mode;
if (!opts.allowUnconfigured && mode !== "local") { if (!opts.allowUnconfigured && mode !== "local") {

View File

@@ -166,7 +166,7 @@ export function buildProgram() {
.option("--mode <mode>", "Wizard mode: local|remote") .option("--mode <mode>", "Wizard mode: local|remote")
.option("--auth-choice <choice>", "Auth: oauth|apiKey|minimax|skip") .option("--auth-choice <choice>", "Auth: oauth|apiKey|minimax|skip")
.option("--anthropic-api-key <key>", "Anthropic API key") .option("--anthropic-api-key <key>", "Anthropic API key")
.option("--gateway-port <port>", "Gateway port", "18789") .option("--gateway-port <port>", "Gateway port")
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|tailnet|auto") .option("--gateway-bind <mode>", "Gateway bind: loopback|lan|tailnet|auto")
.option("--gateway-auth <mode>", "Gateway auth: off|token|password") .option("--gateway-auth <mode>", "Gateway auth: off|token|password")
.option("--gateway-token <token>", "Gateway token (token auth)") .option("--gateway-token <token>", "Gateway token (token auth)")
@@ -194,10 +194,10 @@ export function buildProgram() {
| "skip" | "skip"
| undefined, | undefined,
anthropicApiKey: opts.anthropicApiKey as string | undefined, anthropicApiKey: opts.anthropicApiKey as string | undefined,
gatewayPort: Number.parseInt( gatewayPort:
String(opts.gatewayPort ?? "18789"), typeof opts.gatewayPort === "string"
10, ? Number.parseInt(opts.gatewayPort, 10)
), : undefined,
gatewayBind: opts.gatewayBind as gatewayBind: opts.gatewayBind as
| "loopback" | "loopback"
| "lan" | "lan"

View File

@@ -15,6 +15,7 @@ import type { ClawdisConfig } from "../config/config.js";
import { import {
CONFIG_PATH_CLAWDIS, CONFIG_PATH_CLAWDIS,
readConfigFileSnapshot, readConfigFileSnapshot,
resolveGatewayPort,
writeConfigFile, writeConfigFile,
} from "../config/config.js"; } from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
@@ -74,7 +75,7 @@ async function promptGatewayConfig(
const portRaw = guardCancel( const portRaw = guardCancel(
await text({ await text({
message: "Gateway port", message: "Gateway port",
initialValue: "18789", initialValue: String(resolveGatewayPort(cfg)),
validate: (value) => validate: (value) =>
Number.isFinite(Number(value)) ? undefined : "Invalid port", Number.isFinite(Number(value)) ? undefined : "Invalid port",
}), }),
@@ -205,6 +206,7 @@ async function promptGatewayConfig(
gateway: { gateway: {
...next.gateway, ...next.gateway,
mode: "local", mode: "local",
port,
bind, bind,
tailscale: { tailscale: {
...next.gateway?.tailscale, ...next.gateway?.tailscale,
@@ -527,7 +529,7 @@ export async function runConfigureWizard(
nextConfig.agent?.workspace ?? nextConfig.agent?.workspace ??
baseConfig.agent?.workspace ?? baseConfig.agent?.workspace ??
DEFAULT_WORKSPACE; DEFAULT_WORKSPACE;
let gatewayPort = 18789; let gatewayPort = resolveGatewayPort(baseConfig);
let gatewayToken: string | undefined; let gatewayToken: string | undefined;
if (selected.includes("workspace")) { if (selected.includes("workspace")) {

View File

@@ -34,6 +34,9 @@ export function summarizeExistingConfig(config: ClawdisConfig): string {
rows.push(`workspace: ${config.agent.workspace}`); rows.push(`workspace: ${config.agent.workspace}`);
if (config.agent?.model) rows.push(`model: ${config.agent.model}`); if (config.agent?.model) rows.push(`model: ${config.agent.model}`);
if (config.gateway?.mode) rows.push(`gateway.mode: ${config.gateway.mode}`); if (config.gateway?.mode) rows.push(`gateway.mode: ${config.gateway.mode}`);
if (typeof config.gateway?.port === "number") {
rows.push(`gateway.port: ${config.gateway.port}`);
}
if (config.gateway?.bind) rows.push(`gateway.bind: ${config.gateway.bind}`); if (config.gateway?.bind) rows.push(`gateway.bind: ${config.gateway.bind}`);
if (config.gateway?.remote?.url) { if (config.gateway?.remote?.url) {
rows.push(`gateway.remote.url: ${config.gateway.remote.url}`); rows.push(`gateway.remote.url: ${config.gateway.remote.url}`);

View File

@@ -14,6 +14,7 @@ import type { ClawdisConfig } from "../config/config.js";
import { import {
CONFIG_PATH_CLAWDIS, CONFIG_PATH_CLAWDIS,
readConfigFileSnapshot, readConfigFileSnapshot,
resolveGatewayPort,
writeConfigFile, writeConfigFile,
} from "../config/config.js"; } from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
@@ -118,7 +119,8 @@ export async function runInteractiveOnboarding(
} }
} }
const localUrl = "ws://127.0.0.1:18789"; const localPort = resolveGatewayPort(baseConfig);
const localUrl = `ws://127.0.0.1:${localPort}`;
const localProbe = await probeGatewayReachable({ const localProbe = await probeGatewayReachable({
url: localUrl, url: localUrl,
token: process.env.CLAWDIS_GATEWAY_TOKEN, token: process.env.CLAWDIS_GATEWAY_TOKEN,
@@ -315,7 +317,7 @@ export async function runInteractiveOnboarding(
const portRaw = guardCancel( const portRaw = guardCancel(
await text({ await text({
message: "Gateway port", message: "Gateway port",
initialValue: "18789", initialValue: String(localPort),
validate: (value) => validate: (value) =>
Number.isFinite(Number(value)) ? undefined : "Invalid port", Number.isFinite(Number(value)) ? undefined : "Invalid port",
}), }),
@@ -457,6 +459,7 @@ export async function runInteractiveOnboarding(
...nextConfig, ...nextConfig,
gateway: { gateway: {
...nextConfig.gateway, ...nextConfig.gateway,
port,
bind, bind,
tailscale: { tailscale: {
...nextConfig.gateway?.tailscale, ...nextConfig.gateway?.tailscale,

View File

@@ -4,6 +4,7 @@ import {
type ClawdisConfig, type ClawdisConfig,
CONFIG_PATH_CLAWDIS, CONFIG_PATH_CLAWDIS,
readConfigFileSnapshot, readConfigFileSnapshot,
resolveGatewayPort,
writeConfigFile, writeConfigFile,
} from "../config/config.js"; } from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
@@ -106,12 +107,18 @@ export async function runNonInteractiveOnboarding(
return; return;
} }
const port = opts.gatewayPort ?? 18789; const hasGatewayPort = opts.gatewayPort !== undefined;
if (!Number.isFinite(port) || port <= 0) { if (
hasGatewayPort &&
(!Number.isFinite(opts.gatewayPort) || (opts.gatewayPort ?? 0) <= 0)
) {
runtime.error("Invalid --gateway-port"); runtime.error("Invalid --gateway-port");
runtime.exit(1); runtime.exit(1);
return; return;
} }
const port = hasGatewayPort
? (opts.gatewayPort as number)
: resolveGatewayPort(baseConfig);
let bind = opts.gatewayBind ?? "loopback"; let bind = opts.gatewayBind ?? "loopback";
let authMode = opts.gatewayAuth ?? "off"; let authMode = opts.gatewayAuth ?? "off";
const tailscaleMode = opts.tailscale ?? "off"; const tailscaleMode = opts.tailscale ?? "off";
@@ -162,6 +169,7 @@ export async function runNonInteractiveOnboarding(
...nextConfig, ...nextConfig,
gateway: { gateway: {
...nextConfig.gateway, ...nextConfig.gateway,
port,
bind, bind,
tailscale: { tailscale: {
...nextConfig.gateway?.tailscale, ...nextConfig.gateway?.tailscale,

View File

@@ -390,6 +390,30 @@ describe("Nix integration (U3, U5, U9)", () => {
}); });
}); });
describe("U6: gateway port resolution", () => {
it("uses default when env and config are unset", async () => {
await withEnvOverride({ CLAWDIS_GATEWAY_PORT: undefined }, async () => {
const { DEFAULT_GATEWAY_PORT, resolveGatewayPort } =
await import("./config.js");
expect(resolveGatewayPort({})).toBe(DEFAULT_GATEWAY_PORT);
});
});
it("prefers CLAWDIS_GATEWAY_PORT over config", async () => {
await withEnvOverride({ CLAWDIS_GATEWAY_PORT: "19001" }, async () => {
const { resolveGatewayPort } = await import("./config.js");
expect(resolveGatewayPort({ gateway: { port: 19002 } })).toBe(19001);
});
});
it("falls back to config when env is invalid", async () => {
await withEnvOverride({ CLAWDIS_GATEWAY_PORT: "nope" }, async () => {
const { resolveGatewayPort } = await import("./config.js");
expect(resolveGatewayPort({ gateway: { port: 19003 } })).toBe(19003);
});
});
});
describe("U9: telegram.tokenFile schema validation", () => { describe("U9: telegram.tokenFile schema validation", () => {
it("accepts config with only botToken", async () => { it("accepts config with only botToken", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {

View File

@@ -438,6 +438,8 @@ export type GatewayRemoteConfig = {
}; };
export type GatewayConfig = { export type GatewayConfig = {
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
port?: number;
/** /**
* Explicit gateway mode. When set to "remote", local gateway start is disabled. * Explicit gateway mode. When set to "remote", local gateway start is disabled.
* When set to "local", the CLI may start the gateway locally. * When set to "local", the CLI may start the gateway locally.
@@ -642,6 +644,24 @@ export const CONFIG_PATH_CLAWDIS =
process.env.CLAWDIS_CONFIG_PATH ?? process.env.CLAWDIS_CONFIG_PATH ??
path.join(STATE_DIR_CLAWDIS, "clawdis.json"); path.join(STATE_DIR_CLAWDIS, "clawdis.json");
export const DEFAULT_GATEWAY_PORT = 18789;
export function resolveGatewayPort(
cfg?: ClawdisConfig,
env: NodeJS.ProcessEnv = process.env,
): number {
const envRaw = env.CLAWDIS_GATEWAY_PORT?.trim();
if (envRaw) {
const parsed = Number.parseInt(envRaw, 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
const configPort = cfg?.gateway?.port;
if (typeof configPort === "number" && Number.isFinite(configPort)) {
if (configPort > 0) return configPort;
}
return DEFAULT_GATEWAY_PORT;
}
const ModelApiSchema = z.union([ const ModelApiSchema = z.union([
z.literal("openai-completions"), z.literal("openai-completions"),
z.literal("openai-responses"), z.literal("openai-responses"),
@@ -1217,6 +1237,7 @@ const ClawdisSchema = z.object({
.optional(), .optional(),
gateway: z gateway: z
.object({ .object({
port: z.number().int().positive().optional(),
mode: z.union([z.literal("local"), z.literal("remote")]).optional(), mode: z.union([z.literal("local"), z.literal("remote")]).optional(),
bind: z bind: z
.union([ .union([

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { loadConfig } from "../config/config.js"; import { loadConfig, resolveGatewayPort } from "../config/config.js";
import { GatewayClient } from "./client.js"; import { GatewayClient } from "./client.js";
import { PROTOCOL_VERSION } from "./protocol/index.js"; import { PROTOCOL_VERSION } from "./protocol/index.js";
@@ -28,6 +28,7 @@ export async function callGateway<T = unknown>(
const isRemoteMode = config.gateway?.mode === "remote"; const isRemoteMode = config.gateway?.mode === "remote";
const remote = isRemoteMode ? config.gateway?.remote : undefined; const remote = isRemoteMode ? config.gateway?.remote : undefined;
const authToken = config.gateway?.auth?.token; const authToken = config.gateway?.auth?.token;
const localPort = resolveGatewayPort(config);
const url = const url =
(typeof opts.url === "string" && opts.url.trim().length > 0 (typeof opts.url === "string" && opts.url.trim().length > 0
? opts.url.trim() ? opts.url.trim()
@@ -35,7 +36,7 @@ export async function callGateway<T = unknown>(
(typeof remote?.url === "string" && remote.url.trim().length > 0 (typeof remote?.url === "string" && remote.url.trim().length > 0
? remote.url.trim() ? remote.url.trim()
: undefined) || : undefined) ||
"ws://127.0.0.1:18789"; `ws://127.0.0.1:${localPort}`;
const token = const token =
(typeof opts.token === "string" && opts.token.trim().length > 0 (typeof opts.token === "string" && opts.token.trim().length > 0
? opts.token.trim() ? opts.token.trim()

View File

@@ -5,6 +5,7 @@ import {
CONFIG_PATH_CLAWDIS, CONFIG_PATH_CLAWDIS,
loadConfig, loadConfig,
readConfigFileSnapshot, readConfigFileSnapshot,
resolveGatewayPort,
validateConfigObject, validateConfigObject,
writeConfigFile, writeConfigFile,
} from "../config/config.js"; } from "../config/config.js";
@@ -128,7 +129,7 @@ export async function runGmailSetup(opts: GmailSetupOptions) {
const hookUrl = const hookUrl =
opts.hookUrl ?? opts.hookUrl ??
baseConfig.hooks?.gmail?.hookUrl ?? baseConfig.hooks?.gmail?.hookUrl ??
buildDefaultHookUrl(hooksPath); buildDefaultHookUrl(hooksPath, resolveGatewayPort(baseConfig));
const serveBind = opts.bind ?? DEFAULT_GMAIL_SERVE_BIND; const serveBind = opts.bind ?? DEFAULT_GMAIL_SERVE_BIND;
const servePort = opts.port ?? DEFAULT_GMAIL_SERVE_PORT; const servePort = opts.port ?? DEFAULT_GMAIL_SERVE_PORT;

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ClawdisConfig } from "../config/config.js"; import { type ClawdisConfig, DEFAULT_GATEWAY_PORT } from "../config/config.js";
import { import {
buildDefaultHookUrl, buildDefaultHookUrl,
buildTopicPath, buildTopicPath,
@@ -20,8 +20,8 @@ const baseConfig = {
describe("gmail hook config", () => { describe("gmail hook config", () => {
it("builds default hook url", () => { it("builds default hook url", () => {
expect(buildDefaultHookUrl("/hooks")).toBe( expect(buildDefaultHookUrl("/hooks", DEFAULT_GATEWAY_PORT)).toBe(
"http://127.0.0.1:18789/hooks/gmail", `http://127.0.0.1:${DEFAULT_GATEWAY_PORT}/hooks/gmail`,
); );
}); });
@@ -41,7 +41,9 @@ describe("gmail hook config", () => {
expect(result.value.label).toBe("INBOX"); expect(result.value.label).toBe("INBOX");
expect(result.value.includeBody).toBe(true); expect(result.value.includeBody).toBe(true);
expect(result.value.serve.port).toBe(8788); expect(result.value.serve.port).toBe(8788);
expect(result.value.hookUrl).toBe("http://127.0.0.1:18789/hooks/gmail"); expect(result.value.hookUrl).toBe(
`http://127.0.0.1:${DEFAULT_GATEWAY_PORT}/hooks/gmail`,
);
} }
}); });

View File

@@ -1,8 +1,10 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { import {
ClawdisConfig, type ClawdisConfig,
DEFAULT_GATEWAY_PORT,
HooksGmailTailscaleMode, HooksGmailTailscaleMode,
resolveGatewayPort,
} from "../config/config.js"; } from "../config/config.js";
export const DEFAULT_GMAIL_LABEL = "INBOX"; export const DEFAULT_GMAIL_LABEL = "INBOX";
@@ -14,7 +16,6 @@ export const DEFAULT_GMAIL_SERVE_PATH = "/gmail-pubsub";
export const DEFAULT_GMAIL_MAX_BYTES = 20_000; export const DEFAULT_GMAIL_MAX_BYTES = 20_000;
export const DEFAULT_GMAIL_RENEW_MINUTES = 12 * 60; export const DEFAULT_GMAIL_RENEW_MINUTES = 12 * 60;
export const DEFAULT_HOOKS_PATH = "/hooks"; export const DEFAULT_HOOKS_PATH = "/hooks";
export const DEFAULT_HOOKS_BASE_URL = "http://127.0.0.1:18789";
export type GmailHookOverrides = { export type GmailHookOverrides = {
account?: string; account?: string;
@@ -87,9 +88,13 @@ export function normalizeServePath(raw?: string): string {
return withSlash.replace(/\/+$/, ""); return withSlash.replace(/\/+$/, "");
} }
export function buildDefaultHookUrl(hooksPath?: string): string { export function buildDefaultHookUrl(
hooksPath?: string,
port: number = DEFAULT_GATEWAY_PORT,
): string {
const basePath = normalizeHooksPath(hooksPath); const basePath = normalizeHooksPath(hooksPath);
return joinUrl(DEFAULT_HOOKS_BASE_URL, `${basePath}/gmail`); const baseUrl = `http://127.0.0.1:${port}`;
return joinUrl(baseUrl, `${basePath}/gmail`);
} }
export function resolveGmailHookRuntimeConfig( export function resolveGmailHookRuntimeConfig(
@@ -122,7 +127,9 @@ export function resolveGmailHookRuntimeConfig(
} }
const hookUrl = const hookUrl =
overrides.hookUrl ?? gmail?.hookUrl ?? buildDefaultHookUrl(hooks?.path); overrides.hookUrl ??
gmail?.hookUrl ??
buildDefaultHookUrl(hooks?.path, resolveGatewayPort(cfg));
const includeBody = overrides.includeBody ?? gmail?.includeBody ?? true; const includeBody = overrides.includeBody ?? gmail?.includeBody ?? true;

View File

@@ -61,15 +61,18 @@ async function main() {
wsLogRaw === "compact" ? "compact" : wsLogRaw === "full" ? "full" : "auto"; wsLogRaw === "compact" ? "compact" : wsLogRaw === "full" ? "full" : "auto";
setGatewayWsLogStyle(wsLogStyle); setGatewayWsLogStyle(wsLogStyle);
const cfg = loadConfig();
const portRaw = const portRaw =
argValue(args, "--port") ?? process.env.CLAWDIS_GATEWAY_PORT ?? "18789"; argValue(args, "--port") ??
process.env.CLAWDIS_GATEWAY_PORT ??
(typeof cfg.gateway?.port === "number" ? String(cfg.gateway.port) : "") ??
"18789";
const port = Number.parseInt(portRaw, 10); const port = Number.parseInt(portRaw, 10);
if (Number.isNaN(port) || port <= 0) { if (Number.isNaN(port) || port <= 0) {
defaultRuntime.error(`Invalid --port (${portRaw})`); defaultRuntime.error(`Invalid --port (${portRaw})`);
process.exit(1); process.exit(1);
} }
const cfg = loadConfig();
const bindRaw = const bindRaw =
argValue(args, "--bind") ?? argValue(args, "--bind") ??
process.env.CLAWDIS_GATEWAY_BIND ?? process.env.CLAWDIS_GATEWAY_BIND ??

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { loadConfig } from "../config/config.js"; import { loadConfig, resolveGatewayPort } from "../config/config.js";
import { GatewayClient } from "../gateway/client.js"; import { GatewayClient } from "../gateway/client.js";
import { import {
type HelloOk, type HelloOk,
@@ -183,6 +183,7 @@ export function resolveGatewayConnection(opts: GatewayConnectionOptions) {
const remote = isRemoteMode ? config.gateway?.remote : undefined; const remote = isRemoteMode ? config.gateway?.remote : undefined;
const authToken = config.gateway?.auth?.token; const authToken = config.gateway?.auth?.token;
const localPort = resolveGatewayPort(config);
const url = const url =
(typeof opts.url === "string" && opts.url.trim().length > 0 (typeof opts.url === "string" && opts.url.trim().length > 0
? opts.url.trim() ? opts.url.trim()
@@ -190,7 +191,7 @@ export function resolveGatewayConnection(opts: GatewayConnectionOptions) {
(typeof remote?.url === "string" && remote.url.trim().length > 0 (typeof remote?.url === "string" && remote.url.trim().length > 0
? remote.url.trim() ? remote.url.trim()
: undefined) || : undefined) ||
"ws://127.0.0.1:18789"; `ws://127.0.0.1:${localPort}`;
const token = const token =
(typeof opts.token === "string" && opts.token.trim().length > 0 (typeof opts.token === "string" && opts.token.trim().length > 0