feat(tools): add background removal capability - #125
Conversation
|
@cursor review |
PR Review — Loreframe StudioRisk: medium Automated review from Findings
Changed files
CONTRIBUTING checklist
Posted by the repo PR review workflow. Re-runs on each push to the PR. |
Code healthQuality score: 49.3/100Higher is better. The score is a trend dashboard; the independent ratchet below remains the CI gate.
Change vs PR base: +0.2 points.
Markdown, JSON catalogs and tests are out of this table. Only Most complex functions
Trend vs baseline
Warnings
Ratchet passed. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 5 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 5 issues found in the latest run.
- ✅ Fixed: Query workspace skips registry check
- El workspace de ?workspace= ahora pasa el mismo filtro de registro que source_workspace antes de llamar a workspace_dir, así que un nombre no registrado ya no crea una carpeta nueva.
- ✅ Fixed: Default root absorbs other workspaces
- Una ruta absoluta bajo outputs// se etiqueta como ese workspace hijo y se rechaza si el caller afirma que pertenece a default.
- ✅ Fixed: Image source can run video tools
- canRun y runTool exigen que upscale/revoice no usen un source de imagen, así que cambiar de pestaña ya no encola un job de vídeo contra una foto.
- ✅ Fixed: Face Rig gains white matte fill
- Face Rig vuelve a llamar rembg con bgcolor=None para conservar el RGB original en los bordes translúcidos.
- ✅ Fixed: Asset source compare skips URL decoding
- La comparación asset_id+source ahora decodifica URLs /api/v1/ antes del basename, así que my%20portrait.png coincide con el archivo en disco.
Or push these changes by commenting:
@cursor push 7e7f33d98e
Preview (7e7f33d98e)
diff --git a/app/routers/tools.py b/app/routers/tools.py
--- a/app/routers/tools.py
+++ b/app/routers/tools.py
@@ -40,6 +40,41 @@
return bool(re.fullmatch(r"(?:default|[A-Za-z0-9][A-Za-z0-9_-]*)", value))
+def _workspace_from_file_query(source: str) -> str | None:
+ """Read ``?workspace=`` from a canonical ``/api/v1/file/`` source URL."""
+ source_without_query = source.split("?", 1)[0].split("#", 1)[0]
+ if not source_without_query.startswith("/api/v1/file/"):
+ return None
+ query_workspace = parse_qs(urlsplit(source).query).get("workspace", [None])[0]
+ if isinstance(query_workspace, str) and query_workspace.strip():
+ return query_workspace.strip()
+ return None
+
+
+def _source_filename(source: str) -> str:
+ source_without_query = source.split("?", 1)[0].split("#", 1)[0]
+ if source_without_query.startswith("/api/v1/"):
+ source_without_query = unquote(source_without_query)
+ return os.path.basename(source_without_query)
+
+
+def _child_workspace_under_default(path: str, default_root: str) -> str | None:
+ """Return the child workspace when ``path`` is under ``default``'s subfolder."""
+ try:
+ relative = os.path.relpath(path, default_root)
+ except ValueError:
+ return None
+ if relative.startswith("..") or os.path.isabs(relative):
+ return None
+ parts = relative.split(os.sep)
+ if len(parts) < 2:
+ return None
+ child = parts[0]
+ if not _valid_workspace_name(child) or child == "default":
+ return None
+ return child
+
+
def _safe_image_in_root(filename: str, root: str) -> str | None:
if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename:
return None
@@ -115,7 +150,7 @@
raise HTTPException(status_code=404, detail="Source asset location is unavailable")
path, resolved_scope = location
filename = os.path.basename(path)
- if payload.source and os.path.basename(payload.source.split("?", 1)[0]) != filename:
+ if payload.source and _source_filename(payload.source) != filename:
raise HTTPException(status_code=409, detail="Source does not match asset_id")
return path, filename, resolved_scope
@@ -144,10 +179,8 @@
# A canonical file URL may carry the physical workspace in its query.
# Preserve that identity when the caller did not send the typed field;
# otherwise a duplicate filename in the active workspace could be used.
- if scope is None and source_without_query.startswith("/api/v1/file/"):
- query_workspace = parse_qs(urlsplit(raw_source).query).get("workspace", [None])[0]
- if isinstance(query_workspace, str) and query_workspace.strip():
- scope = query_workspace.strip()
+ if scope is None:
+ scope = _workspace_from_file_query(raw_source)
if os.path.isabs(source_without_query) and not is_virtual_api_path:
# Preserve the exact absolute source selected by the caller. Falling
# back to ``root / basename`` here could silently process a different
@@ -166,10 +199,21 @@
scope = "__uploads__"
root = uploads_root
elif _contained(absolute_source, destination_root):
- scope = destination_workspace
- root = destination_root
+ child = (
+ _child_workspace_under_default(absolute_source, destination_root)
+ if destination_workspace == "default"
+ else None
+ )
+ if child:
+ scope = child
+ root = os.path.realpath(os.path.abspath(workspace_dir(child)))
+ else:
+ scope = destination_workspace
+ root = destination_root
else:
raise HTTPException(status_code=400, detail="Source image path is not allowed")
+ if scope == "default" and _child_workspace_under_default(absolute_source, root):
+ raise HTTPException(status_code=400, detail="Source image path is not allowed")
if not _contained(absolute_source, root) or absolute_source == root:
raise HTTPException(status_code=400, detail="Source image path is not allowed")
if os.path.splitext(source_name)[1].casefold() not in IMAGE_EXTENSIONS:
@@ -239,6 +283,8 @@
if not _valid_workspace_name(destination_workspace):
raise HTTPException(status_code=400, detail="Invalid workspace")
requested_source_workspace = payload.source_workspace.strip() if payload.source_workspace else None
+ if requested_source_workspace is None and payload.source:
+ requested_source_workspace = _workspace_from_file_query(payload.source.strip())
if requested_source_workspace and requested_source_workspace != "__uploads__" and not _valid_workspace_name(requested_source_workspace):
raise HTTPException(status_code=400, detail="Invalid source workspace")
if requested_source_workspace and requested_source_workspace != "__uploads__":
diff --git a/app/services/character_kit_face_cleanup.py b/app/services/character_kit_face_cleanup.py
--- a/app/services/character_kit_face_cleanup.py
+++ b/app/services/character_kit_face_cleanup.py
@@ -131,9 +131,9 @@
# adapter. The wrapper remains a named seam for existing tests/callers.
from services.rembg_adapter import remove_background_image
- # Preserve Face Rig's historical rembg defaults while sharing session and
- # decoding through the common adapter.
- return remove_background_image(image, alpha_matting=False)
+ # Preserve Face Rig's historical rembg defaults: no alpha matting and
+ # bgcolor=None so translucent edges keep their original RGB (no white halo).
+ return remove_background_image(image, alpha_matting=False, bgcolor=None)
def clean_character_kit_overlay(
diff --git a/tests/test_remove_background_tool.py b/tests/test_remove_background_tool.py
--- a/tests/test_remove_background_tool.py
+++ b/tests/test_remove_background_tool.py
@@ -241,7 +241,108 @@
assert mismatch.status_code == 409
assert len(jobs) == 2
+ unknown_query = FastApiTestClient(app).post("/api/v1/tools/remove-background", json={
+ "source": "/api/v1/file/portrait.png?workspace=ghost",
+ "workspace": "default",
+ })
+ assert unknown_query.status_code == 404
+ assert len(jobs) == 2
+
+def test_tools_route_does_not_treat_child_workspace_as_default(tmp_path):
+ uploads = tmp_path / "uploads"
+ default_workspace = tmp_path / "outputs"
+ film_workspace = default_workspace / "film"
+ uploads.mkdir()
+ default_workspace.mkdir()
+ film_workspace.mkdir()
+ source = film_workspace / "portrait.png"
+ _image(source)
+ created = []
+
+ def workspace_dir(name):
+ path = default_workspace if name == "default" else default_workspace / name
+ if name != "default":
+ created.append(name)
+ path.mkdir(parents=True, exist_ok=True)
+ return str(path)
+
+ jobs = []
+ app = FastAPI()
+ app.include_router(create_tools_router(
+ get_active_workspace=lambda: "default",
+ list_workspaces=lambda: [{"name": "default"}, {"name": "film"}],
+ workspace_dir=workspace_dir,
+ uploads_dir=lambda: str(uploads),
+ register_job=lambda job: jobs.append(job) or job,
+ start_remove_background=lambda _job: None,
+ ))
+ client = FastApiTestClient(app)
+
+ inferred = client.post("/api/v1/tools/remove-background", json={
+ "source": str(source),
+ "workspace": "default",
+ })
+ assert inferred.status_code == 200
+ assert jobs[0]["params"]["_source_path"] == str(source.resolve())
+ assert jobs[0]["params"]["source_workspace"] == "film"
+
+ claimed_default = client.post("/api/v1/tools/remove-background", json={
+ "source": str(source),
+ "source_workspace": "default",
+ "workspace": "default",
+ })
+ assert claimed_default.status_code == 400
+ assert len(jobs) == 1
+
+ unknown_query = client.post("/api/v1/tools/remove-background", json={
+ "source": "/api/v1/file/portrait.png?workspace=ghost",
+ "workspace": "default",
+ })
+ assert unknown_query.status_code == 404
+ assert "ghost" not in created
+ assert not (default_workspace / "ghost").exists()
+
+
+def test_tools_route_accepts_encoded_file_url_with_matching_asset_id(tmp_path):
+ uploads = tmp_path / "uploads"
+ workspace = tmp_path / "outputs"
+ uploads.mkdir()
+ workspace.mkdir()
+ source = workspace / "my portrait.png"
+ _image(source)
+ jobs = []
+ asset = {
+ "id": "asset_portrait",
+ "kind": "image",
+ "filename": "my portrait.png",
+ "locations": [{
+ "workspace_id": "default",
+ "filename": "my portrait.png",
+ }],
+ }
+ app = FastAPI()
+ app.include_router(create_tools_router(
+ get_active_workspace=lambda: "default",
+ list_workspaces=lambda: [{"name": "default"}],
+ workspace_dir=lambda _name: str(workspace),
+ uploads_dir=lambda: str(uploads),
+ asset_finder=lambda asset_id: asset if asset_id == "asset_portrait" else None,
+ register_job=lambda job: jobs.append(job) or job,
+ start_remove_background=lambda _job: None,
+ ))
+
+ response = FastApiTestClient(app).post("/api/v1/tools/remove-background", json={
+ "asset_id": "asset_portrait",
+ "source": "/api/v1/file/my%20portrait.png",
+ "workspace": "default",
+ })
+
+ assert response.status_code == 200
+ assert jobs[0]["params"]["source_asset_id"] == "asset_portrait"
+ assert jobs[0]["params"]["_source_path"] == str(source.resolve())
+
+
def test_background_removal_worker_publishes_lineage_and_finishes(tmp_path):
uploads = tmp_path / "uploads"
workspace = tmp_path / "outputs"
diff --git a/ui/src/components/Sidebar/ToolsPanel.tsx b/ui/src/components/Sidebar/ToolsPanel.tsx
--- a/ui/src/components/Sidebar/ToolsPanel.tsx
+++ b/ui/src/components/Sidebar/ToolsPanel.tsx
@@ -131,7 +131,9 @@
const hasRefs = revoiceRefs.some(r => r && r.path)
const currentIsImage = !!current && current.type === 'image'
- const canRun = !!sourcePath && (tool === 'upscale' || hasRefs || (tool === 'remove_background' && sourceKind === 'image'))
+ const canRun = tool === 'remove_background'
+ ? !!sourcePath && sourceKind === 'image'
+ : !!sourcePath && sourceKind !== 'image' && (tool === 'upscale' || hasRefs)
const flashvsrOff = flashvsrMode === 0 && method.startsWith('flashvsr')
return (
diff --git a/ui/src/stores/useStore.ts b/ui/src/stores/useStore.ts
--- a/ui/src/stores/useStore.ts
+++ b/ui/src/stores/useStore.ts
@@ -3887,6 +3887,7 @@
.map(r => r.path)
if (tool === 'revoice' && refPaths.length === 0) return
if (tool === 'remove_background' && s.toolsSourceKind !== 'image') return
+ if ((tool === 'upscale' || tool === 'revoice') && s.toolsSourceKind === 'image') return
// Placeholder job tile — mirrors the blend/edit submit pattern so the
// progress shows in the main feed and the gallery refreshes on completion.
diff --git a/ui/tests/toolsPanel.test.tsx b/ui/tests/toolsPanel.test.tsx
--- a/ui/tests/toolsPanel.test.tsx
+++ b/ui/tests/toolsPanel.test.tsx
@@ -61,6 +61,12 @@
assert.equal(useStore.getState().toolsSourceKind, 'image')
assert.equal(useStore.getState().toolsSourceWorkspace, 'default')
assert.ok(screen.getByRole('img', { name: 'hero.png' }))
+ assert.equal((screen.getByRole('button', { name: 'Remove Background' }) as HTMLButtonElement).disabled, false)
+
+ fireEvent.click(screen.getByRole('button', { name: 'Upscale' }))
+ assert.equal(useStore.getState().toolsTool, 'upscale')
+ assert.equal(useStore.getState().toolsSourceKind, 'image')
+ assert.equal((screen.getByRole('button', { name: 'Upscale Clip' }) as HTMLButtonElement).disabled, true)
} finally {
cleanup()
globalThis.fetch = previousFetchYou can send follow-ups to the cloud agent here.
Face Rig now shares the Tools rembg adapter, which defaulted bgcolor to white transparent. rembg then composites translucent and clear edge pixels onto white, so overlays pick up a halo. Restore the historical no-bgcolor cutout for Face Rig while keeping the Tools default. Co-authored-by: THEINAOG <IAnMove@users.noreply.github.com>
canRun treated any selected path as valid for upscale (and any path plus voice refs for revoice). After picking an image for background removal and switching tabs, those video tools still submitted it as video_path. Co-authored-by: THEINAOG <IAnMove@users.noreply.github.com>
Treat ?workspace= on /api/v1/file/ sources as source_workspace so the list_workspaces() gate runs before workspace_dir() can create a folder and add a missing workspace to the switcher. Co-authored-by: THEINAOG <IAnMove@users.noreply.github.com>
Canonical /api/v1/file/my%20portrait.png plus a matching asset_id was rejected as 409 because the filename check used the raw URL basename. Reuse the same unquote path as source-only resolution, and decode the Wizard adapter source before submitting both fields. Co-authored-by: THEINAOG <IAnMove@users.noreply.github.com>
When destination is default, its directory is the parent of every other workspace. Prefix containment then treated outputs/<other>/file.png as default-owned and minted unmanaged lineage for the wrong folder. Co-authored-by: THEINAOG <IAnMove@users.noreply.github.com>
|
cursor review |
|
@cursor review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: User jobs get Studio origin tool
- The Tools router now normalizes provenance with capability remove_background so user-run cutouts stamp tool as tools instead of studio.
- ✅ Fixed: Asset ID ignores URL workspace
- Asset resolution now applies the file-URL workspace on both the server and Wizard adapter so a multi-location asset is taken from the named workspace.
Or push these changes by commenting:
@cursor push 5bfa806be1
Preview (5bfa806be1)
diff --git a/app/routers/tools.py b/app/routers/tools.py
--- a/app/routers/tools.py
+++ b/app/routers/tools.py
@@ -78,8 +78,10 @@
f"hocuspocus:unmanaged:{source_workspace}:{source_filename}",
).hex
)
- provenance = normalize_submission_provenance(payload.provenance)
- provenance["capability"] = "remove_background"
+ provenance = normalize_submission_provenance({
+ **payload.provenance,
+ "capability": "remove_background",
+ })
job_id = uuid.uuid4().hex[:8]
source_root = output_dir if source_workspace == "__uploads__" else workspace_dir(source_workspace)
job = build_background_removal_job(
diff --git a/app/shared/tools/background_removal_request.py b/app/shared/tools/background_removal_request.py
--- a/app/shared/tools/background_removal_request.py
+++ b/app/shared/tools/background_removal_request.py
@@ -139,7 +139,7 @@
raise HTTPException(status_code=404, detail="Source asset not found")
if str(asset.get("kind") or "") != "image":
raise HTTPException(status_code=400, detail="Source asset must be an image")
- scope = payload.source_workspace.strip() if payload.source_workspace else None
+ scope = _explicit_source_workspace(payload)
location = _asset_location(
asset,
source_workspace=scope,
diff --git a/tests/test_remove_background_tool.py b/tests/test_remove_background_tool.py
--- a/tests/test_remove_background_tool.py
+++ b/tests/test_remove_background_tool.py
@@ -128,9 +128,21 @@
assert jobs[0]["params"]["_non_durable_tool"] == "remove_background"
assert jobs[0]["provenance"]["actor"] == "wizard"
assert jobs[0]["provenance"]["capability"] == "remove_background"
+ assert jobs[0]["provenance"]["tool"] == "tools"
assert started == jobs
+ user_response = FastApiTestClient(app).post("/api/v1/tools/remove-background", json={
+ "asset_id": "asset_source",
+ "source": "source.png",
+ "workspace": "default",
+ "provenance": {"actor": "user"},
+ })
+ assert user_response.status_code == 200
+ assert jobs[1]["provenance"]["actor"] == "user"
+ assert jobs[1]["provenance"]["capability"] == "remove_background"
+ assert jobs[1]["provenance"]["tool"] == "tools"
+
def test_tools_route_rejects_non_image_asset_and_traversal(tmp_path):
uploads = tmp_path / "uploads"
workspace = tmp_path / "outputs"
@@ -575,6 +587,7 @@
})
assert response.status_code == 200
assert jobs[-1]["params"]["source"] == "my portrait.png"
+ assert jobs[-1]["params"]["source_workspace"] == "default"
source_only = client.post("/api/v1/tools/remove-background", json={
"source": "/api/v1/file/my%20portrait.png", "workspace": "default",
})
@@ -586,6 +599,49 @@
assert len(jobs) == 3
+def test_tools_route_uses_file_url_workspace_when_asset_has_multiple_locations(tmp_path):
+ uploads = tmp_path / "uploads"
+ default_workspace = tmp_path / "default"
+ film_workspace = tmp_path / "film"
+ uploads.mkdir()
+ default_workspace.mkdir()
+ film_workspace.mkdir()
+ default_source = default_workspace / "portrait.png"
+ film_source = film_workspace / "portrait.png"
+ _image(default_source, size=(10, 8))
+ _image(film_source, size=(24, 18))
+ jobs = []
+ asset = {
+ "id": "asset_shared",
+ "kind": "image",
+ "filename": "portrait.png",
+ "locations": [
+ {"workspace_id": "default", "filename": "portrait.png"},
+ {"workspace_id": "film", "filename": "portrait.png"},
+ ],
+ }
+ app = FastAPI()
+ app.include_router(create_tools_router(
+ get_active_workspace=lambda: "default",
+ list_workspaces=lambda: [{"name": "default"}, {"name": "film"}],
+ workspace_dir=lambda name: str({"default": default_workspace, "film": film_workspace}[name]),
+ uploads_dir=lambda: str(uploads),
+ asset_finder=lambda asset_id: asset if asset_id == "asset_shared" else None,
+ register_job=lambda job: jobs.append(job) or job,
+ start_remove_background=lambda _job: None,
+ ))
+
+ response = FastApiTestClient(app).post("/api/v1/tools/remove-background", json={
+ "asset_id": "asset_shared",
+ "source": "/api/v1/file/portrait.png?workspace=film",
+ "workspace": "default",
+ })
+
+ assert response.status_code == 200
+ assert jobs[0]["params"]["_source_path"] == str(film_source.resolve())
+ assert jobs[0]["params"]["source_workspace"] == "film"
+
+
def test_child_workspace_name_distinguishes_default_from_nested_folders(tmp_path):
default_root = str((tmp_path / "outputs").resolve())
assert child_workspace_name(f"{default_root}/hero.png", default_root) is None
diff --git a/ui/src/features/agent/toolsAdapter.ts b/ui/src/features/agent/toolsAdapter.ts
--- a/ui/src/features/agent/toolsAdapter.ts
+++ b/ui/src/features/agent/toolsAdapter.ts
@@ -39,12 +39,28 @@
return `/api/v1/file/${encodeURIComponent(filename)}?workspace=${encodeURIComponent(sourceWorkspace || workspace)}`
}
+function fileUrlWorkspace(source: string | undefined): string | undefined {
+ const raw = (source || '').trim()
+ const path = raw.split(/[?#]/, 1)[0]
+ if (!path.startsWith('/api/v1/file/')) return undefined
+ try {
+ const value = new URL(raw, 'http://local.invalid').searchParams.get('workspace')?.trim()
+ return value || undefined
+ } catch {
+ return undefined
+ }
+}
+
+function explicitSourceWorkspace(action: AgentRemoveBackgroundAction): string | undefined {
+ return action.sourceWorkspace?.trim() || fileUrlWorkspace(action.source)
+}
+
async function resolveSource(
action: AgentRemoveBackgroundAction,
workspace: string,
): Promise<ResolvedSource> {
const assetId = action.assetId?.trim() || undefined
- const assetSource = await loadAssetSource(assetId, action.sourceWorkspace, workspace)
+ const assetSource = await loadAssetSource(assetId, explicitSourceWorkspace(action), workspace)
return finishSource(action, workspace, assetId, assetSource)
}
@@ -70,7 +86,7 @@
if (!source) throw new Error(i18n.t('removeBackgroundMissingSource', { ns: 'wizard' }))
const fallbackName = sourceBasename(source)
const name = assetSource?.asset.filename || fallbackName
- const sourceWorkspace = action.sourceWorkspace?.trim() || assetSource?.sourceWorkspace
+ const sourceWorkspace = explicitSourceWorkspace(action) || assetSource?.sourceWorkspace
return { source, name, url: sourceUrl(source, sourceWorkspace, workspace), assetId, sourceWorkspace }
}
diff --git a/ui/tests/toolCapabilities.test.mjs b/ui/tests/toolCapabilities.test.mjs
--- a/ui/tests/toolCapabilities.test.mjs
+++ b/ui/tests/toolCapabilities.test.mjs
@@ -130,3 +130,81 @@
clearExecutionMemory()
}
})
+
+test('remove-background adapter keeps file-URL workspace when asset exists in two places', async () => {
+ const { createDefaultApplicationAdapters } = await import('../src/features/agent/applicationAdapters.ts')
+ const { useStore } = await import('../src/stores/useStore.ts')
+ const { clearExecutionMemory } = await import('../src/features/agent/agentContract.ts')
+ const before = {
+ mediaFilter: useStore.getState().mediaFilter,
+ sidebarMode: useStore.getState().sidebarMode,
+ sidebarOpen: useStore.getState().sidebarOpen,
+ settingsOpen: useStore.getState().settingsOpen,
+ dashboardOpen: useStore.getState().dashboardOpen,
+ activeWorkspace: useStore.getState().activeWorkspace,
+ toolsSourcePath: useStore.getState().toolsSourcePath,
+ toolsSourceName: useStore.getState().toolsSourceName,
+ toolsSourceAssetId: useStore.getState().toolsSourceAssetId,
+ toolsSourceWorkspace: useStore.getState().toolsSourceWorkspace,
+ }
+ const received = []
+ const previousFetch = globalThis.fetch
+ globalThis.fetch = async (input, init) => {
+ const requestUrl = typeof input === 'string' ? input : input.url || String(input)
+ if (requestUrl.includes('/api/v1/assets/asset_shared')) {
+ return new Response(JSON.stringify({
+ id: 'asset_shared',
+ kind: 'image',
+ filename: 'portrait.png',
+ size_bytes: 12,
+ created_at: 1,
+ completed_at: 2,
+ metadata_status: 'canonical',
+ workspace_ids: ['default', 'film'],
+ locations: [
+ { workspace_id: 'default', filename: 'portrait.png', url: '/api/v1/file/portrait.png?workspace=default' },
+ { workspace_id: 'film', filename: 'portrait.png', url: '/api/v1/file/portrait.png?workspace=film' },
+ ],
+ url: '/api/v1/file/portrait.png?workspace=default',
+ origin: { tool: 'studio' },
+ execution: {},
+ model: { provider: 'local', id: 'flux' },
+ prompt_preview: 'portrait',
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } })
+ }
+ if (requestUrl.includes('/api/v1/tools/remove-background')) {
+ received.push(JSON.parse(init.body))
+ return new Response(JSON.stringify({ job_id: 'job-film', task_id: 'job-film' }), {
+ status: 200, headers: { 'Content-Type': 'application/json' },
+ })
+ }
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })
+ }
+ clearExecutionMemory()
+ useStore.setState({
+ activeWorkspace: 'default',
+ settingsOpen: false,
+ dashboardOpen: false,
+ sidebarMode: 'studio',
+ sidebarOpen: false,
+ })
+ try {
+ const adapters = createDefaultApplicationAdapters()
+ const outcome = await adapters.tools.removeBackground({
+ type: 'remove_background',
+ assetId: 'asset_shared',
+ source: '/api/v1/file/portrait.png?workspace=film',
+ confirm: true,
+ }, { actor: 'wizard', capability: 'remove_background', commandId: 'cmd-film' })
+ assert.equal(received.length, 1)
+ assert.equal(received[0].asset_id, 'asset_shared')
+ assert.equal(received[0].source, 'portrait.png')
+ assert.equal(received[0].source_workspace, 'film')
+ assert.equal(useStore.getState().toolsSourceWorkspace, 'film')
+ assert.equal(outcome.taskId, 'job-film')
+ } finally {
+ globalThis.fetch = previousFetch
+ useStore.setState(before)
+ clearExecutionMemory()
+ }
+})You can send follow-ups to the cloud agent here.
When asset_id is present, location picking used only the typed source_workspace field. Canonical /api/v1/file/?workspace= queries were validated in destination_context but ignored, so multi-location assets resolved in the destination workspace instead of the one named in the URL. Co-authored-by: THEINAOG <IAnMove@users.noreply.github.com>
…alize The Tools panel submits only actor=user. Normalizing first defaulted tool to studio, and setting capability afterwards left sidecar publish recording cutouts as Studio work. Stamp the server-owned capability first so tool derives as tools, and use that surface as the launch sidecar fallback. Co-authored-by: THEINAOG <IAnMove@users.noreply.github.com>
|
cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 2485dcd. Configure here.

