[GH-ISSUE #5991] [Bug] JavaScript application filesystem errors lose errno and path #3658

Closed
opened 2026-10-31 00:42:48 +00:00 by bsakaguchi · 2 comments
Owner

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

Originally assigned to: @kaoru0822-kitauji on GitHub.

Before submitting

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

Area

Evidence, artifacts, or reconstruction

Observed behavior

Through the public analyze_javascript_application MCP tool, three distinct filesystem failures returned identical errors: artifact_operation_failed, category execution_failure, reason io, generic file-access guidance, and no selected path, errno, or diagnostics.

The tested faults were injected at the native filesystem boundary: lstat raising EACCES after input resolution, realpath raising EIO, and access raising EMFILE. Each injected Error carried a path, code, syscall and message, and each replacement fired once. The actual application service, MCP registration, error projection and SDK transport ran normally. This demonstrates loss of available failure facts; it does not establish a naturally occurring permission race, hardware I/O failure or descriptor exhaustion.

Controls through the same MCP path succeeded for a readable empty directory and returned the correct access_denied / unavailable error with path and system_code: EACCES for a real mode-000 selected directory.

Reproduction

In a prepared source checkout with locked dependencies and existing build artifacts, save the following test as tests/boundary/mcp/javascriptFilesystemErrors.repro.test.ts and run:

node node_modules/vitest/vitest.mjs run tests/boundary/mcp/javascriptFilesystemErrors.repro.test.ts --maxWorkers=1

Executed on the repository-pinned Node.js 24.18.0: five tests passed. Fault assertions intentionally describe the incorrect current result. The permission control requires a non-root POSIX host. No external analysis provider is required. The optional REA_REPRO_ROOT selects an existing directory for generated fixtures; otherwise the host temporary directory is used. The native default export and syncBuiltinESMExports let the test replace only the selected filesystem boundary while the real service's named imports see that replacement.

import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
import fs from 'node:fs/promises';
import { syncBuiltinESMExports } from 'node:module';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { expect, it, vi } from 'vitest';
import { createServer } from '../../../src/server/createServer.js';
import { createTestBinarySession } from '../../fixtures/binarySession.js';

const scratch = process.env.REA_REPRO_ROOT ?? tmpdir();
const call = async (path: string) => {
  const session = createTestBinarySession(() => { throw new Error('No native provider expected'); });
  const server = createServer({ kind: 'session', session });
  const client = new Client({ name: 'javascript-fs-test', version: '1' });
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  try {
    await server.connect(serverTransport);
    await client.connect(clientTransport);
    const response = await client.callTool({ name: 'analyze_javascript_application', arguments: { input_path: path, format: 'directory' } });
    if (!response.isError) return { success: true };
    const content = response.content.find(item => item.type === 'text');
    if (content?.type !== 'text') throw new Error('Missing text error');
    return JSON.parse(content.text).error;
  } finally {
    await client.close();
    await server.close();
    await session.close();
  }
};

it('control: readable empty directory succeeds', async () => {
  const root = await fs.mkdtemp(join(scratch, 'readable-'));
  try { expect(await call(root)).toEqual({ success: true }); }
  finally { await fs.rm(root, { recursive: true, force: true }); }
});

it('control: real selected read denial is access_denied', async () => {
  const root = await fs.mkdtemp(join(scratch, 'control-'));
  await fs.chmod(root, 0);
  try {
    const result = await call(root);
    console.log('real-access-control', JSON.stringify(result));
    expect(result).toMatchObject({ code: 'access_denied', category: 'unavailable', details: { path: root, system_code: 'EACCES' } });
  } finally { await fs.chmod(root, 0o700); await fs.rm(root, { recursive: true, force: true }); }
});

it.each([
  { method: 'lstat', code: 'EACCES' },
  { method: 'realpath', code: 'EIO' },
  { method: 'access', code: 'EMFILE' },
] as const)('MCP loses $method $code details', async ({ method, code }) => {
  const root = await fs.realpath(await fs.mkdtemp(join(scratch, 'fault-')));
  const original = fs[method];
  const failure = Object.assign(new Error(`${code}: selected path ${method} failed`), { code, path: root, syscall: method });
  let injected = 0;
  const spy = vi.spyOn(fs, method).mockImplementation((async (...args: any[]) => {
    if (String(args[0]) === root) { injected++; throw failure; }
    return (original as any)(...args);
  }) as any);
  syncBuiltinESMExports();
  try {
    const result = await call(root);
    console.log('fault-result', JSON.stringify({ method, code, injected, result }));
    expect(injected).toBe(1);
    expect(result).toMatchObject({ code: 'artifact_operation_failed', category: 'execution_failure', details: { operation: 'analyze_javascript_application', reason: 'io' } });
    expect(result.details).not.toHaveProperty('path');
    expect(result.details).not.toHaveProperty('system_code');
    expect(result.details).not.toHaveProperty('diagnostics');
    expect(JSON.stringify(result)).not.toContain(root);
    expect(JSON.stringify(result)).not.toContain(code);
  } finally { spy.mockRestore(); syncBuiltinESMExports(); await fs.rm(root, { recursive: true, force: true }); }
});

Expected behavior

Retain the available selected path and filesystem reason. A permission-denied selected read should keep the existing typed access_denied / unavailable result, including its path, system_code and read-access guidance, regardless of whether denial happens during initial selection or the immediately following metadata read. Other host failures may keep a general I/O classification while retaining their actual code, path and meaningful diagnostic text.

This follows the existing AnalysisAccessDeniedError contract and public projection, and the repository requirement to preserve meaningful failure reasons and caller-selected paths. No new error schema or acceptance of unreadable input is required.

Impact and scope

P2: typed callers lose the distinction between host permission denial and generic execution failure, and cannot distinguish the tested permission, I/O and descriptor failures from the response. They receive generic file-access advice even when another host condition was supplied. Manual investigation outside REA is needed to recover the reason.

The impact is bounded to raw filesystem errors that escape the selection classifier. Ordinary initial access denial is already handled correctly. Readable analysis remains available; no corruption or wrong successful Evidence was observed. Only the listed injected faults and real permission/readable controls were executed, on one host. Natural fault frequency, real descriptor exhaustion, a real post-access permission change and other errno values were not measured.

REA version

rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e.

Runtime and platform

macOS 27.0.1 build 26A434 arm64; Node.js v24.18.0, matching the repository pin; Vitest 5.0.3. No external provider or Wakaru process was used.

Target and artifact context

Generated empty local directories. The read-denial control uses actual filesystem mode 000 and restores permissions during cleanup. The three fault cases replace one native filesystem function each only for the selected directory, using an errno-bearing Error; they do not replace an application service or provider.

Interface

MCP

Earliest failure stage

Request parsing or validation

Relevant output or evidence

All three fault cases produced this complete typed error inside the MCP text result:

{
  "code": "artifact_operation_failed",
  "category": "execution_failure",
  "message": "Artifact could not be read or written. Check file access and try again.",
  "retryable": false,
  "remediation": {
    "action": "Artifact could not be read or written. Check file access and try again."
  },
  "details": {
    "operation": "analyze_javascript_application",
    "reason": "io"
  }
}

The real permission control returned code: access_denied, category: unavailable, and details containing the selected path, system_code: EACCES and boundary: filesystem-read. The fault cases each verified that the replacement was called once and that the output contained neither the selected path nor the injected errno.

Typed outcome or recovery expected

For selected-read EACCES/EPERM, preserve AnalysisAccessDeniedError semantics and the existing read-access recovery. For other raw filesystem errors, preserve available diagnostics so callers can identify the actual host condition instead of receiving only reason: io.

Analysis or suspected cause

Source-derived explanation of the executed result: the public MCP registration calls the application service. Reconstruction performs input resolution followed by format resolution. resolveSelectedInput classifies selected permission/missing/loop/length failures, but other host errors escape. resolveFormat performs lstat outside that classifier.

The filesystem catch then recognizes the errno and constructs new ArtifactOperationError(OPERATION, "io") without cause, detail or diagnostics. The adjacent generic exception fallback actually retains more diagnostics. Both typed permission handling at the acquisition owner and preservation of remaining raw I/O facts need to be considered; merely keeping an internal cause does not by itself demonstrate a useful public response.

Additional context

Related #5623 was fixed by PR #5651 for ELOOP and ENAMETOOLONG during selected-path resolution. Those cases are already classified in this revision and are not this reproduction. That fix did not change the raw-filesystem catch; its PR explicitly leaves later uncategorized I/O unchanged. The remaining faults here require preserving this later boundary's facts or extending its acquisition classification.

#5970 concerns RecoveryFiles wrapping a denied recovery-input snapshot as invalid input; #5969 concerns WakaruExecution reporting changed original input as unreadable output. This report uses the separate static application-analysis tool and a different producer. #5229/PR #5230 concern unreadable child inventory entries and partial coverage; PR #847 concerns internal artifact-validation failures. Their repairs do not cover this raw-root-filesystem catch.

Fetched upstream main 25e1a24a3ed3671a0148757555f18d16c29d936a; the relevant catch, selection classifier and lstat boundary remain unchanged. That newer revision was source-inspected, not executed. No fix, CLI replay, other-platform run, real I/O failure or natural permission race was tested. No last-known-good revision is established.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5991 Originally assigned to: @kaoru0822-kitauji on GitHub. ### 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 Evidence, artifacts, or reconstruction ### Observed behavior Through the public `analyze_javascript_application` MCP tool, three distinct filesystem failures returned identical errors: `artifact_operation_failed`, category `execution_failure`, reason `io`, generic file-access guidance, and no selected path, errno, or diagnostics. The tested faults were injected at the native filesystem boundary: `lstat` raising `EACCES` after input resolution, `realpath` raising `EIO`, and `access` raising `EMFILE`. Each injected Error carried a path, code, syscall and message, and each replacement fired once. The actual application service, MCP registration, error projection and SDK transport ran normally. This demonstrates loss of available failure facts; it does not establish a naturally occurring permission race, hardware I/O failure or descriptor exhaustion. Controls through the same MCP path succeeded for a readable empty directory and returned the correct `access_denied` / `unavailable` error with path and `system_code: EACCES` for a real mode-000 selected directory. ### Reproduction In a prepared source checkout with locked dependencies and existing build artifacts, save the following test as `tests/boundary/mcp/javascriptFilesystemErrors.repro.test.ts` and run: ```sh node node_modules/vitest/vitest.mjs run tests/boundary/mcp/javascriptFilesystemErrors.repro.test.ts --maxWorkers=1 ``` Executed on the repository-pinned Node.js 24.18.0: five tests passed. Fault assertions intentionally describe the incorrect current result. The permission control requires a non-root POSIX host. No external analysis provider is required. The optional `REA_REPRO_ROOT` selects an existing directory for generated fixtures; otherwise the host temporary directory is used. The native default export and `syncBuiltinESMExports` let the test replace only the selected filesystem boundary while the real service's named imports see that replacement. ```ts import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; import fs from 'node:fs/promises'; import { syncBuiltinESMExports } from 'node:module'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { expect, it, vi } from 'vitest'; import { createServer } from '../../../src/server/createServer.js'; import { createTestBinarySession } from '../../fixtures/binarySession.js'; const scratch = process.env.REA_REPRO_ROOT ?? tmpdir(); const call = async (path: string) => { const session = createTestBinarySession(() => { throw new Error('No native provider expected'); }); const server = createServer({ kind: 'session', session }); const client = new Client({ name: 'javascript-fs-test', version: '1' }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); try { await server.connect(serverTransport); await client.connect(clientTransport); const response = await client.callTool({ name: 'analyze_javascript_application', arguments: { input_path: path, format: 'directory' } }); if (!response.isError) return { success: true }; const content = response.content.find(item => item.type === 'text'); if (content?.type !== 'text') throw new Error('Missing text error'); return JSON.parse(content.text).error; } finally { await client.close(); await server.close(); await session.close(); } }; it('control: readable empty directory succeeds', async () => { const root = await fs.mkdtemp(join(scratch, 'readable-')); try { expect(await call(root)).toEqual({ success: true }); } finally { await fs.rm(root, { recursive: true, force: true }); } }); it('control: real selected read denial is access_denied', async () => { const root = await fs.mkdtemp(join(scratch, 'control-')); await fs.chmod(root, 0); try { const result = await call(root); console.log('real-access-control', JSON.stringify(result)); expect(result).toMatchObject({ code: 'access_denied', category: 'unavailable', details: { path: root, system_code: 'EACCES' } }); } finally { await fs.chmod(root, 0o700); await fs.rm(root, { recursive: true, force: true }); } }); it.each([ { method: 'lstat', code: 'EACCES' }, { method: 'realpath', code: 'EIO' }, { method: 'access', code: 'EMFILE' }, ] as const)('MCP loses $method $code details', async ({ method, code }) => { const root = await fs.realpath(await fs.mkdtemp(join(scratch, 'fault-'))); const original = fs[method]; const failure = Object.assign(new Error(`${code}: selected path ${method} failed`), { code, path: root, syscall: method }); let injected = 0; const spy = vi.spyOn(fs, method).mockImplementation((async (...args: any[]) => { if (String(args[0]) === root) { injected++; throw failure; } return (original as any)(...args); }) as any); syncBuiltinESMExports(); try { const result = await call(root); console.log('fault-result', JSON.stringify({ method, code, injected, result })); expect(injected).toBe(1); expect(result).toMatchObject({ code: 'artifact_operation_failed', category: 'execution_failure', details: { operation: 'analyze_javascript_application', reason: 'io' } }); expect(result.details).not.toHaveProperty('path'); expect(result.details).not.toHaveProperty('system_code'); expect(result.details).not.toHaveProperty('diagnostics'); expect(JSON.stringify(result)).not.toContain(root); expect(JSON.stringify(result)).not.toContain(code); } finally { spy.mockRestore(); syncBuiltinESMExports(); await fs.rm(root, { recursive: true, force: true }); } }); ``` ### Expected behavior Retain the available selected path and filesystem reason. A permission-denied selected read should keep the existing typed `access_denied` / `unavailable` result, including its path, `system_code` and read-access guidance, regardless of whether denial happens during initial selection or the immediately following metadata read. Other host failures may keep a general I/O classification while retaining their actual code, path and meaningful diagnostic text. This follows the existing [AnalysisAccessDeniedError contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/analysisErrorCore.ts#L188-L202) and [public projection](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/analysisErrorProjection.ts#L318-L324), and the repository requirement to preserve meaningful failure reasons and caller-selected paths. No new error schema or acceptance of unreadable input is required. ### Impact and scope P2: typed callers lose the distinction between host permission denial and generic execution failure, and cannot distinguish the tested permission, I/O and descriptor failures from the response. They receive generic file-access advice even when another host condition was supplied. Manual investigation outside REA is needed to recover the reason. The impact is bounded to raw filesystem errors that escape the selection classifier. Ordinary initial access denial is already handled correctly. Readable analysis remains available; no corruption or wrong successful Evidence was observed. Only the listed injected faults and real permission/readable controls were executed, on one host. Natural fault frequency, real descriptor exhaustion, a real post-access permission change and other errno values were not measured. ### REA version rea-agents 6.7.0; source revision `52415975942a4b9c53a817915b313ccc404f280e`. ### Runtime and platform macOS 27.0.1 build 26A434 arm64; Node.js v24.18.0, matching the repository pin; Vitest 5.0.3. No external provider or Wakaru process was used. ### Target and artifact context Generated empty local directories. The read-denial control uses actual filesystem mode 000 and restores permissions during cleanup. The three fault cases replace one native filesystem function each only for the selected directory, using an errno-bearing Error; they do not replace an application service or provider. ### Interface MCP ### Earliest failure stage Request parsing or validation ### Relevant output or evidence All three fault cases produced this complete typed error inside the MCP text result: ```json { "code": "artifact_operation_failed", "category": "execution_failure", "message": "Artifact could not be read or written. Check file access and try again.", "retryable": false, "remediation": { "action": "Artifact could not be read or written. Check file access and try again." }, "details": { "operation": "analyze_javascript_application", "reason": "io" } } ``` The real permission control returned `code: access_denied`, `category: unavailable`, and details containing the selected path, `system_code: EACCES` and `boundary: filesystem-read`. The fault cases each verified that the replacement was called once and that the output contained neither the selected path nor the injected errno. ### Typed outcome or recovery expected For selected-read `EACCES`/`EPERM`, preserve `AnalysisAccessDeniedError` semantics and the existing read-access recovery. For other raw filesystem errors, preserve available diagnostics so callers can identify the actual host condition instead of receiving only `reason: io`. ### Analysis or suspected cause Source-derived explanation of the executed result: the public [MCP registration](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/server/registerElectronTools.ts#L100-L119) calls the application service. Reconstruction performs [input resolution followed by format resolution](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/javascript/JavaScriptArtifactReconstruction.ts#L92-L99). [resolveSelectedInput](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/javascript/JavaScriptArtifactReconstruction.ts#L350-L379) classifies selected permission/missing/loop/length failures, but other host errors escape. [resolveFormat](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/javascript/JavaScriptArtifactReconstruction.ts#L391-L400) performs `lstat` outside that classifier. The [filesystem catch](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/javascript/JavaScriptApplicationService.ts#L152-L173) then recognizes the errno and constructs `new ArtifactOperationError(OPERATION, "io")` without cause, detail or diagnostics. The adjacent generic exception fallback actually retains more diagnostics. Both typed permission handling at the acquisition owner and preservation of remaining raw I/O facts need to be considered; merely keeping an internal cause does not by itself demonstrate a useful public response. ### Additional context Related #5623 was fixed by PR #5651 for `ELOOP` and `ENAMETOOLONG` during selected-path resolution. Those cases are already classified in this revision and are not this reproduction. That fix did not change the raw-filesystem catch; its PR explicitly leaves later uncategorized I/O unchanged. The remaining faults here require preserving this later boundary's facts or extending its acquisition classification. #5970 concerns `RecoveryFiles` wrapping a denied recovery-input snapshot as invalid input; #5969 concerns `WakaruExecution` reporting changed original input as unreadable output. This report uses the separate static application-analysis tool and a different producer. #5229/PR #5230 concern unreadable child inventory entries and partial coverage; PR #847 concerns internal artifact-validation failures. Their repairs do not cover this raw-root-filesystem catch. Fetched upstream main `25e1a24a3ed3671a0148757555f18d16c29d936a`; the relevant catch, selection classifier and `lstat` boundary remain unchanged. That newer revision was source-inspected, not executed. No fix, CLI replay, other-platform run, real I/O failure or natural permission race was tested. No last-known-good revision is established.
Author
Owner

@kaoru0822-kitauji commented on GitHub (Oct 22, 2026):

Reproduced on current main d77bc0e8a (macOS arm64, non-root uid) with the test from the report via in-memory MCP: 5/5 passed as written, i.e. injected lstat EACCES, realpath EIO and access EMFILE (each fired once) all returned artifact_operation_failed / execution_failure / reason: io with no path, errno or diagnostics; the readable control succeeded and the real mode-000 control returned access_denied with path and system_code: EACCES.

Taking this together with #6116.

<!-- gh-comment-id:6331561501 --> @kaoru0822-kitauji commented on GitHub (Oct 22, 2026): Reproduced on current main `d77bc0e8a` (macOS arm64, non-root uid) with the test from the report via in-memory MCP: 5/5 passed as written, i.e. injected `lstat` EACCES, `realpath` EIO and `access` EMFILE (each fired once) all returned `artifact_operation_failed` / `execution_failure` / `reason: io` with no path, errno or diagnostics; the readable control succeeded and the real mode-000 control returned `access_denied` with `path` and `system_code: EACCES`. Taking this together with #6116.
Author
Owner

@coygeek commented on GitHub (Oct 24, 2026):

Checked the reported fix on main at d87e73f2db.

Selected input access denial is typed at resolution, and remaining native filesystem errors preserve input_path, path, system_code and syscall in diagnostics instead of dropping them. The process-global MCP regression uses the same three injected boundaries as the report, checks the preserved facts, and requires a readable retry to succeed. No MCP runtime run here.

Implementation: JavaScriptArtifactReconstruction.ts, JavaScriptApplicationService.ts.

The relevant regression checks in javascriptFilesystemErrors.test.ts passed in the focused test run on this revision. This closure is based on the current source and those checks; provider fixtures do not establish a real-engine run. Closing as completed on main.

<!-- gh-comment-id:6380753858 --> @coygeek commented on GitHub (Oct 24, 2026): Checked the reported fix on `main` at d87e73f2db50be72d33a4b3c7199af35518b441c. Selected input access denial is typed at resolution, and remaining native filesystem errors preserve input_path, path, system_code and syscall in diagnostics instead of dropping them. The process-global MCP regression uses the same three injected boundaries as the report, checks the preserved facts, and requires a readable retry to succeed. No MCP runtime run here. Implementation: [JavaScriptArtifactReconstruction.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/application/javascript/JavaScriptArtifactReconstruction.ts#L451), [JavaScriptApplicationService.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/application/javascript/JavaScriptApplicationService.ts#L171). The relevant regression checks in [javascriptFilesystemErrors.test.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/tests/process-global/javascriptFilesystemErrors.test.ts) passed in the focused test run on this revision. This closure is based on the current source and those checks; provider fixtures do not establish a real-engine run. Closing as completed on main.
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#3658
No description provided.