simulo 0.27.0__tar.gz → 0.28.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {simulo-0.27.0/src/simulo.egg-info → simulo-0.28.0}/PKG-INFO +2 -2
  2. {simulo-0.27.0 → simulo-0.28.0}/pyproject.toml +2 -2
  3. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/_runner.py +15 -3
  4. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/app.py +37 -1
  5. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/asset_api.py +1 -0
  6. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/bundle.py +2 -1
  7. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/cli.py +192 -51
  8. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/credentials.py +134 -53
  9. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/http.py +115 -2
  10. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/packaging.py +10 -0
  11. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/preflight_api.py +5 -1
  12. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/preflight_render.py +36 -11
  13. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/submit_api.py +45 -28
  14. {simulo-0.27.0 → simulo-0.28.0/src/simulo.egg-info}/PKG-INFO +2 -2
  15. {simulo-0.27.0 → simulo-0.28.0}/src/simulo.egg-info/requires.txt +1 -1
  16. {simulo-0.27.0 → simulo-0.28.0}/MANIFEST.in +0 -0
  17. {simulo-0.27.0 → simulo-0.28.0}/PYPI.md +0 -0
  18. {simulo-0.27.0 → simulo-0.28.0}/setup.cfg +0 -0
  19. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/__init__.py +0 -0
  20. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/__init__.py +0 -0
  21. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/_entrypoint.py +0 -0
  22. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/_mounts.py +0 -0
  23. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/_secure_downloads.py +0 -0
  24. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/asset.py +0 -0
  25. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/asset_package.py +0 -0
  26. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/asset_pins.py +0 -0
  27. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/builtin_aliases.py +0 -0
  28. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/cancel_api.py +0 -0
  29. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/config.py +0 -0
  30. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/discovery.py +0 -0
  31. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/export_api.py +0 -0
  32. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/export_bundle.py +0 -0
  33. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/facades.py +0 -0
  34. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/identity_api.py +0 -0
  35. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/install_samples.py +0 -0
  36. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/jobs_api.py +0 -0
  37. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/learning.py +0 -0
  38. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/login.py +0 -0
  39. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/mode.py +0 -0
  40. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/outputs.py +0 -0
  41. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/registry.py +0 -0
  42. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/runtime.py +0 -0
  43. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/runtime_display.py +0 -0
  44. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/seed_ref.py +0 -0
  45. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/stub.py +0 -0
  46. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/templates/__init__.py +0 -0
  47. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/templates/inference/app.py.tmpl +0 -0
  48. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/templates/inference/simuloignore.tmpl +0 -0
  49. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/templates/scenario/app.py.tmpl +0 -0
  50. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/templates/scenario/simuloignore.tmpl +0 -0
  51. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/templates/training/app.py.tmpl +0 -0
  52. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/templates/training/simuloignore.tmpl +0 -0
  53. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/view_fragment.py +0 -0
  54. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/view_session_api.py +0 -0
  55. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/_client/volume.py +0 -0
  56. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/callbacks.py +0 -0
  57. {simulo-0.27.0 → simulo-0.28.0}/src/simulo/py.typed +0 -0
  58. {simulo-0.27.0 → simulo-0.28.0}/src/simulo.egg-info/SOURCES.txt +0 -0
  59. {simulo-0.27.0 → simulo-0.28.0}/src/simulo.egg-info/dependency_links.txt +0 -0
  60. {simulo-0.27.0 → simulo-0.28.0}/src/simulo.egg-info/entry_points.txt +0 -0
  61. {simulo-0.27.0 → simulo-0.28.0}/src/simulo.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulo
3
- Version: 0.27.0
3
+ Version: 0.28.0
4
4
  Summary: Simulo SDK and CLI — define robotics simulation and training apps in Python and run them on the Simulo cloud.
5
5
  Author-email: Simulo Team <team@simulo.ai>
6
6
  License: BSD-3-Clause
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Typing :: Typed
16
16
  Requires-Python: >=3.11
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: simulo-interfaces<0.20,>=0.19.0
18
+ Requires-Dist: simulo-interfaces<0.21,>=0.20.0
19
19
  Requires-Dist: mcap<2,>=1.3
20
20
  Requires-Dist: defusedxml>=0.7.1
21
21
  Requires-Dist: usd-core<27,>=26.5
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simulo"
7
- version = "0.27.0"
7
+ version = "0.28.0"
8
8
  description = "Simulo SDK and CLI — define robotics simulation and training apps in Python and run them on the Simulo cloud."
9
9
  readme = "PYPI.md"
10
10
  requires-python = ">=3.11"
