Skip to content

feat(tools): add background removal capability - #125

Merged
IAnMove merged 14 commits into
mainfrom
feat/remove-background-tool
Sep 3, 2026
Merged

feat(tools): add background removal capability#125
IAnMove merged 14 commits into
mainfrom
feat/remove-background-tool

Conversation

@IAnMove

@IAnMove IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Summary

  • add a standalone Tools Remove background operation for exact image assets;
  • reuse one cached rembg/U2Net adapter from Tools, Face Rig and existing preprocessing paths;
  • expose the same typed capability to Ask to the Wizard, with canonical asset IDs/locations and explicit confirmation;
  • write a transparent PNG as a derived library asset with lineage, provenance, technical metadata and Activity lifecycle;
  • support simulation, cooperative cancellation and failure cleanup through the existing generation hooks;
  • add EN/ES UI copy, API documentation, contract/API/worker/capability/UI tests, and merge the branch with current origin/main.

Validation

  • 183 passed focused Python suite (tests/test_remove_background_tool.py plus execution, Face Rig, manifest, assets, path-security and Scail2 suites)
  • npm run i18n:check
  • npm run lint -- --max-warnings=0
  • 600 targeted UI tests passed
  • npm run build
  • git diff --cached --check

Notes / limitations

  • CI does not run a real rembg/U2Net inference; the operation is covered through an injected matte and lifecycle/contract tests. The local runtime uses the existing installed rembg backend.
  • Tool jobs are intentionally marked non-durable for the generic generation recovery path: restart recovery needs a dedicated background-removal record/replayer rather than trying to re-run a rembg job as diffusion generation. Activity cancellation/error/retry hooks remain wired for the live process.
  • instruction is retained in provenance/metadata for reproducibility; the current U2Net matte operation does not interpret free-form instructions.
  • No Pinokio launcher files were changed.

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-background queues 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, model rembg-u2net).

Backend: New routers/tools.py plus 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_tool so they skip the durable Studio generation recovery queue. rembg/U2Net is centralized in services/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 registers remove_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.

@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

PR Review — Loreframe Studio

Risk: medium
Scope: 42 file(s); +3594/-221; React UI, backend services

Automated review from scripts/analyze_pr.py. This is a heuristic pass (no LLM) so humans still own the merge decision.

Findings

  • medium — Large pull request
    3594 additions / 221 deletions. Reviewers will have an easier time with smaller, focused PRs.
  • medium — Very large file change (tests/test_remove_background_tool.py)
    tests/test_remove_background_tool.py adds 742 lines. Consider splitting the PR.
  • low — UI changed — rebuild before merge
    Run cd ui && npm run build (CI already does this). Pinokio Update rebuilds for end users; keep ui/dist untracked.

Changed files

  • added: app/routers/tools.py, app/services/rembg_adapter.py, app/shared/tools/background_removal.py, app/shared/tools/background_removal_job.py, app/shared/tools/background_removal_request.py, tests/test_rembg_adapter.py, tests/test_remove_background_tool.py, ui/e2e/specs/tools-background-removal.spec.ts, ui/src/components/Sidebar/ToolsParamsPanel.tsx, ui/src/components/Sidebar/ToolsSourcePanel.tsx, ui/src/features/agent/toolCapabilities.ts, ui/src/features/agent/toolsAdapter.ts, ui/tests/toolCapabilities.test.mjs, ui/tests/toolsPanel.test.tsx
  • modified: app/_launch_runtime.py, app/docs/API.md, app/services/asset_manifest.py, app/services/character_kit_face_cleanup.py, app/services/generation_provenance.py, app/shared/utils/utils.py, tests/fixtures/architecture_wire_inventory.json, tests/fixtures/route_table.json, tests/test_character_kit_face_cleanup.py, tests/test_generation_provenance_submission.py, ui/e2e/helpers/apiRoutes.ts, ui/e2e/helpers/gotoApp.ts, ui/src/api/generation.ts, ui/src/components/Sidebar/ToolsPanel.tsx, ui/src/features/agent/agentActionTypes.ts, ui/src/features/agent/agentActions.ts, ui/src/features/agent/agentContract.ts, ui/src/features/agent/agentKnowledge.ts, ui/src/features/agent/applicationAdapters.ts, ui/src/features/agent/capabilityRegistry.ts
    … and 8 more

CONTRIBUTING checklist

  • python scripts/verify_clean_repo.py
  • python -m compileall -q app/services app/launch.py scripts
  • cd ui && npm run build if the UI changed
  • No weights, CivitAI sidecars, or generated guides
  • Stays local-first (no required accounts / telemetry)

Posted by the repo PR review workflow. Re-runs on each push to the PR.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Code health

Quality score: 49.3/100

Higher is better. The score is a trend dashboard; the independent ratchet below remains the CI gate.

Component Weight Current Change
Cyclomatic health 45% 52.5 +0.2
File concentration 25% 54.3 +0.3
Oversized-file debt 20% 29.4 +0.2
Modularity 10% 62.6 +0.7

Change vs PR base: +0.2 points.

