Skip to content

fix(voyage): bound embedding-batch status, error, and non-OK responses#96608

Merged
sallyom merged 1 commit into
openclaw:mainfrom
Alix-007:fix/bound-voyage
Jun 25, 2026
Merged

fix(voyage): bound embedding-batch status, error, and non-OK responses#96608
sallyom merged 1 commit into
openclaw:mainfrom
Alix-007:fix/bound-voyage

Conversation

@Alix-007

@Alix-007 Alix-007 commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

extensions/voyage/embedding-batch.ts makes several unbounded reads of
external, untrusted Voyage batch responses. A misconfigured / hostile /
SSRF-reachable endpoint (including a self-hosted baseUrl override) can return a
Content-Length-less body that streams forever — and each unbounded read buffers
the entire payload before we can act on it, driving the batch worker into
memory pressure / OOM or a hang. Three reads are affected: the batch status
JSON
, the batch error-file body, and the non-OK (4xx/5xx) diagnostic
body. This is the same untrusted-body surface the merged response-limit campaign
(#95218 / #96027 / #96038) closes across the other bundled providers.

The two success-body reads were bounded in the first revision of this PR. This
revision closes the last remaining gap: the non-OK diagnostic body.
assertVoyageResponseOk still read the failure body with an unbounded
await res.text() before throwing (and a duplicate unbounded
await contentRes.text() existed on the non-OK output-file branch in
runVoyageEmbeddingBatches), so a hostile endpoint returning a 500 with an
unbounded body was still buffered whole on the error path. Both non-OK reads are
now bounded too.

Changes

  • Non-OK diagnostic body (assertVoyageResponseOk): now read through the
    shared readResponseWithLimit (16 MiB cap, media-core bounded reader,
    re-exported via openclaw/plugin-sdk/response-limit-runtime) instead of
    await res.text(). The ${context}: ${status} ${text} diagnostic is preserved
    byte-for-byte for under-cap bodies; on overflow it throws a bounded
    ${context}: ${status} (error body exceeds <N> bytes) and cancels the stream.
    Cap is threaded from each caller so the boundary is testable.
  • Non-OK output-file branch (runVoyageEmbeddingBatches): now routes through
    the same assertVoyageResponseOk (same voyage batch file content failed: <status> <text> message) instead of its own unbounded await contentRes.text() — removing the last unbounded read in the file.
  • Success-body reads (unchanged from the prior revision): status via
    readProviderJsonResponse, error file via readResponseWithLimit. The
    error-file path still fail-softs by design — an oversized error body
    degrades to formatUnavailableBatchError (cap value included so truncation is
    visible) rather than crashing the worker; the normal NDJSON
    trim/split/JSON.parse/extractBatchErrorMessage chain and the empty-file branch
    are preserved.
  • No new abstraction — reuses the shared media-core bounded reader already used
    across the codebase. Plugin-scoped.

Real behavior proof

  • Behavior addressed: Three untrusted Voyage batch reads must not buffer an
    oversized / never-ending body whole. The success-status read, the success
    error-file read, and the non-OK diagnostic read must each stop at the cap,
    cancel the stream, and either throw bounded or fail-soft — while valid small
    bodies (status JSON, NDJSON error file, empty error file, small non-OK
    diagnostic) still parse / format unchanged.
  • Real environment tested: A real node:http server (createServer) bound to
    127.0.0.1, streaming bodies in 64 KiB chunks with no Content-Length
    (~64 MiB per overflow route, 4× the 16 MiB cap) over a real loopback socket,
    on Node v22.22.0. The real exported fetchVoyageBatchStatus and
    readVoyageBatchError (testing exports of the real module) were driven against
    it, with withRemoteHttpResponse performing a real fetch() and feeding the
    real Response into the real onResponse — so the production
    assertVoyageResponseOk + readResponseWithLimit / readProviderJsonResponse
    run exactly as in production.
  • Exact steps: node --import tsx voyage-proof.mts — boot the server →
    drive the real functions against the streaming overflow routes and the small
    routes → after each overflow, wait for the server-side socket-close event and
    read the server's observed bytes-sent / abort counters → run a negative control
    (raw unbounded read of the same non-OK route).
  • Evidence after fix (all 11 checks PASS):
    • A — status success overflow: real fetchVoyageBatchStatus threw
      voyage-batch-status: JSON response exceeds 16777216 bytes; the server saw the
      socket aborted after ~17 MiB (≪ the ~64 MiB it would have sent) → stream
      cancelled, not drained.
    • B — error-file success overflow: real readVoyageBatchError fail-softed
      to error file unavailable: voyage batch error file content exceeds 16777216 bytes (no throw out of the worker); socket aborted after ~17 MiB.
    • C — NON-OK diagnostic overflow (the gap this revision fixes): a streaming
      500 drove assertVoyageResponseOk to throw the bounded
      voyage batch status failed: 500 (error body exceeds 16777216 bytes); socket
      aborted after ~18 MiB ≪ ~64 MiB. Before this fix the 500 body was buffered
      whole by await res.text().
    • D — small / branch correctness: small status JSON parsed unchanged; the
      NDJSON error file still extracted the first error message
      (voyage upstream rejected); the empty error file returned undefined via the
      empty-body branch; a small non-OK body preserved the original
      voyage batch status failed: 503 voyage upstream is down diagnostic.
    • Negative control: a raw unbounded read of the same non-OK route
      buffered past the 16 MiB cap (16,790,567 > 16,777,216 bytes) — proving the
      cap is load-bearing (without it the body keeps accumulating).
  • What was not tested: Did not hit the live api.voyageai.com endpoint (no key
    / would not reproduce a hostile oversized body); the untrusted-body behavior is
    fully reproduced with a local streaming server over a real socket, which is the
    same transport path. The proof drives the bytes-on-wire + early-socket-close
    signal (the load-bearing signal) rather than driving the worker to actual OOM.
    The proof script is not committed.

Evidence

Real node:http terminal proof (real loopback sockets, real exported functions):

[proof] real node:http server on http://127.0.0.1:42763, cap=16777216 bytes, would-stream≈67108864 bytes per overflow route

PASS  A status success overflow: real fetchVoyageBatchStatus throws bounded error :: threw=true msg="voyage-batch-status: JSON response exceeds 16777216 bytes"
PASS  A status overflow stream cancelled: server saw socket abort, bytes ≪ would-stream :: aborted=true bytesSent=17367040 (< 67108864)
PASS  B error-file success overflow: readVoyageBatchError fail-softs (no throw, degraded string) :: result="error file unavailable: voyage batch error file content exceeds 16777216 bytes"
PASS  B error-file overflow stream cancelled: server saw socket abort, bytes ≪ would-stream :: aborted=true bytesSent=17498112 (< 67108864)
PASS  C NON-OK diagnostic overflow (THE FIX): assertVoyageResponseOk bounds the 500 error body :: threw=true msg="voyage batch status failed: 500 (error body exceeds 16777216 bytes)"
PASS  C NON-OK overflow stream cancelled: server saw socket abort, bytes ≪ would-stream :: aborted=true bytesSent=17760256 (< 67108864)
PASS  D1 small valid status JSON still parsed unchanged :: {"id":"batch_1","status":"completed"}
PASS  D2 small NDJSON error file: full trim/split/parse chain still extracts first error :: message="voyage upstream rejected"
PASS  D3 empty error file: empty-body branch returns undefined :: message=undefined
PASS  D4 small NON-OK body: original `${context}: ${status} ${text}` diagnostic preserved :: threw=true msg="voyage batch status failed: 503 voyage upstream is down"
PASS  NEG control: raw UNBOUNDED read of /nonok-overflow buffers PAST the 16 MiB cap (cap is load-bearing) :: buffered=16790567 bytes (> 16777216)

[proof] 11 PASS, 0 FAIL
[proof] ALL PASS

In-repo Vitest suite (real ReadableStream fixtures, including the new non-OK
overflow + small non-OK diagnostic regression tests):

 Test Files  1 passed (1)
      Tests  8 passed (8)

Checks on the changed files: oxlint extensions/voyage/embedding-batch.ts extensions/voyage/embedding-batch.test.ts → exit 0, clean; tsgo -p tsconfig.extensions.json → exit 0, no errors.

Label: security

AI-assisted.

@clawsweeper

clawsweeper Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed June 24, 2026, 11:22 PM ET / 03:22 UTC.

Summary
The PR caps Voyage embedding-batch status JSON, error-file bodies, and non-OK diagnostic bodies with shared bounded response readers and adds regression tests for overflow and small-body behavior.

PR surface: Source +35, Tests +217. Total +252 across 2 files.

Reproducibility: yes. Current main source clearly shows the Voyage status, error-file, and non-OK diagnostic paths use unbounded Response.json() or Response.text() reads, and the PR body supplies loopback terminal proof for the bounded path.

Review metrics: 1 noteworthy metric.

  • Voyage response caps: 3 reads capped; 1 fixed 16 MiB cap added. This is the behavior change maintainers must notice because oversized Voyage status, error-file, and non-OK diagnostic bodies no longer buffer without a byte ceiling.

Stored data model
Persistent data-model change detected: unknown-data-model-change: extensions/voyage/embedding-batch.test.ts, unknown-data-model-change: extensions/voyage/embedding-batch.ts, vector/embedding metadata: extensions/voyage/embedding-batch.test.ts, vector/embedding metadata: extensions/voyage/embedding-batch.ts. Confirm migration or upgrade compatibility proof before merge.

Root-cause cluster
Relationship: canonical
Canonical: #96608
Summary: This PR is the focused Voyage member of a broader bounded untrusted-provider response campaign; the related merged PRs cover shared helpers and sibling providers.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Merge readiness
Overall: 🐚 platinum hermit
Proof: 🦞 diamond lobster
Patch quality: 🐚 platinum hermit
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • [P2] Maintainers should accept or adjust the 16 MiB cap policy before merge.

Risk before merge

  • [P1] Legitimate Voyage status, error-file, or non-OK diagnostic bodies over 16 MiB now throw or fail-soft instead of being fully buffered, so maintainers should explicitly accept or tune that compatibility tradeoff before merge.

Maintainer options:

  1. Accept the 16 MiB cap (recommended)
    Treat the bounded read as the intended availability and security tradeoff for untrusted Voyage batch response bodies, then merge after normal required checks.
  2. Tune the cap or diagnostics first
    If maintainers expect legitimate Voyage batch diagnostics above 16 MiB, adjust the cap or fail-soft wording before merge while keeping all untrusted reads bounded.

Next step before merge

  • No automated repair is needed; the remaining action is maintainer acceptance of the compatibility tradeoff and ordinary merge gating.

Security
Cleared: The diff improves an untrusted-provider memory-pressure surface and does not add dependencies, workflows, secret handling, package metadata changes, or broader execution paths.

Review details

Best possible solution:

Land the plugin-scoped bounded-reader fix after maintainers accept the 16 MiB compatibility tradeoff and required exact-head checks are green.

Do we have a high-confidence way to reproduce the issue?

Yes. Current main source clearly shows the Voyage status, error-file, and non-OK diagnostic paths use unbounded Response.json() or Response.text() reads, and the PR body supplies loopback terminal proof for the bounded path.

Is this the best way to solve the issue?

Yes. Reusing the existing plugin SDK bounded response helpers inside the Voyage plugin is the narrow owner-boundary fix; the submit/upload helpers are already bounded and the successful output-file path remains streaming instead of being buffered.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 643410c1f3c0.

Label changes

Label justifications:

  • P2: This is focused security and availability hardening for one bundled provider path with limited blast radius.
  • merge-risk: 🚨 compatibility: The PR intentionally changes oversized Voyage response handling from full buffering to bounded failure or fail-soft diagnostics.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR body includes after-fix terminal output from a real loopback HTTP server driving the real Voyage exported functions, including overflow cancellation and a negative unbounded control.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes after-fix terminal output from a real loopback HTTP server driving the real Voyage exported functions, including overflow cancellation and a negative unbounded control.
Evidence reviewed

PR surface:

Source +35, Tests +217. Total +252 across 2 files.

View PR surface stats
Area Files Added Removed Net
Source 1 45 10 +35
Tests 1 217 0 +217
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 2 262 10 +252

What I checked:

  • Repository policy read: Root AGENTS.md was read fully, and scoped extension/plugin SDK guidance affected the owner-boundary review because this PR changes a bundled plugin using plugin SDK facades. (AGENTS.md:13, 643410c1f3c0)
  • Current main leaves the Voyage paths unbounded: Current main uses res.text() for non-OK diagnostics and error-file content and res.json() for status JSON, so the PR is not obsolete on main. (extensions/voyage/embedding-batch.ts:68, 643410c1f3c0)
  • PR head bounds the affected reads: The patch adds a 16 MiB cap, routes status JSON through readProviderJsonResponse, and routes error-file plus non-OK diagnostic bodies through readResponseWithLimit. (extensions/voyage/embedding-batch.ts:43, dd3def70dc6f)
  • Regression tests cover the overflow contract: The added tests cover the cap constant, oversized status streams, fail-soft oversized error files, normal NDJSON/empty error-file behavior, oversized non-OK diagnostics, and small non-OK diagnostic preservation. (extensions/voyage/embedding-batch.test.ts:71, dd3def70dc6f)
  • Bounded-reader dependency contract checked: readResponseWithLimit cancels the response stream when the next chunk exceeds the byte cap and throws the configured overflow error, which is the load-bearing behavior reused by this PR. (packages/media-core/src/read-response-with-limit.ts:96, 643410c1f3c0)
  • SDK facade is an allowed plugin boundary: The plugin imports readProviderJsonResponse and readResponseWithLimit through public plugin SDK subpaths rather than reaching into core internals. (src/plugin-sdk/provider-http.ts:4, 643410c1f3c0)

Likely related people:

  • Shakker: Blame and git show show commit 9d82906 created the current Voyage batch implementation and the implicated unbounded reads. (role: introduced Voyage batch implementation; confidence: high; commits: 9d82906f792b; files: extensions/voyage/embedding-batch.ts, extensions/voyage/embedding-provider.ts, extensions/voyage/memory-embedding-adapter.ts)
  • joshavant: Git history shows commit 0a14444 added the shared readProviderJsonResponse helper this PR reuses for bounded provider JSON reads. (role: shared bounded-reader contributor; confidence: medium; commits: 0a14444924e3; files: src/agents/provider-http-errors.ts)
  • Alix-007: Related merged response-limit PRs by this contributor established the current bounded-response campaign pattern that this Voyage PR follows, beyond merely authoring this PR. (role: recent sibling hardening contributor; confidence: medium; commits: a15f8e3aaac5, 8876908d1c11, 1d74cea84d5b; files: src/agents/provider-http-errors.ts, extensions/ollama/src/provider-models.ts, extensions/exa/src/exa-web-search-provider.runtime.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. labels Jun 25, 2026
@Alix-007

Copy link
Copy Markdown
Contributor Author

@clawsweeper review

@clawsweeper

clawsweeper Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels Jun 25, 2026
The batch status read (fetchVoyageBatchStatus) parsed its response with an
unbounded await res.json(), and the batch error-file read (readVoyageBatchError)
buffered the whole body via await res.text(). On top of that, the non-OK
(4xx/5xx) diagnostic body was still read unbounded: assertVoyageResponseOk did
await res.text() before throwing, and the non-OK output-file branch in
runVoyageEmbeddingBatches did the same. Voyage base URLs are user-supplied and
reachable via SSRF, so a misbehaving or hostile endpoint could stream an
unbounded body into memory on any of these paths before parsing.

Route the status JSON through the shared readProviderJsonResponse, the error
file through readResponseWithLimit, and now the non-OK diagnostic body through
readResponseWithLimit as well, all under a single 16 MiB cap, cancelling the
stream on overflow before decode/parse. assertVoyageResponseOk preserves its
original "${context}: ${status} ${text}" diagnostic shape for under-cap bodies
and throws a bounded "(error body exceeds <N> bytes)" on overflow; the non-OK
output-file branch now reuses it instead of a duplicate unbounded read. The
existing error-file fail-soft handling (formatUnavailableBatchError) is
preserved, so a capped endpoint degrades gracefully. The submit path already
bounds its body via postJsonWithRetry/maxResponseBytes and is left untouched.

Symmetric counterpart to the openclaw#96027/openclaw#96038 response-limit campaign.
@Alix-007 Alix-007 changed the title fix(voyage): bound embedding-batch status and error reads fix(voyage): bound embedding-batch status, error, and non-OK responses Jun 25, 2026
@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jun 25, 2026
@sallyom

sallyom commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Maintainer local review: merge-ready, reviewed as a batch:

Current CI red check is unrelated to this PR change

Autoreview is clean, the 16 MiB cap is acceptable for these provider JSON/status/error/usage bodies, and the changes follow the established shared bounded-reader pattern. This is the best narrow fix for these call sites: no new SDK/API/config surface, no upgrade/backward-compatibility concern beyond the intended oversized-body failure mode, and no expected breaking behavior for normal provider responses.

@sallyom
sallyom merged commit d3620da into openclaw:main Jun 25, 2026
123 of 126 checks passed
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jun 26, 2026
openclaw#96608)

The batch status read (fetchVoyageBatchStatus) parsed its response with an
unbounded await res.json(), and the batch error-file read (readVoyageBatchError)
buffered the whole body via await res.text(). On top of that, the non-OK
(4xx/5xx) diagnostic body was still read unbounded: assertVoyageResponseOk did
await res.text() before throwing, and the non-OK output-file branch in
runVoyageEmbeddingBatches did the same. Voyage base URLs are user-supplied and
reachable via SSRF, so a misbehaving or hostile endpoint could stream an
unbounded body into memory on any of these paths before parsing.

Route the status JSON through the shared readProviderJsonResponse, the error
file through readResponseWithLimit, and now the non-OK diagnostic body through
readResponseWithLimit as well, all under a single 16 MiB cap, cancelling the
stream on overflow before decode/parse. assertVoyageResponseOk preserves its
original "${context}: ${status} ${text}" diagnostic shape for under-cap bodies
and throws a bounded "(error body exceeds <N> bytes)" on overflow; the non-OK
output-file branch now reuses it instead of a duplicate unbounded read. The
existing error-file fail-soft handling (formatUnavailableBatchError) is
preserved, so a capped endpoint degrades gracefully. The submit path already
bounds its body via postJsonWithRetry/maxResponseBytes and is left untouched.

Symmetric counterpart to the openclaw#96027/openclaw#96038 response-limit campaign.
QiuYuang pushed a commit to QiuYuang/openclaw that referenced this pull request Jul 1, 2026
openclaw#96608)

The batch status read (fetchVoyageBatchStatus) parsed its response with an
unbounded await res.json(), and the batch error-file read (readVoyageBatchError)
buffered the whole body via await res.text(). On top of that, the non-OK
(4xx/5xx) diagnostic body was still read unbounded: assertVoyageResponseOk did
await res.text() before throwing, and the non-OK output-file branch in
runVoyageEmbeddingBatches did the same. Voyage base URLs are user-supplied and
reachable via SSRF, so a misbehaving or hostile endpoint could stream an
unbounded body into memory on any of these paths before parsing.

Route the status JSON through the shared readProviderJsonResponse, the error
file through readResponseWithLimit, and now the non-OK diagnostic body through
readResponseWithLimit as well, all under a single 16 MiB cap, cancelling the
stream on overflow before decode/parse. assertVoyageResponseOk preserves its
original "${context}: ${status} ${text}" diagnostic shape for under-cap bodies
and throws a bounded "(error body exceeds <N> bytes)" on overflow; the non-OK
output-file branch now reuses it instead of a duplicate unbounded read. The
existing error-file fail-soft handling (formatUnavailableBatchError) is
preserved, so a capped endpoint degrades gracefully. The submit path already
bounds its body via postJsonWithRetry/maxResponseBytes and is left untouched.

Symmetric counterpart to the openclaw#96027/openclaw#96038 response-limit campaign.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. size: M status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants