Skip to content

Commit 6a1a9bb

Browse files
steipetevincentkoc
andcommitted
fix(tui): follow active gateway port
Co-authored-by: haishmg <[email protected]> Co-authored-by: Vincent Koc <[email protected]>
1 parent bf04d04 commit 6a1a9bb

8 files changed

Lines changed: 163 additions & 7 deletions

File tree

docs/cli/tui.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ Aliases: `openclaw chat` and `openclaw terminal` invoke this command with
3838
- `--local` cannot combine with `--url`, `--token`, or `--password`.
3939
- `tui` resolves configured Gateway auth SecretRefs for token/password auth
4040
when possible (`env`/`file`/`exec` providers).
41+
- With no explicit URL or port, `tui` follows the active local Gateway port
42+
recorded by the running Gateway. Explicit `--url`, `OPENCLAW_GATEWAY_URL`,
43+
`OPENCLAW_GATEWAY_PORT`, and remote Gateway config keep precedence.
4144
- Launched from inside a configured agent workspace directory, TUI auto-selects
4245
that agent for the session key default (unless `--session` is explicitly
4346
`agent:<id>:...`).

src/cli/gateway-cli/run-loop.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ export async function runGatewayLoop(params: {
107107
healthHost?: string;
108108
waitForHealthyChild?: (port: number, pid?: number, host?: string) => Promise<boolean>;
109109
}) {
110+
// macOS/BSD process inspection reports process.title instead of the original
111+
// argv. Give the long-running Gateway a verifiable identity for lock readers.
112+
if (process.title === "openclaw") {
113+
process.title = "openclaw-gateway";
114+
}
110115
let startupStartedAt = Date.now();
111116
// Eagerly resolve the lifecycle runtime module before installing signal
112117
// listeners. Without this, every subsequent lifecycle path (SIGUSR1,

src/infra/gateway-lock.test.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import { setTimeout as nativeSleep } from "node:timers/promises";
99
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
1010
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
1111
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
12-
import { acquireGatewayLock, GatewayLockError, type GatewayLockOptions } from "./gateway-lock.js";
12+
import {
13+
acquireGatewayLock,
14+
GatewayLockError,
15+
readActiveGatewayLockPort,
16+
type GatewayLockOptions,
17+
} from "./gateway-lock.js";
1318

1419
type GatewayLock = NonNullable<Awaited<ReturnType<typeof acquireGatewayLock>>>;
1520

@@ -94,11 +99,17 @@ function makeProcStat(pid: number, startTime: number) {
9499
return `${pid} (node) ${fields.join(" ")}`;
95100
}
96101

97-
function createLockPayload(params: { configPath: string; startTime: number; createdAt?: string }) {
102+
function createLockPayload(params: {
103+
configPath: string;
104+
startTime: number;
105+
createdAt?: string;
106+
port?: number;
107+
}) {
98108
return {
99109
pid: process.pid,
100110
createdAt: params.createdAt ?? new Date().toISOString(),
101111
configPath: params.configPath,
112+
...(params.port ? { port: params.port } : {}),
102113
startTime: params.startTime,
103114
};
104115
}
@@ -199,6 +210,66 @@ describe("gateway lock", () => {
199210
await expectGatewayLock(lock2).release();
200211
});
201212

213+
it("records and reads the active runtime port from a verified gateway lock", async () => {
214+
const env = await makeEnv();
215+
const lock = expectGatewayLock(
216+
await acquireForTest(env, {
217+
platform: "darwin",
218+
port: 48789,
219+
readProcessCmdline: () => ["openclaw-gateway"],
220+
}),
221+
);
222+
223+
try {
224+
await expect(
225+
readActiveGatewayLockPort({
226+
env,
227+
lockDir: resolveTestLockDir(),
228+
platform: "darwin",
229+
readProcessCmdline: () => ["openclaw-gateway"],
230+
}),
231+
).resolves.toBe(48789);
232+
} finally {
233+
await lock.release();
234+
}
235+
});
236+
237+
it("keeps a retitled gateway lock owned during concurrent acquisition", async () => {
238+
const env = await makeEnv();
239+
const lock = expectGatewayLock(await acquireForTest(env, { platform: "darwin", port: 48789 }));
240+
const connectSpy = createPortProbeConnectionSpy("connect");
241+
242+
try {
243+
await expect(
244+
acquireForTest(env, {
245+
platform: "darwin",
246+
port: 48789,
247+
timeoutMs: 15,
248+
readProcessCmdline: () => ["openclaw-gateway"],
249+
}),
250+
).rejects.toBeInstanceOf(GatewayLockError);
251+
expect(connectSpy).toHaveBeenCalled();
252+
} finally {
253+
await lock.release();
254+
}
255+
});
256+
257+
it("ignores active-port metadata when the lock owner cannot be verified", async () => {
258+
const env = await makeEnv();
259+
const { lockPath, configPath } = resolveLockPath(env);
260+
const payload = createLockPayload({ configPath, startTime: 111, port: 48789 });
261+
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
262+
263+
await expect(
264+
readActiveGatewayLockPort({
265+
env,
266+
lockDir: resolveTestLockDir(),
267+
platform: "darwin",
268+
readProcessCmdline: () => null,
269+
}),
270+
).resolves.toBeUndefined();
271+
});
272+
202273
it("treats recycled linux pid as stale when start time mismatches", async () => {
203274
const env = await makeEnv();
204275
const { lockPath, configPath } = resolveLockPath(env);

src/infra/gateway-lock.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ type LockPayload = {
2626
pid: number;
2727
createdAt: string;
2828
configPath: string;
29+
port?: number;
2930
startTime?: number;
3031
};
3132

3233
const LockPayloadSchema = z.object({
3334
pid: z.number(),
3435
createdAt: z.string(),
3536
configPath: z.string(),
37+
port: z.number().int().min(1).max(65_535).optional(),
3638
startTime: z.number().optional(),
3739
}) as z.ZodType<LockPayload>;
3840

@@ -172,6 +174,7 @@ async function resolveGatewayOwnerStatus(
172174
platform: NodeJS.Platform,
173175
port: number | undefined,
174176
readCmdline?: (pid: number) => string[] | null,
177+
opts: { trustUnknownCmdlineOwner?: boolean } = {},
175178
): Promise<LockOwnerStatus> {
176179
if (port != null) {
177180
const portFree = await checkPortFree(port);
@@ -204,9 +207,11 @@ async function resolveGatewayOwnerStatus(
204207
// start-time), "unknown" lets the stale-lock heuristic eventually reclaim
205208
// very old locks. On win32/darwin/other, conservatively assume "alive" to
206209
// preserve single-instance guarantees when wmic/ps is unavailable.
207-
return platform === "linux" ? "unknown" : "alive";
210+
return platform === "linux" || opts.trustUnknownCmdlineOwner === false ? "unknown" : "alive";
208211
}
209-
return isGatewayArgv(args) ? "alive" : "dead";
212+
// Long-running gateways retitle themselves so macOS/BSD process inspection
213+
// can identify the owner after the original argv is no longer available.
214+
return isGatewayArgv(args, { allowGatewayBinary: true }) ? "alive" : "dead";
210215
}
211216

212217
async function readLockPayload(lockPath: string): Promise<LockPayload | null> {
@@ -226,6 +231,26 @@ function resolveGatewayLockPath(env: NodeJS.ProcessEnv, lockDir = resolveGateway
226231
return { lockPath, configPath };
227232
}
228233

234+
export async function readActiveGatewayLockPort(
235+
opts: Pick<GatewayLockOptions, "env" | "lockDir" | "platform" | "readProcessCmdline"> = {},
236+
): Promise<number | undefined> {
237+
const env = opts.env ?? process.env;
238+
const { lockPath } = resolveGatewayLockPath(env, opts.lockDir);
239+
const payload = await readLockPayload(lockPath);
240+
if (!payload?.port) {
241+
return undefined;
242+
}
243+
const ownerStatus = await resolveGatewayOwnerStatus(
244+
payload.pid,
245+
payload,
246+
opts.platform ?? process.platform,
247+
undefined,
248+
opts.readProcessCmdline,
249+
{ trustUnknownCmdlineOwner: false },
250+
);
251+
return ownerStatus === "alive" ? payload.port : undefined;
252+
}
253+
229254
export async function acquireGatewayLock(
230255
opts: GatewayLockOptions = {},
231256
): Promise<GatewayLockHandle | null> {
@@ -269,6 +294,9 @@ export async function acquireGatewayLock(
269294
createdAt: resolveTimestampMsToIsoString(now()),
270295
configPath,
271296
};
297+
if (typeof port === "number" && Number.isInteger(port) && port > 0 && port <= 65_535) {
298+
payload.port = port;
299+
}
272300
if (typeof startTime === "number" && Number.isFinite(startTime)) {
273301
payload.startTime = startTime;
274302
}

src/infra/gateway-process-argv.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ describe("isGatewayArgv", () => {
3434
it("matches the openclaw executable but gates the gateway binary behind the opt-in flag", () => {
3535
expect(isGatewayArgv(["C:\\bin\\openclaw.cmd", "gateway"])).toBe(true);
3636
expect(isGatewayArgv(["/usr/local/bin/openclaw-gateway", "gateway"])).toBe(false);
37+
expect(isGatewayArgv(["openclaw-gateway"])).toBe(false);
3738
expect(
3839
isGatewayArgv(["/usr/local/bin/openclaw-gateway", "gateway"], {
3940
allowGatewayBinary: true,
@@ -44,6 +45,7 @@ describe("isGatewayArgv", () => {
4445
allowGatewayBinary: true,
4546
}),
4647
).toBe(true);
48+
expect(isGatewayArgv(["openclaw-gateway"], { allowGatewayBinary: true })).toBe(true);
4749
});
4850

4951
it("rejects unknown gateway argv even when the token is present", () => {

src/infra/gateway-process-argv.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ export function parseProcCmdline(raw: string): string[] {
1212

1313
export function isGatewayArgv(args: string[], opts?: { allowGatewayBinary?: boolean }): boolean {
1414
const normalized = args.map(normalizeProcArg);
15+
const exe = (normalized[0] ?? "").replace(/\.(bat|cmd|exe)$/i, "");
16+
const isGatewayBinary = exe.endsWith("/openclaw-gateway") || exe === "openclaw-gateway";
1517
if (!normalized.includes("gateway")) {
16-
return false;
18+
return opts?.allowGatewayBinary === true && isGatewayBinary;
1719
}
1820

1921
const entryCandidates = [
@@ -28,10 +30,9 @@ export function isGatewayArgv(args: string[], opts?: { allowGatewayBinary?: bool
2830
return true;
2931
}
3032

31-
const exe = (normalized[0] ?? "").replace(/\.(bat|cmd|exe)$/i, "");
3233
return (
3334
exe.endsWith("/openclaw") ||
3435
exe === "openclaw" ||
35-
(opts?.allowGatewayBinary === true && exe.endsWith("/openclaw-gateway"))
36+
(opts?.allowGatewayBinary === true && isGatewayBinary)
3637
);
3738
}

src/tui/gateway-chat.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
} from "../gateway/gateway-connection.test-mocks.js";
1212
import { captureEnv, withEnvAsync } from "../test-utils/env.js";
1313

14+
const readActiveGatewayLockPortMock = vi.hoisted(() => vi.fn());
15+
1416
vi.mock("../config/config.js", async () => {
1517
const mocks = await import("../gateway/gateway-connection.test-mocks.js");
1618
return {
@@ -31,6 +33,10 @@ vi.mock("../gateway/net.js", async () => {
3133
};
3234
});
3335

36+
vi.mock("../infra/gateway-lock.js", () => ({
37+
readActiveGatewayLockPort: readActiveGatewayLockPortMock,
38+
}));
39+
3440
const { GatewayChatClient, resolveGatewayConnection } = await import("./gateway-chat.js");
3541
const { GatewayClientRequestError } = await import("../gateway/client.js");
3642

@@ -110,11 +116,13 @@ describe("resolveGatewayConnection", () => {
110116
beforeEach(() => {
111117
envSnapshot = captureEnv([
112118
"OPENCLAW_GATEWAY_URL",
119+
"OPENCLAW_GATEWAY_PORT",
113120
"OPENCLAW_GATEWAY_TOKEN",
114121
"OPENCLAW_GATEWAY_PASSWORD",
115122
"OPENCLAW_TUI_SETUP_AUTH_SOURCE",
116123
]);
117124
loadConfig.mockReset();
125+
readActiveGatewayLockPortMock.mockReset().mockResolvedValue(undefined);
118126
resolveGatewayPort.mockReset();
119127
resolveStateDir.mockReset();
120128
resolveConfigPath.mockReset();
@@ -127,6 +135,7 @@ describe("resolveGatewayConnection", () => {
127135
env.OPENCLAW_CONFIG_PATH ?? `${stateDir}/openclaw.json`,
128136
);
129137
delete process.env.OPENCLAW_GATEWAY_URL;
138+
delete process.env.OPENCLAW_GATEWAY_PORT;
130139
delete process.env.OPENCLAW_GATEWAY_TOKEN;
131140
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
132141
delete process.env.OPENCLAW_TUI_SETUP_AUTH_SOURCE;
@@ -185,6 +194,32 @@ describe("resolveGatewayConnection", () => {
185194

186195
expect(result.preauthHandshakeTimeoutMs).toBe(30_000);
187196
});
197+
198+
it("uses a verified active local Gateway port when no target is explicit", async () => {
199+
loadConfig.mockReturnValue({
200+
gateway: { mode: "local", port: 18789, auth: { token: "config-token" } },
201+
});
202+
readActiveGatewayLockPortMock.mockResolvedValue(48789);
203+
204+
const result = await resolveGatewayConnection({});
205+
206+
expect(result.url).toBe("ws://127.0.0.1:48789");
207+
expect(result.token).toBe("config-token");
208+
});
209+
210+
it("keeps an explicit Gateway port ahead of active lock metadata", async () => {
211+
loadConfig.mockReturnValue({
212+
gateway: { mode: "local", port: 18789, auth: { token: "config-token" } },
213+
});
214+
readActiveGatewayLockPortMock.mockResolvedValue(48789);
215+
216+
await withEnvAsync({ OPENCLAW_GATEWAY_PORT: "19001" }, async () => {
217+
const result = await resolveGatewayConnection({});
218+
219+
expect(result.url).toBe("ws://127.0.0.1:19001");
220+
expect(readActiveGatewayLockPortMock).not.toHaveBeenCalled();
221+
});
222+
});
188223
it("uses config auth token for local mode when both config and env tokens are set", async () => {
189224
loadConfig.mockReturnValue({ gateway: { mode: "local", auth: { token: "config-token" } } });
190225

src/tui/gateway-chat.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-re
2828
import { GatewayClient, GatewayClientRequestError } from "../gateway/client.js";
2929
import { isLoopbackHost } from "../gateway/net.js";
3030
import { formatErrorMessage } from "../infra/errors.js";
31+
import { readActiveGatewayLockPort } from "../infra/gateway-lock.js";
3132
import { sleep } from "../utils/sleep.js";
3233
import { VERSION } from "../version.js";
3334
import { TUI_SETUP_AUTH_SOURCE_CONFIG, TUI_SETUP_AUTH_SOURCE_ENV } from "./setup-launch-env.js";
@@ -347,9 +348,19 @@ export async function resolveGatewayConnection(
347348
explicitAuth,
348349
errorHint: "Fix: pass --token or --password when using --url.",
349350
});
351+
const hasExplicitGatewayTarget = Boolean(
352+
urlOverride ||
353+
env.OPENCLAW_GATEWAY_URL?.trim() ||
354+
env.OPENCLAW_GATEWAY_PORT?.trim() ||
355+
isRemoteMode,
356+
);
357+
const activeLocalGatewayPort = hasExplicitGatewayTarget
358+
? undefined
359+
: await readActiveGatewayLockPort();
350360
const url = buildGatewayConnectionDetails({
351361
config,
352362
...(urlOverride ? { url: urlOverride } : {}),
363+
...(activeLocalGatewayPort ? { localPortOverride: activeLocalGatewayPort } : {}),
353364
}).url;
354365
const allowInsecureLocalOperatorUi = (() => {
355366
if (config.gateway?.controlUi?.allowInsecureAuth !== true) {

0 commit comments

Comments
 (0)