diff --git a/application/state/shellHistoryPersistence.test.ts b/application/state/shellHistoryPersistence.test.ts new file mode 100644 index 00000000..e554b156 --- /dev/null +++ b/application/state/shellHistoryPersistence.test.ts @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildDockerLogsCommand } from '../../domain/systemManager/dockerShell.ts'; +import { loadSanitizedShellHistory } from './shellHistoryPersistence.ts'; +import type { ShellHistoryEntry } from '../../domain/models.ts'; + +const entry = (id: string, command: string): ShellHistoryEntry => ({ + id, + command, + hostId: 'host-1', + hostLabel: 'Host', + sessionId: 'session-1', + timestamp: 1000, +}); + +test('loadSanitizedShellHistory removes persisted managed startup commands and writes back cleaned history', () => { + const stored = [ + entry('managed', buildDockerLogsCommand('587abcdef123')), + entry('user', 'docker ps -a'), + ]; + let written: ShellHistoryEntry[] | null = null; + + const loaded = loadSanitizedShellHistory({ + read: () => stored, + write: (_key, value) => { + written = value; + return true; + }, + }); + + assert.deepEqual( + loaded?.map((item) => item.command), + ['docker ps -a'], + ); + assert.deepEqual(written, loaded); +}); + +test('loadSanitizedShellHistory does not write when persisted history is already clean', () => { + const stored = [entry('user', 'docker ps -a')]; + let writeCount = 0; + + const loaded = loadSanitizedShellHistory({ + read: () => stored, + write: () => { + writeCount += 1; + return true; + }, + }); + + assert.deepEqual(loaded, stored); + assert.equal(writeCount, 0); +}); diff --git a/application/state/shellHistoryPersistence.ts b/application/state/shellHistoryPersistence.ts new file mode 100644 index 00000000..f9d7fe91 --- /dev/null +++ b/application/state/shellHistoryPersistence.ts @@ -0,0 +1,23 @@ +import type { ShellHistoryEntry } from '../../domain/models'; +import { sanitizeGlobalHistoryEntries } from '../../domain/globalHistory'; +import { STORAGE_KEY_SHELL_HISTORY } from '../../infrastructure/config/storageKeys'; +import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter'; + +type ShellHistoryStorage = { + read(key: string): T | null; + write(key: string, value: T): boolean; +}; + +export function loadSanitizedShellHistory( + storage: ShellHistoryStorage = localStorageAdapter, + storageKey = STORAGE_KEY_SHELL_HISTORY, +): ShellHistoryEntry[] | null { + const savedShellHistory = storage.read(storageKey); + if (!savedShellHistory) return null; + + const cleanedShellHistory = sanitizeGlobalHistoryEntries(savedShellHistory); + if (cleanedShellHistory.length !== savedShellHistory.length) { + storage.write(storageKey, cleanedShellHistory); + } + return cleanedShellHistory; +} diff --git a/application/state/useVaultState.ts b/application/state/useVaultState.ts index fed11ae9..a8fcfcc2 100644 --- a/application/state/useVaultState.ts +++ b/application/state/useVaultState.ts @@ -36,8 +36,9 @@ import { STORAGE_KEY_TERM_SETTINGS, } from "../../infrastructure/config/storageKeys"; import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter"; -import { mergeGlobalHistoryOnAppend } from "../../domain/globalHistory"; +import { mergeGlobalHistoryOnAppend, sanitizeGlobalHistoryEntries } from "../../domain/globalHistory"; import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder"; +import { loadSanitizedShellHistory } from "./shellHistoryPersistence"; import { decryptGroupConfigs, decryptHosts, @@ -598,10 +599,10 @@ export const useVaultState = () => { } // Load shell history - const savedShellHistory = localStorageAdapter.read( - STORAGE_KEY_SHELL_HISTORY, - ); - if (savedShellHistory) setShellHistory(savedShellHistory); + const savedShellHistory = loadSanitizedShellHistory(); + if (savedShellHistory) { + setShellHistory(savedShellHistory); + } // Load connection logs const savedConnectionLogs = localStorageAdapter.read( @@ -729,7 +730,9 @@ export const useVaultState = () => { } if (key === STORAGE_KEY_SHELL_HISTORY) { - const next = safeParse(event.newValue) ?? []; + const next = sanitizeGlobalHistoryEntries( + safeParse(event.newValue) ?? [], + ); setShellHistory(next); return; } diff --git a/domain/globalHistory.test.ts b/domain/globalHistory.test.ts index 9c54302f..77f85d0e 100644 --- a/domain/globalHistory.test.ts +++ b/domain/globalHistory.test.ts @@ -3,10 +3,13 @@ import assert from 'node:assert/strict'; import { mergeGlobalHistoryOnAppend, + sanitizeGlobalHistoryEntries, shouldRecordGlobalHistoryCommand, toGlobalHistoryDisplayEntries, } from './globalHistory.ts'; import { NETCATTY_AI_HISTORY_MARKER } from './remoteHistory.ts'; +import { buildDockerExecShellCommand, buildDockerLogsCommand } from './systemManager/dockerShell.ts'; +import { buildTmuxAttachCommand } from './systemManager/tmuxShell.ts'; import type { ShellHistoryEntry } from './models'; const baseEntry = ( @@ -30,6 +33,17 @@ test('shouldRecordGlobalHistoryCommand: rejects empty and AI marker commands', ( assert.equal(shouldRecordGlobalHistoryCommand('ls -la'), true); }); +test('shouldRecordGlobalHistoryCommand: rejects Netcatty managed Docker and tmux startup commands', () => { + assert.equal(shouldRecordGlobalHistoryCommand(buildDockerExecShellCommand('587abcdef123')), false); + assert.equal(shouldRecordGlobalHistoryCommand(buildDockerLogsCommand('587abcdef123')), false); + assert.equal(shouldRecordGlobalHistoryCommand(buildTmuxAttachCommand('my-session')), false); + assert.equal(shouldRecordGlobalHistoryCommand(buildTmuxAttachCommand('my-session', 2)), false); + assert.equal(shouldRecordGlobalHistoryCommand('docker ps -a'), true); + assert.equal(shouldRecordGlobalHistoryCommand('docker logs -f 587abcdef123'), true); + assert.equal(shouldRecordGlobalHistoryCommand('docker exec -it 587abcdef123 bash'), true); + assert.equal(shouldRecordGlobalHistoryCommand('tmux attach -t my-session'), true); +}); + test('mergeGlobalHistoryOnAppend: trims and prepends a new command', () => { const next = mergeGlobalHistoryOnAppend([], { command: ' pwd ', @@ -41,6 +55,19 @@ test('mergeGlobalHistoryOnAppend: trims and prepends a new command', () => { assert.equal(next[0].command, 'pwd'); }); +test('sanitizeGlobalHistoryEntries: removes persisted Netcatty managed startup commands', () => { + const entries = [ + baseEntry({ id: 'a', command: buildDockerLogsCommand('587abcdef123') }), + baseEntry({ id: 'b', command: 'docker ps -a' }), + baseEntry({ id: 'c', command: buildTmuxAttachCommand('my-session') }), + ]; + const out = sanitizeGlobalHistoryEntries(entries); + assert.deepEqual( + out.map((entry) => entry.command), + ['docker ps -a'], + ); +}); + test('mergeGlobalHistoryOnAppend: bumps timestamp for consecutive duplicate', () => { const prev = [baseEntry({ id: 'a', command: 'ls', timestamp: 1000 })]; const next = mergeGlobalHistoryOnAppend(prev, { diff --git a/domain/globalHistory.ts b/domain/globalHistory.ts index c9562786..e083ce50 100644 --- a/domain/globalHistory.ts +++ b/domain/globalHistory.ts @@ -1,5 +1,8 @@ import type { ShellHistoryEntry } from './models'; -import { isNetcattyAiHistoryCommand } from './remoteHistory'; +import { + isNetcattyAiHistoryCommand, + isNetcattyManagedStartupHistoryCommand, +} from './remoteHistory'; const makeId = (): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { @@ -13,9 +16,16 @@ export function shouldRecordGlobalHistoryCommand(command: string): boolean { const cmd = command.trim(); if (!cmd) return false; if (isNetcattyAiHistoryCommand(cmd)) return false; + if (isNetcattyManagedStartupHistoryCommand(cmd)) return false; return true; } +export function sanitizeGlobalHistoryEntries( + entries: ShellHistoryEntry[], +): ShellHistoryEntry[] { + return entries.filter((entry) => shouldRecordGlobalHistoryCommand(entry.command)); +} + /** * Append one command to global history: trim, drop noise, and de-dupe the most * recent identical command by bumping its timestamp instead of adding a row. @@ -61,7 +71,7 @@ export interface GlobalHistoryDisplayEntry { export function toGlobalHistoryDisplayEntries( entries: ShellHistoryEntry[], ): GlobalHistoryDisplayEntry[] { - return entries.map((entry) => ({ + return sanitizeGlobalHistoryEntries(entries).map((entry) => ({ id: entry.id, command: entry.command, timestamp: entry.timestamp, diff --git a/domain/remoteHistory.test.ts b/domain/remoteHistory.test.ts index 88f662b9..8e66a3b5 100644 --- a/domain/remoteHistory.test.ts +++ b/domain/remoteHistory.test.ts @@ -8,7 +8,10 @@ import { parseShellHistory, mergeRemoteHistory, isNetcattyAiHistoryCommand, + isNetcattyManagedStartupHistoryCommand, } from './remoteHistory.ts'; +import { buildDockerExecShellCommand, buildDockerLogsCommand } from './systemManager/dockerShell.ts'; +import { buildTmuxAttachCommand } from './systemManager/tmuxShell.ts'; test('parseBashHistory: plain lines', () => { const out = parseBashHistory(['ls -la', 'cd /tmp', 'echo hi'].join('\n')); @@ -193,6 +196,17 @@ test('isNetcattyAiHistoryCommand: detects AI PTY marker lines', () => { assert.equal(isNetcattyAiHistoryCommand('grep NCMCP log.txt'), false); }); +test('isNetcattyManagedStartupHistoryCommand: detects Docker and tmux terminal launch commands', () => { + assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerExecShellCommand('587abcdef123')), true); + assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerLogsCommand('587abcdef123')), true); + assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session')), true); + assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session', 2)), true); + assert.equal(isNetcattyManagedStartupHistoryCommand('docker ps -a'), false); + assert.equal(isNetcattyManagedStartupHistoryCommand('docker logs -f 587abcdef123'), false); + assert.equal(isNetcattyManagedStartupHistoryCommand('docker exec -it 587abcdef123 bash'), false); + assert.equal(isNetcattyManagedStartupHistoryCommand('tmux attach -t my-session'), false); +}); + test('mergeRemoteHistory: drops Netcatty AI PTY history lines', () => { const lists = [ parseBashHistory( @@ -205,3 +219,44 @@ test('mergeRemoteHistory: drops Netcatty AI PTY history lines', () => { ['git status', 'ls -la'], ); }); + +test('mergeRemoteHistory: drops Netcatty managed Docker and tmux startup lines', () => { + const lists = [ + parseBashHistory( + [ + 'docker ps -a', + buildDockerLogsCommand('587abcdef123'), + buildTmuxAttachCommand('my-session'), + 'history', + ].join('\n'), + ), + ]; + const merged = mergeRemoteHistory(lists); + assert.deepEqual( + merged.map((e) => e.command), + ['history', 'docker ps -a'], + ); +}); + +test('mergeRemoteHistory: drops Netcatty managed startup lines from zsh and fish history', () => { + const zsh = parseZshHistory( + [ + ': 1700000000:0;git status', + `: 1700000100:0;${buildDockerExecShellCommand('587abcdef123')}`, + ].join('\n'), + ); + const fish = parseFishHistory( + [ + '- cmd: docker ps -a', + ' when: 1700000200', + `- cmd: ${buildTmuxAttachCommand('my-session')}`, + ' when: 1700000300', + ].join('\n'), + ); + + const merged = mergeRemoteHistory([zsh, fish]); + assert.deepEqual( + merged.map((e) => e.command), + ['docker ps -a', 'git status'], + ); +}); diff --git a/domain/remoteHistory.ts b/domain/remoteHistory.ts index 01836675..d3129459 100644 --- a/domain/remoteHistory.ts +++ b/domain/remoteHistory.ts @@ -8,6 +8,14 @@ export function isNetcattyAiHistoryCommand(command: string): boolean { return command.includes(NETCATTY_AI_HISTORY_MARKER); } +const NETCATTY_MANAGED_STARTUP_COMMAND = + /^printf '\\033\[H\\033\[2J\\033\[3J';\s*exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)/; + +/** True when a shell history line came from a Netcatty-managed terminal launch. */ +export function isNetcattyManagedStartupHistoryCommand(command: string): boolean { + return NETCATTY_MANAGED_STARTUP_COMMAND.test(command.trim()); +} + const ZSH_EXTENDED_RECORD = /^: (\d+):\d+;([\s\S]*)$/; // fish_history is a YAML subset: each record starts with `- cmd: `, // optionally followed by ` when: ` and a ` paths:` block. @@ -215,6 +223,7 @@ export function mergeRemoteHistory( const merged: RemoteHistoryEntry[] = []; for (const { entry } of indexed) { if (isNetcattyAiHistoryCommand(entry.command)) continue; + if (isNetcattyManagedStartupHistoryCommand(entry.command)) continue; if (seen.has(entry.command)) continue; seen.add(entry.command); merged.push(entry);