Metric Value
Production LOC 240,595
Production files 531
Test LOC 69,578
Functions measured 15,072
Functions complexity ≥ 15 779
Maximum complexity 667

Markdown, JSON catalogs and tests are out of this table. Only app/ runtime + ui/src TS/JS count.

Most complex functions

Complexity Where
667 app/wgp.py:7157 generate_video
374 ui/src/stores/useStore.ts:4012 Async method 'startGeneration'
355 app/_launch_runtime.py:23251 _run_generation
308 app/wgp.py:12274 generate_video_tab
271 ui/src/components/Sidebar/SceneAnimatorPanel.tsx:474 Function 'SceneAnimatorPanel'
266 ui/src/stores/useStore.ts:8554 Async method 'loadSettingsFromOutput'
258 app/services/director/planners/short_film.py:3433 ShortFilmPlanner._plan_story_driven
248 app/services/director_pipeline.py:13735 _run_video_generation
245 app/services/director_pipeline.py:7860 _run_pipeline
243 ui/src/features/agent/agentActions.ts:1109 Function 'parseAction'
226 app/services/director_pipeline.py:6689 update_comic_preview
225 ui/src/features/agent/agentActions.ts:2766 Async function 'executeAgentActions'

Trend vs baseline

Metric Δ
Production LOC +6,930
Test LOC +5,764
Functions ≥ 15 +5
Maximum complexity +0

Warnings

  • production LOC increased by +6,930
  • functions at complexity >= 15 increased by +5
  • complexity hotspot ui/src/stores/useStore.ts rose 373 -> 374
  • complexity hotspot ui/src/features/agent/agentActions.ts rose 241 -> 243
  • complexity hotspot ui/src/components/Sidebar/InputsPanel.tsx rose 157 -> 162
  • complexity hotspot ui/src/components/MainContent/MediaFeedItem.tsx rose 120 -> 122
  • complexity hotspot ui/src/features/comics/ComicEditorPanel.tsx rose 101 -> 103
  • complexity hotspot ui/src/components/Sidebar/PromptInput.tsx rose 68 -> 70
  • complexity hotspot ui/src/features/series/SeriesSetupPanel.tsx rose 58 -> 62
  • complexity hotspot app/services/model3d_service.py rose 51 -> 54
  • complexity hotspot ui/src/features/series/actions.ts rose 47 -> 50
  • complexity hotspot ui/src/features/comics/generateArtwork.ts rose 42 -> 44
  • complexity hotspot app/services/asset_manifest.py rose 34 -> 38
  • complexity hotspot ui/src/components/MainContent/TabFilter.tsx rose 19 -> 23
  • complexity hotspot ui/src/features/agent/AgentAssistantPanel.tsx rose 19 -> 23
  • complexity hotspot ui/src/features/projects/openProject.ts rose 21 -> 22
  • complexity hotspot app/services/story_library.py rose 16 -> 19
  • complexity hotspot ui/src/components/Sidebar/ToolsPanel.tsx rose 17 -> 19
  • complexity hotspot ui/src/components/Sidebar/ControlVideoSection.tsx rose 15 -> 17
  • hotspot app/services/director_pipeline.py increased by +186 lines
  • hotspot ui/src/features/agent/agentActions.ts increased by +75 lines
  • hotspot ui/src/components/Sidebar/SceneAnimatorPanel.tsx increased by +6 lines
  • hotspot ui/src/features/video-editor/VideoEditorPanel.tsx increased by +27 lines
  • hotspot ui/src/features/comics/ComicWorkflowPanels.tsx increased by +6 lines
  • hotspot ui/src/features/comics/ComicEditorPanel.tsx increased by +45 lines
  • hotspot ui/src/types/index.ts increased by +3 lines
  • hotspot ui/src/features/stories/actions.ts increased by +71 lines
  • hotspot app/services/series_library.py increased by +36 lines
  • hotspot app/services/model3d_service.py increased by +58 lines
  • hotspot ui/src/components/Sidebar/InputsPanel.tsx increased by +32 lines
  • hotspot ui/src/components/DirectorDashboard/DirectorDashboard.tsx increased by +17 lines
  • hotspot ui/src/features/agent/capabilityRegistry.ts increased by +69 lines
  • hotspot ui/src/features/agent/applicationAdapters.ts increased by +37 lines

Ratchet passed.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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 = previousFetch

You can send follow-ups to the cloud agent here.

Comment thread app/routers/tools.py Outdated
Comment thread app/routers/tools.py Outdated
Comment thread ui/src/components/Sidebar/ToolsPanel.tsx Outdated
Comment thread app/services/character_kit_face_cleanup.py Outdated
Comment thread app/routers/tools.py Outdated
cursoragent and others added 6 commits September 3, 2026 20:10
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>
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

cursor review

@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread app/routers/tools.py Outdated
Comment thread app/shared/tools/background_removal_request.py
cursoragent and others added 2 commits September 3, 2026 21:34
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>
@IAnMove

IAnMove commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@IAnMove
IAnMove merged commit 020f37e into main Sep 3, 2026
5 checks passed
@IAnMove
IAnMove deleted the feat/remove-background-tool branch September 5, 2026 11:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants