diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php
index ba01deca5..c478f79b8 100644
--- a/tests/Feature/RealtimeTerminalPackagingTest.php
+++ b/tests/Feature/RealtimeTerminalPackagingTest.php
@@ -58,22 +58,35 @@ it('uses a fast probe timeout when the tab regains visibility', function () {
->toContain("'Visibility-resume timeout'");
});
-it('closes idle terminal sessions after 30 minutes on the server', function () {
+it('does not hard close terminal sessions after 30 minutes on the server', function () {
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
expect($terminalServer)
- ->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
- ->toContain('lastActivityAt')
- ->toContain("ws.send('idle-timeout');")
- ->toContain("ws.close(1000, 'Idle timeout');");
+ ->not->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
+ ->not->toContain("ws.send('idle-timeout');")
+ ->not->toContain("ws.close(1000, 'Idle timeout');");
});
-it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () {
+it('does not close the client terminal from an idle-timeout sentinel', function () {
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
expect($terminalClient)
- ->toContain("event.data === 'idle-timeout'")
- ->toContain('Terminal closed after 30 minutes of inactivity.');
+ ->not->toContain("event.data === 'idle-timeout'")
+ ->not->toContain('Terminal closed after 30 minutes of inactivity.');
+});
+
+it('keeps Livewire alive in background tabs while a terminal is connected', function () {
+ $terminalComponent = file_get_contents(base_path('app/Livewire/Project/Shared/Terminal.php'));
+ $terminalView = file_get_contents(base_path('resources/views/livewire/project/shared/terminal.blade.php'));
+
+ expect($terminalComponent)
+ ->toContain('public bool $isTerminalConnected = false;')
+ ->toContain("#[On('terminalConnected')]")
+ ->toContain('public function markTerminalConnected(): void')
+ ->toContain('public function keepTerminalPageAlive(): void')
+ ->and($terminalView)
+ ->toContain('@if ($isTerminalConnected)')
+ ->toContain('wire:poll.keep-alive.30s="keepTerminalPageAlive"');
});
it('replays the last command on reconnect so the PTY respawns automatically', function () {
diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php
index e97be1f50..b914765f6 100644
--- a/tests/Feature/SshMultiplexingLockTest.php
+++ b/tests/Feature/SshMultiplexingLockTest.php
@@ -73,6 +73,19 @@ it('adds native openssh multiplexing options to ssh commands', function () {
Process::assertNothingRan();
});
+it('can generate terminal ssh commands without a hard command timeout', function () {
+ config(['constants.ssh.mux_enabled' => true]);
+ $server = makeMuxServer();
+ Storage::disk('ssh-keys')->put("ssh_key@{$server->privateKey->uuid}", $server->privateKey->private_key);
+
+ $command = SshMultiplexingHelper::generateSshCommand($server, 'echo ok', commandTimeout: 0);
+
+ expect($command)
+ ->toStartWith('ssh ')
+ ->not->toStartWith('timeout ')
+ ->not->toContain('timeout 3600 ssh');
+});
+
it('omits native multiplexing options when ssh multiplexing is disabled for a command', function () {
config(['constants.ssh.mux_enabled' => true]);
$server = makeMuxServer();
From d1d6f083928b0e2831ea2768a05cc8432191c8e5 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 1 Jun 2026 09:05:04 +0200
Subject: [PATCH 2/4] chore(realtime): bump image to 1.0.16
---
docker-compose.prod.yml | 2 +-
docker-compose.windows.yml | 2 +-
other/nightly/docker-compose.prod.yml | 2 +-
other/nightly/docker-compose.windows.yml | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 3a9bfd501..8907a30b9 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15'
+ image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"
diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml
index cc72d487b..da045fe03 100644
--- a/docker-compose.windows.yml
+++ b/docker-compose.windows.yml
@@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15'
+ image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16'
pull_policy: always
container_name: coolify-realtime
restart: always
diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml
index 3a9bfd501..8907a30b9 100644
--- a/other/nightly/docker-compose.prod.yml
+++ b/other/nightly/docker-compose.prod.yml
@@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.15'
+ image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"
diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml
index cc72d487b..da045fe03 100644
--- a/other/nightly/docker-compose.windows.yml
+++ b/other/nightly/docker-compose.windows.yml
@@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: 'ghcr.io/coollabsio/coolify-realtime:1.0.15'
+ image: 'ghcr.io/coollabsio/coolify-realtime:1.0.16'
pull_policy: always
container_name: coolify-realtime
restart: always
From 4d3182c938cd9d479f163b6e67df1b92f15ff938 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 1 Jun 2026 09:08:20 +0200
Subject: [PATCH 3/4] fix(terminal): allow debug logging via env override
---
docker/coolify-realtime/terminal-server.js | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js
index c76383b9f..22973d88c 100755
--- a/docker/coolify-realtime/terminal-server.js
+++ b/docker/coolify-realtime/terminal-server.js
@@ -63,9 +63,11 @@ function createHttpError(response) {
}
const userSessions = new Map();
-const terminalDebugEnabled = ['local', 'development'].includes(
- String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase()
-);
+const envName = String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase();
+const debugOverride = String(process.env.TERMINAL_DEBUG || '').toLowerCase();
+const terminalDebugEnabled =
+ ['local', 'development'].includes(envName)
+ || ['1', 'true', 'yes', 'on'].includes(debugOverride);
function logTerminal(level, message, context = {}) {
if (!terminalDebugEnabled) {
From 53f24df0a0c0d781b74c8e53278dc8f6e711d791 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Mon, 1 Jun 2026 09:45:56 +0200
Subject: [PATCH 4/4] fix(terminal): enforce eight hour session expiry
Add a visible countdown in the terminal UI and terminate realtime PTY
sessions after the fixed maximum lifetime.
---
docker/coolify-realtime/terminal-server.js | 34 +++++++--
docker/coolify-realtime/terminal-utils.js | 6 ++
.../coolify-realtime/terminal-utils.test.js | 9 +++
resources/js/terminal-session-timer.js | 22 ++++++
resources/js/terminal.js | 73 +++++++++++++++++++
resources/js/terminal.test.js | 15 ++++
.../project/shared/terminal.blade.php | 5 ++
7 files changed, 156 insertions(+), 8 deletions(-)
create mode 100644 resources/js/terminal-session-timer.js
create mode 100644 resources/js/terminal.test.js
diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js
index 22973d88c..519792716 100755
--- a/docker/coolify-realtime/terminal-server.js
+++ b/docker/coolify-realtime/terminal-server.js
@@ -8,6 +8,7 @@ import {
extractSshArgs,
extractTargetHost,
extractTimeout,
+ getTerminalSessionTimeout,
isAuthorizedTargetHost,
} from './terminal-utils.js';
@@ -171,6 +172,7 @@ wss.on('connection', async (ws, req) => {
authorizedIPs: [],
authReady: false,
pendingMessages: [],
+ terminalSessionTimer: null,
};
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
const connectionContext = {
@@ -340,8 +342,14 @@ async function handleCommand(ws, command, userId) {
}
}
+ if (userSession.terminalSessionTimer) {
+ clearTimeout(userSession.terminalSessionTimer);
+ userSession.terminalSessionTimer = null;
+ }
+
const commandString = command[0].split('\n').join(' ');
- const timeout = extractTimeout(commandString);
+ const commandTimeout = extractTimeout(commandString);
+ const terminalSessionTimeout = getTerminalSessionTimeout();
const sshArgs = extractSshArgs(commandString);
const hereDocContent = extractHereDocContent(commandString);
@@ -350,7 +358,8 @@ async function handleCommand(ws, command, userId) {
logTerminal('log', 'Parsed terminal command metadata.', {
userId,
targetHost,
- timeout,
+ commandTimeout,
+ terminalSessionTimeout,
sshArgs,
authorizedIPs: userSession?.authorizedIPs ?? [],
});
@@ -389,7 +398,8 @@ async function handleCommand(ws, command, userId) {
logTerminal('log', 'Spawning PTY process for terminal session.', {
userId,
targetHost,
- timeout,
+ commandTimeout,
+ terminalSessionTimeout,
});
const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options);
@@ -411,13 +421,16 @@ async function handleCommand(ws, command, userId) {
});
ws.send('pty-exited');
userSession.isActive = false;
+
+ if (userSession.terminalSessionTimer) {
+ clearTimeout(userSession.terminalSessionTimer);
+ userSession.terminalSessionTimer = null;
+ }
});
- if (timeout) {
- setTimeout(async () => {
- await killPtyProcess(userId);
- }, timeout * 1000);
- }
+ userSession.terminalSessionTimer = setTimeout(async () => {
+ await killPtyProcess(userId);
+ }, terminalSessionTimeout * 1000);
}
async function handleError(err, userId) {
@@ -459,6 +472,11 @@ async function killPtyProcess(userId) {
setTimeout(() => {
if (!session.isActive || !session.ptyProcess) {
+ if (session.terminalSessionTimer) {
+ clearTimeout(session.terminalSessionTimer);
+ session.terminalSessionTimer = null;
+ }
+
logTerminal('log', 'PTY process terminated successfully.', {
userId,
killAttempts,
diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js
index 7456b282c..8769d62d9 100644
--- a/docker/coolify-realtime/terminal-utils.js
+++ b/docker/coolify-realtime/terminal-utils.js
@@ -1,3 +1,9 @@
+export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60;
+
+export function getTerminalSessionTimeout() {
+ return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS;
+}
+
export function extractTimeout(commandString) {
const timeoutMatch = commandString.match(/timeout (\d+)/);
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;
diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js
index 3da444155..bf863099b 100644
--- a/docker/coolify-realtime/terminal-utils.test.js
+++ b/docker/coolify-realtime/terminal-utils.test.js
@@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
+ MAX_TERMINAL_SESSION_TIMEOUT_SECONDS,
extractSshArgs,
extractTargetHost,
+ getTerminalSessionTimeout,
isAuthorizedTargetHost,
normalizeHostForAuthorization,
} from './terminal-utils.js';
@@ -45,3 +47,10 @@ test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => {
test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => {
assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false);
});
+
+
+test('getTerminalSessionTimeout always enforces the maximum terminal session lifetime', () => {
+ assert.equal(getTerminalSessionTimeout(null), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
+ assert.equal(getTerminalSessionTimeout(60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
+ assert.equal(getTerminalSessionTimeout(MAX_TERMINAL_SESSION_TIMEOUT_SECONDS + 60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
+});
diff --git a/resources/js/terminal-session-timer.js b/resources/js/terminal-session-timer.js
new file mode 100644
index 000000000..60c7f7311
--- /dev/null
+++ b/resources/js/terminal-session-timer.js
@@ -0,0 +1,22 @@
+export const MAX_TERMINAL_SESSION_SECONDS = 8 * 60 * 60;
+export const TERMINAL_SESSION_WARNING_SECONDS = 30 * 60;
+export const TERMINAL_SESSION_DANGER_SECONDS = 5 * 60;
+
+export function formatTerminalSessionRemainingTime(seconds) {
+ const remainingSeconds = Math.max(0, Math.ceil(seconds));
+
+ if (remainingSeconds === 0) {
+ return 'expired';
+ }
+
+ const totalMinutes = Math.floor(remainingSeconds / 60);
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+ const secondsPart = remainingSeconds % 60;
+
+ if (hours === 0) {
+ return `${minutes}m ${String(secondsPart).padStart(2, '0')}s`;
+ }
+
+ return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(secondsPart).padStart(2, '0')}s`;
+}
diff --git a/resources/js/terminal.js b/resources/js/terminal.js
index cb3a26b3a..6c1d3057a 100644
--- a/resources/js/terminal.js
+++ b/resources/js/terminal.js
@@ -1,5 +1,11 @@
import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css';
+import {
+ MAX_TERMINAL_SESSION_SECONDS,
+ TERMINAL_SESSION_DANGER_SECONDS,
+ TERMINAL_SESSION_WARNING_SECONDS,
+ formatTerminalSessionRemainingTime,
+} from './terminal-session-timer.js';
import { FitAddon } from '@xterm/addon-fit';
const terminalDebugEnabled = import.meta.env.DEV;
@@ -52,6 +58,9 @@ export function initializeTerminalComponent() {
// Visibility handling - prevent disconnects when tab loses focus
isDocumentVisible: true,
wasConnectedBeforeHidden: false,
+ terminalSessionStartedAt: null,
+ terminalSessionRemainingSeconds: null,
+ terminalSessionCountdownInterval: null,
init() {
this.setupTerminal();
@@ -135,6 +144,7 @@ export function initializeTerminalComponent() {
this.clearAllTimers();
this.connectionState = 'disconnected';
this.pendingCommand = null;
+ this.resetTerminalSessionCountdown();
if (this.socket) {
this.socket.close(1000, 'Client cleanup');
}
@@ -157,11 +167,68 @@ export function initializeTerminalComponent() {
}
[this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
.forEach(timer => timer && clearTimeout(timer));
+ if (this.terminalSessionCountdownInterval) {
+ clearInterval(this.terminalSessionCountdownInterval);
+ }
this.keepAliveInterval = null;
this.reconnectInterval = null;
this.connectionTimeoutId = null;
this.pingTimeoutId = null;
this.resizeTimeout = null;
+ this.terminalSessionCountdownInterval = null;
+ },
+
+ resetTerminalSessionCountdown() {
+ if (this.terminalSessionCountdownInterval) {
+ clearInterval(this.terminalSessionCountdownInterval);
+ }
+
+ this.terminalSessionStartedAt = null;
+ this.terminalSessionRemainingSeconds = null;
+ this.terminalSessionCountdownInterval = null;
+ },
+
+ startTerminalSessionCountdown() {
+ this.resetTerminalSessionCountdown();
+ this.terminalSessionStartedAt = Date.now();
+ this.updateTerminalSessionCountdown();
+ this.terminalSessionCountdownInterval = setInterval(() => {
+ this.updateTerminalSessionCountdown();
+ }, 1000);
+ },
+
+ updateTerminalSessionCountdown() {
+ if (!this.terminalSessionStartedAt) {
+ this.terminalSessionRemainingSeconds = null;
+ return;
+ }
+
+ const elapsedSeconds = (Date.now() - this.terminalSessionStartedAt) / 1000;
+ this.terminalSessionRemainingSeconds = Math.max(0, MAX_TERMINAL_SESSION_SECONDS - elapsedSeconds);
+ },
+
+ terminalSessionRemainingLabel() {
+ if (this.terminalSessionRemainingSeconds === null) {
+ return '';
+ }
+
+ return `Session expires in ${formatTerminalSessionRemainingTime(this.terminalSessionRemainingSeconds)}`;
+ },
+
+ terminalSessionTimerClass() {
+ if (this.terminalSessionRemainingSeconds === null) {
+ return 'text-neutral-300 bg-black/70 border-white/10';
+ }
+
+ if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_DANGER_SECONDS) {
+ return 'text-red-200 bg-red-950/80 border-red-500/40';
+ }
+
+ if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_WARNING_SECONDS) {
+ return 'text-yellow-200 bg-yellow-950/80 border-yellow-500/40';
+ }
+
+ return 'text-neutral-300 bg-black/70 border-white/10';
},
resetTerminal() {
@@ -181,6 +248,7 @@ export function initializeTerminalComponent() {
this.paused = false;
this.commandBuffer = '';
this.pendingCommand = null;
+ this.resetTerminalSessionCountdown();
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
@@ -328,6 +396,7 @@ export function initializeTerminalComponent() {
this.connectionState = 'disconnected';
this.clearAllTimers();
+ this.resetTerminalSessionCountdown();
// Only reset terminal and reconnect if it wasn't a clean close
if (event.code !== 1000) {
@@ -424,6 +493,7 @@ export function initializeTerminalComponent() {
}
}
this.terminalActive = true;
+ this.startTerminalSessionCountdown();
this.term.focus();
document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm');
@@ -450,12 +520,14 @@ export function initializeTerminalComponent() {
if (this.term) this.term.reset();
this.terminalActive = false;
this.lastSentCommand = null;
+ this.resetTerminalSessionCountdown();
this.message = '(sorry, something went wrong, please try again)';
// Notify parent component that terminal connection failed
this.$wire.dispatch('terminalDisconnected');
} else if (event.data === 'pty-exited') {
this.terminalActive = false;
+ this.resetTerminalSessionCountdown();
this.term.reset();
this.commandBuffer = '';
this.lastSentCommand = null;
@@ -469,6 +541,7 @@ export function initializeTerminalComponent() {
logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data);
this.$wire.dispatch('error', event.data);
this.terminalActive = false;
+ this.resetTerminalSessionCountdown();
} else {
try {
this.pendingWrites++;
diff --git a/resources/js/terminal.test.js b/resources/js/terminal.test.js
new file mode 100644
index 000000000..e0a4fb852
--- /dev/null
+++ b/resources/js/terminal.test.js
@@ -0,0 +1,15 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ MAX_TERMINAL_SESSION_SECONDS,
+ formatTerminalSessionRemainingTime,
+} from './terminal-session-timer.js';
+
+test('formatTerminalSessionRemainingTime formats the eight hour terminal limit countdown', () => {
+ assert.equal(MAX_TERMINAL_SESSION_SECONDS, 8 * 60 * 60);
+ assert.equal(formatTerminalSessionRemainingTime(MAX_TERMINAL_SESSION_SECONDS), '8h 00m 00s');
+ assert.equal(formatTerminalSessionRemainingTime((7 * 60 * 60) + (59 * 60) + 59), '7h 59m 59s');
+ assert.equal(formatTerminalSessionRemainingTime(65 * 60), '1h 05m 00s');
+ assert.equal(formatTerminalSessionRemainingTime(59), '0m 59s');
+ assert.equal(formatTerminalSessionRemainingTime(0), 'expired');
+});
diff --git a/resources/views/livewire/project/shared/terminal.blade.php b/resources/views/livewire/project/shared/terminal.blade.php
index abcb366f5..97f4c65d6 100644
--- a/resources/views/livewire/project/shared/terminal.blade.php
+++ b/resources/views/livewire/project/shared/terminal.blade.php
@@ -23,6 +23,11 @@