Skip to content

fix(cli): drop tqdm from file uploader, use rich progress (DX-458) - #523

Open
blainekasten wants to merge 12 commits into
mainfrom
cursor/dx-458-file-uploader-rich-progress-9bf7
Open

fix(cli): drop tqdm from file uploader, use rich progress (DX-458)#523
blainekasten wants to merge 12 commits into
mainfrom
cursor/dx-458-file-uploader-rich-progress-9bf7

Conversation

@blainekasten

@blainekasten blainekasten commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Resolves DX-458: make file transfer logic presentation-free and render progress with Rich in the CLI.

Changes

  • Removed all tqdm usage from upload/download managers and check_file
  • Removed tqdm / types-tqdm dependencies
  • Upload/download managers report progress via optional callbacks (FileUploadProgress / FileDownloadProgress); SDK stays quiet by default
  • Reused shared UploadProgressTracker / DownloadProgressTracker CLI components (extracted from beta models upload/download) for:
    • tg files upload
    • tg fine-tuning create (local training/validation uploads)
    • tg fine-tuning download
    • existing tg beta models upload / download flows
  • Validate upload redirect URLs (https only, reject localhost/private IPs) and file IDs to mitigate SSRF
  • Dropped TOGETHER_DISABLE_TQDM CLI env toggles (no longer needed)
  • Kept deprecated DISABLE_TQDM = False export for compatibility

Tests

  • Unit/CLI coverage for upload progress callbacks, async upload streaming, redirect validation, and json-mode progress disable
  • ./scripts/lint + related CLI/unit suites pass locally

Linear Issue: DX-458

Open in Web Open in Cursor 

@broly-code-security-scanner

broly-code-security-scanner Bot commented Aug 12, 2026

Copy link
Copy Markdown

Broly Security Scan

Note

Summary

6 actionable finding(s) in this PR
7 total in scan · 0 dismissed false positives

  • 🟡 6 medium

5 highest-priority actionable rows in the table below (critical/high first, then top medium).

1 finding(s) below the medium reporting threshold are not listed above — see the repository Security tab for the full set.

No finding is at or above high, so this check is not blocking. The findings above are still tracked and reported.

Severity Scanner Issue Location Dismiss Verdict
🟡 MEDIUM SCA aiohttp@3.13.3 — 48 vulnerabilities (worst:
GHSA-2fqr-mr3j-6wp8)
→ >= 3.14.3
uv.lock:1 d2 🔺 TRUE_POSITIVE · Confidence: HIGH
🟡 MEDIUM SCA pygments@2.19.2 — 2 vulnerabilities (worst:
GHSA-5239-wwwm-4pmq)
→ >= 2.20.0
uv.lock:1 d5 🔺 TRUE_POSITIVE · Confidence: HIGH
🟡 MEDIUM SCA idna@3.11 — 2 vulnerabilities (worst:
GHSA-65pc-fj4g-8rjx)
→ >= 3.15
uv.lock:1 d3 🔺 TRUE_POSITIVE · Confidence: HIGH
🟡 MEDIUM SCA pytest@9.0.2 — 2 vulnerabilities (worst:
GHSA-6w46-j5rx-g56g)
→ >= 9.0.3
uv.lock:1 d6 🔺 TRUE_POSITIVE · Confidence: HIGH
🟡 MEDIUM SAST Path Traversal via unsanitized output_dir
parameter
src/together/lib/cli/api/fine_tuning/download.py:90 d7 🔺 TRUE_POSITIVE · Confidence: HIGH

Dismiss false positives

Tick a box to dismiss the finding; untick it to bring the finding back. That is the same as replying /broly dismiss d1 and /broly undismiss d1. To record why it is a false positive, reply with /broly dismiss d1: your reason instead — Broly reuses those reasons to triage similar findings across the org.

  • d2 · 🟡 MEDIUM   · uv.lock:1 · aiohttp@3.13.3 — 48 vulnerabilities (worst: GHSA-2fqr-mr3j-6wp8)
  • d3 · 🟡 MEDIUM   · uv.lock:1 · idna@3.11 — 2 vulnerabilities (worst: GHSA-65pc-fj4g-8rjx)
  • d5 · 🟡 MEDIUM   · uv.lock:1 · pygments@2.19.2 — 2 vulnerabilities (worst: GHSA-5239-wwwm-4pmq)
  • d6 · 🟡 MEDIUM   · uv.lock:1 · pytest@9.0.2 — 2 vulnerabilities (worst: GHSA-6w46-j5rx-g56g)
  • d7 · 🟡 MEDIUM   · src/together/lib/cli/api/fine_tuning/download.py:90 · Path Traversal via unsanitized output_dir parameter
  • d20 · 🟡 MEDIUM   · src/together/lib/resources/files.py:92 · SSRF bypass via DNS rebinding in upload redirect validation

