mirror of
https://github.com/guillaumemeyer/watermarks-remover.git
synced 2026-08-22 13:11:57 +02:00
* feat: split skill from service, add HTTP API and Docker distribution The agent skill (skills/remove-ai-marks/) is now a code-free remote client: all implementation moved to service/scripts/ and runs behind a stdlib HTTP service (server.py) with /health, /capabilities, /inspect, /clean and a dynamically generated OpenAPI 3.0.3 spec at /openapi.json. - Move scripts/ and the backend Dockerfiles under service/ - server.py: JSON/base64 HTTP entrypoint with size caps, binary guard, atomic writes, loopback default, optional bearer auth - Core Dockerfile (exiftool/qpdf/c2patool preinstalled) and a GHCR publish workflow for the core/markllm/markdiffusion images - compose.yaml (wr-* services, harness/heavy profiles) + compose-check.sh to validate the running stack (exit code only) - Fix markllm image build (tokenizers 0.22.2, CPU-only torch) and ctrlregen build (python:3.11 base for the 2023-era research pins) - Fix markllm/markdiffusion harness images missing common.py at runtime * docs: add .env.example and service configuration guide * fix: disable chain-of-thought for openai-compatible Layer B rewrites deepseek-v4-flash is a reasoning model: a one-line paraphrase burned 9,894 reasoning tokens (~100s) and hit the default timeout. Send reasoning_effort=none by default for the openai-compatible backend (--reasoning-effort / WATERMARKS_REWRITE_REASONING_EFFORT; 'off' omits the parameter), cutting the same rewrite to ~1s / 12 tokens. Tested end-to-end against api.deepseek.com. * fix: sanitize client-supplied filename in HTTP service CodeQL 'uncontrolled data in path expression' (server.py): a name like '../../x' flowed into Path(tmpdir) / name, letting an upload escape the request temp dir on write. Sanitize name to its basename in _decode_input (_safe_name) and refuse any joined path whose parent is not the tmpdir at the write sites (_tmp_path). Tests cover traversal names. * chore: gitignore .env (contains local rewrite credentials) * chore: deny-by-default gitignore and dockerignore; document compose env config .gitignore and service/.dockerignore now exclude everything by default and explicitly allow only what is publishable/needed: tracked source, docs, tests, .github, and (for images) the service/scripts/ tree that every Dockerfile COPYs. Root .dockerignore documents that all builds use service/ as context. README Configuration section now covers .env setup for docker compose, host-side export for CLI runs, and the full variable table.
93 lines
3.3 KiB
Python
93 lines
3.3 KiB
Python
"""--json must not suppress the residual-signal exit code (was: always 0)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SCRIPTS = ROOT / "service" / "scripts"
|
|
sys.path.insert(0, str(SCRIPTS))
|
|
|
|
import clean_file # noqa: E402
|
|
import clean_image # noqa: E402
|
|
|
|
|
|
def _container_result(dest: Path, residual: bool, degraded: bool = False) -> dict:
|
|
return {
|
|
"input": "x.md",
|
|
"output": str(dest),
|
|
"format": "markdown",
|
|
"actions": ["clean"],
|
|
"bytes_in": 1,
|
|
"bytes_out": 1,
|
|
"still_has_c2pa": residual,
|
|
"still_has_ai_metadata": residual,
|
|
"post_findings": ["marker:c2pa"] if residual else [],
|
|
"meta": {"degraded": degraded},
|
|
}
|
|
|
|
|
|
def _run_clean_file(monkeypatch, tmp_path, *, json_flag, residual, degraded=False):
|
|
src = tmp_path / "x.md"
|
|
src.write_text("---\ngenerator: Claude\n---\nhi\n", encoding="utf-8")
|
|
dest = tmp_path / "x.cleaned.md"
|
|
monkeypatch.setattr(
|
|
clean_file, "clean_container", lambda *a, **k: _container_result(dest, residual, degraded)
|
|
)
|
|
argv = ["clean_file.py", str(src), "-o", str(dest)]
|
|
if json_flag:
|
|
argv.append("--json")
|
|
monkeypatch.setattr(sys, "argv", argv)
|
|
return clean_file.main()
|
|
|
|
|
|
def test_clean_file_json_and_human_agree_on_residual(monkeypatch, tmp_path):
|
|
# The bug: --json returned 0 while human mode returned 1.
|
|
assert _run_clean_file(monkeypatch, tmp_path, json_flag=False, residual=True) == 1
|
|
assert _run_clean_file(monkeypatch, tmp_path, json_flag=True, residual=True) == 1
|
|
|
|
|
|
def test_clean_file_json_and_human_agree_on_clean(monkeypatch, tmp_path):
|
|
assert _run_clean_file(monkeypatch, tmp_path, json_flag=False, residual=False) == 0
|
|
assert _run_clean_file(monkeypatch, tmp_path, json_flag=True, residual=False) == 0
|
|
|
|
|
|
def test_clean_file_degraded_pdf_is_not_a_failure_in_either_mode(monkeypatch, tmp_path):
|
|
assert _run_clean_file(monkeypatch, tmp_path, json_flag=False, residual=True, degraded=True) == 0
|
|
assert _run_clean_file(monkeypatch, tmp_path, json_flag=True, residual=True, degraded=True) == 0
|
|
|
|
|
|
def _image_result(dest: Path, residual: bool) -> dict:
|
|
return {
|
|
"input": "x.png",
|
|
"output": str(dest),
|
|
"actions": ["strip"],
|
|
"bytes_in": 1,
|
|
"bytes_out": 1,
|
|
"still_has_c2pa": residual,
|
|
"still_has_ai_metadata": residual,
|
|
"post_findings": ["byte-scan c2pa"] if residual else [],
|
|
"synthid_before": None,
|
|
"synthid_after": None,
|
|
"pixel_removal": None,
|
|
}
|
|
|
|
|
|
def _run_clean_image(monkeypatch, tmp_path, *, json_flag, residual):
|
|
src = tmp_path / "x.png"
|
|
src.write_bytes(b"\x89PNG\r\n\x1a\n")
|
|
dest = tmp_path / "x.cleaned.png"
|
|
monkeypatch.setattr(clean_image, "clean_image", lambda *a, **k: _image_result(dest, residual))
|
|
argv = ["clean_image.py", str(src), "-o", str(dest)]
|
|
if json_flag:
|
|
argv.append("--json")
|
|
monkeypatch.setattr(sys, "argv", argv)
|
|
return clean_image.main()
|
|
|
|
|
|
def test_clean_image_json_and_human_agree_on_residual(monkeypatch, tmp_path):
|
|
assert _run_clean_image(monkeypatch, tmp_path, json_flag=False, residual=True) == 1
|
|
assert _run_clean_image(monkeypatch, tmp_path, json_flag=True, residual=True) == 1
|
|
assert _run_clean_image(monkeypatch, tmp_path, json_flag=True, residual=False) == 0
|