Files
Netcatty/domain/remoteHistory.ts
陈大猫 ecadc1fc2d
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
[codex] Enable sudo fallback for Docker panel (#1466)
* Enable sudo fallback for Docker panel

* Prefer sudo for Docker panel commands

* Use pending saved sudo password immediately

* Try plain Docker before sudo fallback

* Detect Docker before sudo fallback

* Add sudo fallback for Docker popup commands

* Harden Docker popup sudo fallback
2026-06-14 10:47:21 +08:00

234 lines
7.8 KiB
TypeScript

import { RemoteHistoryEntry, RemoteHistorySource } from './models';
/** Marker prefix Netcatty AI uses when executing commands via the PTY bridge. */
export const NETCATTY_AI_HISTORY_MARKER = '__NCMCP_';
/** True when a shell history line came from Netcatty AI PTY exec, not the user. */
export function isNetcattyAiHistoryCommand(command: string): boolean {
return command.includes(NETCATTY_AI_HISTORY_MARKER);
}
const NETCATTY_MANAGED_STARTUP_COMMAND =
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*_nc_docker_err=.*\bdocker\s+inspect\b|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_docker_err=.*\bdocker\s+inspect\b|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.
const FISH_CMD_LINE = /^- cmd:\s?(.*)$/;
const FISH_WHEN_LINE = /^\s+when:\s*(\d+)/;
const makeId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `rh-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
/**
* zsh writes a multi-line command across several physical lines, escaping each
* embedded newline with a trailing backslash. Reassemble those physical lines
* back into one logical record: a line continues onto the next when it ends
* with an odd number of backslashes (an even count is escaped literal
* backslashes, not a continuation). The escaping backslash is dropped.
*/
function joinContinuations(lines: string[]): string[] {
const records: string[] = [];
let buffer: string | null = null;
for (const line of lines) {
const trailingBackslashes = /\\*$/.exec(line)?.[0].length ?? 0;
const continues = trailingBackslashes % 2 === 1;
const body = continues ? line.slice(0, -1) : line;
buffer = buffer === null ? body : `${buffer}\n${body}`;
if (!continues) {
records.push(buffer);
buffer = null;
}
}
if (buffer !== null) records.push(buffer);
return records;
}
/**
* Reverse fish's history escaping: it stores commands on a single line,
* encoding backslash as `\\` and newline as `\n`.
*/
const unescapeFishValue = (value: string): string => {
let result = '';
for (let i = 0; i < value.length; i += 1) {
const ch = value[i];
if (ch === '\\' && i + 1 < value.length) {
const next = value[i + 1];
if (next === 'n') {
result += '\n';
i += 1;
continue;
}
if (next === '\\') {
result += '\\';
i += 1;
continue;
}
}
result += ch;
}
return result;
};
export function parseBashHistory(text: string): RemoteHistoryEntry[] {
if (!text) return [];
const result: RemoteHistoryEntry[] = [];
const lines = text.split(/\r?\n/).map((line) => line.replace(/\r$/, ''));
let pendingTimestamp: number | undefined;
let pendingLines: string[] = [];
let inTimestampedRecord = false;
const flush = () => {
if (pendingLines.length) {
const command = pendingLines.join('\n').trim();
if (command) {
result.push({ id: makeId(), command, source: 'bash', timestamp: pendingTimestamp });
}
}
pendingLines = [];
pendingTimestamp = undefined;
};
for (const line of lines) {
// Bash HISTTIMEFORMAT writes a `#<epoch>` line before each command. That
// marker also delimits records, which lets us regroup a multi-line command
// (stored with embedded newlines under `lithist`) back into one entry.
const tsMatch = /^#(\d{9,})$/.exec(line);
if (tsMatch) {
flush();
pendingTimestamp = Number(tsMatch[1]) * 1000;
inTimestampedRecord = true;
continue;
}
if (inTimestampedRecord) {
pendingLines.push(line);
continue;
}
// Without timestamp markers the file has no record delimiter, so fall back
// to one command per line (this is also how bash itself re-reads the file).
const command = line.trim();
if (command) {
result.push({ id: makeId(), command, source: 'bash', timestamp: undefined });
}
}
flush();
return result;
}
export function parseZshHistory(text: string): RemoteHistoryEntry[] {
if (!text) return [];
const result: RemoteHistoryEntry[] = [];
const lines = text.split(/\r?\n/).map((line) => line.replace(/\r$/, ''));
for (const record of joinContinuations(lines)) {
const extended = ZSH_EXTENDED_RECORD.exec(record);
if (extended) {
const command = (extended[2] ?? '').trim();
if (!command) continue;
result.push({
id: makeId(),
command,
source: 'zsh',
timestamp: Number(extended[1]) * 1000,
});
continue;
}
const command = record.trim();
if (!command) continue;
result.push({
id: makeId(),
command,
source: 'zsh',
});
}
return result;
}
export function parseFishHistory(text: string): RemoteHistoryEntry[] {
if (!text) return [];
const result: RemoteHistoryEntry[] = [];
const lines = text.split(/\r?\n/);
let current: RemoteHistoryEntry | null = null;
const flush = () => {
if (current) {
result.push(current);
current = null;
}
};
for (const raw of lines) {
const line = raw.replace(/\r$/, '');
const cmdMatch = FISH_CMD_LINE.exec(line);
if (cmdMatch) {
flush();
const command = unescapeFishValue(cmdMatch[1] ?? '').trim();
if (!command) continue; // skip empty command, stay outside a record
current = { id: makeId(), command, source: 'fish' };
continue;
}
if (current) {
const whenMatch = FISH_WHEN_LINE.exec(line);
if (whenMatch) {
current.timestamp = Number(whenMatch[1]) * 1000;
}
// ` paths:` and its ` - …` entries (and any leading remnant lines
// from a tail-truncated first record) are ignored.
}
}
flush();
return result;
}
export function parseShellHistory(
source: RemoteHistorySource,
text: string,
): RemoteHistoryEntry[] {
if (source === 'bash') return parseBashHistory(text);
if (source === 'fish') return parseFishHistory(text);
return parseZshHistory(text);
}
/**
* Merge multiple history lists into one newest-first, de-duplicated list.
*
* Entries are ordered by their real timestamp when they carry one (zsh
* EXTENDED_HISTORY, fish `when`, bash HISTTIMEFORMAT). Entries without a
* timestamp are treated as older than any timestamped entry and otherwise keep
* their original file order (later in the file = newer). This stops an
* always-timestamped source (e.g. fish) from leap-frogging another source
* purely because `flat()` placed it last. De-duplication is by exact command
* text, keeping the newest occurrence, and the result is capped to `max`.
*/
export function mergeRemoteHistory(
lists: RemoteHistoryEntry[][],
max = 1000,
): RemoteHistoryEntry[] {
const indexed = lists.flat().map((entry, index) => ({ entry, index }));
indexed.sort((a, b) => {
const ta = a.entry.timestamp ?? 0;
const tb = b.entry.timestamp ?? 0;
if (ta !== tb) return tb - ta; // newest timestamp first
return b.index - a.index; // same/no timestamp: later in the file first
});
const seen = new Set<string>();
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);
if (merged.length >= max) break;
}
return merged;
}