Note

Re-scan this PR anytime with /broly scan — useful after /broly undismiss, or to refresh findings without a new push.

Broly — SAST (zai-org/GLM-5.2) · Secrets · SCA · IaC · GH Actions · Base Images · Supply Chain Threats · Exploit Chains · Adversarial Verification

We're continuously improving Broly's accuracy and finding quality — your feedback is valuable. False positives, missed findings, bugs, and feature requests all welcome.

Ask in #security-engineering   Powered by Together AI

@blainekasten

Copy link
Copy Markdown
Collaborator Author

@cursoragent i still see references to tqdm. Please remove all usage and apply proper code changes to keep progress visibility to CLI callers.

@cursor

cursor Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Pushed. All real tqdm usage is gone.

  • Upload + download managers: callbacks only (FileUploadProgress / FileDownloadProgress)
  • check_file: plain iteration, no console progress
  • CLI Rich progress: files upload, ft create uploads, ft download
  • Dropped tqdm / types-tqdm deps + TOGETHER_DISABLE_TQDM toggles
  • Left deprecated DISABLE_TQDM = False export for compat

PR: #523

Open in Web Open in Cursor 

@blainekasten

Copy link
Copy Markdown
Collaborator Author

Broly Security Scan

Note

Summary

1 actionable finding(s) in this PR

  • 🟠 1 high

All actionable items are in the table below.

Severity Scanner Issue Location Dismiss Verdict
🟠 HIGH SAST Server-Side Request Forgery (SSRF) via unvalidated
redirect URL in single file upload src/together/lib/resources/files.py:545 d1 🔺 TRUE_POSITIVE · Confidence: HIGH

Fix Suggestions

Server-Side Request Forgery (SSRF) via unvalidated redirect URL in single file upload — src/together/lib/resources/files.py:545

Validate that redirect_url points to a trusted host (e.g., an allowlisted storage domain) before passing it to self._client._client.put.

  • Adds urllib.parse.urlparse validation of redirect_url immediately after it is extracted from the Location header at line 545, rejecting any URL whose scheme is not https or whose netloc is not in an allowlist of trusted storage hosts.
  • Prevents redirect_url from reaching the self._client._client.put(url=redirect_url, ...) sink at line 614 unless it points to a trusted destination, blocking SSRF-based file exfiltration.
  • Limitation: The async variant at lines 959-1041 has the identical flaw and needs the same validation; the allowlist of trusted hosts (storage.together.ai, upload.together.ai) is a guess based on the API domain and must be confirmed against the actual redirect targets the Together API uses.
--- a/src/together/lib/resources/files.py
+++ b/src/together/lib/resources/files.py
@@ -542,15 +542,14 @@
                 ) from e
             response = e.response
 
-        redirect_url = response.headers.get("Location")
-        file_id = response.headers.get("X-Together-File-Id")
-
-        if not redirect_url or not file_id:
-            raise APIStatusError(
-                f"Missing required headers in response. Location: {redirect_url}, File-Id: {file_id}",
-                response=response,
-                body=response.content.decode() if hasattr(response, "content") else "",
-            )
+redirect_url = response.headers.get("Location")
+file_id = response.headers.get("X-Together-File-Id")
+if not redirect_url or not file_id:
+    raise APIStatusError(
+        f"Missing required headers in response. Location: {redirect_url}, File-Id: {file_id}",
+        response=response,
+        body=response.content.decode() if hasattr(response, "content") else "",
+    )
 
         return redirect_url, file_id
 

Dismiss false positives

Tick a box to dismiss the finding; untick it to bring the finding back. That is the same as replying /broly dismiss d1 and /broly undismiss d1. To record why it is a false positive, reply with /broly dismiss d1: your reason instead — Broly reuses those reasons to triage similar findings across the org.

  • d1 · 🟠 HIGH     · src/together/lib/resources/files.py:545 · Server-Side Request Forgery (SSRF) via unvalidated redirect URL in single fil...

Note

Re-scan this PR anytime with /broly scan — useful after /broly undismiss, or to refresh findings without a new push.

Broly — SAST (zai-org/GLM-5.2) · Secrets · SCA · IaC · GH Actions · Base Images · Supply Chain Threats · Exploit Chains · Adversarial Verification
We're continuously improving Broly's accuracy and finding quality — your feedback is valuable. False positives, missed findings, bugs, and feature requests all welcome.
Ask in #security-engineering   Powered by Together AI

@cursoragent please address this security issue as well

Comment on lines +90 to +96
file_path, file_size = await AsyncDownloadManager(config.client).download(
url=url,
output=output,
remote_name=ft_job.x_model_output_name,
fetch_metadata=True,
progress_callback=progress_callback,
)
Comment thread src/together/lib/resources/files.py Fixed
cursoragent and others added 2 commits August 12, 2026 14:24
Make UploadManager/AsyncUploadManager presentation-free by reporting
FileUploadProgress via callback instead of printing with tqdm. Wire
rich Progress into `files upload` and `fine-tuning create`.

Co-authored-by: Blaine Kasten <blainekasten@gmail.com>
Drop tqdm from download managers and check_file, report download
progress via callbacks, render Rich progress in fine-tuning download,
and remove the tqdm dependency.

Co-authored-by: Blaine Kasten <blainekasten@gmail.com>
@blainekasten
blainekasten force-pushed the cursor/dx-458-file-uploader-rich-progress-9bf7 branch from 036ae55 to ea6e6a0 Compare August 12, 2026 19:26
Comment thread src/together/lib/resources/files.py Fixed
@blainekasten

Copy link
Copy Markdown
Collaborator Author

@cursoragent instead of making a new upload progress, can we reuse the UploadProgressTracker from src/together/lib/cli/api/beta/models/upload.py?

Also make sure tests/lint passes

@cursor

cursor Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Done.

  • Moved UploadProgressTracker into lib/cli/components/upload_progress.py and reused it for files upload / ft create (beta models upload imports the same class)
  • Same for DownloadProgressTrackerft download + beta models download
  • SSRF: validate redirect URLs (https, no localhost/private IPs) + safe file_id
  • ./scripts/lint clean, 89 related tests green

PR: #523

Open in Web Open in Cursor 

Move UploadProgressTracker/DownloadProgressTracker into shared CLI
components and wire files/fine-tuning upload+download through them.
Also validate upload redirect URLs/file IDs to mitigate SSRF.

Co-authored-by: Blaine Kasten <blainekasten@gmail.com>
Comment thread src/together/lib/resources/files.py Fixed
@blainekasten
blainekasten marked this pull request as ready for review August 12, 2026 21:39
@blainekasten
blainekasten requested a review from zainhas August 12, 2026 21:40
Comment thread src/together/lib/resources/files.py Outdated
Comment on lines +1111 to +1119
callback_response = await self._client._client.put(
url=redirect_url,
content=_aiter_file_upload_chunks(
file,
total_bytes=file_size,
progress_callback=progress_callback,
),
headers={"Content-Length": str(file_size)},
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The PUT body went from bytes to a one-shot generator, but self._client._client is built with follow_redirects=True (_base_client.py:826).

If the presigned endpoint answers with a 307 — S3 does this for cross-region/newly-created buckets, and GCS resumable endpoints do it too — httpx will try to replay the request body and blow up with StreamConsumed instead of just re-PUTing like it used to when content was a plain bytes object.

Same for any transport-level retry that happens after the first byte is written. Worth passing follow_redirects=False here and handling the redirect ourselves, or at least re-creating the iterator per attempt.

Comment thread src/together/lib/resources/files.py Outdated
Comment on lines +684 to +692
callback_response = self._client._client.put(
url=redirect_url,
content=_iter_file_upload_chunks(
file,
total_bytes=file_size,
progress_callback=progress_callback,
),
headers={"Content-Length": str(file_size)},
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The PUT body went from bytes to a one-shot generator, but self._client._client is built with follow_redirects=True (_base_client.py:826).

If the presigned endpoint answers with a 307 — S3 does this for cross-region/newly-created buckets, and GCS resumable endpoints do it too — httpx will try to replay the request body and blow up with StreamConsumed instead of just re-PUTing like it used to when content was a plain bytes object.

Same for any transport-level retry that happens after the first byte is written. Worth passing follow_redirects=False here and handling the redirect ourselves, or at least re-creating the iterator per attempt.



def _validate_upload_file_id(file_id: str) -> str:
if not _SAFE_FILE_ID_RE.fullmatch(file_id):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

_SAFE_FILE_ID_RE allows ., so a bare .. passes validation, and this value goes straight into self.callback(f"{url}/{file_id}/preprocess") at line 711. An X-Together-File-Id: .. header gets us to /files/../preprocess, which httpx normalizes to /preprocess — exactly the traversal the check was added to stop. The test only covers ../evil, which is caught by the /. Can we tighten this to something like ^file-[A-Za-z0-9-]+$, or at minimum reject any segment that is . or ..?

Comment thread src/together/lib/resources/files.py Outdated
_SAFE_FILE_ID_RE = re.compile(r"^[A-Za-z0-9._:-]+$")


def _validate_upload_redirect_url(redirect_url: str) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two things on _validate_upload_redirect_url:
(a) ipaddress.ip_address() only recognizes dotted-quad, so https://2130706433/x or https://0x7f000001/x sails through and then resolves to 127.0.0.1 at connect time. Same for any DNS name pointing at a private range (127.0.0.1.nip.io). If we're calling this an SSRF mitigation it's worth a comment saying it's best-effort at the URL layer, or resolving the host before deciding.

(b) The hard https-only rule is a behavior change for anyone pointing TOGETHER_BASE_URL at a local/on-prem/staging gateway that returns an http:// Location. The sync get_upload_url has no mock-server escape hatch (the async one at :1035 does), so tg files upload against a local stack now fails outright. Could we allow http when the client's own base_url is http, or gate it behind an env opt-out?

if _check_path_exists(training_args["training_file"]):
file_upload = await config.client.files.upload(Path(training_args["training_file"]), purpose="fine-tune")
training_path = Path(training_args["training_file"])
file_upload = await upload_file_with_progress(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

upload_file_with_progress starts the Rich progress bar and then awaits files.upload, which still runs check_file first because check defaults to True here. For a multi-GB training file the user stares at a live Uploading training file foo.jsonl bar pinned at 0% for the whole validation pass, which reads as a hang. Either run the check before entering the tracker, or give validation its own phase/description.

if _check_path_exists(training_args["validation_file"]):
file_upload = await config.client.files.upload(Path(training_args["validation_file"]), purpose="fine-tune")
validation_path = Path(training_args["validation_file"])
file_upload = await upload_file_with_progress(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

upload_file_with_progress starts the Rich progress bar and then awaits files.upload, which still runs check_file first because check defaults to True here. For a multi-GB training file the user stares at a live Uploading training file foo.jsonl bar pinned at 0% for the whole validation pass, which reads as a hang. Either run the check before entering the tracker, or give validation its own phase/description.

Comment thread src/together/lib/utils/files.py Outdated
unit=" lines",
disable=bool(DISABLE_TQDM),
):
for line_index, raw_line in enumerate(f):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Dropping tqdm here removed the only feedback validation ever had, and nothing replaced it. tg files check and tg files upload on a large jsonl now sit completely silent while we stream the whole file. Given the point of the PR is better progress reporting, this feels like the one place that got worse — a Rich spinner or a per-N-lines update would keep parity.

Comment thread src/together/lib/utils/files.py Outdated
unit=" lines",
disable=bool(DISABLE_TQDM),
):
for idx, line in enumerate(f):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Dropping tqdm here removed the only feedback validation ever had, and nothing replaced it. tg files check and tg files upload on a large jsonl now sit completely silent while we stream the whole file. Given the point of the PR is better progress reporting, this feels like the one place that got worse — a Rich spinner or a per-N-lines update would keep parity.

Comment thread src/together/lib/resources/files.py Fixed
Comment thread src/together/lib/resources/files.py Fixed
Comment thread src/together/lib/resources/files.py Fixed
Comment thread src/together/lib/resources/files.py Outdated
Comment on lines +92 to +129
def _validate_upload_redirect_url(redirect_url: str, *, allow_http: bool = False) -> str:
"""Best-effort URL-layer filter for upload PUT targets.

This is not a complete SSRF defense: we do not resolve DNS, so a name like
``127.0.0.1.nip.io`` still passes. We do reject non-public dotted-quad / IPv6
literals, IPv4-mapped loopback (``::ffff:127.0.0.1``), and dword decimal/hex
IPv4 (``2130706433``, ``0x7f000001``) that getaddrinfo will treat as 127.0.0.1.

HTTPS is required unless ``allow_http`` is set — either the client's
``base_url`` is ``http`` (local/on-prem stacks) or
``TOGETHER_ALLOW_INSECURE_UPLOAD_REDIRECTS`` is enabled. When ``allow_http``
is set, localhost and non-public literals are also allowed so MinIO /
LocalStack endpoints like ``http://127.0.0.1:9000/...`` work.
"""

parsed = urlparse(redirect_url)
if parsed.scheme == "https" or (parsed.scheme == "http" and allow_http):
pass
else:
raise ValueError(f"Refusing non-HTTPS upload redirect URL: {redirect_url!r}")

hostname = parsed.hostname
if not hostname:
raise ValueError(f"Upload redirect URL is missing a hostname: {redirect_url!r}")

lowered = hostname.lower()
if lowered == "metadata.google.internal":
raise ValueError(f"Refusing upload redirect to local host: {redirect_url!r}")

if not allow_http:
if lowered == "localhost" or lowered.endswith(".localhost"):
raise ValueError(f"Refusing upload redirect to local host: {redirect_url!r}")

ip = _parse_hostname_ip(hostname)
if ip is not None and _ip_is_non_public(ip):
raise ValueError(f"Refusing upload redirect to non-public address: {redirect_url!r}")

return redirect_url
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes"}


def _allow_http_upload_redirects(client: Any) -> bool:
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.

4 participants