Some checks failed
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 / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1338 lines
52 KiB
JavaScript
1338 lines
52 KiB
JavaScript
/**
|
|
* Temp Directory Bridge - Manages Netcatty's dedicated temp directory
|
|
*
|
|
* All temporary files (SFTP downloads, etc.) are stored in a dedicated
|
|
* Netcatty folder within the system temp directory for easier cleanup.
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const os = require("node:os");
|
|
const crypto = require("node:crypto");
|
|
|
|
// Keep the legacy name when the OS already provides a private per-user temp
|
|
// root. Shared temp roots fall back to a stable directory under the user's home
|
|
// so another OS user cannot claim Netcatty's path before startup.
|
|
const NETCATTY_TEMP_DIR_NAME = "Netcatty";
|
|
const MAX_TOOL_OUTPUT_TEMP_CHARS = 4_000_000;
|
|
const MAX_TOOL_OUTPUT_TEMP_BYTES = 8_000_000;
|
|
const TOOL_OUTPUT_ORPHAN_TTL_MS = 30 * 60 * 1_000;
|
|
const TOOL_OUTPUT_PERSISTED_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
const TOOL_OUTPUT_MANIFEST_MAX_BYTES = 16_384;
|
|
const TOOL_OUTPUT_MAX_HANDLES_PER_SESSION = 64;
|
|
const TOOL_OUTPUT_MAX_CHARS_PER_SESSION = 8_000_000;
|
|
const TOOL_OUTPUT_MAX_HANDLES_GLOBAL = 256;
|
|
const TOOL_OUTPUT_MAX_CHARS_GLOBAL = 32_000_000;
|
|
const TOOL_OUTPUT_READ_MAX_CHARS = 12_000;
|
|
const TOOL_OUTPUT_SEARCH_CONTEXT_CHARS = 320;
|
|
const TOOL_OUTPUT_SEARCH_MAX_MATCHES = 20;
|
|
const TOOL_OUTPUT_SIGNING_KEY_FILE = ".tool-output-signing-key";
|
|
const TOOL_OUTPUT_TEMP_DIR_REBOUND = "NETCATTY_TEMP_DIR_REBOUND";
|
|
|
|
// Cached temp directory path
|
|
let cachedTempDir = null;
|
|
let cachedTempDirIdentity = null;
|
|
let tempFileCounter = 0;
|
|
let toolOutputSigningKeyPromise = Promise.resolve(crypto.randomBytes(32));
|
|
let toolOutputSigningKeyRecoveryPromise = null;
|
|
let toolOutputSigningKeyRecoveryGeneration = null;
|
|
let toolOutputSafeStorage = null;
|
|
let tempDirRebindGeneration = 0;
|
|
const toolOutputSessionDeletions = new Map();
|
|
const toolOutputChatDeletionGenerations = new Map();
|
|
const closedToolOutputTerminalSessions = new Set();
|
|
|
|
function isSecureToolOutputStorageAvailable(safeStorage, platform = process.platform) {
|
|
if (!safeStorage?.isEncryptionAvailable?.()) return false;
|
|
if (platform !== "linux" || typeof safeStorage.getSelectedStorageBackend !== "function") return true;
|
|
const backend = safeStorage.getSelectedStorageBackend();
|
|
return backend !== "basic_text" && backend !== "unknown";
|
|
}
|
|
|
|
function getToolOutputChatDeletionGeneration(chatSessionId) {
|
|
return toolOutputChatDeletionGenerations.get(chatSessionId) ?? 0;
|
|
}
|
|
|
|
function createTempDirReboundError() {
|
|
const error = new Error("Temporary directory was replaced while saving output.");
|
|
error.code = TOOL_OUTPUT_TEMP_DIR_REBOUND;
|
|
return error;
|
|
}
|
|
|
|
async function readFileAtMost(file, maxBytes) {
|
|
const buffer = Buffer.alloc(maxBytes + 1);
|
|
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
return bytesRead > maxBytes ? null : buffer.subarray(0, bytesRead);
|
|
}
|
|
|
|
async function listPendingSigningKeyFiles(keyPath) {
|
|
try {
|
|
const files = await fs.promises.readdir(path.dirname(keyPath));
|
|
return files.filter(file => (
|
|
file.startsWith(`${TOOL_OUTPUT_SIGNING_KEY_FILE}.`)
|
|
&& file.endsWith(".pending")
|
|
)).map(file => path.join(path.dirname(keyPath), file));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function hasPendingSigningKeyFile(keyPath, expectedStat) {
|
|
const keyStat = expectedStat ?? await fs.promises.lstat(keyPath).catch(() => null);
|
|
if (!keyStat) return false;
|
|
for (const pendingPath of await listPendingSigningKeyFiles(keyPath)) {
|
|
try {
|
|
const pendingStat = await fs.promises.lstat(pendingPath);
|
|
if (pendingStat.dev === keyStat.dev && pendingStat.ino === keyStat.ino) return true;
|
|
} catch {
|
|
// Another publisher may have removed the pending link concurrently.
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function cleanupPendingSigningKeyFiles(keyPath, expectedStat) {
|
|
const keyStat = expectedStat ?? await fs.promises.lstat(keyPath).catch(() => null);
|
|
if (!keyStat) return;
|
|
for (const pendingPath of await listPendingSigningKeyFiles(keyPath)) {
|
|
try {
|
|
const pendingStat = await fs.promises.lstat(pendingPath);
|
|
if (pendingStat.dev === keyStat.dev && pendingStat.ino === keyStat.ino) {
|
|
await safeUnlink(pendingPath);
|
|
}
|
|
} catch {
|
|
// Best effort; the key remains usable even if cleanup loses a race.
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadOrCreateToolOutputSigningKey(safeStorage) {
|
|
if (!isSecureToolOutputStorageAvailable(safeStorage)) return null;
|
|
const keyPath = path.join(getTempDir(), TOOL_OUTPUT_SIGNING_KEY_FILE);
|
|
const generation = tempDirRebindGeneration;
|
|
const isCurrentGeneration = () => generation === tempDirRebindGeneration;
|
|
const isCurrentTempDir = () => {
|
|
try {
|
|
return isCurrentGeneration() && getTempDir() === path.dirname(keyPath);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
try {
|
|
const stat = await fs.promises.lstat(keyPath);
|
|
if (stat.isFile() && !stat.isSymbolicLink() && (stat.nlink === 1 || stat.nlink === 2) && stat.size <= 4096) {
|
|
const opened = await openSafeToolOutputFile(keyPath, 4096, false, true);
|
|
if (!opened) return null;
|
|
try {
|
|
const encrypted = await readFileAtMost(opened.file, 4096);
|
|
if (!encrypted) return null;
|
|
if (!isCurrentTempDir()) return null;
|
|
const decoded = Buffer.from(safeStorage.decryptString(encrypted), "base64");
|
|
if (decoded.length === 32 && isCurrentTempDir()) {
|
|
if (stat.nlink === 2) await cleanupPendingSigningKeyFiles(keyPath, stat);
|
|
return decoded;
|
|
}
|
|
} catch {
|
|
// A locked or temporarily unavailable OS keychain must not destroy the
|
|
// only key capable of verifying previously persisted output.
|
|
return null;
|
|
} finally {
|
|
await opened.file.close().catch(() => {});
|
|
}
|
|
}
|
|
if (!isCurrentTempDir()) return null;
|
|
if ((stat.isFile() || stat.isSymbolicLink())) {
|
|
await fs.promises.unlink(keyPath);
|
|
} else {
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") return null;
|
|
}
|
|
|
|
const key = crypto.randomBytes(32);
|
|
const pendingPath = `${keyPath}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.pending`;
|
|
try {
|
|
if (!isCurrentTempDir()) return null;
|
|
const encrypted = safeStorage.encryptString(key.toString("base64"));
|
|
await fs.promises.writeFile(pendingPath, encrypted, { mode: 0o600, flag: "wx" });
|
|
if (!isCurrentTempDir()) return null;
|
|
await fs.promises.link(pendingPath, keyPath);
|
|
await safeUnlink(pendingPath);
|
|
return key;
|
|
} catch (error) {
|
|
await safeUnlink(pendingPath);
|
|
if (error?.code !== "EEXIST") return null;
|
|
if (!isCurrentTempDir()) return null;
|
|
try {
|
|
const opened = await openSafeToolOutputFile(keyPath, 4096, false);
|
|
if (!opened) return null;
|
|
try {
|
|
const encrypted = await readFileAtMost(opened.file, 4096);
|
|
if (!encrypted) return null;
|
|
if (!isCurrentTempDir()) return null;
|
|
const decoded = Buffer.from(safeStorage.decryptString(encrypted), "base64");
|
|
if (decoded.length !== 32 || !isCurrentTempDir()) return null;
|
|
await cleanupPendingSigningKeyFiles(keyPath, await fs.promises.lstat(keyPath).catch(() => null));
|
|
return decoded;
|
|
} finally {
|
|
await opened.file.close().catch(() => {});
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function configureToolOutputSigningKey(electronModule) {
|
|
if (!electronModule) return;
|
|
toolOutputSafeStorage = electronModule.safeStorage;
|
|
toolOutputSigningKeyPromise = loadOrCreateToolOutputSigningKey(toolOutputSafeStorage);
|
|
}
|
|
|
|
async function getToolOutputSigningKey({ retry = true } = {}) {
|
|
const generation = tempDirRebindGeneration;
|
|
const key = await toolOutputSigningKeyPromise.catch(() => null);
|
|
if (generation !== tempDirRebindGeneration) {
|
|
return retry ? getToolOutputSigningKey({ retry }) : null;
|
|
}
|
|
if (key || !retry || !toolOutputSafeStorage) return key;
|
|
if (toolOutputSigningKeyRecoveryPromise && toolOutputSigningKeyRecoveryGeneration === generation) {
|
|
return toolOutputSigningKeyRecoveryPromise;
|
|
}
|
|
const recovery = loadOrCreateToolOutputSigningKey(toolOutputSafeStorage).catch(() => null);
|
|
const recoveryGeneration = tempDirRebindGeneration;
|
|
toolOutputSigningKeyRecoveryPromise = recovery;
|
|
toolOutputSigningKeyRecoveryGeneration = recoveryGeneration;
|
|
try {
|
|
const recovered = await recovery;
|
|
if (recoveryGeneration !== tempDirRebindGeneration) {
|
|
return retry ? getToolOutputSigningKey({ retry }) : null;
|
|
}
|
|
toolOutputSigningKeyPromise = Promise.resolve(recovered);
|
|
return recovered;
|
|
} finally {
|
|
if (toolOutputSigningKeyRecoveryPromise === recovery) {
|
|
toolOutputSigningKeyRecoveryPromise = null;
|
|
toolOutputSigningKeyRecoveryGeneration = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function ensureToolOutputSigningKeyFile(key, expectedGeneration = tempDirRebindGeneration) {
|
|
if (!isSecureToolOutputStorageAvailable(toolOutputSafeStorage)) return true;
|
|
const keyPath = path.join(getTempDir(), TOOL_OUTPUT_SIGNING_KEY_FILE);
|
|
const mustRevalidateKey = expectedGeneration !== tempDirRebindGeneration;
|
|
try {
|
|
const stat = await fs.promises.lstat(keyPath);
|
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) return false;
|
|
if (stat.nlink !== 1 && !(stat.nlink === 2 && await hasPendingSigningKeyFile(keyPath, stat))) return false;
|
|
if (!mustRevalidateKey) return true;
|
|
const opened = await openSafeToolOutputFile(keyPath, 4096, false);
|
|
if (!opened) return false;
|
|
try {
|
|
const encrypted = await readFileAtMost(opened.file, 4096);
|
|
if (!encrypted) return false;
|
|
const persistedKey = Buffer.from(toolOutputSafeStorage.decryptString(encrypted), "base64");
|
|
return persistedKey.length === key.length
|
|
&& crypto.timingSafeEqual(persistedKey, key);
|
|
} finally {
|
|
await opened.file.close().catch(() => {});
|
|
}
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT" || mustRevalidateKey) return false;
|
|
}
|
|
if (mustRevalidateKey) {
|
|
return false;
|
|
}
|
|
try {
|
|
const encrypted = toolOutputSafeStorage.encryptString(key.toString("base64"));
|
|
await fs.promises.writeFile(keyPath, encrypted, { mode: 0o600, flag: "wx" });
|
|
return true;
|
|
} catch (error) {
|
|
return error?.code === "EEXIST";
|
|
}
|
|
}
|
|
|
|
function unsignedToolOutputManifest(manifest) {
|
|
return {
|
|
record: manifest.record,
|
|
contentFile: manifest.contentFile,
|
|
contentBytes: manifest.contentBytes,
|
|
contentSha256: manifest.contentSha256,
|
|
};
|
|
}
|
|
|
|
function signToolOutputManifest(manifest, key) {
|
|
return crypto.createHmac("sha256", key)
|
|
.update(JSON.stringify(unsignedToolOutputManifest(manifest)))
|
|
.digest("hex");
|
|
}
|
|
|
|
async function hasValidToolOutputManifestSignature(manifest, signingKey) {
|
|
const key = signingKey ?? await getToolOutputSigningKey();
|
|
if (!key || !isBoundedString(manifest.signature, 64) || !/^[a-f0-9]{64}$/.test(manifest.signature)) return false;
|
|
const expected = Buffer.from(signToolOutputManifest(manifest, key), "hex");
|
|
const actual = Buffer.from(manifest.signature, "hex");
|
|
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
|
}
|
|
|
|
function resolvePrivateTempDir(systemTempDir = os.tmpdir(), homeDir = os.homedir()) {
|
|
if (typeof process.getuid !== "function") {
|
|
return path.join(systemTempDir, NETCATTY_TEMP_DIR_NAME);
|
|
}
|
|
try {
|
|
const stat = fs.lstatSync(systemTempDir);
|
|
const isPrivate = stat.isDirectory()
|
|
&& !stat.isSymbolicLink()
|
|
&& stat.uid === process.getuid()
|
|
&& (stat.mode & 0o077) === 0;
|
|
if (isPrivate) return path.join(systemTempDir, NETCATTY_TEMP_DIR_NAME);
|
|
} catch {
|
|
// Fall through to the stable per-user directory.
|
|
}
|
|
return path.join(homeDir, ".netcatty", "tmp", NETCATTY_TEMP_DIR_NAME);
|
|
}
|
|
|
|
/**
|
|
* Get the Netcatty temp directory path
|
|
* Creates the directory if it doesn't exist
|
|
*/
|
|
function getTempDir() {
|
|
if (cachedTempDir) {
|
|
try {
|
|
assertSafeTempDir(cachedTempDir, cachedTempDirIdentity);
|
|
return cachedTempDir;
|
|
} catch (error) {
|
|
// ENOENT: OS/temp cleaner removed the directory. Identity mismatch: the
|
|
// path still exists but was deleted and recreated (new inode). Both are
|
|
// recoverable if the replacement still passes full safety checks below.
|
|
const identityChanged = error?.message === "Netcatty temp directory identity changed during this process.";
|
|
if (error?.code !== "ENOENT" && !identityChanged) throw error;
|
|
cachedTempDir = null;
|
|
cachedTempDirIdentity = null;
|
|
// The previous key file lived on the old inode (or vanished with ENOENT).
|
|
// Drop it so the next getToolOutputSigningKey() reloads from the rebound root.
|
|
tempDirRebindGeneration += 1;
|
|
toolOutputSigningKeyPromise = Promise.resolve(null);
|
|
}
|
|
}
|
|
|
|
const netcattyTempDir = resolvePrivateTempDir();
|
|
|
|
try {
|
|
if (!fs.existsSync(netcattyTempDir)) {
|
|
fs.mkdirSync(netcattyTempDir, { recursive: true, mode: 0o700 });
|
|
console.log(`[TempDir] Created Netcatty temp directory: ${netcattyTempDir}`);
|
|
}
|
|
const safeStat = assertSafeTempDir(netcattyTempDir);
|
|
cachedTempDir = netcattyTempDir;
|
|
cachedTempDirIdentity = { dev: safeStat.dev, ino: safeStat.ino };
|
|
return netcattyTempDir;
|
|
} catch (err) {
|
|
console.error(`[TempDir] Failed to create temp directory:`, err.message);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function getTempDirRebindGeneration() {
|
|
return tempDirRebindGeneration;
|
|
}
|
|
|
|
function assertSafeTempDir(tempDir, expectedIdentity) {
|
|
const stat = fs.lstatSync(tempDir);
|
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Netcatty temp path is not a safe directory.");
|
|
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
|
throw new Error("Netcatty temp directory is not owned by the current user.");
|
|
}
|
|
if (expectedIdentity && (stat.dev !== expectedIdentity.dev || stat.ino !== expectedIdentity.ino)) {
|
|
throw new Error("Netcatty temp directory identity changed during this process.");
|
|
}
|
|
fs.chmodSync(tempDir, 0o700);
|
|
const expectedRealPath = path.join(fs.realpathSync(path.dirname(tempDir)), path.basename(tempDir));
|
|
if (fs.realpathSync(tempDir) !== expectedRealPath) {
|
|
throw new Error("Netcatty temp directory must not traverse symbolic links.");
|
|
}
|
|
return stat;
|
|
}
|
|
|
|
/**
|
|
* Ensure the temp directory exists (call on app startup)
|
|
*/
|
|
function ensureTempDir() {
|
|
const tempDir = getTempDir();
|
|
console.log(`[TempDir] Netcatty temp directory: ${tempDir}`);
|
|
return tempDir;
|
|
}
|
|
|
|
/**
|
|
* Get temp directory info (path, size, file count)
|
|
*/
|
|
async function getTempDirInfo() {
|
|
const tempDir = getTempDir();
|
|
|
|
try {
|
|
const files = await fs.promises.readdir(tempDir);
|
|
let totalSize = 0;
|
|
let fileCount = 0;
|
|
|
|
for (const file of files) {
|
|
if (file === TOOL_OUTPUT_SIGNING_KEY_FILE) continue;
|
|
try {
|
|
const filePath = path.join(tempDir, file);
|
|
const stat = await fs.promises.stat(filePath);
|
|
if (stat.isFile()) {
|
|
totalSize += stat.size;
|
|
fileCount++;
|
|
}
|
|
} catch {
|
|
// Skip files that can't be stat'd
|
|
}
|
|
}
|
|
|
|
return {
|
|
path: tempDir,
|
|
totalSize,
|
|
fileCount,
|
|
};
|
|
} catch (err) {
|
|
console.error(`[TempDir] Failed to get temp dir info:`, err.message);
|
|
return {
|
|
path: tempDir,
|
|
totalSize: 0,
|
|
fileCount: 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all files in the temp directory
|
|
* Returns the number of files deleted
|
|
*/
|
|
async function clearTempDir() {
|
|
const tempDir = getTempDir();
|
|
let deletedCount = 0;
|
|
let failedCount = 0;
|
|
const resetUnavailableSigningKey = Boolean(toolOutputSafeStorage)
|
|
&& !await getToolOutputSigningKey();
|
|
|
|
try {
|
|
const files = await fs.promises.readdir(tempDir);
|
|
|
|
for (const file of files) {
|
|
if (file === TOOL_OUTPUT_SIGNING_KEY_FILE && !resetUnavailableSigningKey) continue;
|
|
try {
|
|
const filePath = path.join(tempDir, file);
|
|
const stat = await fs.promises.stat(filePath);
|
|
|
|
if (stat.isFile()) {
|
|
await fs.promises.unlink(filePath);
|
|
deletedCount++;
|
|
console.log(`[TempDir] Deleted: ${file}`);
|
|
} else if (stat.isDirectory()) {
|
|
// Recursively delete subdirectories
|
|
await fs.promises.rm(filePath, { recursive: true, force: true });
|
|
deletedCount++;
|
|
console.log(`[TempDir] Deleted directory: ${file}`);
|
|
}
|
|
} catch (err) {
|
|
failedCount++;
|
|
console.log(`[TempDir] Could not delete ${file}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
if (resetUnavailableSigningKey) {
|
|
toolOutputSigningKeyPromise = Promise.resolve(null);
|
|
await getToolOutputSigningKey();
|
|
}
|
|
|
|
console.log(`[TempDir] Cleanup complete: ${deletedCount} deleted, ${failedCount} failed`);
|
|
return { deletedCount, failedCount };
|
|
} catch (err) {
|
|
console.error(`[TempDir] Failed to clear temp dir:`, err.message);
|
|
return { deletedCount: 0, failedCount: 0, error: err.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a unique temp file path for a given filename
|
|
*/
|
|
function getTempFilePath(fileName) {
|
|
const tempDir = getTempDir();
|
|
const timestamp = Date.now();
|
|
tempFileCounter = (tempFileCounter + 1) % 1000000;
|
|
const safeFileName = fileName.replace(/[<>:"/\\|?*]/g, "_");
|
|
return path.join(tempDir, `${timestamp}_${tempFileCounter}_${safeFileName}`);
|
|
}
|
|
|
|
function getTransferTempFilePath(transferId, fileName) {
|
|
const tempDir = getTempDir();
|
|
const safeTransferId = String(transferId || "transfer").replace(/[^A-Za-z0-9_-]/g, "_");
|
|
const safeFileName = String(fileName || "file").replace(/[<>:"/\\|?*]/g, "_");
|
|
return path.join(tempDir, `.transfer_${safeTransferId}_${safeFileName}.part`);
|
|
}
|
|
|
|
function isNetcattyTempPath(filePath) {
|
|
if (typeof filePath !== "string" || !filePath) return false;
|
|
const tempDir = path.resolve(getTempDir());
|
|
const resolved = path.resolve(filePath);
|
|
const relative = path.relative(tempDir, resolved);
|
|
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
}
|
|
|
|
async function openSafeToolOutputFile(
|
|
filePath,
|
|
maxBytes = MAX_TOOL_OUTPUT_TEMP_BYTES,
|
|
requireEvenBytes = true,
|
|
allowSigningKeyPublication = false,
|
|
) {
|
|
if (!isNetcattyTempPath(filePath)) return null;
|
|
let file;
|
|
try {
|
|
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
file = await fs.promises.open(filePath, fs.constants.O_RDONLY | noFollow);
|
|
const stat = await file.stat();
|
|
assertSafeTempDir(getTempDir(), cachedTempDirIdentity);
|
|
const pathStat = await fs.promises.lstat(filePath);
|
|
if (pathStat.isSymbolicLink() || pathStat.dev !== stat.dev || pathStat.ino !== stat.ino) {
|
|
await file.close();
|
|
return null;
|
|
}
|
|
const publishedSigningKey = allowSigningKeyPublication
|
|
&& stat.nlink === 2
|
|
&& await hasPendingSigningKeyFile(filePath, stat);
|
|
if (
|
|
!stat.isFile()
|
|
|| (!publishedSigningKey && stat.nlink !== 1)
|
|
|| stat.size > maxBytes
|
|
|| (requireEvenBytes && stat.size % 2 !== 0)
|
|
) {
|
|
await file.close();
|
|
return null;
|
|
}
|
|
return { file, stat };
|
|
} catch {
|
|
await file?.close().catch(() => {});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isBoundedString(value, maxLength, allowEmpty = false) {
|
|
return typeof value === "string"
|
|
&& value.length <= maxLength
|
|
&& (allowEmpty || value.length > 0);
|
|
}
|
|
|
|
function isSafeToolOutputRecord(record) {
|
|
return record
|
|
&& record.schemaVersion === 1
|
|
&& isBoundedString(record.handleId, 200)
|
|
&& /^[A-Za-z0-9_.-]+$/.test(record.handleId)
|
|
&& isBoundedString(record.chatSessionId, 512)
|
|
&& isBoundedString(record.capabilityId, 256)
|
|
&& (record.terminalSessionId == null || isBoundedString(record.terminalSessionId, 512))
|
|
&& Number.isSafeInteger(record.totalChars)
|
|
&& record.totalChars >= 0
|
|
&& Number.isSafeInteger(record.storedChars)
|
|
&& record.storedChars >= 0
|
|
&& record.storedChars <= MAX_TOOL_OUTPUT_TEMP_CHARS
|
|
&& record.totalChars >= record.storedChars
|
|
&& typeof record.sourceTruncated === "boolean"
|
|
&& isBoundedString(record.preview, 2_000, true)
|
|
&& Number.isFinite(record.storedAt)
|
|
&& record.storedAt > 0
|
|
&& Number.isFinite(record.accessedAt)
|
|
&& record.accessedAt > 0;
|
|
}
|
|
|
|
function toolOutputManifestPath(filePath) {
|
|
return `${filePath}.meta.json`;
|
|
}
|
|
|
|
function toolOutputOwnershipMarker(chatSessionId, terminalSessionId) {
|
|
const digest = value => crypto.createHash("sha256").update(value).digest("hex").slice(0, 24);
|
|
return `_tool-output-${digest(chatSessionId)}-${digest(terminalSessionId ?? "")}-`;
|
|
}
|
|
|
|
async function deleteToolOutputsByOwnership(chatSessionId, terminalSessionId) {
|
|
const tempDir = getTempDir();
|
|
const generation = tempDirRebindGeneration;
|
|
const marker = terminalSessionId == null
|
|
? `_tool-output-${crypto.createHash("sha256").update(chatSessionId).digest("hex").slice(0, 24)}-`
|
|
: toolOutputOwnershipMarker(chatSessionId, terminalSessionId);
|
|
let deletedCount = 0;
|
|
let files = [];
|
|
try {
|
|
files = await fs.promises.readdir(tempDir);
|
|
} catch {
|
|
return { deletedCount };
|
|
}
|
|
for (const file of files) {
|
|
if (!file.includes(marker) || !file.endsWith(".log")) continue;
|
|
if (await deleteToolOutputPair(path.join(tempDir, file), generation)) deletedCount += 1;
|
|
}
|
|
return { deletedCount };
|
|
}
|
|
|
|
async function deleteToolOutputsByTerminal(terminalSessionId) {
|
|
const tempDir = getTempDir();
|
|
const generation = tempDirRebindGeneration;
|
|
const terminalHash = crypto.createHash("sha256").update(terminalSessionId).digest("hex").slice(0, 24);
|
|
const marker = new RegExp(`_tool-output-[a-f0-9]{24}-${terminalHash}-`);
|
|
let deletedCount = 0;
|
|
let files = [];
|
|
try {
|
|
files = await fs.promises.readdir(tempDir);
|
|
} catch {
|
|
return { deletedCount };
|
|
}
|
|
for (const file of files) {
|
|
if (!marker.test(file) || !file.endsWith(".log")) continue;
|
|
if (await deleteToolOutputPair(path.join(tempDir, file), generation)) deletedCount += 1;
|
|
}
|
|
return { deletedCount };
|
|
}
|
|
|
|
async function safeUnlink(filePath, expectedGeneration) {
|
|
// Deletion must stay bound to the generation that validated this pathname.
|
|
// If the temp root was rebound, the same pathname now addresses the
|
|
// replacement root, so the unlink must abort instead of deleting that data.
|
|
if (expectedGeneration != null && tempDirRebindGeneration !== expectedGeneration) return false;
|
|
if (!isNetcattyTempPath(filePath)) return false;
|
|
try {
|
|
const stat = await fs.promises.lstat(filePath);
|
|
if (expectedGeneration != null && tempDirRebindGeneration !== expectedGeneration) return false;
|
|
if (!stat.isFile() || stat.isSymbolicLink()) return false;
|
|
await fs.promises.unlink(filePath);
|
|
return true;
|
|
} catch (error) {
|
|
return error?.code === "ENOENT";
|
|
}
|
|
}
|
|
|
|
async function deleteToolOutputPair(filePath, expectedGeneration) {
|
|
const manifestPath = toolOutputManifestPath(filePath);
|
|
const manifestDeleted = await safeUnlink(manifestPath, expectedGeneration);
|
|
const contentDeleted = await safeUnlink(filePath, expectedGeneration);
|
|
return manifestDeleted && contentDeleted;
|
|
}
|
|
|
|
async function readSafeManifest(manifestPath, signingKey) {
|
|
if (!isNetcattyTempPath(manifestPath) || !manifestPath.endsWith(".log.meta.json")) return null;
|
|
const entryTempDir = getTempDir();
|
|
const entryGeneration = tempDirRebindGeneration;
|
|
let file;
|
|
try {
|
|
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
file = await fs.promises.open(manifestPath, fs.constants.O_RDONLY | noFollow);
|
|
const stat = await file.stat();
|
|
const pathStat = await fs.promises.lstat(manifestPath);
|
|
if (
|
|
!stat.isFile()
|
|
|| stat.nlink !== 1
|
|
|| stat.size > TOOL_OUTPUT_MANIFEST_MAX_BYTES
|
|
|| pathStat.isSymbolicLink()
|
|
|| pathStat.dev !== stat.dev
|
|
|| pathStat.ino !== stat.ino
|
|
) return null;
|
|
const parsed = JSON.parse(await file.readFile({ encoding: "utf8" }));
|
|
if (!isSafeToolOutputRecord(parsed.record)) return null;
|
|
if (!isBoundedString(parsed.contentFile, 512) || path.basename(parsed.contentFile) !== parsed.contentFile) return null;
|
|
if (!Number.isSafeInteger(parsed.contentBytes) || parsed.contentBytes < 0 || parsed.contentBytes > MAX_TOOL_OUTPUT_TEMP_BYTES) return null;
|
|
if (!isBoundedString(parsed.contentSha256, 64) || !/^[a-f0-9]{64}$/.test(parsed.contentSha256)) return null;
|
|
if (!await hasValidToolOutputManifestSignature(parsed, signingKey)) return null;
|
|
const contentPath = path.join(entryTempDir, parsed.contentFile);
|
|
if (toolOutputManifestPath(contentPath) !== manifestPath) return null;
|
|
if (entryGeneration !== tempDirRebindGeneration || getTempDir() !== entryTempDir) return null;
|
|
return {
|
|
manifest: parsed,
|
|
manifestPath,
|
|
manifestStat: stat,
|
|
contentPath,
|
|
tempDir: entryTempDir,
|
|
tempDirRebindGeneration: entryGeneration,
|
|
};
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
await file?.close().catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function readSafeManifestWithTempRootRecovery(manifestPath, signingKey, retry = true) {
|
|
const generation = tempDirRebindGeneration;
|
|
const entry = await readSafeManifest(manifestPath, signingKey);
|
|
if (entry || !retry) return entry;
|
|
try {
|
|
getTempDir();
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (generation === tempDirRebindGeneration) return null;
|
|
return readSafeManifest(manifestPath);
|
|
}
|
|
|
|
function isToolOutputEntryStale(entry) {
|
|
if (entry.tempDir != null || entry.tempDirRebindGeneration != null) {
|
|
try {
|
|
if (entry.tempDir != null && entry.tempDir !== getTempDir()) return true;
|
|
} catch {
|
|
// The temp root cannot be resolved right now; treat the entry as stale.
|
|
return true;
|
|
}
|
|
if (entry.tempDirRebindGeneration != null && entry.tempDirRebindGeneration !== tempDirRebindGeneration) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function readVerifiedManifestContent(entry) {
|
|
if (isToolOutputEntryStale(entry)) return null;
|
|
const generation = tempDirRebindGeneration;
|
|
const opened = await openSafeToolOutputFile(entry.contentPath);
|
|
if (!opened) return null;
|
|
try {
|
|
// The temp root may be rebound while the content is being read; never
|
|
// verify data across generations because entry.contentPath would then
|
|
// address the replacement root instead of the manifest's own root.
|
|
if (generation !== tempDirRebindGeneration) return null;
|
|
if (opened.stat.size !== entry.manifest.contentBytes) return null;
|
|
const contentBuffer = await opened.file.readFile();
|
|
if (generation !== tempDirRebindGeneration) return null;
|
|
const digest = crypto.createHash("sha256").update(contentBuffer).digest("hex");
|
|
if (digest !== entry.manifest.contentSha256) return null;
|
|
if (generation !== tempDirRebindGeneration) return null;
|
|
return { stat: opened.stat, contentBuffer };
|
|
} finally {
|
|
await opened.file.close();
|
|
}
|
|
}
|
|
|
|
async function verifyManifestContent(entry) {
|
|
return Boolean(await readVerifiedManifestContent(entry));
|
|
}
|
|
|
|
async function listToolOutputManifestEntries(retry = true) {
|
|
const tempDir = getTempDir();
|
|
const generation = tempDirRebindGeneration;
|
|
const entries = [];
|
|
const signingKey = await getToolOutputSigningKey();
|
|
if (generation !== tempDirRebindGeneration) {
|
|
return retry ? listToolOutputManifestEntries(false) : [];
|
|
}
|
|
if (!signingKey) return entries;
|
|
let files = [];
|
|
try {
|
|
files = await fs.promises.readdir(tempDir);
|
|
} catch {
|
|
if (retry) {
|
|
try {
|
|
getTempDir();
|
|
} catch {
|
|
// Return the normal empty result if the temp root cannot recover.
|
|
}
|
|
if (generation !== tempDirRebindGeneration) {
|
|
return listToolOutputManifestEntries(false);
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
if (generation !== tempDirRebindGeneration || getTempDir() !== tempDir) {
|
|
return retry ? listToolOutputManifestEntries(false) : [];
|
|
}
|
|
for (const file of files) {
|
|
if (!file.endsWith(".log.meta.json")) continue;
|
|
const entry = await readSafeManifestWithTempRootRecovery(path.join(tempDir, file), signingKey);
|
|
if (generation !== tempDirRebindGeneration || getTempDir() !== tempDir) {
|
|
return retry ? listToolOutputManifestEntries(false) : [];
|
|
}
|
|
if (entry) entries.push(entry);
|
|
}
|
|
if (generation !== tempDirRebindGeneration || getTempDir() !== tempDir) {
|
|
return retry ? listToolOutputManifestEntries(false) : [];
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
async function touchToolOutputEntry(entry, now = new Date()) {
|
|
const tempDir = getTempDir();
|
|
const generation = tempDirRebindGeneration;
|
|
if (
|
|
(entry.tempDir && entry.tempDir !== tempDir)
|
|
|| (entry.tempDirRebindGeneration != null && entry.tempDirRebindGeneration !== generation)
|
|
|| path.dirname(entry.manifestPath) !== tempDir
|
|
) return false;
|
|
const key = await getToolOutputSigningKey();
|
|
if (!key || generation !== tempDirRebindGeneration) return false;
|
|
const pendingPath = getTempFilePath(`${entry.manifest.record.handleId}.manifest.pending`);
|
|
if (generation !== tempDirRebindGeneration || path.dirname(pendingPath) !== tempDir) return false;
|
|
const manifest = {
|
|
...unsignedToolOutputManifest(entry.manifest),
|
|
record: { ...entry.manifest.record, accessedAt: now.getTime() },
|
|
};
|
|
manifest.signature = signToolOutputManifest(manifest, key);
|
|
try {
|
|
await fs.promises.writeFile(pendingPath, JSON.stringify(manifest), {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
flag: "wx",
|
|
});
|
|
getTempDir();
|
|
if (generation !== tempDirRebindGeneration) throw createTempDirReboundError();
|
|
await fs.promises.rename(pendingPath, entry.manifestPath);
|
|
getTempDir();
|
|
if (generation !== tempDirRebindGeneration) throw createTempDirReboundError();
|
|
entry.manifest = manifest;
|
|
entry.manifestStat = await fs.promises.stat(entry.manifestPath);
|
|
return true;
|
|
} catch {
|
|
if (generation === tempDirRebindGeneration) await safeUnlink(pendingPath, generation);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isToolOutputEntryExpired(entry, now = Date.now()) {
|
|
return now - entry.manifest.record.accessedAt >= TOOL_OUTPUT_PERSISTED_TTL_MS;
|
|
}
|
|
|
|
async function enforcePersistedToolOutputLimits() {
|
|
const entries = await listToolOutputManifestEntries();
|
|
const active = [...entries].sort((a, b) => (
|
|
b.manifest.record.accessedAt - a.manifest.record.accessedAt
|
|
));
|
|
const kept = [];
|
|
const sessionCounts = new Map();
|
|
const sessionChars = new Map();
|
|
let globalChars = 0;
|
|
for (const entry of active) {
|
|
if (!await verifyManifestContent(entry)) {
|
|
// Never delete a pair whose pathname may now address a rebound root.
|
|
if (!isToolOutputEntryStale(entry)) {
|
|
await deleteToolOutputPair(entry.contentPath, entry.tempDirRebindGeneration);
|
|
}
|
|
continue;
|
|
}
|
|
const { chatSessionId, storedChars } = entry.manifest.record;
|
|
const sessionCount = sessionCounts.get(chatSessionId) ?? 0;
|
|
const sessionTotal = sessionChars.get(chatSessionId) ?? 0;
|
|
const keep = kept.length < TOOL_OUTPUT_MAX_HANDLES_GLOBAL
|
|
&& globalChars + storedChars <= TOOL_OUTPUT_MAX_CHARS_GLOBAL
|
|
&& sessionCount < TOOL_OUTPUT_MAX_HANDLES_PER_SESSION
|
|
&& sessionTotal + storedChars <= TOOL_OUTPUT_MAX_CHARS_PER_SESSION;
|
|
if (!keep) {
|
|
await deleteToolOutputPair(entry.contentPath, entry.tempDirRebindGeneration);
|
|
continue;
|
|
}
|
|
kept.push(entry);
|
|
globalChars += storedChars;
|
|
sessionCounts.set(chatSessionId, sessionCount + 1);
|
|
sessionChars.set(chatSessionId, sessionTotal + storedChars);
|
|
}
|
|
}
|
|
|
|
async function cleanupExpiredToolOutputFiles(now = Date.now()) {
|
|
const tempDir = getTempDir();
|
|
const generation = tempDirRebindGeneration;
|
|
let deletedCount = 0;
|
|
try {
|
|
const files = await fs.promises.readdir(tempDir);
|
|
const signingKeyAvailable = Boolean(await getToolOutputSigningKey());
|
|
const managedContent = new Set();
|
|
for (const file of files) {
|
|
if (
|
|
file.endsWith(".manifest.pending")
|
|
|| (file.startsWith(`${TOOL_OUTPUT_SIGNING_KEY_FILE}.`) && file.endsWith(".pending"))
|
|
) {
|
|
const pendingPath = path.join(tempDir, file);
|
|
try {
|
|
const stat = await fs.promises.lstat(pendingPath);
|
|
if (stat.isFile() && !stat.isSymbolicLink() && now - stat.mtimeMs >= TOOL_OUTPUT_ORPHAN_TTL_MS) {
|
|
if (await safeUnlink(pendingPath, generation)) deletedCount += 1;
|
|
}
|
|
} catch {
|
|
// Best-effort startup cleanup.
|
|
}
|
|
continue;
|
|
}
|
|
if (!file.endsWith(".log.meta.json")) continue;
|
|
const manifestPath = path.join(tempDir, file);
|
|
if (!signingKeyAvailable) {
|
|
try {
|
|
const stat = await fs.promises.lstat(manifestPath);
|
|
if (stat.isSymbolicLink() || !stat.isFile()) continue;
|
|
const contentPath = manifestPath.slice(0, -".meta.json".length);
|
|
if (now - stat.mtimeMs >= TOOL_OUTPUT_PERSISTED_TTL_MS) {
|
|
if (await safeUnlink(manifestPath, generation)) deletedCount += 1;
|
|
if (await safeUnlink(contentPath, generation)) deletedCount += 1;
|
|
} else {
|
|
managedContent.add(path.basename(contentPath));
|
|
}
|
|
} catch {
|
|
// Best-effort startup cleanup.
|
|
}
|
|
continue;
|
|
}
|
|
const entry = await readSafeManifest(manifestPath);
|
|
if (!entry) {
|
|
try {
|
|
const stat = await fs.promises.lstat(manifestPath);
|
|
if (stat.isFile() && !stat.isSymbolicLink() && now - stat.mtimeMs >= TOOL_OUTPUT_ORPHAN_TTL_MS) {
|
|
if (await safeUnlink(manifestPath, generation)) deletedCount += 1;
|
|
}
|
|
} catch {
|
|
// Best-effort startup cleanup.
|
|
}
|
|
continue;
|
|
}
|
|
if (!await verifyManifestContent(entry)) {
|
|
// Never delete a pair whose pathname may now address a rebound root.
|
|
if (!isToolOutputEntryStale(entry)) {
|
|
if (await safeUnlink(entry.manifestPath, generation)) deletedCount += 1;
|
|
if (await safeUnlink(entry.contentPath, generation)) deletedCount += 1;
|
|
}
|
|
continue;
|
|
}
|
|
managedContent.add(path.basename(entry.contentPath));
|
|
if (!isToolOutputEntryExpired(entry, now)) continue;
|
|
if (await safeUnlink(entry.manifestPath, generation)) deletedCount += 1;
|
|
if (await safeUnlink(entry.contentPath, generation)) deletedCount += 1;
|
|
}
|
|
for (const file of files) {
|
|
if (!file.includes("_tool-output-") || !file.endsWith(".log")) continue;
|
|
if (managedContent.has(file)) continue;
|
|
const filePath = path.join(tempDir, file);
|
|
try {
|
|
const stat = await fs.promises.lstat(filePath);
|
|
if (stat.isSymbolicLink() || !stat.isFile()) continue;
|
|
if (now - stat.mtimeMs < TOOL_OUTPUT_ORPHAN_TTL_MS) continue;
|
|
await fs.promises.unlink(filePath);
|
|
deletedCount += 1;
|
|
} catch {
|
|
// Best-effort startup cleanup.
|
|
}
|
|
}
|
|
} catch {
|
|
// Temp persistence is optional; keep startup resilient.
|
|
}
|
|
if (await getToolOutputSigningKey()) {
|
|
await enforcePersistedToolOutputLimits();
|
|
}
|
|
return deletedCount;
|
|
}
|
|
|
|
function safeUtf16SliceBounds(content, requestedStart, requestedEnd) {
|
|
let start = Math.min(content.length, Math.max(0, requestedStart));
|
|
let end = Math.min(content.length, Math.max(start, requestedEnd));
|
|
const isHigh = value => value >= 0xd800 && value <= 0xdbff;
|
|
const isLow = value => value >= 0xdc00 && value <= 0xdfff;
|
|
if (start > 0 && start < content.length && isLow(content.charCodeAt(start))) start -= 1;
|
|
if (end > start && end < content.length && isHigh(content.charCodeAt(end - 1))) end -= 1;
|
|
return [start, end];
|
|
}
|
|
|
|
async function readToolOutputChunk(content, request) {
|
|
const storedChars = content.length;
|
|
const requestedMax = Number.isFinite(request?.maxChars) ? Math.floor(request.maxChars) : TOOL_OUTPUT_READ_MAX_CHARS;
|
|
const maxChars = Math.min(TOOL_OUTPUT_READ_MAX_CHARS, Math.max(1, requestedMax));
|
|
const mode = request?.mode ?? "head";
|
|
|
|
if (mode === "search") {
|
|
const query = String(request?.query ?? "");
|
|
if (!query) {
|
|
return { mode, content: "Search query is required.", totalChars: storedChars, startOffset: 0, endOffset: 0, nextOffset: 0, hasMore: false, matchOffsets: [] };
|
|
}
|
|
const haystack = content.toLocaleLowerCase();
|
|
const needle = query.toLocaleLowerCase();
|
|
const offsets = [];
|
|
let cursor = Math.max(0, Math.floor(request?.offset ?? 0));
|
|
while (offsets.length < TOOL_OUTPUT_SEARCH_MAX_MATCHES) {
|
|
const match = haystack.indexOf(needle, cursor);
|
|
if (match < 0) break;
|
|
offsets.push(match);
|
|
cursor = match + Math.max(1, needle.length);
|
|
}
|
|
const excerpts = [];
|
|
const renderedOffsets = [];
|
|
let renderedChars = 0;
|
|
for (const match of offsets) {
|
|
const [start, end] = safeUtf16SliceBounds(content, match - TOOL_OUTPUT_SEARCH_CONTEXT_CHARS, match + query.length + TOOL_OUTPUT_SEARCH_CONTEXT_CHARS);
|
|
const excerpt = `[match offset=${match}]\n${content.slice(start, end)}`;
|
|
const separator = excerpts.length > 0 ? "\n\n" : "";
|
|
const available = maxChars - renderedChars - separator.length;
|
|
if (available <= 0) break;
|
|
if (excerpt.length > available) {
|
|
if (excerpts.length > 0) break;
|
|
const [, safeEnd] = safeUtf16SliceBounds(excerpt, 0, available);
|
|
excerpts.push(excerpt.slice(0, safeEnd));
|
|
renderedOffsets.push(match);
|
|
renderedChars += safeEnd;
|
|
break;
|
|
}
|
|
excerpts.push(excerpt);
|
|
renderedOffsets.push(match);
|
|
renderedChars += separator.length + excerpt.length;
|
|
}
|
|
const nextOffset = renderedOffsets.length
|
|
? renderedOffsets[renderedOffsets.length - 1] + Math.max(1, query.length)
|
|
: storedChars;
|
|
return {
|
|
mode,
|
|
content: excerpts.join("\n\n") || `No matches found for "${query}".`,
|
|
totalChars: storedChars,
|
|
startOffset: Math.max(0, Math.floor(request?.offset ?? 0)),
|
|
endOffset: nextOffset,
|
|
nextOffset,
|
|
hasMore: haystack.indexOf(needle, nextOffset) >= 0,
|
|
matchOffsets: renderedOffsets,
|
|
};
|
|
}
|
|
|
|
let startOffset = mode === "tail"
|
|
? Math.max(0, storedChars - maxChars)
|
|
: mode === "range" ? Math.min(storedChars, Math.max(0, Math.floor(request?.offset ?? 0))) : 0;
|
|
const readStart = Math.max(0, startOffset - 1);
|
|
const window = content.slice(readStart, Math.min(storedChars, readStart + maxChars + 2));
|
|
const relativeStart = startOffset - readStart;
|
|
const [safeStart, safeEnd] = safeUtf16SliceBounds(window, relativeStart, relativeStart + maxChars);
|
|
startOffset = readStart + safeStart;
|
|
const chunk = window.slice(safeStart, safeEnd);
|
|
const endOffset = startOffset + chunk.length;
|
|
return { mode, content: chunk, totalChars: storedChars, startOffset, endOffset, nextOffset: endOffset, hasMore: endOffset < storedChars };
|
|
}
|
|
|
|
/**
|
|
* Register IPC handlers
|
|
*/
|
|
function registerHandlers(ipcMain, shell, electronModule) {
|
|
configureToolOutputSigningKey(electronModule);
|
|
void cleanupExpiredToolOutputFiles();
|
|
ipcMain.handle("netcatty:tempdir:getInfo", async () => {
|
|
return getTempDirInfo();
|
|
});
|
|
|
|
ipcMain.handle("netcatty:tempdir:clear", async () => {
|
|
return clearTempDir();
|
|
});
|
|
|
|
ipcMain.handle("netcatty:tempdir:getPath", () => {
|
|
return getTempDir();
|
|
});
|
|
|
|
ipcMain.handle("netcatty:tempdir:createUploadPath", (_event, payload = {}) => (
|
|
getTransferTempFilePath(payload.transferId, payload.fileName)
|
|
));
|
|
|
|
ipcMain.handle("netcatty:tempdir:open", async () => {
|
|
const tempDir = getTempDir();
|
|
if (shell?.openPath) {
|
|
await shell.openPath(tempDir);
|
|
return { success: true };
|
|
}
|
|
return { success: false };
|
|
});
|
|
|
|
ipcMain.handle("netcatty:tempdir:toolOutputPersistenceStatus", async () => {
|
|
const durable = Boolean(await getToolOutputSigningKey());
|
|
return {
|
|
durable,
|
|
reason: durable ? undefined : "Secure local storage is unavailable.",
|
|
};
|
|
});
|
|
|
|
ipcMain.handle("netcatty:tempdir:toolOutputWrite", async (_event, payload = {}) => {
|
|
const content = String(payload.content ?? "");
|
|
const record = payload.record;
|
|
if (
|
|
!isSafeToolOutputRecord(record)
|
|
|| content.length > MAX_TOOL_OUTPUT_TEMP_CHARS
|
|
|| record.storedChars !== content.length
|
|
) {
|
|
return { ok: false, error: "Tool output exceeds the temp-file limit." };
|
|
}
|
|
const contentBuffer = Buffer.from(content, "utf16le");
|
|
if (contentBuffer.length > MAX_TOOL_OUTPUT_TEMP_BYTES) {
|
|
return { ok: false, error: "Tool output exceeds the temp-file limit." };
|
|
}
|
|
const ownershipMarker = toolOutputOwnershipMarker(record.chatSessionId, record.terminalSessionId);
|
|
const chatDeletionGeneration = getToolOutputChatDeletionGeneration(record.chatSessionId);
|
|
const filePath = getTempFilePath(`${ownershipMarker.slice(1)}${record.handleId}.log`);
|
|
const manifestPath = toolOutputManifestPath(filePath);
|
|
const pendingManifestPath = getTempFilePath(`${record.handleId}.manifest.pending`);
|
|
const writeOnce = async attempt => {
|
|
const attemptGeneration = tempDirRebindGeneration;
|
|
let validatedGeneration = attemptGeneration;
|
|
try {
|
|
if (record.terminalSessionId && closedToolOutputTerminalSessions.has(record.terminalSessionId)) {
|
|
throw new Error("Terminal session is already closed.");
|
|
}
|
|
await toolOutputSessionDeletions.get(record.chatSessionId);
|
|
if (getToolOutputChatDeletionGeneration(record.chatSessionId) !== chatDeletionGeneration) {
|
|
throw new Error("Chat session was cleared while output was being saved.");
|
|
}
|
|
let signingKey = await getToolOutputSigningKey();
|
|
if (!signingKey) throw new Error("Secure local storage is unavailable.");
|
|
const signingKeyGeneration = tempDirRebindGeneration;
|
|
if (!await ensureToolOutputSigningKeyFile(signingKey, signingKeyGeneration)) {
|
|
// The temp root may have been rebound after the key was acquired.
|
|
// Reload once so this write cannot sign into the replacement root with
|
|
// a key that only belongs to the old inode.
|
|
toolOutputSigningKeyPromise = Promise.resolve(null);
|
|
signingKey = await getToolOutputSigningKey();
|
|
if (!signingKey || !await ensureToolOutputSigningKeyFile(signingKey, tempDirRebindGeneration)) {
|
|
throw new Error("Unable to prepare secure local storage.");
|
|
}
|
|
}
|
|
validatedGeneration = tempDirRebindGeneration;
|
|
await fs.promises.writeFile(filePath, contentBuffer, { mode: 0o600, flag: "wx" });
|
|
getTempDir();
|
|
if (tempDirRebindGeneration !== validatedGeneration) {
|
|
throw createTempDirReboundError();
|
|
}
|
|
const manifest = {
|
|
record,
|
|
contentFile: path.basename(filePath),
|
|
contentBytes: contentBuffer.length,
|
|
contentSha256: crypto.createHash("sha256").update(contentBuffer).digest("hex"),
|
|
};
|
|
manifest.signature = signToolOutputManifest(manifest, signingKey);
|
|
await fs.promises.writeFile(pendingManifestPath, JSON.stringify(manifest), {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
flag: "wx",
|
|
});
|
|
await fs.promises.rename(pendingManifestPath, manifestPath);
|
|
getTempDir();
|
|
if (tempDirRebindGeneration !== validatedGeneration) {
|
|
throw createTempDirReboundError();
|
|
}
|
|
if (getToolOutputChatDeletionGeneration(record.chatSessionId) !== chatDeletionGeneration) {
|
|
await deleteToolOutputPair(filePath, validatedGeneration);
|
|
throw new Error("Chat session was cleared while output was being saved.");
|
|
}
|
|
if (record.terminalSessionId && closedToolOutputTerminalSessions.has(record.terminalSessionId)) {
|
|
await deleteToolOutputPair(filePath, validatedGeneration);
|
|
throw new Error("Terminal session closed while output was being saved.");
|
|
}
|
|
await enforcePersistedToolOutputLimits();
|
|
getTempDir();
|
|
if (tempDirRebindGeneration !== validatedGeneration) {
|
|
throw createTempDirReboundError();
|
|
}
|
|
const persistedEntry = await readSafeManifest(manifestPath);
|
|
if (tempDirRebindGeneration !== validatedGeneration) {
|
|
throw createTempDirReboundError();
|
|
}
|
|
if (!persistedEntry || path.resolve(persistedEntry.contentPath) !== path.resolve(filePath)) {
|
|
throw new Error("Saved output was removed while enforcing storage limits.");
|
|
}
|
|
if (getToolOutputChatDeletionGeneration(record.chatSessionId) !== chatDeletionGeneration) {
|
|
await deleteToolOutputPair(filePath, validatedGeneration);
|
|
throw new Error("Chat session was cleared while output was being saved.");
|
|
}
|
|
if (record.terminalSessionId && closedToolOutputTerminalSessions.has(record.terminalSessionId)) {
|
|
await deleteToolOutputPair(filePath, validatedGeneration);
|
|
throw new Error("Terminal session closed while output was being saved.");
|
|
}
|
|
return { ok: true, path: filePath, manifestPath };
|
|
} catch (error) {
|
|
await Promise.allSettled([
|
|
safeUnlink(pendingManifestPath, validatedGeneration),
|
|
safeUnlink(manifestPath, validatedGeneration),
|
|
safeUnlink(filePath, validatedGeneration),
|
|
]);
|
|
let tempDirWasRebound = error?.code === TOOL_OUTPUT_TEMP_DIR_REBOUND;
|
|
if (!tempDirWasRebound && error?.code === "ENOENT" && attempt === 0) {
|
|
try {
|
|
getTempDir();
|
|
} catch {
|
|
// The normal error is returned below if the temp root cannot recover.
|
|
}
|
|
tempDirWasRebound = tempDirRebindGeneration !== attemptGeneration;
|
|
}
|
|
if (tempDirWasRebound && attempt === 0) {
|
|
return writeOnce(1);
|
|
}
|
|
return { ok: false, error: error?.message || "Unable to persist tool output." };
|
|
}
|
|
};
|
|
return writeOnce(0);
|
|
});
|
|
|
|
ipcMain.handle("netcatty:tempdir:toolOutputRestore", async (_event, payload = {}) => {
|
|
const handleId = String(payload.handleId ?? "");
|
|
const chatSessionId = String(payload.chatSessionId ?? "");
|
|
if (!isBoundedString(handleId, 200) || !isBoundedString(chatSessionId, 512)) return null;
|
|
const restoreOnce = async (attempt) => {
|
|
const chatDeletionGeneration = getToolOutputChatDeletionGeneration(chatSessionId);
|
|
await toolOutputSessionDeletions.get(chatSessionId);
|
|
if (getToolOutputChatDeletionGeneration(chatSessionId) !== chatDeletionGeneration) return null;
|
|
const entries = await listToolOutputManifestEntries();
|
|
const entry = entries.find(candidate => (
|
|
candidate.manifest.record.handleId === handleId
|
|
&& candidate.manifest.record.chatSessionId === chatSessionId
|
|
));
|
|
if (!entry) return null;
|
|
if (
|
|
entry.manifest.record.terminalSessionId
|
|
&& closedToolOutputTerminalSessions.has(entry.manifest.record.terminalSessionId)
|
|
) {
|
|
await deleteToolOutputPair(entry.contentPath, entry.tempDirRebindGeneration);
|
|
return null;
|
|
}
|
|
if (isToolOutputEntryExpired(entry)) {
|
|
await deleteToolOutputPair(entry.contentPath, entry.tempDirRebindGeneration);
|
|
return null;
|
|
}
|
|
if (!await verifyManifestContent(entry)) {
|
|
// A rebound temp root may hold different data at the same pathname;
|
|
// never delete it based on a stale generation's verification failure.
|
|
// Retry enumeration and verification against the current generation
|
|
// instead of reporting a durable handle as missing.
|
|
if (isToolOutputEntryStale(entry)) {
|
|
return attempt === 0 ? restoreOnce(1) : null;
|
|
}
|
|
await deleteToolOutputPair(entry.contentPath, entry.tempDirRebindGeneration);
|
|
return null;
|
|
}
|
|
if (
|
|
entry.manifest.record.terminalSessionId
|
|
&& closedToolOutputTerminalSessions.has(entry.manifest.record.terminalSessionId)
|
|
) {
|
|
await deleteToolOutputPair(entry.contentPath, entry.tempDirRebindGeneration);
|
|
return null;
|
|
}
|
|
await touchToolOutputEntry(entry);
|
|
if (getToolOutputChatDeletionGeneration(chatSessionId) !== chatDeletionGeneration) {
|
|
await deleteToolOutputPair(entry.contentPath, entry.tempDirRebindGeneration);
|
|
return null;
|
|
}
|
|
if (
|
|
entry.manifest.record.terminalSessionId
|
|
&& closedToolOutputTerminalSessions.has(entry.manifest.record.terminalSessionId)
|
|
) {
|
|
await deleteToolOutputPair(entry.contentPath, entry.tempDirRebindGeneration);
|
|
return null;
|
|
}
|
|
return {
|
|
path: entry.contentPath,
|
|
record: entry.manifest.record,
|
|
};
|
|
};
|
|
return restoreOnce(0);
|
|
});
|
|
|
|
ipcMain.handle("netcatty:tempdir:toolOutputRead", async (_event, payload = {}) => {
|
|
const filePath = payload.path;
|
|
const readOnce = async (attempt) => {
|
|
const manifestEntry = await readSafeManifestWithTempRootRecovery(toolOutputManifestPath(filePath));
|
|
if (!manifestEntry || path.resolve(manifestEntry.contentPath) !== path.resolve(filePath)) return null;
|
|
const attemptGeneration = manifestEntry.tempDirRebindGeneration;
|
|
// Only remove persisted data while the temp root still matches the
|
|
// manifest's generation; after a rebind the same pathname addresses the
|
|
// replacement root, so the read must be retried instead of deleting
|
|
// current-generation data.
|
|
const deletePairIfCurrentGeneration = async () => {
|
|
if (tempDirRebindGeneration !== attemptGeneration) return;
|
|
await deleteToolOutputPair(manifestEntry.contentPath, attemptGeneration);
|
|
};
|
|
const chatSessionId = manifestEntry.manifest.record.chatSessionId;
|
|
const chatDeletionGeneration = getToolOutputChatDeletionGeneration(chatSessionId);
|
|
await toolOutputSessionDeletions.get(chatSessionId);
|
|
if (getToolOutputChatDeletionGeneration(chatSessionId) !== chatDeletionGeneration) return null;
|
|
if (
|
|
manifestEntry.manifest.record.terminalSessionId
|
|
&& closedToolOutputTerminalSessions.has(manifestEntry.manifest.record.terminalSessionId)
|
|
) {
|
|
await deletePairIfCurrentGeneration();
|
|
return null;
|
|
}
|
|
if (isToolOutputEntryExpired(manifestEntry)) {
|
|
await deletePairIfCurrentGeneration();
|
|
return null;
|
|
}
|
|
const verified = await readVerifiedManifestContent(manifestEntry);
|
|
if (!verified) {
|
|
if (tempDirRebindGeneration !== attemptGeneration) {
|
|
return attempt === 0 ? readOnce(1) : null;
|
|
}
|
|
await deletePairIfCurrentGeneration();
|
|
return null;
|
|
}
|
|
const content = verified.contentBuffer.toString("utf16le");
|
|
const result = !payload.request ? content : await readToolOutputChunk(content, payload.request);
|
|
await touchToolOutputEntry(manifestEntry);
|
|
if (getToolOutputChatDeletionGeneration(chatSessionId) !== chatDeletionGeneration) {
|
|
await deletePairIfCurrentGeneration();
|
|
return null;
|
|
}
|
|
if (
|
|
manifestEntry.manifest.record.terminalSessionId
|
|
&& closedToolOutputTerminalSessions.has(manifestEntry.manifest.record.terminalSessionId)
|
|
) {
|
|
await deletePairIfCurrentGeneration();
|
|
return null;
|
|
}
|
|
return result;
|
|
};
|
|
return readOnce(0);
|
|
});
|
|
|
|
ipcMain.handle("netcatty:tempdir:toolOutputDelete", async (_event, payload = {}) => {
|
|
const filePath = payload.path;
|
|
if (!isNetcattyTempPath(filePath)) return { ok: false };
|
|
return { ok: await deleteToolOutputPair(filePath) };
|
|
});
|
|
|
|
ipcMain.handle("netcatty:tempdir:toolOutputDeleteSession", async (_event, payload = {}) => {
|
|
const chatSessionId = String(payload.chatSessionId ?? "");
|
|
if (!isBoundedString(chatSessionId, 512)) return { deletedCount: 0 };
|
|
toolOutputChatDeletionGenerations.set(
|
|
chatSessionId,
|
|
getToolOutputChatDeletionGeneration(chatSessionId) + 1,
|
|
);
|
|
const existing = toolOutputSessionDeletions.get(chatSessionId);
|
|
if (existing) return existing;
|
|
const deletion = (async () => {
|
|
return deleteToolOutputsByOwnership(chatSessionId);
|
|
})().finally(() => {
|
|
if (toolOutputSessionDeletions.get(chatSessionId) === deletion) {
|
|
toolOutputSessionDeletions.delete(chatSessionId);
|
|
}
|
|
});
|
|
toolOutputSessionDeletions.set(chatSessionId, deletion);
|
|
return deletion;
|
|
});
|
|
|
|
ipcMain.handle("netcatty:tempdir:toolOutputDeleteTerminalSession", async (_event, payload = {}) => {
|
|
const chatSessionId = String(payload.chatSessionId ?? "");
|
|
const terminalSessionId = String(payload.terminalSessionId ?? "");
|
|
if (!isBoundedString(chatSessionId, 512) || !isBoundedString(terminalSessionId, 512)) {
|
|
return { deletedCount: 0 };
|
|
}
|
|
closedToolOutputTerminalSessions.add(terminalSessionId);
|
|
return deleteToolOutputsByOwnership(chatSessionId, terminalSessionId);
|
|
});
|
|
|
|
ipcMain.handle("netcatty:tempdir:toolOutputDeleteTerminal", async (_event, payload = {}) => {
|
|
const terminalSessionId = String(payload.terminalSessionId ?? "");
|
|
if (!isBoundedString(terminalSessionId, 512)) return { deletedCount: 0 };
|
|
closedToolOutputTerminalSessions.add(terminalSessionId);
|
|
return deleteToolOutputsByTerminal(terminalSessionId);
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
getTempDir,
|
|
getTempDirRebindGeneration,
|
|
ensureTempDir,
|
|
getTempDirInfo,
|
|
clearTempDir,
|
|
getTempFilePath,
|
|
getTransferTempFilePath,
|
|
cleanupExpiredToolOutputFiles,
|
|
registerHandlers,
|
|
resolvePrivateTempDir,
|
|
isSecureToolOutputStorageAvailable,
|
|
};
|