paperclipai/paperclip · error
Teams upload bytes do not match consent
Error message
Teams upload bytes do not match consent
What it means
For operation 'put', exchange() requires an actual Buffer snapshot whose length equals the consented byteSize and whose SHA-256 equals the sha256 recorded in the consent binding. This error means the bytes offered for upload are not exactly the bytes the user consented to receive — either no/invalid buffer, a size mismatch, or different content. It is an integrity guard so the uploaded file can never diverge from the consent card shown to the user.
Source
Thrown at server/src/services/chat-teams-file-consent.ts:443
options: UploadRequestOptions,
): Promise<TeamsUploadOutcome> {
if (
(operation !== "put" && operation !== "status") ||
options.byteSize !== this.#byteSize
)
throw new Error("Invalid Teams upload binding");
// The capability is itself a security boundary; calling it directly cannot
// bypass the outer convenience function's exact-byte checks or mutate a
// caller-owned Buffer while current authorization is awaited.
const snapshot =
operation === "put" && Buffer.isBuffer(bytes) ? Buffer.from(bytes) : null;
if (
operation === "put" &&
(!snapshot ||
snapshot.length !== this.#byteSize ||
createHash("sha256").update(snapshot).digest("hex") !== this.#sha256)
)
throw new Error("Teams upload bytes do not match consent");
const controller = new AbortController();
const signal = options.signal
? AbortSignal.any([options.signal, controller.signal])
: controller.signal;
const timer = setTimeout(() => controller.abort(), 30_000);
try {
await abortable(Promise.resolve().then(options.authorize), signal);
signal.throwIfAborted();
if (this.#expiresAt <= Date.now())
return { kind: "uncertain", reason: "session_unavailable" };
if (operation === "put") {
if (this.#confirmed) return { kind: "uploaded" };
if (this.#putStarted)
return { kind: "uncertain", reason: "put_already_attempted" };
this.#putStarted = true;
}
const response = await abortable(
(options.request ?? guardedRemoteHttpFetch)(View on GitHub (pinned to 01ad858492)
Solutions
- Recompute sha256 and byte length from the exact buffer you pass and compare against binding.sha256/binding.byteSize before calling exchange; if they differ, refresh the source attachment rather than forcing the upload.
- Pass a Node Buffer (not string/Uint8Array view of different content) — exchange snapshots it via Buffer.from.
- If the file legitimately changed, create a new consent binding and send a new consent card; the old consent is bound to the old bytes.
- Check that the digest was computed with createHash('sha256').update(buffer).digest('hex') on the same bytes actually uploaded (no encoding conversion).
Example fix
// before
await exchangeTeamsFileUpload({ upload, binding, operation: "put", bytes: maybeStaleBuffer });
// after
const bytes = await loadAttachmentBytes(attachmentId); // fresh read
const sha256 = createHash("sha256").update(bytes).digest("hex");
if (bytes.length !== binding.byteSize || sha256 !== binding.sha256) {
throw new Error("source attachment changed since consent; re-issue consent card");
}
await exchangeTeamsFileUpload({ upload, binding, operation: "put", bytes }); Defensive patterns
Strategy: validation
Validate before calling
import { createHash } from "node:crypto";
const sha256 = createHash("sha256").update(bytes).digest("hex");
if (!(bytes instanceof Buffer) || bytes.length !== binding.byteSize || sha256 !== binding.sha256) {
throw new Error("upload bytes no longer match consented content");
} Type guard
function matchesConsent(bytes: unknown, binding: TeamsFileConsentBinding): bytes is Buffer {
return bytes instanceof Buffer &&
bytes.length === binding.byteSize &&
createHash("sha256").update(bytes).digest("hex") === binding.sha256;
} Try / catch
try {
await exchangeTeamsFileUpload({ upload, binding, operation: "put", bytes });
} catch (e) {
if (e instanceof Error && e.message === "Teams upload bytes do not match consent") {
// source changed since consent: re-issue the consent card with a new binding
return { kind: "uncertain", reason: "consent_stale" };
}
throw e;
} Prevention
- Compute and store sha256 at publication time and re-verify immediately before upload.
- Freeze/snapshot the attachment bytes at consent creation so later edits cannot slip in.
- Compare digest + size from the binding only; never trust caller-supplied metadata.
- If a mismatch is detected, always re-run consent with a fresh binding instead of forcing the PUT.
When it happens
Trigger: exchange('put', bytes, ...) where bytes is not a Buffer (snapshot null), snapshot.length !== this.#byteSize, or sha256(snapshot) !== this.#sha256. Happens when the source file was modified or re-encoded after the binding was created, the wrong file/buffer is passed, or the caller hashed a different variant (e.g. normalized line endings) than the binding's digest.
Common situations: Attachment content regenerated between consent grant and upload (digest recorded at publication time, file changed since); passing a string or Blob instead of Buffer; streaming pipeline that dropped or added bytes; retrieving the wrong attachment row so size or hash differs.
Understand the failure class
Background: Checksum mismatch errors: "checksum verification failed", "digest mismatch", "expected vs actual checksum" — what they mean and how to fix them — this error's family across 41 libraries.
Related errors
- runnerd digest mismatch: expected ${request.runnerd.sha256},
- Materialized OpenCode executable digest mismatch
- Public viewer asset differs from trusted build: ${file}
- ACPX ${agent} runtime executable digest mismatch
- ACPX snapshot manifest digest mismatch
AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10).
Data as JSON: /api/errors/b4a868c542b78161.
Report an issue: GitHub.