[codex] Hide managed startup commands from history
Hide Netcatty-managed Docker and tmux terminal launch commands from command history. Validated locally with lint, full tests, and build. Multi-agent review completed with no remaining issues.
This commit is contained in:
53
application/state/shellHistoryPersistence.test.ts
Normal file
53
application/state/shellHistoryPersistence.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
23
application/state/shellHistoryPersistence.ts
Normal file
23
application/state/shellHistoryPersistence.ts
Normal file
@@ -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<T>(key: string): T | null;
|
||||
write<T>(key: string, value: T): boolean;
|
||||
};
|
||||
|
||||
export function loadSanitizedShellHistory(
|
||||
storage: ShellHistoryStorage = localStorageAdapter,
|
||||
storageKey = STORAGE_KEY_SHELL_HISTORY,
|
||||
): ShellHistoryEntry[] | null {
|
||||
const savedShellHistory = storage.read<ShellHistoryEntry[]>(storageKey);
|
||||
if (!savedShellHistory) return null;
|
||||
|
||||
const cleanedShellHistory = sanitizeGlobalHistoryEntries(savedShellHistory);
|
||||
if (cleanedShellHistory.length !== savedShellHistory.length) {
|
||||
storage.write(storageKey, cleanedShellHistory);
|
||||
}
|
||||
return cleanedShellHistory;
|
||||
}
|
||||
@@ -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<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
const savedShellHistory = loadSanitizedShellHistory();
|
||||
if (savedShellHistory) {
|
||||
setShellHistory(savedShellHistory);
|
||||
}
|
||||
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
@@ -729,7 +730,9 @@ export const useVaultState = () => {
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_SHELL_HISTORY) {
|
||||
const next = safeParse<ShellHistoryEntry[]>(event.newValue) ?? [];
|
||||
const next = sanitizeGlobalHistoryEntries(
|
||||
safeParse<ShellHistoryEntry[]>(event.newValue) ?? [],
|
||||
);
|
||||
setShellHistory(next);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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: <value>`,
|
||||
// optionally followed by ` when: <epoch>` 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);
|
||||
|
||||
Reference in New Issue
Block a user