feat(intake): talk to the rembg sidecar, always naming u2net (#281)

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 12:22:43 -05:00
co-authored by Claude Opus 5
parent 534e3d6228
commit 61e9c239d3
4 changed files with 245 additions and 0 deletions
+95
View File
@@ -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.12.3 s against
* 1420 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<Buffer> {
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;
}
+134
View File
@@ -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<void>
): Promise<void> {
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<void>((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<void>((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('<html>not an image</html>');
},
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/);
});
});
+6
View File
@@ -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
+10
View File
@@ -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