From d8bff043804e57ecc2c752c39dbb927db7e4c077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Jul 2026 03:34:11 -0400 Subject: [PATCH] fix: preserve failed transaction rollbacks --- AGENTS.md | 2 +- src/plugins/managed-installation.ts | 52 +++++++++++++- test/codex-transaction.test.mjs | 105 ++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 57f7348..1f47602 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ This repository owns the published `@elydora/sdk` package, its `elydora` CLI, lo - Avoid compatibility shims without a named public or user configuration contract. - Keep private keys and API tokens out of process arguments and generated setup commands. Accept them through hidden terminal input or owner-only credential files. - Persist credential-bearing files through owner-only same-directory temporary files and atomic rename. -- Read runtime config, private keys, status cache, chain state, and error logs through physical-file descriptors with identity checks. Write cache and chain state atomically, and append error logs through no-follow owner-only descriptors. +- Read runtime config, private keys, status cache, chain state, and error logs through physical-file descriptors with identity checks. Write cache and validated chain state atomically, and append error logs through no-follow owner-only descriptors. Preserve rollback artifacts when recovery cannot safely restore an original file and include the recovery path in the surfaced error. - Resolve every agent runtime directory as one physical child of `~/.elydora`; reject separators, traversal segments, cross-platform reserved names, symbolic-link directories, and linked identity configs before writes or recursive removal. Validate stored directory identity before changing host CLI configuration, and require an explicit agent ID when discovery is ambiguous. ## Verification diff --git a/src/plugins/managed-installation.ts b/src/plugins/managed-installation.ts index 3c9d41e..7eca954 100644 --- a/src/plugins/managed-installation.ts +++ b/src/plugins/managed-installation.ts @@ -29,7 +29,8 @@ interface FileChange { interface StagedChange { readonly change: FileChange; readonly temporaryPath: string; - readonly rollbackPath?: string; + rollbackPath?: string; + committedSnapshot?: FileSnapshot; committed: boolean; } export interface ManagedRuntimePaths { @@ -361,16 +362,63 @@ async function commit( await assertUnchanged(staged.change, displayName); await renameFile(staged.temporaryPath, staged.change.filePath); staged.committed = true; + const current = await readPhysicalFile( + staged.change.filePath, + staged.change.label, + staged.change.maximumBytes, + ); + if (!current || current.contents !== staged.change.next) { + throw new Error( + `${staged.change.label} changed immediately after commit: ${staged.change.filePath}`, + ); + } + staged.committedSnapshot = current; +} + +async function assertCommittedUnchanged(staged: StagedChange): Promise { + const current = await readPhysicalFile( + staged.change.filePath, + staged.change.label, + staged.change.maximumBytes, + ); + const committed = staged.committedSnapshot; + if (!current || !committed + || current.contents !== committed.contents + || current.device !== committed.device + || current.inode !== committed.inode) { + throw new Error( + `${staged.change.label} changed during transaction recovery: ${staged.change.filePath}`, + ); + } +} + +function preserveRollback(staged: StagedChange, cause: unknown): Error { + if (!staged.rollbackPath) return asError(cause); + const rollbackPath = staged.rollbackPath; + staged.rollbackPath = undefined; + return new Error( + `${errorMessage(cause)}; original content preserved at ${rollbackPath}`, + { cause: asError(cause) }, + ); } async function rollback(staged: StagedChange, renameFile: RenameFile): Promise { if (!staged.committed) return; + try { + await assertCommittedUnchanged(staged); + } catch (error) { + throw preserveRollback(staged, error); + } if (!staged.change.original) { await unlinkOptional(staged.change.filePath); return; } if (!staged.rollbackPath) throw new Error(`Missing rollback data for ${staged.change.label}`); - await renameFile(staged.rollbackPath, staged.change.filePath); + try { + await renameFile(staged.rollbackPath, staged.change.filePath); + } catch (error) { + throw preserveRollback(staged, error); + } } async function cleanup(staged: StagedChange): Promise { diff --git a/test/codex-transaction.test.mjs b/test/codex-transaction.test.mjs index 6d15076..f126bf7 100644 --- a/test/codex-transaction.test.mjs +++ b/test/codex-transaction.test.mjs @@ -43,6 +43,15 @@ async function assertNoTransactionFiles(fixture) { assert.equal(names.some((name) => /\.(tmp|rollback)$/.test(name)), false); } +async function preservedRollback(fixture, basename) { + const entries = await readdir(fixture.agentDir); + const matches = entries.filter( + (name) => name.startsWith(`.${basename}.`) && name.endsWith('.rollback'), + ); + assert.equal(matches.length, 1, entries.join(', ')); + return path.join(fixture.agentDir, matches[0]); +} + test('Codex rolls back all five files when a transaction commit fails', async () => { const fixture = await createFixture(); try { @@ -131,6 +140,102 @@ test('Codex detects concurrent hook changes before committing runtime updates', } }); +test('managed installation preserves rollback data after a committed file changes', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const original = await readFile(fixture.guardScriptPath, 'utf-8'); + const source = ` + import { rename, rm, writeFile } from 'node:fs/promises'; + import { readDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + commitCodexInstallation, + prepareCodexInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const document = await readDocument(); + const prepared = await prepareCodexInstallation( + JSON.parse(process.env.ELYDORA_INSTALL_CONFIG), + { document, changed: false }, + ); + let calls = 0; + await commitCodexInstallation(prepared, async (from, to) => { + calls += 1; + if (calls === 1) { + await rename(from, to); + await rm(to); + await writeFile(to, 'external change\\n'); + return; + } + if (calls === 2) throw new Error('injected later commit failure'); + await rename(from, to); + }); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, { orgId: 'updated' })), + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /original content preserved at/i); + assert.equal(await readFile(fixture.guardScriptPath, 'utf-8'), 'external change\n'); + assert.equal(await readFile( + await preservedRollback(fixture, 'guard.js'), + 'utf-8', + ), original); + } finally { + await fixture.close(); + } +}); + +test('managed installation preserves rollback data after restore rename failure', async () => { + const fixture = await createFixture(); + try { + assert.equal((await fixture.install()).code, 0); + const original = await readFile(fixture.guardScriptPath, 'utf-8'); + const source = ` + import { rename } from 'node:fs/promises'; + import { readDocument } from ${JSON.stringify(ioModuleUrl)}; + import { + commitCodexInstallation, + prepareCodexInstallation, + } from ${JSON.stringify(installationModuleUrl)}; + const document = await readDocument(); + const prepared = await prepareCodexInstallation( + JSON.parse(process.env.ELYDORA_INSTALL_CONFIG), + { document, changed: false }, + ); + let calls = 0; + await commitCodexInstallation(prepared, async (from, to) => { + calls += 1; + if (calls === 2) throw new Error('injected later commit failure'); + if (from.endsWith('.rollback')) throw new Error('injected rollback failure'); + await rename(from, to); + }); + `; + const result = await runNode( + ['--input-type=module', '--eval', source], + { + HOME: fixture.homeDir, + USERPROFILE: fixture.homeDir, + ELYDORA_INSTALL_CONFIG: JSON.stringify(installConfig(fixture, { orgId: 'updated' })), + }, + fixture.projectDir, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /original content preserved at/i); + assert.equal(await readFile( + await preservedRollback(fixture, 'guard.js'), + 'utf-8', + ), original); + } finally { + await fixture.close(); + } +}); + test('Codex rejects a stale hook document before staging files', async () => { const fixture = await createFixture(); try {