Skip to content

fix(clearfolio): bound provider transport and JSON responses - #498

Open
seonghobae wants to merge 45 commits into
fix/clearfolio-production-configurationfrom
fix/clearfolio-provider-response-boundary
Open

fix(clearfolio): bound provider transport and JSON responses#498
seonghobae wants to merge 45 commits into
fix/clearfolio-production-configurationfrom
fix/clearfolio-provider-response-boundary

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Buyer and security impact

A configured Clearfolio endpoint is still an untrusted external API. This bounded stacked slice prevents slow, redirecting, oversized, non-JSON, or rejected provider responses from consuming unbounded ScopeWeave time/memory or leaking tenant HMAC claims across redirects. It now also preserves timeout identity across the Clearfolio→attachment-refresh boundary so operator metrics distinguish provider deadline exhaustion from generic downstream lookup failure without exposing provider detail.

This is a follow-up slice of #489. It is stacked on #493 and does not close #489.

Exact current stack and scope

  • current parent: fix/clearfolio-production-configuration@78f8b557cd2b9cab238af72304f0ed42e1557759 (fix(clearfolio): fail closed on production configuration #493);
  • current child head: d60e281e7e2375207aa8b266f2938dfb51e65c2e;
  • PR is Ready and mechanically mergeable at the latest fresh refetch;
  • parent→child ancestry is direct (ahead_by: 39, behind_by: 0).

Fresh parent→child comparison contains exactly ten child-owned files:

  • CHANGELOG.md;
  • docs/deploy.md;
  • docs/doctoring/clearfolio-provider-response-boundary.md;
  • package.json;
  • server/attachment_status.mjs;
  • server/clearfolio.mjs;
  • tests/unit/clearfolio-adapter-mock-hmac.test.mjs;
  • tests/unit/clearfolio-provider-boundary.test.mjs;
  • tests/unit/clearfolio-refresh-timeout.test.mjs;
  • tests/unit/clearfolio-status-signal.test.mjs.

No database, auth/session, dependency lockfile, workflow, scanner suppression, parent production-configuration doctoring, or branch-protection artifact is in the child semantic delta.

Request, resource, and timeout boundary

  • all hosted submit/status/artifact-link calls use redirect: "error";
  • each operation has a disposable 15-second total-request budget;
  • status refresh composes caller cancellation with the provider timeout;
  • provider TimeoutError identity is retained while its message is replaced with the fixed non-secret clearfolio status unavailable surface;
  • attachment refresh classifies both its own hard deadline and a sanitized downstream standard TimeoutError as the fixed timeout metric category;
  • document name, MIME, bytes, and provider job IDs are validated before allocation or URL construction;
  • successful responses require application/json;
  • declared and streamed response bytes are capped at 256 KiB before JSON parsing;
  • invalid UTF-8, malformed JSON/streams, empty responses, and incompatible shapes fail closed;
  • rejected and early-invalid response bodies are cancelled without parsing or surfacing payload bytes;
  • cancellation failures remain private cleanup details; and
  • cross-origin, credential-bearing, or fragmented artifact links remain fail-closed under the inherited fix(clearfolio): fail closed on production configuration #493 origin contract until a separately reviewed allowlist lands.

Review-driven timeout repair

Current review identified a real observability defect: when the Clearfolio adapter's 15-second internal budget fired before an attachment refresh timeout configured above 15 seconds, jobStatus() collapsed the timeout to a generic error and the refresh worker counted it as downstream_lookup.

The repair was driven on the existing branch:

  1. 8faa7c279c71b5e591fe694a5152c338a2a2a5d4 added a realistic provider-timeout regression before production changes. The repository's explicit test script did not yet execute that new file, so the old Server Tests success was correctly treated as a false-green test-registration gap rather than RED/GREEN evidence.
  2. 6b5854836dc9852d511a5f8019e28d7e67753b61 preserves the standard TimeoutError name while retaining the fixed sanitized status-unavailable message.
  3. 35c8157b499c4df2c7d08d6b336d75cf43d4ea24 maps standard downstream timeout identity to the existing low-cardinality timeout metrics bucket while preserving stale attachment state.
  4. d60e281e7e2375207aa8b266f2938dfb51e65c2e wires the new regression into both test:unit and the owned coverage case list so omission cannot silently green the contract again.

The current unit-and-api job 98315148974 explicitly executed tests/unit/clearfolio-refresh-timeout.test.mjs; the regression passed and verified the sanitized error name/message, timeout metric increment, zero downstream-lookup increment, and stale-state preservation.

Current evidence boundary

For current head d60e281e7e2375207aa8b266f2938dfb51e65c2e:

  • Dependency Review run 33010479245: GitHub-success;
  • OSV Scanner run 33010479942: GitHub-success;
  • Server Tests run 33010479277: GitHub-success;
  • unit-and-api job 98315148974: success, including the new timeout regression and the existing Clearfolio/attachment suites;
  • cloud-e2e in the same Server Tests run completed successfully.

This Server Tests result is useful behavioral evidence, not exact-contributor-head merge authority: the job fetched and checked out synthetic merge 41bd9329f0b0d5244d12a6d0c87d401285e59956, logging HEAD is now at 41bd932 Merge d60e281... into 78f8b557.... Repository exact-head checkout/coverage remains owned by #523, and centrally reusable SAST/Security exact-head evidence remains owner-controlled by ContextualWisdomLab/.github#1222. Until those controls are protected-shipped and this unchanged branch is freshly re-evidenced, synthetic/absent/stale evidence remains non-authorizing.

No current qualifying independent approval exists for d60e281....

Documentation inheritance

The child inherits #493's production-configuration authority: tenant-signed requests reject redirects and browser artifact authority is same-origin by default. This child adds bounded transport/resource and timeout-observability behavior; it does not weaken or supersede the parent configuration contract.

Remaining #489 work

This slice does not complete the Clearfolio production lifecycle. Dependent work still owns any explicit reviewed cross-origin artifact allowlist that is actually required, capability readiness, broader persistence/lifecycle controls, incident/recovery evidence, and protected integration.

Merge gate

Do not integrate this child before #493. After the parent reaches protected develop, retarget or reconcile this bounded semantic diff against the resulting protected head and rerun all then-applicable gates. Merge or auto-merge only after the unchanged exact contributor head has terminal-passing exact-head CI/browser/owned coverage/docstring/security/dependency/supply-chain/package/provenance evidence, zero valid unresolved findings, and qualifying independent approval under live rulesets. Pending, queued, skipped-required, cancelled, absent, neutral-required, failed, stale, predecessor, synthetic-only, status-only, author-only, or model-only evidence is non-authorizing.

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bc4b5bce-9285-4076-b5c9-a0ed6af989c7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@seonghobae
seonghobae marked this pull request as ready for review August 14, 2026 19:43

Copy link
Copy Markdown
Contributor Author

@opencode-agent
@cwl-noema-review

Review exact current head a733a2f3c78a43b1fed178524067b25cfde76ff7 against stacked base fix/clearfolio-production-configuration@c8da068538e5f5032136f0a39a68ab387edc3646 and independently compare the cumulative stack to protected develop@b88e66e81e9701404d29a0f5de4f58573ceee14f. Verify the hosted Clearfolio provider path is non-redirecting, total-request bounded, caller-cancellation aware, media-type/stream-size bounded before JSON parsing, identifier/document bounded before URL/allocation, and still preserves #493's explicit-dev mock, canonical signed origin, sanitized errors, exact status enum, and artifact-token origin rule. Also verify no unrelated auth/session/attachment-refresh/database/workflow/dependency/security-runtime regression is introduced. Publish current-head review evidence only; do not mutate or merge.

@seonghobae seonghobae left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current Clearfolio response boundary correctly bounds successful JSON bodies, but all three non-2xx paths return before consuming or cancelling the Undici-backed fetch body. Node/Undici explicitly requires fetch callers to consume or cancel response bodies rather than relying on GC, because unread bodies can reduce connection reuse and eventually stall or exhaust the pool. Fix this once at the provider-response boundary and cover submit/status/artifact rejection paths with cancellation-observable regressions; preserve the existing rule that rejected payload bytes are never parsed or surfaced.

Comment thread server/clearfolio.mjs Outdated
@seonghobae
seonghobae marked this pull request as draft August 15, 2026 07:12
@seonghobae
seonghobae marked this pull request as ready for review August 15, 2026 07:49
@seonghobae
seonghobae marked this pull request as draft August 15, 2026 12:03
@seonghobae
seonghobae marked this pull request as ready for review August 15, 2026 23:45

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

OpenCode cannot approve yet because required coverage evidence did not pass.

Review outcome

1. HIGH .github/workflows/opencode-review.yml:1 - Coverage evidence did not prove required test/docstring evidence

  • Problem: The required coverage-evidence job result was failure, so OpenCode cannot establish approval sufficiency for this head.

  • Root cause: Automated approval is only valid when the same-head coverage-evidence job proves supported repository test suites passed and configured docstring gates passed or were advisory, or reports not applicable because no supported source files or package manifests exist. Missing, failed, skipped, unavailable, or unsupported-tooling test evidence is a blocker.

  • Fix: Install or configure the repository test/docstring evidence tooling when source files or package manifests exist, rerun the current-head coverage-evidence job, and approve only after it reports success with required evidence or explicit no-source not-applicable evidence.

  • Regression test: Keep the approval branch checking needs.coverage-evidence.result == success before posting APPROVE, and publish REQUEST_CHANGES when coverage-evidence blocker states such as cancelled, skipped, failed, unsupported-tooling, or below-100 evidence are present.

  • Result: REQUEST_CHANGES

  • Reason: coverage-evidence result was failure, so required test/docstring evidence was not proven for current head 4f017140c1ab375e4f302bf2094a64e7b290d14b.

  • Head SHA: 4f017140c1ab375e4f302bf2094a64e7b290d14b

  • Workflow run: 31917786768

  • Workflow attempt: 1

Coverage evidence

Coverage Decision

  • Result: FAIL
  • Test evidence: not proven passing
  • Docstring evidence: not proven passing when configured
  • Failure count: 1

Changed-File Evidence Map

flowchart LR
  PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
  Evidence --> S1["Changed file (3 files)"]
  S1 --> I1["repository behavior"]
  I1 --> R1["Review risk: Changed file (3 files)"]
  R1 --> V1["required checks"]
  Evidence --> S2["Docs (2 files)"]
  S2 --> I2["operator or user guidance"]
  I2 --> R2["Review risk: Docs (2 files)"]
  R2 --> V2["docs review"]
  Evidence --> S3["Test (3 files)"]
  S3 --> I3["regression suite"]
  I3 --> R3["Review risk: Test (3 files)"]
  R3 --> V3["targeted test run"]
Loading

@opencode-agent

Copy link
Copy Markdown
Contributor

OpenCode Review Overview

  • Head SHA: 4f017140c1ab375e4f302bf2094a64e7b290d14b
  • Workflow run: 31917786768
  • Workflow attempt: 1
  • Gate result: REQUEST_CHANGES (approval step)

Pull request overview

OpenCode cannot approve yet because required coverage evidence did not pass.

Review outcome

1. HIGH .github/workflows/opencode-review.yml:1 - Coverage evidence did not prove required test/docstring evidence

  • Problem: The required coverage-evidence job result was failure, so OpenCode cannot establish approval sufficiency for this head.

  • Root cause: Automated approval is only valid when the same-head coverage-evidence job proves supported repository test suites passed and configured docstring gates passed or were advisory, or reports not applicable because no supported source files or package manifests exist. Missing, failed, skipped, unavailable, or unsupported-tooling test evidence is a blocker.

  • Fix: Install or configure the repository test/docstring evidence tooling when source files or package manifests exist, rerun the current-head coverage-evidence job, and approve only after it reports success with required evidence or explicit no-source not-applicable evidence.

  • Regression test: Keep the approval branch checking needs.coverage-evidence.result == success before posting APPROVE, and publish REQUEST_CHANGES when coverage-evidence blocker states such as cancelled, skipped, failed, unsupported-tooling, or below-100 evidence are present.

  • Result: REQUEST_CHANGES

  • Reason: coverage-evidence result was failure, so required test/docstring evidence was not proven for current head 4f017140c1ab375e4f302bf2094a64e7b290d14b.

  • Head SHA: 4f017140c1ab375e4f302bf2094a64e7b290d14b

  • Workflow run: 31917786768

  • Workflow attempt: 1

Coverage evidence

Coverage Decision

  • Result: FAIL
  • Test evidence: not proven passing
  • Docstring evidence: not proven passing when configured
  • Failure count: 1

Changed-File Evidence Map

flowchart LR
  PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
  Evidence --> S1["Changed file (3 files)"]
  S1 --> I1["repository behavior"]
  I1 --> R1["Review risk: Changed file (3 files)"]
  R1 --> V1["required checks"]
  Evidence --> S2["Docs (2 files)"]
  S2 --> I2["operator or user guidance"]
  I2 --> R2["Review risk: Docs (2 files)"]
  R2 --> V2["docs review"]
  Evidence --> S3["Test (3 files)"]
  S3 --> I3["regression suite"]
  I3 --> R3["Review risk: Test (3 files)"]
  R3 --> V3["targeted test run"]
Loading

Rebuild the child from the current #493 parent tree, preserve only the bounded provider transport/JSON response slice, and retain protected adaptive-orchestrator attribution changes without widening the stack.
seonghobae added a commit that referenced this pull request Aug 20, 2026
…arent

Rebuild the artifact-origin child from the exact current #498 parent tree while preserving the child's bounded Clearfolio origin-policy delta. Shared package and changelog paths retain both the protected orchestrator attribution work and the child origin-policy coverage.
@opencode-agent opencode-agent Bot added priority: medium Normal-priority or P2 work status: draft Draft pull request type: bug Defect or incorrect behavior labels Aug 22, 2026
@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Caution

Review failed

An error occurred during the review process. Please try again later.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@seonghobae
seonghobae marked this pull request as ready for review August 24, 2026 01:30
devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment thread server/clearfolio.mjs
Comment on lines +296 to +359
async function readBoundedJson(response, invalidMessage) {
const contentType = response?.headers?.get?.('content-type');
if (
typeof contentType !== 'string'
|| contentType.split(';', 1)[0].trim().toLowerCase() !== 'application/json'
) {
return rejectProviderResponse(response, invalidMessage);
}

const contentLength = response.headers.get('content-length');
if (contentLength !== null) {
if (!/^\d+$/.test(contentLength)) return rejectProviderResponse(response, invalidMessage);
const declaredBytes = Number(contentLength);
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > CLEARFOLIO_MAX_RESPONSE_BYTES) {
return rejectProviderResponse(response, invalidMessage);
}
}

if (!response.body || typeof response.body.getReader !== 'function') {
return rejectProviderResponse(response, invalidMessage);
}

const reader = response.body.getReader();
const chunks = [];
let totalBytes = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!(value instanceof Uint8Array)) throw new Error(invalidMessage);
totalBytes += value.byteLength;
if (totalBytes > CLEARFOLIO_MAX_RESPONSE_BYTES) {
try { await reader.cancel(); } catch { /* validation remains authoritative */ }
throw new Error(invalidMessage);
}
chunks.push(value);
}
} catch (error) {
if (error?.message === invalidMessage) throw error;
throw new Error(invalidMessage);
} finally {
try { reader.releaseLock(); } catch { /* no observable effect */ }
}

