From 61e9c239d390eb127f8436ab28119508911d6ea1 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Thu, 3 Sep 2026 12:22:43 -0500 Subject: [PATCH] feat(intake): talk to the rembg sidecar, always naming u2net (#281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the client that will let the intake path remove backgrounds from submitted photos via the rembg sidecar over HTTP. isRembgConfigured() reports whether REMBG_URL is set (unconfigured is a normal, working state, not a failure), and removeBackground() posts a file to /api/remove and resolves with the PNG bytes it gets back, rejecting on every failure — unconfigured, unreachable, a non-2xx response, or a body that fails the same magic-byte PNG check the upload path already uses. The one hard rule: every request names model=u2net explicitly and this is never configurable. The sidecar's default model, reached simply by omitting the parameter, is bria-rmbg, which is licensed non-commercial — a licensing problem that a shop cannot silently ship, and one that would produce a perfectly good image with nothing in it to reveal the mistake. The test that posts against a real stub HTTP server and asserts model=u2net appears on the wire is the only thing guarding against that regressing. Wires REMBG_URL into both docker-compose.qa.yml and docker-compose.prod.yml as an optional variable, right after ANTHROPIC_WORKSPACE_ID, following the existing style in each file's environment block and header comment. It is deliberately left out of envValidation.ts's ALWAYS_REQUIRED — requiring it would make an environment with no sidecar refuse to boot, which is exactly the failure mode this feature is designed to avoid. Co-Authored-By: Claude Opus 5 --- backend/src/intake/rembgClient.ts | 95 ++++++++++++++++++ backend/tests/unit/rembgClient.test.ts | 134 +++++++++++++++++++++++++ docker-compose.prod.yml | 6 ++ docker-compose.qa.yml | 10 ++ 4 files changed, 245 insertions(+) create mode 100644 backend/src/intake/rembgClient.ts create mode 100644 backend/tests/unit/rembgClient.test.ts diff --git a/backend/src/intake/rembgClient.ts b/backend/src/intake/rembgClient.ts new file mode 100644 index 0000000..e93b79d --- /dev/null +++ b/backend/src/intake/rembgClient.ts @@ -0,0 +1,95 @@ +import { trimTrailingSlashes } from '../utils'; +import { SIGNATURE_BYTES, signatureMatches } from '../uploadTypes'; + +/** + * The one place that talks to the background-removal sidecar. + * + * A sidecar rather than in-process inference: the application runs in a + * container, and putting Python and ONNX into the image would add roughly + * 300 MB to one already over a gigabyte. See + * docs/ops/image-background-removal-stack.md for the measurements. + */ + +/** + * NEVER remove this, and never make it configurable. + * + * The sidecar's default model is `bria-rmbg`, and BRIA's RMBG models are + * licensed for NON-COMMERCIAL use. This is a shop. The default is reached by + * simply not naming a model, so it is a licensing problem that happens + * silently and produces a perfectly good image — there is nothing in the + * output that could reveal it. + * + * `u2net` is Apache-2.0, and also ten times faster (1.1–2.3 s against + * 14–20 s) at a sixth the size, so nothing is being traded away for it. + */ +const MODEL = 'u2net'; + +/** + * Generous on purpose. The sidecar takes about 40 seconds to answer after a + * container start and its first call per model downloads 168 MB, so a tight + * timeout would turn an ordinary cold start into a failure. Nobody is waiting + * on this in the worker's path, and an admin who clicked a button would rather + * wait than be told it did not work. + */ +const TIMEOUT_MS = 120_000; + +/** The configured base URL, or null when there is none. */ +function baseUrl(): string | null { + const raw = process.env.REMBG_URL; + if (raw === undefined || raw.trim() === '') return null; + return trimTrailingSlashes(raw.trim()); +} + +/** + * Whether the feature exists in this environment. + * + * Unconfigured is not a failure. It means the submitter sees no checkbox, the + * admin sees no control and the worker skips the step — an unconfigured + * environment must be a working one, which is the same rule + * `getAnthropicClient` follows by returning null rather than throwing. + */ +export function isRembgConfigured(): boolean { + return baseUrl() !== null; +} + +/** + * The cut-out, as PNG bytes. + * + * Rejects on every failure — unconfigured, unreachable, a non-2xx answer, or a + * body that is not actually a PNG. Every caller catches, and none of them lets + * the rejection reach a submission or a draft. + */ +export async function removeBackground(bytes: Buffer, mediaType: string): Promise { + const base = baseUrl(); + if (base === null) { + throw new Error('REMBG_URL is not set'); + } + + const body = new FormData(); + // A copy through Uint8Array because Buffer is not a BlobPart. The filename is + // a constant: the sidecar does not use it, and passing the stored name would + // put a value from the uploads volume into an outbound request for nothing. + body.append('file', new Blob([new Uint8Array(bytes)], { type: mediaType }), 'photo'); + body.append('model', MODEL); + + const res = await fetch(`${base}/api/remove`, { + method: 'POST', + body, + signal: AbortSignal.timeout(TIMEOUT_MS) + }); + + if (!res.ok) { + throw new Error(`rembg answered ${res.status}`); + } + + const out = Buffer.from(await res.arrayBuffer()); + + // The bytes, not the Content-Type header. A proxy error page served as + // image/png would otherwise be written over a photograph — the same reason + // uploads are checked by signature rather than by what the caller declared. + if (!signatureMatches('image/png', out.subarray(0, SIGNATURE_BYTES))) { + throw new Error('rembg response is not a PNG'); + } + + return out; +} diff --git a/backend/tests/unit/rembgClient.test.ts b/backend/tests/unit/rembgClient.test.ts new file mode 100644 index 0000000..1779d86 --- /dev/null +++ b/backend/tests/unit/rembgClient.test.ts @@ -0,0 +1,134 @@ +import http from 'http'; +import { AddressInfo } from 'net'; +import { isRembgConfigured, removeBackground } from '../../src/intake/rembgClient'; + +/** A real PNG header, so the client's own signature check sees what it expects. */ +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); +const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]); + +interface Capture { + url: string; + body: string; +} + +/** + * A stub sidecar on an ephemeral port. + * + * Port 0 rather than a fixed number: several ports in the 55000s are + * Hyper-V-reserved on the development machine and bind with EACCES, and a + * fixed port would also stop this suite running beside itself. + * + * A real HTTP server rather than a mocked `fetch`, because what is being + * checked is the shape of the request that reaches the wire — above all that + * `model=u2net` is in it. + */ +async function withStub( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void, + run: (capture: Capture) => Promise +): Promise { + const capture: Capture = { url: '', body: '' }; + const server = http.createServer((req, res) => { + capture.url = req.url ?? ''; + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + capture.body = Buffer.concat(chunks).toString('latin1'); + handler(req, res); + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + process.env.REMBG_URL = `http://127.0.0.1:${port}`; + + try { + await run(capture); + } finally { + delete process.env.REMBG_URL; + await new Promise((resolve) => server.close(() => resolve())); + } +} + +function respondWithPng(_req: http.IncomingMessage, res: http.ServerResponse): void { + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(PNG); +} + +describe('whether the sidecar is configured', () => { + it('is false when REMBG_URL is unset', () => { + delete process.env.REMBG_URL; + expect(isRembgConfigured()).toBe(false); + }); + + // A variable set to spaces is a configuration mistake, not a value — the + // same reading envValidation applies everywhere else. + it('is false when REMBG_URL is blank', () => { + process.env.REMBG_URL = ' '; + expect(isRembgConfigured()).toBe(false); + delete process.env.REMBG_URL; + }); + + it('is true when REMBG_URL is set', () => { + process.env.REMBG_URL = 'http://rembg-syn:7000'; + expect(isRembgConfigured()).toBe(true); + delete process.env.REMBG_URL; + }); +}); + +describe('asking the sidecar to remove a background', () => { + /** + * The single most important assertion in this file. + * + * The image's default model is `bria-rmbg`, licensed non-commercial, and it + * is selected by simply not naming a model. Nothing about the returned image + * would reveal it had been used, so this is the only place it can be caught. + */ + it('names u2net explicitly, because the default is licensed non-commercial', async () => { + await withStub(respondWithPng, async (capture) => { + await removeBackground(JPEG, 'image/jpeg'); + expect(capture.body).toContain('name="model"'); + expect(capture.body).toContain('u2net'); + }); + }); + + it('posts the file to /api/remove and returns the PNG it gets back', async () => { + await withStub(respondWithPng, async (capture) => { + const out = await removeBackground(JPEG, 'image/jpeg'); + expect(capture.url).toBe('/api/remove'); + expect(capture.body).toContain('name="file"'); + expect(out.subarray(0, 8)).toEqual(PNG.subarray(0, 8)); + }); + }); + + it('rejects rather than returning bytes when the sidecar errors', async () => { + await withStub( + (_req, res) => { + res.writeHead(500); + res.end('boom'); + }, + async () => { + await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/500/); + } + ); + }); + + // The failure that would otherwise write an HTML error page over a + // photograph. Checked with the same magic-byte helper the upload path uses, + // rather than by trusting the Content-Type the sidecar sent. + it('rejects a response that is not actually a PNG', async () => { + await withStub( + (_req, res) => { + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end('not an image'); + }, + async () => { + await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/not a PNG/); + } + ); + }); + + it('rejects when it is not configured at all', async () => { + delete process.env.REMBG_URL; + await expect(removeBackground(JPEG, 'image/jpeg')).rejects.toThrow(/REMBG_URL/); + }); +}); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9a21546..17b3857 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -98,6 +98,8 @@ # INTAKE_ACTION_SECRET Optional. Signs the regenerate and discard links in the # intake notification email (#224). Absent, the email # still sends and carries no shortcuts. +# REMBG_URL Optional. The background-removal sidecar, e.g. +# http://rembg-syn:7000. Unset turns the feature off. # undrafted, which is a working configuration for the # same reason USPS is. The one credential here that # spends money per call, and on a path anybody holding @@ -238,6 +240,10 @@ services: # keys need no workspace. - ANTHROPIC_WORKSPACE_ID=${ANTHROPIC_WORKSPACE_ID:-} + # Optional. The background-removal sidecar (#281). + # See docs/ops/image-background-removal-stack.md. + - REMBG_URL=${REMBG_URL:-} + # Signs the regenerate and discard links in the intake notification email # (#224). Optional: absent, the notification still sends and links to the # review queue without shortcuts. Rotating it revokes every outstanding diff --git a/docker-compose.qa.yml b/docker-compose.qa.yml index f797512..5e084a5 100644 --- a/docker-compose.qa.yml +++ b/docker-compose.qa.yml @@ -63,6 +63,10 @@ # in the notification email (#224). Absent, the email still # sends and simply carries no shortcuts. Its own value, not # production's: a link signed with it acts without a login. +# QA_REMBG_URL — optional. The background-removal sidecar, e.g. +# http://rembg-syn:7000. Unset turns the feature off rather +# than breaking anything. The sidecar must be on the same +# network as this stack. services: redefined-designs-qa: @@ -164,6 +168,12 @@ services: # would turn the ordinary case into a different error. - ANTHROPIC_WORKSPACE_ID=${QA_ANTHROPIC_WORKSPACE_ID:-} + # Optional. The background-removal sidecar (#281). Unset means the + # feature does not exist: no checkbox on the submission page, no control + # in the review queue, and the worker skips the step. Empty default so an + # unset stack variable cannot fail a deploy. + - REMBG_URL=${QA_REMBG_URL:-} + # Signs the regenerate and discard links in the intake notification email # (#224). Optional: absent, the notification still sends and simply links # to the review queue without shortcuts. Anyone holding a link can act on