diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 86517ab31..07d37dc69 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -578,7 +578,7 @@ The runtime evaluates tmux and cmux availability to determine operating mode: | ------------------ | ---------------------------------------------- | ------------ | ------------- | | `cmux-shim` | Live cmux + live tmux pane control | tmux | cmux (if capable), else desktop | | `tmux-only` | Live tmux pane control, no live cmux | tmux | desktop | -| `cmux-notify-only` | Live cmux (no pane control), tmux unavailable | none | cmux (if capable), else desktop | +| `cmux-notify-only` | Live cmux, tmux pane control unavailable | none | cmux (if capable), else desktop | | `none` | Neither tmux nor cmux available | none | desktop | #### Backend Precedence Semantics diff --git a/src/openclaw/__tests__/index.test.ts b/src/openclaw/__tests__/index.test.ts new file mode 100644 index 000000000..39cb0bb20 --- /dev/null +++ b/src/openclaw/__tests__/index.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import type { OpenClawConfig } from "../types" + +const wakeGatewayMock = mock(async () => ({ + gateway: "http-gateway", + success: true, + statusCode: 200, +})) +const wakeCommandGatewayMock = mock(async () => ({ + gateway: "command-gateway", + success: true, +})) +const interpolateInstructionMock = mock((template: string) => template) +const getCurrentTmuxSessionMock = mock(async () => "workspace-main") +const captureTmuxPaneMock = mock(async () => null) +const startReplyListenerMock = mock(async () => {}) +const stopReplyListenerMock = mock(() => {}) + +mock.module("../dispatcher", () => ({ + wakeGateway: wakeGatewayMock, + wakeCommandGateway: wakeCommandGatewayMock, + interpolateInstruction: interpolateInstructionMock, +})) + +mock.module("../tmux", () => ({ + getCurrentTmuxSession: getCurrentTmuxSessionMock, + captureTmuxPane: captureTmuxPaneMock, +})) + +mock.module("../reply-listener", () => ({ + startReplyListener: startReplyListenerMock, + stopReplyListener: stopReplyListenerMock, +})) + +const { wakeOpenClaw } = await import("../index") + +describe("wakeOpenClaw tmux session resolution", () => { + beforeEach(() => { + wakeGatewayMock.mockClear() + wakeCommandGatewayMock.mockClear() + interpolateInstructionMock.mockClear() + getCurrentTmuxSessionMock.mockClear() + captureTmuxPaneMock.mockClear() + startReplyListenerMock.mockClear() + stopReplyListenerMock.mockClear() + }) + + test("awaits asynchronous tmux session lookup before dispatch", async () => { + const config: OpenClawConfig = { + enabled: true, + gateways: { + commandGateway: { + type: "command", + method: "POST", + command: "echo {{tmuxSession}}", + }, + }, + hooks: { + "session-start": { + enabled: true, + gateway: "commandGateway", + instruction: "tmux session: {{tmuxSession}}", + }, + }, + } + + await wakeOpenClaw(config, "session-start", {}) + + expect(getCurrentTmuxSessionMock).toHaveBeenCalledTimes(1) + expect(interpolateInstructionMock).toHaveBeenCalledTimes(1) + expect(interpolateInstructionMock.mock.calls[0]?.[1]?.tmuxSession).toBe("workspace-main") + expect(wakeCommandGatewayMock).toHaveBeenCalledTimes(1) + expect(wakeCommandGatewayMock.mock.calls[0]?.[2]?.tmuxSession).toBe("workspace-main") + }) +}) diff --git a/src/openclaw/__tests__/tmux.test.ts b/src/openclaw/__tests__/tmux.test.ts index 790a1bbe0..6d20fcb12 100644 --- a/src/openclaw/__tests__/tmux.test.ts +++ b/src/openclaw/__tests__/tmux.test.ts @@ -1,7 +1,14 @@ -import { describe, expect, test } from "bun:test" -import { analyzePaneContent } from "../tmux" +import { beforeEach, describe, expect, test } from "bun:test" +import { resetMultiplexerPathCacheForTesting } from "../../tools/interactive-bash/tmux-path-resolver" +import { resetResolvedMultiplexerRuntimeForTesting } from "../../shared/tmux" +import { analyzePaneContent, getCurrentTmuxSession } from "../tmux" describe("openclaw tmux helpers", () => { + beforeEach(() => { + resetMultiplexerPathCacheForTesting() + resetResolvedMultiplexerRuntimeForTesting() + }) + test("analyzePaneContent recognizes the opencode welcome prompt", () => { const content = "opencode\nAsk anything...\nRun /help" expect(analyzePaneContent(content).confidence).toBeGreaterThanOrEqual(1) @@ -10,4 +17,38 @@ describe("openclaw tmux helpers", () => { test("analyzePaneContent returns zero confidence for empty content", () => { expect(analyzePaneContent(null).confidence).toBe(0) }) + + test("getCurrentTmuxSession does not synthesize a session from TMUX_PANE", async () => { + const originalTmux = process.env.TMUX + const originalTmuxPane = process.env.TMUX_PANE + const originalDisableTmuxFlag = process.env.OH_MY_OPENCODE_DISABLE_TMUX + + try { + process.env.TMUX = "/tmp/tmux-501/default,1,0" + process.env.TMUX_PANE = "%42" + process.env.OH_MY_OPENCODE_DISABLE_TMUX = "1" + + const sessionName = await getCurrentTmuxSession() + + expect(sessionName).toBeNull() + } finally { + if (originalTmux === undefined) { + delete process.env.TMUX + } else { + process.env.TMUX = originalTmux + } + + if (originalTmuxPane === undefined) { + delete process.env.TMUX_PANE + } else { + process.env.TMUX_PANE = originalTmuxPane + } + + if (originalDisableTmuxFlag === undefined) { + delete process.env.OH_MY_OPENCODE_DISABLE_TMUX + } else { + process.env.OH_MY_OPENCODE_DISABLE_TMUX = originalDisableTmuxFlag + } + } + }) }) diff --git a/src/openclaw/index.ts b/src/openclaw/index.ts index 5cbbe3362..a33f079bd 100644 --- a/src/openclaw/index.ts +++ b/src/openclaw/index.ts @@ -55,7 +55,10 @@ export async function wakeOpenClaw( ...(replyThread !== undefined && { replyThread }), } - const tmuxSession = enrichedContext.tmuxSession ?? getCurrentTmuxSession() ?? undefined + const tmuxSession = + enrichedContext.tmuxSession + ?? (await getCurrentTmuxSession()) + ?? undefined let tmuxTail = enrichedContext.tmuxTail if (!tmuxTail && (event === "stop" || event === "session-end") && process.env.TMUX) { diff --git a/src/openclaw/tmux.ts b/src/openclaw/tmux.ts index 65a7a53af..4d95f653f 100644 --- a/src/openclaw/tmux.ts +++ b/src/openclaw/tmux.ts @@ -6,7 +6,7 @@ import { isInsideTmux, } from "../shared/tmux" -export function getCurrentTmuxSession(): string | null { +export async function getCurrentTmuxSession(): Promise { const resolvedMultiplexer = getResolvedMultiplexerRuntime() if (resolvedMultiplexer && resolvedMultiplexer.paneBackend !== "tmux") { return null @@ -19,9 +19,7 @@ export function getCurrentTmuxSession(): string | null { const paneId = getCurrentPaneId(resolvedMultiplexer ?? undefined) if (!paneId) return null - const match = paneId.match(/(\d+)$/) - return match ? `session-${match[1]}` : null - // Reference tmux.js gets session name via `tmux display-message -p '#S'` + return getTmuxSessionName() } export async function getTmuxSessionName(): Promise { diff --git a/src/tools/interactive-bash/tmux-path-resolver.test.ts b/src/tools/interactive-bash/tmux-path-resolver.test.ts index f5a504775..b3121be46 100644 --- a/src/tools/interactive-bash/tmux-path-resolver.test.ts +++ b/src/tools/interactive-bash/tmux-path-resolver.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, spyOn, test } from "bun:test" +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" import { classifyCmuxEndpoint, isConnectionRefusedText, + probeCmuxNotificationCapability, probeCmuxReachability, probeTmuxRuntime, resetMultiplexerPathCacheForTesting, @@ -55,6 +59,62 @@ describe("tmux-path-resolver probe environment", () => { whichSpy.mockRestore() } }) + + test("probeTmuxRuntime honors explicit PATH removal in custom environment", async () => { + const whichSpy = spyOn(Bun, "which").mockImplementation(() => null) + + try { + await probeTmuxRuntime({ + environment: { + PATH: undefined, + TMUX: "/tmp/tmux-501/default,1,0", + TMUX_PANE: "%1", + }, + }) + + expect(whichSpy).toHaveBeenCalledTimes(1) + expect(whichSpy.mock.calls[0]).toEqual([ + "tmux", + { PATH: "" }, + ]) + } finally { + whichSpy.mockRestore() + } + }) + + test("probeCmuxNotificationCapability returns promptly after probe timeout", async () => { + const tempDirectory = mkdtempSync(join(tmpdir(), "tmux-path-resolver-timeout-")) + const fakeCmuxPath = join(tempDirectory, "cmux") + const slowCmuxScript = `#!/bin/sh +if [ "$1" = "notify" ]; then + trap '' TERM + /bin/sleep 1 + exit 0 +fi + +exit 1 +` + + writeFileSync(fakeCmuxPath, slowCmuxScript) + chmodSync(fakeCmuxPath, 0o755) + + try { + const startedAt = Date.now() + const probe = await probeCmuxNotificationCapability({ + cmuxPath: fakeCmuxPath, + environment: { + PATH: tempDirectory, + }, + timeoutMs: 40, + }) + const elapsedMs = Date.now() - startedAt + + expect(probe.failureKind).toBe("timeout") + expect(elapsedMs).toBeLessThan(500) + } finally { + rmSync(tempDirectory, { recursive: true, force: true }) + } + }) }) describe("tmux-path-resolver cmux endpoint helpers", () => { diff --git a/src/tools/interactive-bash/tmux-path-resolver.ts b/src/tools/interactive-bash/tmux-path-resolver.ts index 23b4412d4..db711b1d9 100644 --- a/src/tools/interactive-bash/tmux-path-resolver.ts +++ b/src/tools/interactive-bash/tmux-path-resolver.ts @@ -240,7 +240,16 @@ async function runProbeCommand( clearTimeout(timeoutHandle) } - const exitCode = timedOut ? null : await proc.exited.catch(() => null) + if (timedOut) { + return { + exitCode: null, + stdout: "", + stderr: "", + timedOut: true, + } + } + + const exitCode = await proc.exited.catch(() => null) const stdout = await new Response(proc.stdout).text().catch(() => "") const stderr = await new Response(proc.stderr).text().catch(() => "") @@ -266,8 +275,12 @@ function findCommandPath( ): string | null { try { const probeEnvironment = toProbeEnvironment(environment) - const whichOptions = - probeEnvironment.PATH !== undefined + const hasExplicitPathOverride = + environment !== undefined + && Object.prototype.hasOwnProperty.call(environment, "PATH") + const whichOptions = hasExplicitPathOverride + ? { PATH: probeEnvironment.PATH ?? "" } + : probeEnvironment.PATH !== undefined ? { PATH: probeEnvironment.PATH } : undefined