diff --git a/CHANGELOG.md b/CHANGELOG.md index 270503705b5..342e8424126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Telegram/Discord extensions: propagate trusted `mediaLocalRoots` through extension outbound `sendMedia` options so extension direct-send media paths honor agent-scoped local-media allowlists. (#20029, #21903, #23227) +- Exec/Background: stop applying the default exec timeout to background sessions (`background: true` or explicit `yieldMs`) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303) - Plugins/Media sandbox: propagate trusted `mediaLocalRoots` through plugin action dispatch (including Discord/Telegram action adapters) so plugin send paths enforce the same agent-scoped local-media sandbox roots as core outbound sends. (#20258, #22718) - Agents/Workspace guard: map sandbox container-workdir file-tool paths (for example `/workspace/...` and `file:///workspace/...`) to host workspace roots before workspace-only validation, preventing false `Path escapes sandbox root` rejections for sandbox file tools. (#9560) - Gateway/Exec approvals: expire approval requests immediately when no approval-capable gateway clients are connected and no forwarding targets are available, avoiding delayed approvals after restarts/offline approver windows. (#22144) diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index e342df6232b..39e36b5581e 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -267,7 +267,7 @@ export async function runExecProcess(opts: { notifyOnExitEmptySuccess?: boolean; scopeKey?: string; sessionKey?: string; - timeoutSec: number; + timeoutSec: number | null; onUpdate?: (partialResult: AgentToolResult) => void; }): Promise { const startedAt = Date.now(); @@ -504,7 +504,9 @@ export async function runExecProcess(opts: { } const reason = exit.reason === "overall-timeout" - ? `Command timed out after ${opts.timeoutSec} seconds` + ? typeof opts.timeoutSec === "number" && opts.timeoutSec > 0 + ? `Command timed out after ${opts.timeoutSec} seconds` + : "Command timed out" : exit.reason === "no-output-timeout" ? "Command timed out waiting for output" : exit.exitSignal != null diff --git a/src/agents/bash-tools.exec.background-abort.test.ts b/src/agents/bash-tools.exec.background-abort.test.ts index 4767c265a8a..0e312e64687 100644 --- a/src/agents/bash-tools.exec.background-abort.test.ts +++ b/src/agents/bash-tools.exec.background-abort.test.ts @@ -142,6 +142,35 @@ test("background exec still times out after tool signal abort", async () => { }); }); +test("background exec without explicit timeout ignores default timeout", async () => { + const tool = createTestExecTool({ + allowBackground: true, + backgroundMs: 0, + timeoutSec: BACKGROUND_TIMEOUT_SEC, + }); + const result = await tool.execute("toolcall", { command: BACKGROUND_HOLD_CMD, background: true }); + expect(result.details.status).toBe("running"); + const sessionId = (result.details as { sessionId: string }).sessionId; + const waitMs = Math.max(ABORT_SETTLE_MS + 120, BACKGROUND_TIMEOUT_SEC * 1000 + 120); + + const startedAt = Date.now(); + await expect + .poll( + () => { + const running = getSession(sessionId); + const finished = getFinishedSession(sessionId); + return Date.now() - startedAt >= waitMs && !finished && running?.exited === false; + }, + { + timeout: waitMs + ABORT_WAIT_TIMEOUT_MS, + interval: POLL_INTERVAL_MS, + }, + ) + .toBe(true); + + cleanupRunningSession(sessionId); +}); + test("yielded background exec is not killed when tool signal aborts", async () => { const tool = createTestExecTool({ allowBackground: true, backgroundMs: 10 }); await expectBackgroundSessionSurvivesAbort({ diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 6b41db5fe4f..1d0b416a6b2 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -442,8 +442,12 @@ export function createExecTool( execCommandOverride = gatewayResult.execCommandOverride; } - const effectiveTimeout = - typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec; + const explicitTimeoutSec = typeof params.timeout === "number" ? params.timeout : null; + const backgroundTimeoutBypass = + allowBackground && explicitTimeoutSec === null && (backgroundRequested || yieldRequested); + const effectiveTimeout = backgroundTimeoutBypass + ? null + : (explicitTimeoutSec ?? defaultTimeoutSec); const getWarningText = () => (warnings.length ? `${warnings.join("\n")}\n\n` : ""); const usePty = params.pty === true && !sandbox;