@@ -26,7 +26,7 @@ classifiers = [
26
26
  # mcap: used by `simulo recordings` to read back a downloaded recording and
27
27
  # verify it (message count) — MIT-licensed, deps only lz4+zstandard.
28
28
  dependencies = [
29
- "simulo-interfaces>=0.19.0,<0.20",
29
+ "simulo-interfaces>=0.20.0,<0.21",
30
30
  "mcap>=1.3,<2",
31
31
  # Hardened XML parsing for URDF ingestion (USD Asset Catalogs, PR-12 fix
32
32
  # loop 2, security NIT): stdlib xml.etree.ElementTree relies on
@@ -27,14 +27,26 @@ from typing import Any, NoReturn, Optional
27
27
 
28
28
  from simulo.interfaces.ids import JobId, JobPublicId
29
29
  from simulo.interfaces.platform.enums import JobStatus
30
- from simulo.interfaces.platform.runs import JOB_SCOPE_MINE, TERMINAL_JOB_STATUSES
30
+ from simulo.interfaces.platform.runs import JOB_SCOPE_MINE
31
31
 
32
32
  #: Poll cadence for cloud ``.get()``. Module-level so tests can shrink it.
33
33
  _POLL_INTERVAL_S = 1.0
34
34
 
35
- _TERMINAL_STATUS_STRINGS = frozenset(str(status) for status in TERMINAL_JOB_STATUSES)
36
35
  _COMPLETED_STATUS = str(JobStatus.COMPLETED)
37
36
 
37
+ #: The tolerant-client check (`M55-org-runtime-policy`, issue #1537):
38
+ #: EVERY status this client can observe outside these two live values is
39
+ #: terminal, whether or not it is one this client's own ``JobStatus`` happens
40
+ #: to know by name. Membership is checked as "not one of the known LIVE
41
+ #: values", never "is one of the known TERMINAL values" — a closed terminal
42
+ #: set silently treats a FUTURE value it has never heard of as still-live
43
+ #: and polls forever (:class:`JobTimeoutError` only fires when the caller
44
+ #: passed a ``timeout``; with none, :meth:`JobHandle._get_cloud` hangs).
45
+ #: Mirrors the existing precedent for an unrecognized ``kind``
46
+ #: (``simulo.interfaces.platform.submit``): a value outside the known set is
47
+ #: treated as data to render, never as grounds to keep waiting on it.
48
+ _LIVE_STATUS_STRINGS = frozenset({str(JobStatus.QUEUED), str(JobStatus.RUNNING)})
49
+
38
50
 
39
51
  class JobFailedError(RuntimeError):
40
52
  """A cloud job reached a terminal ``failed``/``cancelled`` status.
@@ -154,7 +166,7 @@ class JobHandle:
154
166
  while True:
155
167
  record = client.get_job(str(self._job_id), scope=JOB_SCOPE_MINE)
156
168
  status = str(record.get("status"))
157
- if status in _TERMINAL_STATUS_STRINGS:
169
+ if status not in _LIVE_STATUS_STRINGS:
158
170
  if status == _COMPLETED_STATUS:
159
171
  return client.get_result(str(self._job_id), scope=JOB_SCOPE_MINE)
160
172
  exit_code = record.get("exit_code")
@@ -514,6 +514,26 @@ def _spawn_cloud(
514
514
  # already lives there, keyed by this same `job_name`.
515
515
  job_entry = manifest.get("jobs", {}).get(job_name) or {}
516
516
  system = (job_entry.get("resources") or {}).get("system")
517
+ # The job's own manifest-declared `timeout` (`M55-org-runtime-policy`,
518
+ # issue #1537) — already validated at `@app.job(timeout=...)` decoration
519
+ # time and recorded on THIS job's own manifest entry, exactly like
520
+ # `system` just above. Forwarded explicitly to `create_job` because the
521
+ # control plane never parses the uploaded manifest archive itself (see
522
+ # `JobSubmitRequest.manifest_timeout_s`'s own docstring).
523
+ manifest_timeout_s = job_entry.get("timeout")
524
+ if manifest_timeout_s is not None and (
525
+ isinstance(manifest_timeout_s, bool) or not isinstance(manifest_timeout_s, int)
526
+ ):
527
+ # This manifest was just written by THIS process (see the comment on
528
+ # `Manifest.from_json` re-parsing above) — a non-integer `timeout`
529
+ # here would mean `@app.job`'s own decoration-time validation
530
+ # somehow let one through, not an untrusted value to degrade
531
+ # silently on. Fail loudly rather than send a malformed wire field.
532
+ raise RuntimeError(
533
+ f"Package at {package_path} has a non-integer jobs[{job_name!r}].timeout "
534
+ f"({manifest_timeout_s!r}) in its manifest — this should be impossible; "
535
+ "please report this as a bug."
536
+ )
517
537
 
518
538
  tar_path = ensure_tar(package_path)
519
539
  archive_bytes = tar_path.read_bytes()
@@ -568,6 +588,7 @@ def _spawn_cloud(
568
588
  assets=asset_pins,
569
589
  app_name=app_name,
570
590
  system=system,
591
+ timeout_s=manifest_timeout_s,
571
592
  )
572
593
 
573
594
  raw_public_id = record.get("public_id")
@@ -590,9 +611,24 @@ def _spawn_cloud(
590
611
  # the ref resolved to the intended source job. Seed provenance still
591
612
  # carries machine UUIDs only, so keep this confirmation neutral rather
592
613
  # than leaking a UUID while that response contract catches up.
614
+ #
615
+ # `seed["name"]`/`seed["kind"]` are server-supplied text: a
616
+ # compromised control plane — or a user pointed at an attacker-run
617
+ # `SIMULO_API_URL` — could embed ANSI/OSC sequences here, so both
618
+ # route through the same module `cli.py`'s own sanitizers are built
619
+ # on (`preflight_render`) rather than a second one.
620
+ # `sanitize_server_text` alone is NOT enough here (cross-vendor
621
+ # review): it deliberately preserves TAB/LF for its usual
622
+ # multi-line preflight-finding callers, but this confirmation is a
623
+ # single line — an unstripped LF would forge an extra line this
624
+ # code never printed. `_single_line` collapses it instead.
625
+ from simulo._client.preflight_render import sanitize_server_text_single_line
626
+
593
627
  seed = record.get("seed") or {}
628
+ seed_name = sanitize_server_text_single_line(str(seed.get("name")))
629
+ seed_kind = sanitize_server_text_single_line(str(seed.get("kind")))
594
630
  print(
595
- f"Continuing from selected job ({seed.get('name')}, {seed.get('kind')} checkpoint)",
631
+ f"Continuing from selected job ({seed_name}, {seed_kind} checkpoint)",
596
632
  file=sys.stderr,
597
633
  )
598
634
 
@@ -296,6 +296,7 @@ class AssetApiClient:
296
296
  api_base_url=self._base_url,
297
297
  timeout=_ARCHIVE_DOWNLOAD_TIMEOUT_S,
298
298
  unavailable_hint=_UNAVAILABLE_HINT,
299
+ validate_download_url=True,
299
300
  )
300
301
  except http.HttpError as exc:
301
302
  raise _translate(exc) from exc
@@ -20,7 +20,7 @@ from pathlib import Path
20
20
  from typing import TYPE_CHECKING, Any, Optional, Sequence
21
21
 
22
22
  from simulo._client.asset import Asset
23
- from simulo._client.packaging import canonical_args, package, read_bundle_package_id
23
+ from simulo._client.packaging import assert_credentials_outside_project, canonical_args, package, read_bundle_package_id
24
24
  from simulo._client.volume import Volume
25
25
  from simulo.interfaces.platform import is_platform_pinned
26
26
  from simulo.interfaces.platform.manifest import Manifest
@@ -222,6 +222,7 @@ def ensure_bundle(
222
222
  (deterministically restoring the correct content for this exact
223
223
  submission), never served as a silently wrong package.
224
224
  """
225
+ assert_credentials_outside_project(project_root)
225
226
  extra_asset_uris = tuple(sorted({asset.uri for asset in extra_assets}))
226
227
  cache_key = (id(app), job_name, canonical_args(args), extra_asset_uris)
227
228
  cached = _BUNDLE_CACHE.get(cache_key)
@@ -361,6 +361,20 @@ _MAX_LIST_LIMIT = 100
361
361
  #: (``--all`` on the output commands) or a machine-readable dump
362
362
  #: (``--json``), not a table — so those are what the ceiling footer names.
363
363
  _MAX_LIST_VIEW_ROWS = 250
364
+ #: A bounded list view may retry a rate-limited page, but no more than this
365
+ #: many times. Unlike follow loops, a list is a finite read rather than an
366
+ #: operator-controlled wait, so retrying forever would leave the command hung.
367
+ _LIST_RATE_LIMIT_MAX_RETRIES = 3
368
+ #: Total time a bounded list view may spend waiting to retry one or more 429s.
369
+ #: This covers the control plane's current one-minute jobs-list rate-limit
370
+ #: window plus request overhead. A Retry-After that would exceed the remaining
371
+ #: operation budget remains terminal rather than making a listing wait without
372
+ #: a bound.
373
+ _LIST_RATE_LIMIT_RETRY_BUDGET_S = 90.0
374
+ #: Retry-After is required on the platform's 429 responses. Keep a small
375
+ #: fallback for an older or faulty server that omits it so the bounded retry
376
+ #: does not immediately repeat the request that was just rate-limited.
377
+ _LIST_RATE_LIMIT_RETRY_FALLBACK_S = 1.0
364
378
 
365
379
 
366
380
  def _limit_int(value: str, ceiling: int) -> int:
@@ -416,15 +430,33 @@ def _fetch_list_view(
416
430
  ``limit <= _MAX_LIST_LIMIT`` still issues exactly ONE request of exactly
417
431
  that size — byte-identical to the single-request behavior this replaced.
418
432
 
419
- Terminates in at most ``ceil(limit / page_size)`` requests: every
433
+ A 429 retries the same page after its Retry-After delay. Retries and
434
+ their total wait have explicit bounds; when either is exhausted the
435
+ original error remains terminal, so callers never render accumulated rows
436
+ as a complete listing.
437
+
438
+ Apart from bounded retries, it terminates in at most
439
+ ``ceil(limit / page_size)`` successful requests: every successful
420
440
  iteration either breaks or appends a full page.
421
441
  """
422
442
  page_size = min(limit, _MAX_LIST_LIMIT)
423
443
  rows: list[dict[str, Any]] = []
424
444
  total = 0
425
445
  page = 1
446
+ retries = 0
447
+ retry_deadline = time.monotonic() + _LIST_RATE_LIMIT_RETRY_BUDGET_S
426
448
  while True:
427
- page_rows, total = fetch_page(page, page_size)
449
+ try:
450
+ page_rows, total = fetch_page(page, page_size)
451
+ except http.HttpHTTPError as exc:
452
+ if exc.status != 429 or retries >= _LIST_RATE_LIMIT_MAX_RETRIES:
453
+ raise
454
+ retry_delay = max(exc.retry_after or 0, _LIST_RATE_LIMIT_RETRY_FALLBACK_S)
455
+ if time.monotonic() + retry_delay > retry_deadline:
456
+ raise
457
+ retries += 1
458
+ _sleep_for_rate_limit(exc.retry_after, _LIST_RATE_LIMIT_RETRY_FALLBACK_S, deadline=retry_deadline)
459
+ continue
428
460
  rows.extend(page_rows)
429
461
  # Enough for the view, a short page (nothing more exists), or we hold
430
462
  # everything the server says there is.
@@ -543,9 +575,32 @@ _COMPLETED_STATUS = str(JobStatus.COMPLETED)
543
575
  _RUNNING_STATUS = str(JobStatus.RUNNING)
544
576
  _CANCELLED_STATUS = str(JobStatus.CANCELLED)
545
577
  #: The terminal statuses that carry a self-diagnosis reason (JobRecord's
546
- #: ``status_reason``/``status_detail``): a failed or cancelled job. A cleanly
547
- #: ``completed`` job has none.
548
- _FAILURE_STATUS_STRINGS = frozenset({str(JobStatus.FAILED), str(JobStatus.CANCELLED)})
578
+ #: ``status_reason``/``status_detail``): a failed, cancelled, or timed-out
579
+ #: job. A cleanly ``completed`` job has none.
580
+ #:
581
+ #: ``timed_out`` (`M55-org-runtime-policy`) is added here even though the
582
+ #: control plane still projects a stored ``timed_out`` job as ``failed`` on
583
+ #: every read route today (see that milestone's PR description) — this set
584
+ #: must already be correct for the moment that projection lifts, not
585
+ #: rewritten again at that later date to add the one value its own author
586
+ #: had every reason to add now, while this exact line is open.
587
+ _FAILURE_STATUS_STRINGS = frozenset({str(JobStatus.FAILED), str(JobStatus.CANCELLED), str(JobStatus.TIMED_OUT)})
588
+
589
+ #: The tolerant-client check for ``simulo view`` specifically
590
+ #: (`M55-org-runtime-policy`, issue #1537) — used ONLY by
591
+ #: :func:`_mint_view_session` and :func:`_cmd_view`'s own post-wait check,
592
+ #: never by the OTHER ``_TERMINAL_STATUS_STRINGS`` call sites on this
593
+ #: surface (``logs --follow``/``cancel``'s own wait loops), which are
594
+ #: unaffected by this milestone. Checked as "not one of the known LIVE
595
+ #: values" rather than "is one of the known TERMINAL values": a job whose
596
+ #: status is anything OTHER than ``queued``/``running`` — including a
597
+ #: FUTURE value this client has never heard of — has ended, and this
598
+ #: command must refuse to mint a view session against it rather than
599
+ #: proceed as though the job were still live (the exact bug an unrecognized
600
+ #: ``timed_out`` produced before this set existed: ``status in
601
+ #: _TERMINAL_STATUS_STRINGS`` reads false for an unknown value, and the
602
+ #: command fell through to minting a session anyway).
603
+ _LIVE_STATUS_STRINGS = frozenset({str(JobStatus.QUEUED), _RUNNING_STATUS})
549
604
 
550
605
 
551
606
  def _log_archive_unavailable(exc: JobsApiHTTPError) -> bool:
@@ -593,7 +648,7 @@ def _report_log_archive_unavailable(exc: JobsApiHTTPError, job_id: str) -> int:
593
648
  # longer available": the logs are almost certainly still there and the
594
649
  # single most useful thing to tell the user is to try again.
595
650
  print(
596
- f"Logs for job {job_id} could not be read right now — this is temporary, "
651
+ f"Logs for job {_safe_table_cell(job_id)} could not be read right now — this is temporary, "
597
652
  "not a lost log. Try again in a moment.",
598
653
  file=sys.stderr,
599
654
  flush=True,
@@ -620,7 +675,7 @@ def _report_log_archive_unavailable(exc: JobsApiHTTPError, job_id: str) -> int:
620
675
  "Simulo could not save this job's log output, so it could not be kept. "
621
676
  "The job's status and result are unaffected."
622
677
  )
623
- print(f"Logs for job {job_id} are no longer available. {detail}", file=sys.stderr, flush=True)
678
+ print(f"Logs for job {_safe_table_cell(job_id)} are no longer available. {detail}", file=sys.stderr, flush=True)
624
679
  return 1
625
680
 
626
681
 
@@ -800,6 +855,25 @@ def _display_resource_id(record: dict[str, Any], machine_field: str, *, unavaila
800
855
  output must not regain a UUID leak merely because one response is stale.
801
856
  Explicit JSON/info output bypasses this helper and preserves the raw wire
802
857
  record unchanged.
858
+
859
+ The ``public_id`` branch is a strictly-validated public ID (``parse_*_
860
+ public_id``) and needs nothing further. The fallback branch is server
861
+ text with no such validation: bounded in width here so every caller —
862
+ table cells, prompts, and narrative prose alike — gets a safe value
863
+ without individually remembering to wrap it, matching the choke-point
864
+ reasoning :func:`_safe_table_cell` documents.
865
+
866
+ The NUL sentinel below is load-bearing, NOT vestigial (cross-vendor
867
+ review caught a version of this fix that assumed otherwise and
868
+ dropped it, breaking ``_resource_display_name``'s empty-name path,
869
+ which returns THIS function's value verbatim as a filename basis —
870
+ ``_safe_download_filename`` refuses a NUL-tainted name outright rather
871
+ than silently reconstructing a different one). Every DISPLAY-only
872
+ caller (a table cell, a prompt, narrative prose — never a filename)
873
+ strips it by also routing through :func:`_safe_table_cell` /
874
+ :func:`_safe_single_line_text`, which remove every Unicode ``C*``
875
+ category including NUL; this function does not strip it itself only
876
+ because a filename-path caller needs it to survive this boundary.
803
877
  """
804
878
  public_id = _record_public_id(record, machine_field)
805
879
  if public_id is not None:
@@ -816,6 +890,8 @@ def _display_resource_id(record: dict[str, Any], machine_field: str, *, unavaila
816
890
  nul_sentinel = "\x00" if "\x00" in raw_machine_text else ""
817
891
  if _is_uuid_text(machine_text) or _MACHINE_UUID_RE.search(machine_text):
818
892
  return unavailable + nul_sentinel
893
+ if len(machine_text) > _MAX_OUTPUTS_LINE_CHARS:
894
+ machine_text = machine_text[: _MAX_OUTPUTS_LINE_CHARS - 1].rstrip() + "…"
819
895
  return machine_text + nul_sentinel
820
896
 
821
897
 
@@ -871,7 +947,17 @@ def _handle_display_id(handle: JobHandle) -> str:
871
947
 
872
948
 
873
949
  def _resolved_job_ref(record: dict[str, Any], fallback: str) -> str:
874
- """The complete friendly ref for subsequent calls, failing closed on UUIDs."""
950
+ """The complete friendly ref for subsequent calls, failing closed on UUIDs.
951
+
952
+ Deliberately returns ``fallback`` UNSANITIZED in the synthetic-ID
953
+ compatibility case: this value feeds follow-up API requests, and
954
+ mutating it here would send a different identifier than the one the
955
+ server actually issued (`test_announce_latest_sanitizes_every_rendered_
956
+ field` in test_cli_outputs.py pins exactly this). Every CALLER that also
957
+ prints this value to the terminal is responsible for its own
958
+ :func:`_safe_table_cell`/:func:`_short_job_id` wrap at the print site —
959
+ do not sanitize here.
960
+ """
875
961
  public_id = _record_public_id(record, "job_id")
876
962
  if public_id is not None:
877
963
  return public_id
@@ -1852,8 +1938,19 @@ def _seed_summary(
1852
1938
  public_id_cache[machine_id] = resolved
1853
1939
  except JobsApiError:
1854
1940
  pass
1855
- short = job_id
1856
- name = str(seed.get("name") or seed.get("kind") or "checkpoint")
1941
+ # `_safe_table_cell`, not a bare assignment: `job_id` can come from
1942
+ # `_display_resource_id`'s non-public-id fallback, which can carry a
1943
+ # NUL sentinel through this DISPLAY path on purpose (cross-vendor
1944
+ # review) — only safe to leave unstripped for
1945
+ # `_resource_display_name`'s filename-basis use, never this summary.
1946
+ short = _safe_table_cell(job_id)
1947
+ # `seed["name"]`/`seed["kind"]` are server-supplied text (#623) — route
1948
+ # through the same choke point every other rendered field in this file
1949
+ # uses (`_safe_table_cell`) so BOTH of this function's callers (the
1950
+ # `simulo jobs` "CONTINUED FROM" column and `simulo result`'s stderr
1951
+ # line) are covered by one fix, per this file's own row-builder-SHAPE
1952
+ # design principle (see `_safe_table_cell`'s docstring).
1953
+ name = _safe_table_cell(seed.get("name") or seed.get("kind") or "checkpoint")
1857
1954
  return f"{short} ({name})"
1858
1955
 
1859
1956
 
@@ -1942,10 +2039,12 @@ def _safe_output_detail_text(text: str) -> str:
1942
2039
  terminal encoding can print — left alone, ``print()`` raises
1943
2040
  ``UnicodeEncodeError: surrogates not allowed`` and crashes an
1944
2041
  otherwise-successful command (NFR: users never see a stack trace).
1945
- Control-char stripping does NOT cover this (surrogates are outside
1946
- ``\\x00-\\x9f``), so it is handled separately here: round-tripping
1947
- through UTF-8 with ``errors="replace"`` turns any unencodable
1948
- codepoint into a literal ``?`` instead of raising.
2042
+ ``sanitize_server_text`` now strips these too (Unicode category
2043
+ ``Cs``, cross-vendor review), but this UTF-8 round-trip with
2044
+ ``errors="replace"`` stays as the belt-and-braces layer — a
2045
+ category check is not the ONLY way an unencodable codepoint can
2046
+ reach this function, and turning it into a literal ``?`` instead of
2047
+ raising is what actually prevents the crash either way.
1949
2048
  """
1950
2049
  text = sanitize_server_text(text)
1951
2050
  return text.encode("utf-8", "replace").decode("utf-8")
@@ -2235,15 +2334,27 @@ def _cmd_jobs(args: argparse.Namespace) -> int:
2235
2334
  )
2236
2335
  rows = [
2237
2336
  (
2238
- _job_display_id(job),
2239
- str(job.get("job_name", "-")),
2240
- str(job.get("status", "-")),
2337
+ # `_safe_table_cell` (not a bare call): `_job_display_id`'s
2338
+ # non-public-id fallback can carry a NUL sentinel through this
2339
+ # DISPLAY path on purpose (cross-vendor review) — it is only
2340
+ # safe to leave unstripped for `_resource_display_name`'s
2341
+ # filename-basis use, never for a table cell.
2342
+ _safe_table_cell(_job_display_id(job)),
2343
+ # `job_name`/`status` are server-supplied text (#623) — the same
2344
+ # class of defect `_announce_latest` already guards against for
2345
+ # these exact two fields on a sibling code path; this table
2346
+ # never had the matching fix.
2347
+ _safe_table_cell(job.get("job_name", "-")),
2348
+ _safe_table_cell(job.get("status", "-")),
2241
2349
  )
2242
2350
  + ((reason or "-",) if show_reason_column else ())
2243
2351
  + ((outputs_summary or "-",) if show_outputs_column else ())
2244
2352
  + (
2245
- str(job.get("started_at") or "-"),
2246
- "-" if job.get("exit_code") is None else str(job.get("exit_code")),
2353
+ # `started_at`/`exit_code` are server-supplied too (#623
2354
+ # cross-vendor review) — the same table row builder already
2355
+ # wraps job_name/status, but left these two raw.
2356
+ _safe_table_cell(job.get("started_at") or "-"),
2357
+ "-" if job.get("exit_code") is None else _safe_table_cell(job.get("exit_code")),
2247
2358
  )
2248
2359
  + ((seed_summary or "-",) if show_seed_column else ())
2249
2360
  for job, seed_summary, reason, outputs_summary in zip(jobs, seed_summaries, reason_codes, outputs_summaries)
@@ -2899,9 +3010,9 @@ def _cmd_recordings(args: argparse.Namespace) -> int:
2899
3010
  # the chosen convention (issue #682).
2900
3011
  targeted = args.all or args.name is not None
2901
3012
  if targeted:
2902
- print(f"No recordings for job {job_id!r}.", file=sys.stderr)
3013
+ print(f"No recordings for job {_safe_repr(job_id)}.", file=sys.stderr)
2903
3014
  return 1
2904
- print(f"No recordings for job {job_id!r}.")
3015
+ print(f"No recordings for job {_safe_repr(job_id)}.")
2905
3016
  return 0
2906
3017
 
2907
3018
  if args.all:
@@ -2953,7 +3064,7 @@ def _cmd_recordings(args: argparse.Namespace) -> int:
2953
3064
  else:
2954
3065
  available = _join_bounded(sorted(_safe_table_cell(_recording_name(record)) for record in records))
2955
3066
  print(
2956
- f"Job {job_id!r} has {total} recordings; pass one of ({available}) or --all.",
3067
+ f"Job {_safe_repr(job_id)} has {total} recordings; pass one of ({available}) or --all.",
2957
3068
  file=sys.stderr,
2958
3069
  )
2959
3070
  _print_list_truncation_footer(len(records), total, mention_all=False, cap=_RECORDINGS_VIEW_CAP, file=sys.stderr)
@@ -2987,10 +3098,17 @@ def _cmd_recordings(args: argparse.Namespace) -> int:
2987
3098
  # (see _safe_download_filename).
2988
3099
  destination_observation, dest = _implicit_server_download_destination(_recording_name(record))
2989
3100
  if destination_observation is None or dest is None:
3101
+ # BOTH interpolations are server text and BOTH are bounded
3102
+ # (`_safe_repr`, #623): `repr()` already neutralizes the control
3103
+ # bytes `_safe_download_filename` lets through, but a name that
3104
+ # is long AND unsafe passes the length check, fails the shape
3105
+ # check, and would otherwise land here at full length — the same
3106
+ # wall-of-text hazard `simulo outputs`'/`simulo models`' own
3107
+ # refusal messages already guard against (this one had not).
2990
3108
  print(
2991
3109
  f"Refusing to download recording "
2992
- f"{_child_display_id(record, 'recording_id', label='recording')!r}: server-reported "
2993
- f"name {_recording_name(record)!r} is not a safe local filename. Pass -o PATH to "
3110
+ f"{_safe_repr(_child_display_id(record, 'recording_id', label='recording'))}: server-reported "
3111
+ f"name {_safe_repr(_recording_name(record))} is not a safe local filename. Pass -o PATH to "
2994
3112
  "choose the destination yourself.",
2995
3113
  file=sys.stderr,
2996
3114
  )
@@ -3440,9 +3558,15 @@ def _download_model_and_report(
3440
3558
  label = f" ({_safe_table_cell(kind)})" if kind else ""
3441
3559
  verification_note = "sha256 verified" if expected_sha256 else "sha256 not provided by server"
3442
3560
  # Report the LOCAL filename actually written (dest.name), not the raw
3443
- # server-supplied record["name"] — dest.name is what _safe_download_filename
3444
- # already vetted, so this print can never echo an unsanitized value back
3445
- # to the terminal.
3561
+ # server-supplied record["name"]. `_safe_download_filename` DOES strip
3562
+ # control characters (it calls `_safe_single_line_text`, not only the
3563
+ # traversal/reserved-name shape checks) — an earlier version of this
3564
+ # comment claimed otherwise, which cross-vendor review caught as
3565
+ # reversing the actual security ownership. Both halves of this line
3566
+ # are still wrapped in `_safe_table_cell` below as defense-in-depth
3567
+ # (this print does not itself re-derive whether `resolved.name` came
3568
+ # from that exact vetting path), never as the ONLY thing standing
3569
+ # between server text and the terminal.
3446
3570
  print(
3447
3571
  f"Saved {_safe_table_cell(resolved.name)}{label} -> "
3448
3572
  f"{_safe_table_cell(resolved.resolve())} ({verification_note})"
@@ -3634,7 +3758,7 @@ def _cmd_models(args: argparse.Namespace) -> int:
3634
3758
  _warn_json_bounded(len(records), total)
3635
3759
  return 0
3636
3760
  if not records:
3637
- print(f"No models for job {job_id!r} (only training jobs produce models).")
3761
+ print(f"No models for job {_safe_repr(job_id)} (only training jobs produce models).")
3638
3762
  return 0
3639
3763
  _print_model_table(records)
3640
3764
  if paginated:
@@ -3643,12 +3767,12 @@ def _cmd_models(args: argparse.Namespace) -> int:
3643
3767
  # is exactly the user who wants the next step, and they should not have
3644
3768
  # to read documentation to find it. Human output only — `--json`
3645
3769
  # returned above, so machine output is unaffected.
3646
- print(f"Take a policy off the platform (ONNX + local verification): simulo export {job_id}")
3770
+ print(f"Take a policy off the platform (ONNX + local verification): simulo export {_safe_table_cell(job_id)}")
3647
3771
  return 0
3648
3772
 
3649
3773
  records = client.list_models(job_id, scope=scope) # NAME/--all path — needs the complete set
3650
3774
  if not records:
3651
- print(f"No models for job {job_id!r} (only training jobs produce models).", file=sys.stderr)
3775
+ print(f"No models for job {_safe_repr(job_id)} (only training jobs produce models).", file=sys.stderr)
3652
3776
  return 1
3653
3777
 
3654
3778
  if args.all:
@@ -4417,11 +4541,16 @@ def _no_checkpoint_message(
4417
4541
  selected_kind = kind or MODEL_KINDS[0]
4418
4542
  suggestion = _first_job_with_models(jobs_client, submit_client, exclude_job_id=job_id, scope=scope)
4419
4543
  lines = [
4420
- f"Job {job_id} has no {selected_kind} checkpoint to export — only a training run produces "
4544
+ f"Job {_safe_table_cell(job_id)} has no {selected_kind} checkpoint to export — only a training run produces "
4421
4545
  "checkpoints (a rollout, a cancelled run, or a run that failed before its first save has none).",
4422
4546
  ]
4423
4547
  if suggestion is not None:
4424
- lines += ["", "Export a run that has one:", f" simulo export {suggestion}"]
4548
+ # `suggestion` comes from `_resolved_job_ref` (via
4549
+ # `_first_job_with_models`), which deliberately returns its
4550
+ # non-public-id fallback RAW for follow-up API calls (cross-vendor
4551
+ # review) — this use is display-only, so it needs its own wrap
4552
+ # here rather than at the source.
4553
+ lines += ["", "Export a run that has one:", f" simulo export {_safe_table_cell(suggestion)}"]
4425
4554
  else:
4426
4555
  lines += [
4427
4556
  "",
@@ -4458,11 +4587,11 @@ def _export_create_error_message(
4458
4587
  model_ref = "the selected checkpoint"
4459
4588
  return (
4460
4589
  f"No checkpoint {model_ref} under job {_human_job_ref(job_id)} — a --model ID must name one of THIS job's "
4461
- f"checkpoints. List them with `simulo models {job_id}`."
4590
+ f"checkpoints. List them with `simulo models {_safe_table_cell(job_id)}`."
4462
4591
  )
4463
4592
  return (
4464
- f"Simulo could not resolve a checkpoint to export for job {job_id}. "
4465
- f"List its checkpoints with `simulo models {job_id}`."
4593
+ f"Simulo could not resolve a checkpoint to export for job {_safe_table_cell(job_id)}. "
4594
+ f"List its checkpoints with `simulo models {_safe_table_cell(job_id)}`."
4466
4595
  )
4467
4596
  if exc.code == "seed_model_unavailable":
4468
4597
  return _no_checkpoint_message(
@@ -4475,9 +4604,9 @@ def _export_create_error_message(
4475
4604
  )
4476
4605
  if exc.code == "seed_job_not_terminal":
4477
4606
  return (
4478
- f"Job {job_id} has not finished yet — an export converts a FINISHED run's checkpoint.\n"
4607
+ f"Job {_safe_table_cell(job_id)} has not finished yet — an export converts a FINISHED run's checkpoint.\n"
4479
4608
  "Wait for it, then export:\n"
4480
- f" simulo logs {job_id} --follow"
4609
+ f" simulo logs {_safe_table_cell(job_id)} --follow"
4481
4610
  )
4482
4611
  if exc.code == "export_selector_conflict":
4483
4612
  return "Pass either --model MODEL_ID or --kind best|latest, not both — they answer the same question."
@@ -4609,7 +4738,7 @@ def _export_failure_copy(reason: Optional[str], *, job_id: str, kind: Optional[s
4609
4738
  "through it disagreed with the source PyTorch policy beyond the tolerance. No bundle was "
4610
4739
  "published — a converted-but-inequivalent policy is worse than no policy.",
4611
4740
  "Try the other checkpoint of this run:",
4612
- f" simulo export {job_id} --kind {other_kind}",
4741
+ f" simulo export {_safe_table_cell(job_id)} --kind {other_kind}",
4613
4742
  )
4614
4743
  if reason == PolicyExportFailureReason.LOCAL_VALIDATION_FAILED.value:
4615
4744
  return (
@@ -4617,19 +4746,19 @@ def _export_failure_copy(reason: Optional[str], *, job_id: str, kind: Optional[s
4617
4746
  "verify.py on your machine, not to the platform-side conversion check, so this export cannot "
4618
4747
  "be trusted either way.",
4619
4748
  "Try again:",
4620
- f" simulo export {job_id}",
4749
+ f" simulo export {_safe_table_cell(job_id)}",
4621
4750
  )
4622
4751
  if reason == PolicyExportFailureReason.CONVERSION_FAILED.value:
4623
4752
  return (
4624
4753
  "The converter failed before it produced a loadable model. Nothing about your policy is known "
4625
4754
  "to be unexportable — this is the retryable one of the four export failures.",
4626
4755
  "Try again:",
4627
- f" simulo export {job_id}",
4756
+ f" simulo export {_safe_table_cell(job_id)}",
4628
4757
  )
4629
4758
  return (
4630
4759
  "The export job did not finish. This is an ordinary job failure, not a verdict about your policy.",
4631
4760
  "Try again:",
4632
- f" simulo export {job_id}",
4761
+ f" simulo export {_safe_table_cell(job_id)}",
4633
4762
  )
4634
4763
 
4635
4764
 
@@ -4800,7 +4929,7 @@ def _cmd_export(args: argparse.Namespace) -> int:
4800
4929
  print(
4801
4930
  "\nStopped watching. The export is still running on Simulo.\n"
4802
4931
  "Reattach to it — this returns the export already on its way rather than starting a second one:\n"
4803
- f" simulo export {job_id}",
4932
+ f" simulo export {_safe_table_cell(job_id)}",
4804
4933
  file=sys.stderr,
4805
4934
  )
4806
4935
  return 130
@@ -4825,7 +4954,7 @@ def _cmd_export(args: argparse.Namespace) -> int:
4825
4954
  )
4826
4955
  echo("")
4827
4956
  echo("Try again:")
4828
- echo(f" simulo export {job_id}")
4957
+ echo(f" simulo export {_safe_table_cell(job_id)}")
4829
4958
  else:
4830
4959
  _report_export_failure(echo, record, job_id=job_id, kind=args.kind)
4831
4960
  if json_mode:
@@ -4840,7 +4969,7 @@ def _cmd_export(args: argparse.Namespace) -> int:
4840
4969
  echo("")
4841
4970
  echo("Bundle left on the platform (--no-download). Run the export again without it to download and")
4842
4971
  echo("unpack the bundle:")
4843
- echo(f" simulo export {job_id}")
4972
+ echo(f" simulo export {_safe_table_cell(job_id)}")
4844
4973
  if json_mode:
4845
4974
  print(json.dumps({"export": record, "bundle_dir": None}, indent=2))
4846
4975
  return 0
@@ -5093,7 +5222,7 @@ def _mint_view_session(
5093
5222
  if exc.code != _STREAM_ENDPOINT_NOT_READY:
5094
5223
  raise
5095
5224
  job = jobs_client.get_job(job_id, scope=scope)
5096
- if str(job.get("status")) in _TERMINAL_STATUS_STRINGS:
5225
+ if str(job.get("status")) not in _LIVE_STATUS_STRINGS:
5097
5226
  return None, _job_ended_message(job_id)
5098
5227
  if time.monotonic() >= deadline:
5099
5228
  display_ref = _human_job_ref(job_id)
@@ -5256,7 +5385,7 @@ def _cmd_view(args: argparse.Namespace) -> int:
5256
5385
  jobs_client = JobsApiClient(base_url, token=token)
5257
5386
  view_client = ViewSessionApiClient(base_url, token=token)
5258
5387
 
5259
- if str(job.get("status")) in _TERMINAL_STATUS_STRINGS:
5388
+ if str(job.get("status")) not in _LIVE_STATUS_STRINGS:
5260
5389
  print(_job_ended_message(job_id), file=sys.stderr)
5261
5390
  return 1
5262
5391
 
@@ -5282,7 +5411,7 @@ def _cmd_view(args: argparse.Namespace) -> int:
5282
5411
  # The actual bootstrap URL deliberately retains the internal job UUID in
5283
5412
  # its fragment. It is a browser-only transport detail: ordinary CLI
5284
5413
  # output names the public job and must not echo that UUID-bearing URL.
5285
- print(f"Opening live view for job {job_id} in your browser...")
5414
+ print(f"Opening live view for job {_safe_table_cell(job_id)} in your browser...")
5286
5415
  webbrowser.open(url)
5287
5416
  return 0
5288
5417
 
@@ -5375,9 +5504,18 @@ def _confirm_latest_job_cancel(job: dict[str, Any], *, assume_yes: bool) -> Opti
5375
5504
  if not _stdin_is_tty():
5376
5505
  print(_CANCEL_NON_INTERACTIVE_REFUSAL, file=sys.stderr)
5377
5506
  return 2
5378
- job_id = _job_display_id(job)
5379
- name = job.get("job_name", "-")
5380
- status = job.get("status", "-")
5507
+ # `_safe_table_cell`, not a bare call: `_job_display_id`'s non-public-id
5508
+ # fallback can carry a NUL sentinel through this DISPLAY path on
5509
+ # purpose (cross-vendor review) — only safe to leave unstripped for
5510
+ # `_resource_display_name`'s filename-basis use, never a prompt.
5511
+ job_id = _safe_table_cell(_job_display_id(job))
5512
+ # `job_name`/`status` are server-supplied text (#623) — the same two
5513
+ # fields `simulo jobs`' table and `_announce_latest` already sanitize.
5514
+ # `_safe_table_cell` (not `_safe_single_line_text`) is deliberate here
5515
+ # too: an unbounded hostile name could otherwise push the y/N question
5516
+ # off-screen, the identical width hazard the table guards against.
5517
+ name = _safe_table_cell(job.get("job_name", "-"))
5518
+ status = _safe_table_cell(job.get("status", "-"))
5381
5519
  answer = input(f"Cancel job {job_id} ({name}, {status})? [y/N] ")
5382
5520
  if answer.strip().lower() not in ("y", "yes"):
5383
5521
  print("Nothing cancelled.", file=sys.stderr)
@@ -8663,7 +8801,10 @@ def _build_parser() -> argparse.ArgumentParser:
8663
8801
  )
8664
8802
  asset_delete.set_defaults(func=_observe_handler(asset_delete, _cmd_asset_delete))
8665
8803
 
8666
- login = sub.add_parser("login", help="Authenticate the CLI via browser (PKCE loopback or manual fallback).")
8804
+ login = sub.add_parser(
8805
+ "login",
8806
+ help="Authenticate via browser; set SIMULO_CREDENTIALS_FILE to save a separate actor session.",
8807
+ )
8667
8808
  login.set_defaults(func=_observe_handler(login, _cmd_login))
8668
8809
 
8669
8810
  logout = sub.add_parser("logout", help="Clear the local session and revoke the refresh token server-side.")