if (totalBytes === 0) throw new Error(invalidMessage);
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}

let text;
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} catch {
throw new Error(invalidMessage);
}
try {
return JSON.parse(text);
} catch {
throw new Error(invalidMessage);
}
}

@devin-ai-integration devin-ai-integration Bot Aug 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Byte ceiling correctly independent of Content-Length

readBoundedJson treats Content-Length as a hint only and independently counts streamed bytes, cancelling the reader past CLEARFOLIO_MAX_RESPONSE_BYTES. Dishonest or absent length headers cannot bypass the memory ceiling. Not a bug.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread server/clearfolio.mjs
Comment on lines +385 to 387
export async function submitJob(orgId, userId, document) {
const validatedDocument = validateDocument(document);
const configuration = clearfolioConfiguration();

@devin-ai-integration devin-ai-integration Bot Aug 25, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Validation precedence shifted ahead of config/mock

submitJob now validates the document before clearfolioConfiguration, and jobStatus/artifactUrl validate the job id first. Invalid input in unconfigured or mock mode now surfaces clearfolio document invalid/clearfolio job id invalid instead of the configuration error, and mock mode now validates documents it previously stored verbatim.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment on lines +284 to +287
const category = (
error?.name === ATTACHMENT_STATUS_TIMEOUT_ERROR
|| error?.name === 'TimeoutError'
) ? 'timeout' : failureCategory;

@devin-ai-integration devin-ai-integration Bot Aug 26, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Timeout guard scoped to lookup phase

The timeout mapping in worker triggers only while failureCategory is downstream_lookup. A standard TimeoutError thrown during persistence or status validation keeps its original category. This matches the adapter, which can only emit its 15s TimeoutError during the fetch phase, and each failure still increments exactly one category.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread server/clearfolio.mjs
Comment on lines +208 to +225
function validateDocument(document) {
if (!isJsonRecord(document)) throw new Error('clearfolio document invalid');
const { name, mime, bytes } = document;
if (
typeof name !== 'string'
|| name.trim().length === 0
|| name.length > MAX_DOCUMENT_NAME_LENGTH
|| CONTROL_CHARACTER_PATTERN.test(name)
|| typeof mime !== 'string'
|| mime.length > MAX_MIME_LENGTH
|| CONTROL_CHARACTER_PATTERN.test(mime)
|| !(bytes instanceof Uint8Array)
|| bytes.byteLength > MAX_DOCUMENT_BYTES
) {
throw new Error('clearfolio document invalid');
}
return { name, mime, bytes };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Long or control-char filenames now rejected on upload

validateDocument rejects names over 512 chars, MIME over 255 chars, or control characters before transport. The upload path forwards file.name||'document' directly (server/app.mjs:1052), so an upload with such a filename that previously reached the provider now returns a 502. Intentional hardening, but a behavior change for edge-case filenames.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 2 new potential issues.

Devin Review

Comment thread tests/unit/clearfolio-refresh-timeout.test.mjs Outdated
Comment thread server/clearfolio.mjs
Comment on lines +437 to +439
} catch {
throw new Error('clearfolio submit unavailable');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Only jobStatus preserves timeout identity

submitJob and artifactUrl collapse all transport failures to generic errors, while jobStatus preserves the TimeoutError name. This asymmetry is consistent: only jobStatus feeds the refresh worker's timeout metric categorization; submit and artifact-link are not.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Devin Review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Malformed stored job IDs counted as downstream lookup failures

validateJobId rejects a persisted job ID over 256 chars or with control characters before any request. The refresh worker only screens empty IDs as skipped, so such rows fall into the catch as a plain error and increment downstream_lookup instead of a data-quality category.

(Refers to this code)

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread server/clearfolio.mjs
Comment on lines +501 to +508
} catch (error) {
if (request.signal.aborted && request.signal.reason?.name === 'TimeoutError') {
const unavailable = new Error('clearfolio status unavailable');
unavailable.name = 'TimeoutError';
throw unavailable;
}
throw error;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Body-read timeout recovery relies on signal reason

When the 15s budget aborts an in-progress body read, readBoundedJson rethrows a fresh sanitized error, losing the TimeoutError name; the catch recovers timeout identity from request.signal.reason. A caller cancellation mid-body instead surfaces as the invalid-response error, but the worker's Promise.race discards that rejection, so refresh metrics are unaffected.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: medium Normal-priority or P2 work status: draft Draft pull request type: bug Defect or incorrect behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant