fix: harden loop-status snapshot writes

This commit is contained in:
Affaan Mustafa
2026-04-30 12:07:22 -04:00
committed by Affaan Mustafa
parent 2715315438
commit 61992f7f5e
2 changed files with 117 additions and 23 deletions

View File

@@ -614,12 +614,20 @@ function hashString(value) {
return crypto.createHash('sha256').update(String(value)).digest('hex'); return crypto.createHash('sha256').update(String(value)).digest('hex');
} }
function isWindowsReservedBasename(value) {
const basename = String(value).split('.')[0];
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(basename);
}
function sanitizeSnapshotName(value, fallback = 'session') { function sanitizeSnapshotName(value, fallback = 'session') {
const raw = String(value || '').trim() || fallback; const raw = String(value || '').trim() || fallback;
const sanitized = raw.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^_+|_+$/g, ''); const sanitized = raw.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^_+|_+$/g, '');
if (sanitized && sanitized.length <= 96) { if (sanitized && sanitized.length <= 96 && !isWindowsReservedBasename(sanitized)) {
return sanitized; return sanitized;
} }
if (sanitized && isWindowsReservedBasename(sanitized)) {
return `${sanitized}-${hashString(raw).slice(0, 8)}`;
}
const prefix = sanitized ? sanitized.slice(0, 48).replace(/[._-]+$/g, '') : fallback; const prefix = sanitized ? sanitized.slice(0, 48).replace(/[._-]+$/g, '') : fallback;
return `${prefix || fallback}-${hashString(raw).slice(0, 12)}`; return `${prefix || fallback}-${hashString(raw).slice(0, 12)}`;
@@ -629,7 +637,18 @@ function atomicWriteJson(filePath, payload) {
const data = JSON.stringify(payload, null, 2) + '\n'; const data = JSON.stringify(payload, null, 2) + '\n';
const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`; const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
fs.writeFileSync(tempPath, data, 'utf8'); fs.writeFileSync(tempPath, data, 'utf8');
try {
fs.renameSync(tempPath, filePath); fs.renameSync(tempPath, filePath);
} catch (error) {
try {
fs.unlinkSync(tempPath);
} catch (cleanupError) {
if (cleanupError.code !== 'ENOENT') {
console.error(`[loop-status] WARNING: could not remove temporary snapshot file ${tempPath}: ${cleanupError.message}`);
}
}
throw error;
}
} }
function getSnapshotPath(outputDir, session, usedNames) { function getSnapshotPath(outputDir, session, usedNames) {

View File

@@ -6,10 +6,16 @@ const assert = require('assert');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { execFileSync } = require('child_process'); const { spawnSync } = require('child_process');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'loop-status.js'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'loop-status.js');
const { analyzeTranscript, buildStatus, getStatusExitCode, parseArgs } = require('../../scripts/loop-status'); const {
analyzeTranscript,
buildStatus,
getStatusExitCode,
parseArgs,
writeStatusSnapshots,
} = require('../../scripts/loop-status');
const NOW = '2026-04-30T10:00:00.000Z'; const NOW = '2026-04-30T10:00:00.000Z';
function run(args = [], options = {}) { function run(args = [], options = {}) {
@@ -25,8 +31,7 @@ function run(args = [], options = {}) {
envOverrides.HOME = envOverrides.USERPROFILE; envOverrides.HOME = envOverrides.USERPROFILE;
} }
try { const result = spawnSync('node', [SCRIPT, ...args], {
const stdout = execFileSync('node', [SCRIPT, ...args], {
encoding: 'utf8', encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000, timeout: 10000,
@@ -36,15 +41,13 @@ function run(args = [], options = {}) {
...envOverrides, ...envOverrides,
}, },
}); });
return { code: 0, stdout, stderr: '' };
} catch (error) {
return { return {
code: error.status || 1, code: result.status || (result.signal ? 1 : 0),
stdout: error.stdout || '', stdout: result.stdout || '',
stderr: error.stderr || '', stderr: result.stderr || '',
}; };
} }
}
function createTempHome() { function createTempHome() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-home-')); return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-home-'));
@@ -631,6 +634,77 @@ function runTests() {
} }
})) passed++; else failed++; })) passed++; else failed++;
if (test('avoids Windows reserved basenames for session snapshots', () => {
const homeDir = createTempHome();
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-windows-name-'));
try {
writeTranscript(homeDir, '-Users-affoon-project-windows-name', 'con.jsonl', [
assistantMessage('2026-04-30T09:55:00.000Z', 'con', 'Loop checkpoint.'),
]);
const result = run([
'--home',
homeDir,
'--now',
NOW,
'--json',
'--write-dir',
snapshotDir,
]);
assert.strictEqual(result.code, 0, result.stderr);
const indexPath = path.join(snapshotDir, 'index.json');
const indexPayload = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
const snapshotName = path.basename(indexPayload.sessions[0].snapshotPath);
assert.strictEqual(indexPayload.sessions[0].sessionId, 'con');
assert.notStrictEqual(snapshotName.toLowerCase(), 'con.json');
const snapshotPayload = JSON.parse(fs.readFileSync(indexPayload.sessions[0].snapshotPath, 'utf8'));
assert.strictEqual(snapshotPayload.schemaVersion, 'ecc.loop-status.session.v1');
assert.strictEqual(snapshotPayload.session.sessionId, 'con');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(snapshotDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('cleans temporary snapshot files when atomic rename fails', () => {
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-rename-failure-'));
const originalRenameSync = fs.renameSync;
try {
fs.renameSync = () => {
throw new Error('simulated rename failure');
};
assert.throws(() => writeStatusSnapshots({
errors: [],
generatedAt: NOW,
sessions: [
{
eventCount: 1,
lastEventAt: NOW,
pendingTools: [],
recommendedAction: 'No action needed.',
sessionId: 'rename-failure',
signals: [],
state: 'ok',
transcriptPath: path.join(snapshotDir, 'rename-failure.jsonl'),
},
],
source: {},
}, snapshotDir), /simulated rename failure/);
const tempFiles = fs.readdirSync(snapshotDir).filter(fileName => fileName.endsWith('.tmp'));
assert.deepStrictEqual(tempFiles, []);
} finally {
fs.renameSync = originalRenameSync;
fs.rmSync(snapshotDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('write-dir failures do not suppress normal stdout', () => { if (test('write-dir failures do not suppress normal stdout', () => {
const homeDir = createTempHome(); const homeDir = createTempHome();
@@ -655,6 +729,7 @@ function runTests() {
const payload = parsePayload(result.stdout); const payload = parsePayload(result.stdout);
assert.strictEqual(payload.schemaVersion, 'ecc.loop-status.v1'); assert.strictEqual(payload.schemaVersion, 'ecc.loop-status.v1');
assert.strictEqual(payload.sessions[0].sessionId, 'session-write-error'); assert.strictEqual(payload.sessions[0].sessionId, 'session-write-error');
assert.match(result.stderr, /\[loop-status\] WARNING: could not write status snapshots:/);
} finally { } finally {
fs.rmSync(homeDir, { recursive: true, force: true }); fs.rmSync(homeDir, { recursive: true, force: true });
} }