Summary
rembg/U2Net adapter from Tools, Face Rig and existing preprocessing paths;origin/main.Validation
183 passedfocused Python suite (tests/test_remove_background_tool.pyplus execution, Face Rig, manifest, assets, path-security and Scail2 suites)npm run i18n:checknpm run lint -- --max-warnings=0npm run buildgit diff --cached --checkNotes / limitations
instructionis retained in provenance/metadata for reproducibility; the current U2Net matte operation does not interpret free-form instructions.Please review the security boundaries around canonical asset resolution, cross-workspace sources and output lineage.
Note
Medium Risk
New HTTP surface with cross-workspace path and asset resolution boundaries; file writes and job lifecycle integrate with shared GPU coordination but tool jobs are intentionally non-durable on restart.
Overview
Adds a Remove background tool end-to-end:
POST /api/v1/tools/remove-backgroundqueues a job that writes a transparent PNG (never overwriting the source), publishes canonical asset metadata with source lineage, and plugs into the existing GPU slot, status/cancel, and Activity task UI (Tools · Remove background, modelrembg-u2net).Backend: New
routers/tools.pyplus shared modules for request validation (asset IDs, workspaces, path safety), file I/O, and a worker wired through_launch_runtime. Tool jobs are tagged_non_durable_toolso they skip the durable Studio generation recovery queue. rembg/U2Net is centralized inservices/rembg_adapter.py; Recast, Face Rig, and preprocessing call it instead of duplicating session logic.UI & Wizard: Studio Tools gains a third tab with image library/upload source selection, optional instruction text, and
submitToolRemoveBackground. The Wizard registersremove_background(confirm required) with a tools adapter that opens Tools and submits the same API. i18n (EN/ES), API docs, Playwright E2E, and broad contract tests accompany the change.Reviewed by Cursor Bugbot for commit 2485dcd. Configure here.