[GH-ISSUE #6100] [Bug] Detached PGP cancellation drops the caller's exact abort reason #3732

Closed
opened 2026-10-31 00:43:27 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 21, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6100

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Setup, packaging, or upgrade

Observed behavior

verifyDetachedPgpSignature returns DetachedPgpSignatureError(reason: "cancelled") with cause === undefined when the caller aborts with an explicit reason. The result loses null, primitive string and object reasons, both before admission and when cancellation arrives while verification is awaiting.

A focused production-boundary test also reaches this omission through LinuxHopperPublisher.verifyArchive: the returned chain is HopperReleaseError(cancelled) → DetachedPgpSignatureError(cancelled) → undefined. Cancellation classification survives, but the caller's exact reason does not.

Reproduction

Executed two forms: a built-helper script with real generated signing keys/signatures (one successful control and six cancellation cases), then the focused source test below through the actual Linux publisher. The publisher test substitutes the fixture publisher fingerprint and schedules cancellation after the real signature helper starts; file reads, digest checks, cryptography, error wrapping and resource close are real. The bytes are never unpacked or installed because cancellation is observed first.

Save this test as src/application/pgpCancellation.repro.test.ts in the prepared checkout. Set REA_PGP_REPRO_ROOT to an existing disposable writable directory and run:

node node_modules/vitest/vitest.mjs run src/application/pgpCancellation.repro.test.ts --maxWorkers=1
import { createHash } from 'node:crypto';
import { mkdtemp, realpath, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { createMessage, generateKey, readPrivateKey, sign } from 'openpgp';
import { expect, it, vi } from 'vitest';
import { LinuxHopperPublisher } from './LinuxHopperPublisher.js';
import * as signatures from '../process/DetachedPgpSignature.js';

it('observes exact cancellation reason loss through Linux publisher verification', async () => {
 if (!process.env.REA_PGP_REPRO_ROOT) throw new Error('Set REA_PGP_REPRO_ROOT to a disposable writable directory');
 const root=await realpath(await mkdtemp(join(process.env.REA_PGP_REPRO_ROOT,'pgp-reason-')));
 const archive=join(root,'publisher-package');
 const bytes=Buffer.from('package fixture');
 await writeFile(archive,bytes);
 const generated=await generateKey({type:'ecc',curve:'curve25519Legacy',userIDs:[{name:'Fixture publisher'}],format:'armored'});
 const key=await readPrivateKey({armoredKey:generated.privateKey});
 const fingerprint=key.getFingerprint().toUpperCase();
 const signature=await sign({message:await createMessage({binary:bytes}),signingKeys:key,detached:true,format:'binary'});
 const release={version:'1.0',filename:'https://www.hopperapp.com:443/downloader/public/Hopper-1.0-Linux-demo.deb',file_length:String(bytes.length),file_hash:createHash('sha1').update(bytes).digest('hex'),main_exe_hash:'0'.repeat(64),signature:Buffer.from(signature).toString('base64'),public:'1' as const,beta:'0' as const};
 const realVerify=signatures.verifyDetachedPgpSignature;
 try {
  for(const reason of [null,'opaque token',{kind:'opaque object'}]) {
   const controller=new AbortController();
   let reached=false;
   const spy=vi.spyOn(signatures,'verifyDetachedPgpSignature').mockImplementation((payload,sig,publicKey,_primary,signal)=>{
    reached=true;
    const pending=realVerify(payload,sig,publicKey,fingerprint,signal);
    queueMicrotask(()=>controller.abort(reason));
    return pending;
   });
   const publisher=new LinuxHopperPublisher({},async ()=>'deb');
   try {
    const result=await publisher.verifyArchive('deb',release,archive,controller.signal,generated.publicKey);
    expect(reached).toBe(true);
    expect(result).toMatchObject({ok:false,error:{_tag:'HopperReleaseError',reason:'cancelled',cause:{_tag:'DetachedPgpSignatureError',reason:'cancelled'}}});
    if(!result.ok){
     const inner=result.error.cause as signatures.DetachedPgpSignatureError;
     expect(inner.cause).toBeUndefined();
     console.log(JSON.stringify({reasonKind:reason===null?'null':typeof reason,outer:result.error._tag,outerReason:result.error.reason,inner:inner._tag,innerReason:inner.reason,exactReasonRetained:inner.cause===reason}));
    }
   } finally { await publisher.close();spy.mockRestore(); }
  }
 } finally { vi.restoreAllMocks();await rm(root,{recursive:true,force:true}); }
});

The single test passes by characterizing the defective outcome for all three reason types. It was rerun after making its retained source portable, with the same results. Each publisher close succeeded and each owned input directory was removed.

Expected behavior

Retain the exact caller AbortSignal.reason as the typed cancellation error's cause, including null and non-Error values, and keep it recoverable through the publisher wrapper. The lifecycle contract explicitly requires exact caller-reason preservation, including cancellation observed after provider completion. The error constructor already accepts ErrorOptions.

Impact and scope

P2: typed cancellation diagnostics irretrievably lose caller-selected provenance. Consumers receiving the publisher failure chain cannot distinguish those reason values. A caller can separately retain its own signal, but the returned error should preserve the declared contract.

Cancellation still reports cancelled; no accepted invalid package, bypass of signature checking, data loss or leaked resource was observed. The direct helper and controlled publisher boundary were executed on macOS. Linux installation and public setup/CLI cancellation timing were not executed; their use of this publisher is source-derived.

REA version

rea-agents 6.7.0; source 52415975942a4b9c53a817915b313ccc404f280e.

Runtime and platform

macOS 27.0.1 arm64; Node.js v26.12.0; locked OpenPGP 6.3.2 and Vitest 5.0.3. No Hopper executable, package manager or network operation.

Target and artifact context

Generated ECC fixture publisher and detached binary signature over authored package bytes. The test supplies matching package length and SHA-1 metadata and a supplied public key. Only the pinned publisher identity is replaced for this controlled boundary; no vendor authenticity claim is made.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

The built helper's uncancelled valid-signature control returned ok: true. Each of six calls (three reason kinds, before and during verification) returned reason: cancelled, causeAbsent: true, exactReasonRetained: false.

The focused publisher test reported the following shape for reasonKind values null, string, and object:

{
  "outer": "HopperReleaseError",
  "outerReason": "cancelled",
  "inner": "DetachedPgpSignatureError",
  "innerReason": "cancelled",
  "exactReasonRetained": false
}

The test asserts that the inner error's cause is undefined. Its production helper is invoked with the actual caller signal, and the real publisher returns that inner error through normal wrapping and successful cleanup.

Typed outcome or recovery expected

DetachedPgpSignatureError("cancelled", ..., { cause: signal.reason }), or an equivalent producer-owned representation preserving exact reason identity through the publisher. Preserve the existing cancellation classification and signature-validation failure behavior.

Analysis or suspected cause

The local cancellation constructor supplies no cause. Checks after readKey, signature reading, signing-key verification, and the catch path all reuse it. The publisher wraps the result as HopperReleaseError with the already incomplete inner error as its cause. Successful operation cleanup returns that result unchanged.

The setup route is source-derived: installLinuxHopper calls the system host's archive-verification method, whose implementation delegates to this publisher. The method name is verifyArchive.

Additional context

Relevant helper and publisher source files are unchanged at freshly fetched main d8a9673fc44fde4bd30ff98c241886e6683580ca; that later revision was inspected, not executed.

Related cancellation-provenance reports cover other producers: JavaScript artifact reading (#5935), derived MCP operations (#5944), Rizin (#5959), call-path tracing (#5960), JavaScript recovery (#5964), managed static entry (#5985), JavaScript application checkpoints (#5987), asset catalogs (#5998), ZIP extraction (#5999), WABT (#6002), DMG commands (#6032), LLDB discovery (#6037), web-script export (#6042), and managed snapshot finalization (#6065). This report concerns the separate cancellation constructor in the detached OpenPGP helper and its publisher wrapper.

#4638 concerns exact cancellation facts owned by runOwnedCommand. Closed PR #4711 concerns Kuna query waits, and PR #5296 concerns command-failure precedence. This helper calls OpenPGP directly and does not traverse those owners, so their fixes cannot preserve its omitted cause. Merged PR #3590 introduced the current publisher and tests cancellation classification, but the omission remains in the tested source. #4735 concerns native signature-snapshot finalization, and #5920 concerns archive-write failure diagnosis. No same-owner duplicate was found.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6100 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Setup, packaging, or upgrade ### Observed behavior `verifyDetachedPgpSignature` returns `DetachedPgpSignatureError(reason: "cancelled")` with `cause === undefined` when the caller aborts with an explicit reason. The result loses null, primitive string and object reasons, both before admission and when cancellation arrives while verification is awaiting. A focused production-boundary test also reaches this omission through `LinuxHopperPublisher.verifyArchive`: the returned chain is `HopperReleaseError(cancelled)` → `DetachedPgpSignatureError(cancelled)` → `undefined`. Cancellation classification survives, but the caller's exact reason does not. ### Reproduction Executed two forms: a built-helper script with real generated signing keys/signatures (one successful control and six cancellation cases), then the focused source test below through the actual Linux publisher. The publisher test substitutes the fixture publisher fingerprint and schedules cancellation after the real signature helper starts; file reads, digest checks, cryptography, error wrapping and resource close are real. The bytes are never unpacked or installed because cancellation is observed first. Save this test as `src/application/pgpCancellation.repro.test.ts` in the prepared checkout. Set `REA_PGP_REPRO_ROOT` to an existing disposable writable directory and run: ```sh node node_modules/vitest/vitest.mjs run src/application/pgpCancellation.repro.test.ts --maxWorkers=1 ``` ```ts import { createHash } from 'node:crypto'; import { mkdtemp, realpath, writeFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { createMessage, generateKey, readPrivateKey, sign } from 'openpgp'; import { expect, it, vi } from 'vitest'; import { LinuxHopperPublisher } from './LinuxHopperPublisher.js'; import * as signatures from '../process/DetachedPgpSignature.js'; it('observes exact cancellation reason loss through Linux publisher verification', async () => { if (!process.env.REA_PGP_REPRO_ROOT) throw new Error('Set REA_PGP_REPRO_ROOT to a disposable writable directory'); const root=await realpath(await mkdtemp(join(process.env.REA_PGP_REPRO_ROOT,'pgp-reason-'))); const archive=join(root,'publisher-package'); const bytes=Buffer.from('package fixture'); await writeFile(archive,bytes); const generated=await generateKey({type:'ecc',curve:'curve25519Legacy',userIDs:[{name:'Fixture publisher'}],format:'armored'}); const key=await readPrivateKey({armoredKey:generated.privateKey}); const fingerprint=key.getFingerprint().toUpperCase(); const signature=await sign({message:await createMessage({binary:bytes}),signingKeys:key,detached:true,format:'binary'}); const release={version:'1.0',filename:'https://www.hopperapp.com:443/downloader/public/Hopper-1.0-Linux-demo.deb',file_length:String(bytes.length),file_hash:createHash('sha1').update(bytes).digest('hex'),main_exe_hash:'0'.repeat(64),signature:Buffer.from(signature).toString('base64'),public:'1' as const,beta:'0' as const}; const realVerify=signatures.verifyDetachedPgpSignature; try { for(const reason of [null,'opaque token',{kind:'opaque object'}]) { const controller=new AbortController(); let reached=false; const spy=vi.spyOn(signatures,'verifyDetachedPgpSignature').mockImplementation((payload,sig,publicKey,_primary,signal)=>{ reached=true; const pending=realVerify(payload,sig,publicKey,fingerprint,signal); queueMicrotask(()=>controller.abort(reason)); return pending; }); const publisher=new LinuxHopperPublisher({},async ()=>'deb'); try { const result=await publisher.verifyArchive('deb',release,archive,controller.signal,generated.publicKey); expect(reached).toBe(true); expect(result).toMatchObject({ok:false,error:{_tag:'HopperReleaseError',reason:'cancelled',cause:{_tag:'DetachedPgpSignatureError',reason:'cancelled'}}}); if(!result.ok){ const inner=result.error.cause as signatures.DetachedPgpSignatureError; expect(inner.cause).toBeUndefined(); console.log(JSON.stringify({reasonKind:reason===null?'null':typeof reason,outer:result.error._tag,outerReason:result.error.reason,inner:inner._tag,innerReason:inner.reason,exactReasonRetained:inner.cause===reason})); } } finally { await publisher.close();spy.mockRestore(); } } } finally { vi.restoreAllMocks();await rm(root,{recursive:true,force:true}); } }); ``` The single test passes by characterizing the defective outcome for all three reason types. It was rerun after making its retained source portable, with the same results. Each publisher close succeeded and each owned input directory was removed. ### Expected behavior Retain the exact caller `AbortSignal.reason` as the typed cancellation error's cause, including null and non-Error values, and keep it recoverable through the publisher wrapper. The [lifecycle contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L101-L110) explicitly requires exact caller-reason preservation, including cancellation observed after provider completion. The error constructor already accepts `ErrorOptions`. ### Impact and scope P2: typed cancellation diagnostics irretrievably lose caller-selected provenance. Consumers receiving the publisher failure chain cannot distinguish those reason values. A caller can separately retain its own signal, but the returned error should preserve the declared contract. Cancellation still reports `cancelled`; no accepted invalid package, bypass of signature checking, data loss or leaked resource was observed. The direct helper and controlled publisher boundary were executed on macOS. Linux installation and public setup/CLI cancellation timing were not executed; their use of this publisher is source-derived. ### REA version `rea-agents` 6.7.0; source `52415975942a4b9c53a817915b313ccc404f280e`. ### Runtime and platform macOS 27.0.1 arm64; Node.js v26.12.0; locked OpenPGP 6.3.2 and Vitest 5.0.3. No Hopper executable, package manager or network operation. ### Target and artifact context Generated ECC fixture publisher and detached binary signature over authored package bytes. The test supplies matching package length and SHA-1 metadata and a supplied public key. Only the pinned publisher identity is replaced for this controlled boundary; no vendor authenticity claim is made. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence The built helper's uncancelled valid-signature control returned `ok: true`. Each of six calls (three reason kinds, before and during verification) returned `reason: cancelled`, `causeAbsent: true`, `exactReasonRetained: false`. The focused publisher test reported the following shape for `reasonKind` values `null`, `string`, and `object`: ```json { "outer": "HopperReleaseError", "outerReason": "cancelled", "inner": "DetachedPgpSignatureError", "innerReason": "cancelled", "exactReasonRetained": false } ``` The test asserts that the inner error's cause is undefined. Its production helper is invoked with the actual caller signal, and the real publisher returns that inner error through normal wrapping and successful cleanup. ### Typed outcome or recovery expected `DetachedPgpSignatureError("cancelled", ..., { cause: signal.reason })`, or an equivalent producer-owned representation preserving exact reason identity through the publisher. Preserve the existing cancellation classification and signature-validation failure behavior. ### Analysis or suspected cause The [local cancellation constructor](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/process/DetachedPgpSignature.ts#L31-L44) supplies no cause. Checks after `readKey`, signature reading, signing-key verification, and the catch path all reuse it. The [publisher](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/LinuxHopperPublisher.ts#L259-L275) wraps the result as `HopperReleaseError` with the already incomplete inner error as its cause. [Successful operation cleanup](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/LinuxHopperPublisher.ts#L614-L658) returns that result unchanged. The setup route is source-derived: [installLinuxHopper](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/LinuxHopper.ts#L304-L357) calls the system host's archive-verification method, whose [implementation delegates](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/LinuxHopper.ts#L469-L470) to this publisher. The method name is `verifyArchive`. ### Additional context Relevant helper and publisher source files are unchanged at freshly fetched main `d8a9673fc44fde4bd30ff98c241886e6683580ca`; that later revision was inspected, not executed. Related cancellation-provenance reports cover other producers: [JavaScript artifact reading (#5935)](https://github.com/morluto/rea/issues/5935), [derived MCP operations (#5944)](https://github.com/morluto/rea/issues/5944), [Rizin (#5959)](https://github.com/morluto/rea/issues/5959), [call-path tracing (#5960)](https://github.com/morluto/rea/issues/5960), [JavaScript recovery (#5964)](https://github.com/morluto/rea/issues/5964), [managed static entry (#5985)](https://github.com/morluto/rea/issues/5985), [JavaScript application checkpoints (#5987)](https://github.com/morluto/rea/issues/5987), [asset catalogs (#5998)](https://github.com/morluto/rea/issues/5998), [ZIP extraction (#5999)](https://github.com/morluto/rea/issues/5999), [WABT (#6002)](https://github.com/morluto/rea/issues/6002), [DMG commands (#6032)](https://github.com/morluto/rea/issues/6032), [LLDB discovery (#6037)](https://github.com/morluto/rea/issues/6037), [web-script export (#6042)](https://github.com/morluto/rea/issues/6042), and [managed snapshot finalization (#6065)](https://github.com/morluto/rea/issues/6065). This report concerns the separate cancellation constructor in the detached OpenPGP helper and its publisher wrapper. #4638 concerns exact cancellation facts owned by `runOwnedCommand`. Closed PR #4711 concerns Kuna query waits, and PR #5296 concerns command-failure precedence. This helper calls OpenPGP directly and does not traverse those owners, so their fixes cannot preserve its omitted cause. Merged PR #3590 introduced the current publisher and tests cancellation classification, but the omission remains in the tested source. #4735 concerns native signature-snapshot finalization, and #5920 concerns archive-write failure diagnosis. No same-owner duplicate was found.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3732
No description provided.