urun-cli 0.7.5__tar.gz → 0.7.6__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 (35) hide show
  1. {urun_cli-0.7.5 → urun_cli-0.7.6}/CHANGELOG.md +21 -0
  2. {urun_cli-0.7.5 → urun_cli-0.7.6}/PKG-INFO +1 -1
  3. {urun_cli-0.7.5 → urun_cli-0.7.6}/pyproject.toml +1 -1
  4. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/cli.py +173 -7
  5. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/client_tokens.py +45 -13
  6. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/dev.py +1 -1
  7. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/openai_proxy_bin.py +51 -6
  8. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/repo_deps.py +64 -0
  9. urun_cli-0.7.6/src/urun/reserve.py +156 -0
  10. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/serve.py +68 -1
  11. {urun_cli-0.7.5 → urun_cli-0.7.6}/.gitignore +0 -0
  12. {urun_cli-0.7.5 → urun_cli-0.7.6}/LICENSE +0 -0
  13. {urun_cli-0.7.5 → urun_cli-0.7.6}/README.md +0 -0
  14. {urun_cli-0.7.5 → urun_cli-0.7.6}/SECURITY.md +0 -0
  15. {urun_cli-0.7.5 → urun_cli-0.7.6}/hatch_build.py +0 -0
  16. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/__init__.py +0 -0
  17. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/_bin/README.md +0 -0
  18. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/api.py +0 -0
  19. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/buildlogs.py +0 -0
  20. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/catalog.py +0 -0
  21. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/config.py +0 -0
  22. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/deps.py +0 -0
  23. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/devconsole.py +0 -0
  24. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/devstream.py +0 -0
  25. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/discovery.py +0 -0
  26. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/errors.py +0 -0
  27. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/fleet.py +0 -0
  28. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/frontend_launch.py +0 -0
  29. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/launch_auth.py +0 -0
  30. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/manifest.py +0 -0
  31. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/nodes.py +0 -0
  32. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/retry.py +0 -0
  33. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/runtime_health.py +0 -0
  34. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/rust.py +0 -0
  35. {urun_cli-0.7.5 → urun_cli-0.7.6}/src/urun/scratch.py +0 -0
@@ -1,3 +1,24 @@
1
+ ## Unreleased
2
+
3
+ - Security (ENG-570 / pentest urun-cli-01, mirrored in urun-python
4
+ `urun/core/store/repo.py`): `store.repo(...)` specs parsed from untrusted
5
+ app source are validated before any git call. The `@<ref>` pin must be a
6
+ full 40-hex SHA or a plain refname over `[A-Za-z0-9._/-]` (never a leading
7
+ `-`, never `=`), and the spec head may not start with `-`. A crafted
8
+ `@--upload-pack=<payload>` tail on a local-path spec made git EXECUTE the
9
+ payload on the deployer's machine.
10
+ - Security (ENG-570 / pentest urun-cli-02): the `urun dev --attach` loopback
11
+ token endpoint reflects `Access-Control-Allow-Origin` only for loopback
12
+ browser origins (any port) in dev mode; a remote page's Origin gets 403
13
+ and no CORS headers.
14
+ - Security (ENG-570 / pentest urun-cli-03): `urun compat` no longer hands the
15
+ raw org API key plus the developer's full exported environment to a
16
+ PATH-resolved `urun-openai`. The compat child env is the frontend scrub
17
+ allowlist plus the compat credential keys (explicit env wins per key are
18
+ forwarded through the credentials dict), and the PATH lane is refused
19
+ while credentials are handed over — explicit `URUN_OPENAI_PROXY_BIN` or
20
+ the vendored binary only.
21
+
1
22
  ## 0.7.3
2
23
 
3
24
  - `urun serve --urun-wheel <version>` — pin a deploy to a specific urun wheel
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urun-cli
3
- Version: 0.7.5
3
+ Version: 0.7.6
4
4
  Summary: End-user CLI for deploying apps to urun
5
5
  Project-URL: Homepage, https://urun.sh
6
6
  Project-URL: Repository, https://github.com/urun-sh/urun-cli
@@ -20,7 +20,7 @@ name = "urun-cli"
20
20
  # so every merge remains on the already-published version until this value is
21
21
  # advanced. Wiring semantic-release into CI remains separate work; this patch
22
22
  # bump is the minimal release unblock (post-v0.6.3 fixes + the Rust feature minor).
23
- version = "0.7.5"
23
+ version = "0.7.6"
24
24
  description = "End-user CLI for deploying apps to urun"
25
25
  readme = "README.md"
26
26
  requires-python = ">=3.11"
@@ -361,6 +361,8 @@ def main(argv: list[str] | None = None) -> int:
361
361
  return fleet_command(args)
362
362
  if args.command == "scratch":
363
363
  return scratch_command(args)
364
+ if args.command == "reserve":
365
+ return reserve_command(args)
364
366
  parser.print_help()
365
367
  return 2
366
368
  except UrunError as exc:
@@ -623,6 +625,61 @@ def add_inverted_noun_parsers(sub: argparse._SubParsersAction) -> None:
623
625
  add_activity_parsers(sub)
624
626
  add_failure_group_parsers(sub)
625
627
  add_git_installation_parsers(sub)
628
+ add_reserve_parser(sub)
629
+
630
+
631
+ def add_reserve_parser(sub: argparse._SubParsersAction) -> None:
632
+ parser = sub.add_parser(
633
+ "reserve",
634
+ help="Reserve a GPU node for one app (operator; app-exclusive nodes)",
635
+ description=(
636
+ "App-exclusive nodes (operator verb): reserve a GPUWorkerClaim "
637
+ "for ONE app so the node serves only it — foreign serving "
638
+ "residents drain, foreign scratch residents are reaped, and "
639
+ "fresh demand for other apps mints fresh capacity instead of "
640
+ "squatting the reserved node. Writes the SAME "
641
+ "urun.sh/reserved-for (+ urun.sh/pin-until with --until) "
642
+ "annotations the control plane's reservation mechanism reads, "
643
+ "through its sanctioned /node-reservations surface.\n\n"
644
+ "Usage: urun reserve <claim> --app <app_id>[/<fn>] "
645
+ "[--until <RFC3339>] --context <ctx> | urun reserve release <claim> "
646
+ "--context <ctx> | urun reserve list [--context <ctx>]\n\n"
647
+ "--context is REQUIRED for reserve/release (a reservation is a "
648
+ "live cluster write; be looking at the cluster you write to). "
649
+ "A bare reservation holds at most 8h; pass --until for an "
650
+ "explicit bounded TTL (also capped at 8h, the pin's clamp)."
651
+ ),
652
+ formatter_class=argparse.RawDescriptionHelpFormatter,
653
+ )
654
+ reserve_sub = parser.add_subparsers(dest="reserve_command")
655
+
656
+ set_p = reserve_sub.add_parser(
657
+ "set", help="Reserve a claim for an app (implicit: urun reserve <claim> --app …)"
658
+ )
659
+ set_p.add_argument(
660
+ "claim", help="GPUWorkerClaim name (e.g. node-autoscaler-rtx6000-ba6262d689)"
661
+ )
662
+ set_p.add_argument(
663
+ "--app", required=True, help="<app_id>[/<fn>] the node will serve exclusively"
664
+ )
665
+ set_p.add_argument("--until", default=None, help="RFC3339 deadline (bounded by the 8h pin cap)")
666
+ _add_reserve_cluster_args(set_p)
667
+
668
+ release_p = reserve_sub.add_parser("release", help="Release a claim's reservation (idempotent)")
669
+ release_p.add_argument("claim")
670
+ _add_reserve_cluster_args(release_p)
671
+
672
+ list_p = reserve_sub.add_parser("list", help="List every reserved claim")
673
+ _add_reserve_cluster_args(list_p)
674
+
675
+
676
+ def _add_reserve_cluster_args(parser: argparse.ArgumentParser) -> None:
677
+ parser.add_argument(
678
+ "--context",
679
+ default=None,
680
+ help="kubectl context (REQUIRED for set/release; the active context for list)",
681
+ )
682
+ parser.add_argument("--namespace", default="urun", help="Namespace (default: urun)")
626
683
 
627
684
 
628
685
  def add_session_parsers(sub: argparse._SubParsersAction) -> None:
@@ -1414,7 +1471,7 @@ def add_org_parser(sub: argparse._SubParsersAction) -> None:
1414
1471
  "cap back to unlimited."
1415
1472
  ),
1416
1473
  )
1417
- limits_parser.add_argument("--monthly-usd-cap", dest="monthly_usd_cap", type=float)
1474
+ limits_parser.add_argument("--monthly-credit-cap", dest="monthly_credit_cap", type=int)
1418
1475
  limits_parser.add_argument(
1419
1476
  "--max-concurrent-sessions", dest="max_concurrent_sessions", type=int
1420
1477
  )
@@ -1423,7 +1480,7 @@ def add_org_parser(sub: argparse._SubParsersAction) -> None:
1423
1480
  limits_parser.add_argument(
1424
1481
  "--clear",
1425
1482
  action="append",
1426
- choices=("monthly_usd_cap", "max_concurrent_sessions", "max_gpu_units"),
1483
+ choices=("monthly_credit_cap", "max_concurrent_sessions", "max_gpu_units"),
1427
1484
  help="Cap(s) to reset to unlimited (repeatable)",
1428
1485
  )
1429
1486
  # defer_to_parent: `org` defines these same flags, so this child must not
@@ -1956,7 +2013,7 @@ def org_identity_lines(config: dict[str, Any], resolved: ResolvedCredentials) ->
1956
2013
  def org_set_limits(args: argparse.Namespace) -> int:
1957
2014
  payload: dict[str, Any] = {}
1958
2015
  for key in (
1959
- "monthly_usd_cap",
2016
+ "monthly_credit_cap",
1960
2017
  "max_concurrent_sessions",
1961
2018
  "max_gpu_units",
1962
2019
  "alert_threshold_pct",
@@ -1968,7 +2025,7 @@ def org_set_limits(args: argparse.Namespace) -> int:
1968
2025
  payload["clear"] = args.clear
1969
2026
  if not payload:
1970
2027
  raise UrunError(
1971
- "nothing to set; pass at least one of --monthly-usd-cap, "
2028
+ "nothing to set; pass at least one of --monthly-credit-cap, "
1972
2029
  "--max-concurrent-sessions, --max-gpu-units, --alert-threshold-pct, --clear"
1973
2030
  )
1974
2031
  return print_or_table(_client(args).set_org_limits(payload), args, "org limits")
@@ -4107,6 +4164,40 @@ def _builders_resolve_command(args: argparse.Namespace, client: ApiClient) -> in
4107
4164
  return print_or_table(response, args, "builder")
4108
4165
 
4109
4166
 
4167
+ def reserve_command(args: argparse.Namespace) -> int:
4168
+ from .reserve import list_reservations, release, reserve
4169
+
4170
+ command = getattr(args, "reserve_command", None)
4171
+ context = getattr(args, "context", None)
4172
+ namespace = getattr(args, "namespace", None) or "urun"
4173
+ if command == "list":
4174
+ rows = list_reservations(context, namespace)
4175
+ reservations = rows.get("reservations") or []
4176
+ if not reservations:
4177
+ print("no reserved claims")
4178
+ return 0
4179
+ for row in reservations:
4180
+ until = (
4181
+ row.get("pin_until") or f"bare (≤{int((row.get('max_hold_s') or 0) / 3600)}h hold)"
4182
+ )
4183
+ print(
4184
+ f"{row.get('claim')} reserved-for={row.get('reserved_for')} until={until} phase={row.get('phase') or '?'}"
4185
+ )
4186
+ return 0
4187
+ if command == "release":
4188
+ state = release(context, namespace, args.claim)
4189
+ print(f"released {args.claim} (was_reserved={state.get('was_reserved')})")
4190
+ return 0
4191
+ if command == "set":
4192
+ state = reserve(context, namespace, args.claim, args.app, getattr(args, "until", None))
4193
+ until = state.get("pin_until") or "bare (bounded at the 8h hold cap)"
4194
+ print(f"reserved {state.get('claim')} for {state.get('reserved_for')} (until={until})")
4195
+ print("the node's scheduler applies it on its next poll tick (~15s)")
4196
+ return 0
4197
+ print("usage: urun reserve {set|release|list}", file=sys.stderr)
4198
+ return 2
4199
+
4200
+
4110
4201
  def node_command(args: argparse.Namespace) -> int:
4111
4202
  target = getattr(args, "node_command", None)
4112
4203
  if target == "ls":
@@ -4584,6 +4675,39 @@ def _app_payload(response: dict[str, Any]) -> dict[str, Any]:
4584
4675
  return app
4585
4676
 
4586
4677
 
4678
+ def _actual_release_entries(app: dict[str, Any]) -> list[dict[str, Any]]:
4679
+ """Presence-derived actual-release rollup entries, oldest planes tolerated.
4680
+
4681
+ The plane (migration 20270319000000) reports `actual_release_ids` as a list
4682
+ of {release_id, manifest_hash, pods} for the fresh ready pods actually
4683
+ serving the function. Older planes omit the field entirely and scale-to-zero
4684
+ reports JSON null; both degrade to an empty list so the rendered output is
4685
+ exactly what it is today.
4686
+ """
4687
+ entries = app.get("actual_release_ids")
4688
+ if entries is None:
4689
+ return []
4690
+ if not isinstance(entries, list) or any(not isinstance(entry, dict) for entry in entries):
4691
+ raise UrunError("server returned invalid actual_release_ids payload")
4692
+ return entries
4693
+
4694
+
4695
+ def _actual_release_label(entry: dict[str, Any]) -> str:
4696
+ return _short_hash(entry.get("manifest_hash") or entry.get("release_id"))
4697
+
4698
+
4699
+ def _pods_str(count: Any) -> str:
4700
+ if _is_non_bool_int(count):
4701
+ return f"{count} pod" if count == 1 else f"{count} pods"
4702
+ return "? pods"
4703
+
4704
+
4705
+ def _actual_release_summary(entries: list[dict[str, Any]]) -> str:
4706
+ return ", ".join(
4707
+ f"{_actual_release_label(entry)} ({_pods_str(entry.get('pods'))})" for entry in entries
4708
+ )
4709
+
4710
+
4587
4711
  def app_status(args: argparse.Namespace) -> int:
4588
4712
  api_url, api_key = resolve_api_credentials(args)
4589
4713
  environment = resolve_environment(args)
@@ -4618,8 +4742,15 @@ def app_status(args: argparse.Namespace) -> int:
4618
4742
  ("Compute", _coalesce_str(app.get("compute_shape"), "-")),
4619
4743
  ("GPU", _gpu_str(app)),
4620
4744
  ("Release", _short_hash(app.get("manifest_hash") or app.get("release_version"))),
4621
- ("Live sessions", _int_str(app.get("active_sessions"))),
4622
4745
  ]
4746
+ # Desired vs actual: `Release` above is the DESIRED (promoted) release; the
4747
+ # plane additionally reports which release the fresh ready pods are ACTUALLY
4748
+ # serving (runtime_presence rollup). Absent on older planes and on
4749
+ # scale-to-zero — in both cases the output stays exactly what it is today.
4750
+ actual_entries = _actual_release_entries(app)
4751
+ if actual_entries:
4752
+ fields.append(("Release (actual)", _actual_release_summary(actual_entries)))
4753
+ fields.append(("Live sessions", _int_str(app.get("active_sessions"))))
4623
4754
  health_rows = response.get("runtime_health")
4624
4755
  if isinstance(health_rows, list):
4625
4756
  for health_value in health_rows:
@@ -4643,6 +4774,22 @@ def app_status(args: argparse.Namespace) -> int:
4643
4774
  width = max(len(label) for label, _ in fields)
4644
4775
  for label, value in fields:
4645
4776
  print(f"{label.rjust(width)}: {value}")
4777
+ # The displayed `Status` value stays untouched (scripted consumers gate on
4778
+ # it); the mismatch is surfaced as an explicit warning line instead.
4779
+ if app.get("release_mismatch") is True:
4780
+ current_release_id = app.get("current_release_id")
4781
+ stale_entries = [
4782
+ entry
4783
+ for entry in actual_entries
4784
+ if entry.get("release_id") is not None and entry.get("release_id") != current_release_id
4785
+ ]
4786
+ stale_summary = _actual_release_summary(stale_entries) or "unknown"
4787
+ explanation = (
4788
+ 'status "ready" reflects warm capacity, not materialization of the desired release'
4789
+ if _coalesce_str(app.get("status"), "") == _DISPLAY_READY
4790
+ else "the desired release is not yet materialized on these pods"
4791
+ )
4792
+ print(f"WARNING: pods still running previous release {stale_summary} — {explanation}")
4646
4793
  return 0
4647
4794
 
4648
4795
 
@@ -5989,14 +6136,33 @@ def resolve_compat_credentials(app_flag: str | None = None) -> dict[str, str]:
5989
6136
  only DERIVED (via :func:`sdk_base_url`) when the caller did not set it:
5990
6137
  deriving eagerly would raise on a custom-path api_url even when the caller
5991
6138
  already supplied the exact override :func:`sdk_base_url`'s error recommends.
6139
+
6140
+ SECURITY (pentest urun-cli-03): the compat child env is the frontend scrub
6141
+ allowlist (:func:`urun.frontend_launch.frontend_child_env`) plus these
6142
+ credential keys — NEVER the developer's full exported
6143
+ environment, and the PATH binary lane is refused. The env-wins-per-key
6144
+ contract is preserved by forwarding explicit URUN_BASE_URL / URUN_ORG_ID
6145
+ through this dict.
5992
6146
  """
5993
6147
  args = argparse.Namespace(api_key=None, api_url=None, gateway_url=None)
5994
6148
  api_url, api_key = resolve_api_credentials(args)
5995
6149
  credentials = {"URUN_API_KEY": api_key}
5996
- if not string_value(os.environ.get("URUN_BASE_URL")):
6150
+ explicit_base_url = string_value(os.environ.get("URUN_BASE_URL"))
6151
+ if explicit_base_url:
6152
+ # SECURITY (pentest urun-cli-03): the compat child env is SCRUBBED
6153
+ # (run_proxy builds it from frontend_child_env), so an explicit
6154
+ # URUN_BASE_URL rides to the child through the credentials dict —
6155
+ # the same env-wins-per-key contract as before the scrub.
6156
+ credentials["URUN_BASE_URL"] = explicit_base_url
6157
+ else:
5997
6158
  credentials["URUN_BASE_URL"] = sdk_base_url(api_url)
5998
6159
  saved = load_credentials()
5999
- if api_key == string_value(saved.get("api_key")):
6160
+ explicit_org_id = string_value(os.environ.get("URUN_ORG_ID"))
6161
+ if explicit_org_id:
6162
+ # Same env-wins-per-key contract as URUN_BASE_URL above: the scrubbed
6163
+ # child env would otherwise swallow an explicit URUN_ORG_ID.
6164
+ credentials["URUN_ORG_ID"] = explicit_org_id
6165
+ elif api_key == string_value(saved.get("api_key")):
6000
6166
  org_id = string_value(saved.get("org_id"))
6001
6167
  if org_id:
6002
6168
  credentials["URUN_ORG_ID"] = org_id
@@ -29,11 +29,13 @@ treats a CLI launch and a hosted token route identically).
29
29
 
30
30
  from __future__ import annotations
31
31
 
32
+ import ipaddress
32
33
  import json
33
34
  import re
34
35
  import sys
35
36
  import threading
36
37
  import time
38
+ import urllib.parse
37
39
  from collections.abc import Callable
38
40
  from dataclasses import dataclass
39
41
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -91,6 +93,31 @@ def is_loopback_host(host: str) -> bool:
91
93
  return canonical_demo_host(host) in _LOOPBACK_HOSTS
92
94
 
93
95
 
96
+ def browser_origin_is_loopback(origin: str) -> bool:
97
+ """Whether a browser ``Origin`` names THIS machine (loopback host, any port).
98
+
99
+ ``http://127.0.0.1:5173``, ``http://localhost:3000``, any ``127.x.x.x``
100
+ address and ``*.localhost`` subdomains (browsers resolve those to loopback,
101
+ RFC 6761) are pages on this machine; ``https://proxy.example`` or a LAN
102
+ address is not. Dev-mode CORS reflection accepts ONLY these (pentest
103
+ urun-cli-02): reflecting an arbitrary Origin handed the live, silently
104
+ re-minting session token to any website open in the developer's browser,
105
+ which needs only a loopback port scan to read it cross-origin.
106
+ """
107
+ try:
108
+ host = (urllib.parse.urlsplit(origin).hostname or "").lower().strip("[]")
109
+ except ValueError:
110
+ return False
111
+ if not host:
112
+ return False
113
+ if host == "localhost" or host.endswith(".localhost"):
114
+ return True
115
+ try:
116
+ return ipaddress.ip_address(host).is_loopback
117
+ except ValueError:
118
+ return False
119
+
120
+
94
121
  def require_loopback_demo_host(host: str) -> str:
95
122
  """The CANONICAL demo host, or a LOUD refusal for anything routable.
96
123
 
@@ -379,19 +406,20 @@ class _TokenRequestHandler(BaseHTTPRequestHandler):
379
406
  does and reads as a stronger guarantee than exists:
380
407
 
381
408
  * ``/token`` — demo callers must present an allowed Origin. Dev callers
382
- may present any Origin because the endpoint remains loopback-bound and a
383
- developer may attach through any browser or proxy. A caller that sends
384
- NO Origin is also served, because the hosted token route this mirrors is
385
- fetched by server-side code, which sends none. Any local process can
386
- therefore read a token, which is why the socket is bound to loopback and
387
- only loopback (see ``require_loopback_demo_host``).
409
+ may present any LOOPBACK Origin (any port): a developer may attach
410
+ through any page served on this machine. Reflecting an arbitrary
411
+ non-loopback Origin is refused (403) — pentest urun-cli-02: an
412
+ arbitrary website open in the browser could otherwise read the live,
413
+ silently re-minting token cross-origin with a plain loopback port
414
+ scan. A caller that sends NO Origin is also served, because the
415
+ hosted token route this mirrors is fetched by server-side code, which
416
+ sends none. Any local process can therefore read a token, which is
417
+ why the socket is bound to loopback and only loopback (see
418
+ ``require_loopback_demo_host``).
388
419
  * ``/events`` — demo callers must present an allowed Origin, while dev
389
- callers may present any Origin for the same reason as ``/token``. An
390
- absent Origin is always refused because this route is browser-only.
391
-
392
- The Origin check is a browser-context control and nothing more: outside a
393
- browser the header is caller-controlled, and the permitted origins are a
394
- readable claim inside the token itself. What actually bounds this token is
420
+ callers may present any LOOPBACK Origin for the same reason as
421
+ ``/token``. An absent Origin is always refused because this route is
422
+ browser-only.
395
423
  ``allowed_functions``, its expiry, and ``max_session_s``.
396
424
  """
397
425
 
@@ -403,7 +431,11 @@ class _TokenRequestHandler(BaseHTTPRequestHandler):
403
431
  if origin is None:
404
432
  return None
405
433
  if self.server.unrestricted_browser_origins:
406
- return origin
434
+ # SECURITY (pentest urun-cli-02): dev reflects ONLY loopback
435
+ # browser origins (any port) — pages on THIS machine. Reflecting
436
+ # an arbitrary Origin handed the live, silently re-minting token
437
+ # to any website in the browser; a remote page gets 403.
438
+ return origin if browser_origin_is_loopback(origin) else ""
407
439
  return origin if origin in self.server.allowed_origins else ""
408
440
 
409
441
  def _reject_disallowed_origin(self, origin: str | None) -> bool:
@@ -1439,7 +1439,7 @@ def _maybe_launch_frontend(
1439
1439
  "frontend | ",
1440
1440
  "browser auth: scoped client token "
1441
1441
  f"(functions: {', '.join(launch.allowed_functions)}; "
1442
- "origins: unrestricted (dev only); "
1442
+ "origins: loopback browser origins only (dev only); "
1443
1443
  f"max session: {f'{launch.max_session_s}s' if launch.max_session_s else 'uncapped (ends with the CLI)'})",
1444
1444
  )
1445
1445
  mux.line(
@@ -30,11 +30,14 @@ Resolution is fail-loud, one canonical order, no silent fallback:
30
30
  1. ``URUN_OPENAI_PROXY_BIN`` (explicit path — always wins, must exist);
31
31
  2. the vendored platform binary shipped inside this package
32
32
  (``src/urun/_bin/urun-openai``);
33
- 3. ``urun-openai`` on ``PATH`` (dev installs);
33
+ 3. ``urun-openai`` on ``PATH`` (dev installs) — refused whenever org
34
+ credentials are handed to the child (pentest urun-cli-03);
34
35
  4. a loud error naming all three lanes.
35
36
 
36
37
  A missing vendored binary falls through to PATH by design — that fall-through
37
- is part of the documented canonical order above, not a silent fallback. The
38
+ is part of the documented canonical order above, not a silent fallback (the
39
+ credential-carrying call is the exception: there the missing vendored binary
40
+ fails LOUD rather than hand the org key to a PATH-resolved binary). The
38
41
  proxy's own session config (``URUN_BASE_URL`` / ``URUN_ORG_ID`` / ``URUN_APP``
39
42
  / ``URUN_JWT``) passes through the environment untouched.
40
43
  """
@@ -73,8 +76,20 @@ def vendored_proxy_binary() -> str | None:
73
76
  return str(path)
74
77
 
75
78
 
76
- def resolve_proxy_binary(env: Mapping[str, str] | None = None) -> str:
77
- """The proxy binary path — explicit env override, else vendored, else PATH, else loud."""
79
+ def resolve_proxy_binary(
80
+ env: Mapping[str, str] | None = None, *, allow_path_lane: bool = True
81
+ ) -> str:
82
+ """The proxy binary path — explicit env override, else vendored, else PATH.
83
+
84
+ ``allow_path_lane=False`` (used whenever credentials are handed to the
85
+ child) refuses the PATH lane: a repo-run dev script can plant a
86
+ ``urun-openai`` executable on the user's PATH (an ordinary file write
87
+ within its scrubbed powers) and capture the org API key the CLI injects —
88
+ exactly the capability the frontend env scrub removed from
89
+ repository-run processes (pentest urun-cli-03). The explicit
90
+ ``URUN_OPENAI_PROXY_BIN`` override and the package-vendored binary stay
91
+ allowed: those are operator-controlled, not attacker-placeable.
92
+ """
78
93
  environ = os.environ if env is None else env
79
94
  override = (environ.get(BIN_ENV) or "").strip()
80
95
  if override:
@@ -88,6 +103,14 @@ def resolve_proxy_binary(env: Mapping[str, str] | None = None) -> str:
88
103
  vendored = vendored_proxy_binary()
89
104
  if vendored:
90
105
  return vendored
106
+ if not allow_path_lane:
107
+ raise FileNotFoundError(
108
+ f"the {BIN_NAME!r} proxy binary cannot be resolved from PATH while "
109
+ "org credentials are handed to the child — a repo-run dev script "
110
+ "can plant a PATH entry and capture the key (pentest urun-cli-03). "
111
+ f"Set {BIN_ENV} to an explicit path or use a urun-cli build that "
112
+ f"vendors it at {VENDOR_DIR / BIN_NAME}"
113
+ )
91
114
  # Search the SUPPLIED environment's PATH — the same env the child launches
92
115
  # with. An env mapping without PATH has no PATH lane at all (loud below).
93
116
  found = shutil.which(BIN_NAME, path=environ.get("PATH", ""))
@@ -120,12 +143,34 @@ def run_proxy(
120
143
  them HERE, so they reach the child PROCESS-SCOPED — never shell-exported,
121
144
  never written to disk by this seam. Explicit env vars win over the store
122
145
  (the override lane); the store fills what the user did not set.
146
+
147
+ SECURITY (pentest urun-cli-03): when ``credentials`` are handed over, the
148
+ child env is NEVER the developer's full exported environment — it is the
149
+ frontend scrub allowlist (:func:`urun.frontend_launch.frontend_child_env`)
150
+ plus the credential keys only, and the PATH resolution lane is refused
151
+ (explicit ``URUN_OPENAI_PROXY_BIN`` path or vendored binary only). A
152
+ repo-run dev script could otherwise plant a ``urun-openai`` on PATH and
153
+ re-capture the raw org key and every exported secret (AWS_*,
154
+ GITHUB_TOKEN, ...) the scrub removed from repository-run processes.
123
155
  """
124
- binary = resolve_proxy_binary(env)
125
- base = dict(env) if env is not None else dict(os.environ)
126
156
  if credentials:
157
+ # Lazy import: frontend_launch pulls the API client surface; this
158
+ # module must stay importable standalone.
159
+ from .frontend_launch import frontend_child_env
160
+
161
+ base = frontend_child_env()
162
+ if env is not None:
163
+ # The CALLER env may be a full process environment — copy only the
164
+ # credential keys it sets (per-key env-wins), never the whole map.
165
+ for key in credentials:
166
+ if key in env:
167
+ base[key] = env[key]
168
+ binary = resolve_proxy_binary(env, allow_path_lane=False)
127
169
  for key, value in credentials.items():
128
170
  base.setdefault(key, value) # env overrides store, store fills gaps
171
+ else:
172
+ binary = resolve_proxy_binary(env)
173
+ base = dict(env) if env is not None else dict(os.environ)
129
174
  completed = subprocess.run( # noqa: S603 — the resolved binary IS the product
130
175
  [binary, *args],
131
176
  env=base,
@@ -48,6 +48,62 @@ class RepoDepsError(Exception):
48
48
 
49
49
  _FULL_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$")
50
50
 
51
+ #: A fetchable ref tail: a plain refname (branch / tag — a full 40-hex SHA is a
52
+ #: subset of this charset). The FIRST character is alphanumeric so the ref can
53
+ #: never start with ``-`` (git's parse-options would read it as an option) and
54
+ #: the charset excludes ``=``, ``:``, ``@``, whitespace and every shell
55
+ #: metacharacter. The ref rides as ONE argv element into
56
+ #: ``git fetch --depth 1 origin <ref>``; for a local-path spec
57
+ #: (``/abs/path`` / ``file://``) a ``--upload-pack=<payload>`` tail made git
58
+ #: EXECUTE the payload on the deployer's machine (pentest urun-cli-01), so a
59
+ #: ref that is not a plain refname is refused before any git call.
60
+ _REFNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
61
+
62
+
63
+ def _require_fetchable_ref(ref: str, spec: str) -> None:
64
+ """Refuse a ref git parse-options could read as an OPTION (ENG-570).
65
+
66
+ The spec is a string literal in UNTRUSTED app source and the ref tail
67
+ reaches git argv verbatim. A full 40-char commit SHA or a plain refname
68
+ over ``[A-Za-z0-9._/-]`` (never leading ``-``, never ``=``) is fetchable;
69
+ anything else — ``--upload-pack=<payload>`` above all — is option
70
+ injection and aborts the splice loudly.
71
+ """
72
+ if _FULL_SHA_RE.fullmatch(ref):
73
+ return
74
+ if _REFNAME_RE.fullmatch(ref):
75
+ # git-check-ref-format rules beyond the charset: no empty components
76
+ # (the ref tail can never contain "/", so ".." is the empty-
77
+ # component shape here), no trailing "." or ".lock". These would
78
+ # die at fetch time anyway; rejecting at parse time gives the bad
79
+ # pin a named, actionable error.
80
+ if ".." in ref or "//" in ref or "/." in ref or ref.endswith(".") or ref.endswith(".lock"):
81
+ raise RepoDepsError(
82
+ f"store.repo spec {spec!r}: invalid ref {ref!r} — not a valid "
83
+ "git refname (empty/dot-leading components or a trailing "
84
+ "'.lock'); fix the pin"
85
+ )
86
+ return
87
+ raise RepoDepsError(
88
+ f"store.repo spec {spec!r}: invalid ref {ref!r} — the pin must be a full "
89
+ "40-char commit SHA or a refname over [A-Za-z0-9._/-] not starting with "
90
+ "'-' (refuses git option injection)"
91
+ )
92
+
93
+
94
+ def _require_fetchable_head(head: str, spec: str) -> None:
95
+ """Refuse a spec head (URL / local path) git would parse as an OPTION.
96
+
97
+ The head reaches ``git remote add origin <head>`` verbatim; a leading ``-``
98
+ turns the address into a git option instead of a remote.
99
+ """
100
+ if head.startswith("-"):
101
+ raise RepoDepsError(
102
+ f"store.repo spec {spec!r}: the repo URL/path may not start with '-' "
103
+ "(refuses git option injection)"
104
+ )
105
+
106
+
51
107
  #: Metadata files scanned for the upstream's own python deps, in priority order
52
108
  #: (mirrors urun-python ``repo._DEP_FILES``).
53
109
  _DEP_FILES = ("requirements.txt", "pyproject.toml", "setup.py")
@@ -92,10 +148,18 @@ def parse_repo_spec(spec: str) -> RepoSpec:
92
148
  # path separator and no scheme.
93
149
  if "/" not in maybe_ref and ":" not in maybe_ref:
94
150
  raw, ref = head, maybe_ref
151
+ # SECURITY (pentest urun-cli-01 / ENG-570): the spec is an attacker-
152
+ # controlled string literal in app source and BOTH halves reach git argv
153
+ # verbatim (``git remote add origin <head>`` / ``git fetch ... <ref>``).
154
+ # Refuse option-injection shapes before any git call.
155
+ if ref is not None:
156
+ _require_fetchable_ref(ref, spec)
157
+ _require_fetchable_head(raw, spec)
95
158
 
96
159
  # Local filesystem repo (absolute path or file:// URL).
97
160
  if raw.startswith("file://"):
98
161
  path = raw[len("file://") :]
162
+ _require_fetchable_head(path, spec)
99
163
  return RepoSpec(
100
164
  clone_url=path,
101
165
  ref=ref,
@@ -0,0 +1,156 @@
1
+ """``urun reserve`` — the operator verb for app-exclusive nodes (lane 10).
2
+
3
+ Reserves a GPUWorkerClaim for ONE app (`<app_id>[/<fn>]`), optionally
4
+ bounded by a deadline, through the control plane's SANCTIONED surface
5
+ (``POST``/``DELETE``/``GET /node-reservations`` on the urun-control-plane
6
+ health port) — the same annotations the hand-rolled
7
+ ``kubectl annotate gpuworkerclaims … urun.sh/reserved-for=…`` path stamped,
8
+ never a second mechanism.
9
+
10
+ Dev/prod posture: unlike ``urun scratch`` (dev-only by context naming),
11
+ reserving is FOR prod too — the whole point is guaranteeing a serving or
12
+ training node to one app. The guard is therefore inverted: the operator's
13
+ ACTIVE kubectl context is refused as a silent default for a WRITE; the
14
+ context must be passed explicitly (``--context``), so a reservation can
15
+ never land on a cluster the operator is not looking at. Listing takes the
16
+ active context freely (a read).
17
+
18
+ The verb shells kubectl port-forward to the control plane's health port —
19
+ the same operator posture ``urun scratch`` already established (explicit
20
+ context/namespace, kubectl-driven, no API-key surface involved).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import subprocess
27
+ import time
28
+ import urllib.error
29
+ import urllib.request
30
+ from typing import Any
31
+
32
+ from .errors import UrunError
33
+
34
+ #: The control-plane deployment whose health port carries the routes.
35
+ CONTROL_PLANE_DEPLOYMENT = "urun-control-plane"
36
+ #: The health server's port (URUN_CONTROL_HEALTH_PORT default).
37
+ CONTROL_PLANE_PORT = 8080
38
+
39
+
40
+ def _free_port() -> int:
41
+ import socket
42
+
43
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
44
+ s.bind(("127.0.0.1", 0))
45
+ return s.getsockname()[1]
46
+
47
+
48
+ class _PortForward:
49
+ """A bounded kubectl port-forward to the control plane's health port."""
50
+
51
+ def __init__(self, context: str | None, namespace: str) -> None:
52
+ port = _free_port()
53
+ self.url = f"http://127.0.0.1:{port}"
54
+ cmd = ["kubectl"]
55
+ if context:
56
+ cmd += ["--context", context]
57
+ cmd += [
58
+ "-n",
59
+ namespace,
60
+ "port-forward",
61
+ f"deploy/{CONTROL_PLANE_DEPLOYMENT}",
62
+ f"{port}:{CONTROL_PLANE_PORT}",
63
+ ]
64
+ self.proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
65
+ # port-forward needs a moment; the request below retries transiently.
66
+ self._ready_deadline = time.monotonic() + 10.0
67
+
68
+ def request(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any:
69
+ payload = json.dumps(body).encode("utf-8") if body is not None else None
70
+ last_exc: Exception | None = None
71
+ while time.monotonic() < self._ready_deadline + 20.0:
72
+ req = urllib.request.Request(
73
+ self.url + path,
74
+ data=payload,
75
+ method=method,
76
+ headers={"content-type": "application/json"} if payload else {},
77
+ )
78
+ try:
79
+ with urllib.request.urlopen(req, timeout=30) as resp:
80
+ return json.loads(resp.read().decode("utf-8"))
81
+ except urllib.error.HTTPError as exc:
82
+ # A defined API answer (404/409/4xx) is the SERVER speaking —
83
+ # relay its JSON body, never retry it away.
84
+ try:
85
+ detail = json.loads(exc.read().decode("utf-8"))
86
+ except (ValueError, UnicodeDecodeError):
87
+ detail = {}
88
+ message = str(detail.get("error") or detail.get("reason") or exc)
89
+ raise UrunError(message) from exc
90
+ except urllib.error.URLError as exc:
91
+ last_exc = exc
92
+ if self.proc.poll() is not None:
93
+ raise UrunError("kubectl port-forward exited early") from exc
94
+ time.sleep(0.3)
95
+ raise UrunError(f"control-plane health port unreachable: {last_exc}")
96
+
97
+ def close(self) -> None:
98
+ self.proc.terminate()
99
+ try:
100
+ self.proc.wait(timeout=5)
101
+ except subprocess.TimeoutExpired:
102
+ self.proc.kill()
103
+
104
+
105
+ def _connect(context: str | None, namespace: str) -> _PortForward:
106
+ # Refuse the ACTIVE context as a silent default for any WRITE; a read
107
+ # (list) takes it. main() passes context=None for reads only when the
108
+ # operator did not name one — the write paths resolve it themselves.
109
+ return _PortForward(context, namespace)
110
+
111
+
112
+ def list_reservations(context: str | None, namespace: str) -> dict[str, Any]:
113
+ """GET /node-reservations — every reserved claim (scope, pin, phase)."""
114
+ pf = _connect(context, namespace)
115
+ try:
116
+ return pf.request("GET", "/node-reservations")
117
+ finally:
118
+ pf.close()
119
+
120
+
121
+ def reserve(
122
+ context: str | None,
123
+ namespace: str,
124
+ claim: str,
125
+ app: str,
126
+ until: str | None,
127
+ ) -> dict[str, Any]:
128
+ """POST /node-reservations/{claim} — stamp urun.sh/reserved-for (+ pin)."""
129
+ if context is None:
130
+ raise UrunError(
131
+ "refusing to reserve against the ACTIVE kubectl context as a silent "
132
+ "default — pass --context explicitly (a reservation is a live "
133
+ "cluster write; be looking at the cluster you write to)"
134
+ )
135
+ pf = _connect(context, namespace)
136
+ try:
137
+ body: dict[str, Any] = {"app": app}
138
+ if until:
139
+ body["until"] = until
140
+ return pf.request("POST", f"/node-reservations/{claim}", body)
141
+ finally:
142
+ pf.close()
143
+
144
+
145
+ def release(context: str | None, namespace: str, claim: str) -> dict[str, Any]:
146
+ """DELETE /node-reservations/{claim} — idempotent release."""
147
+ if context is None:
148
+ raise UrunError(
149
+ "refusing to release against the ACTIVE kubectl context as a silent "
150
+ "default — pass --context explicitly"
151
+ )
152
+ pf = _connect(context, namespace)
153
+ try:
154
+ return pf.request("DELETE", f"/node-reservations/{claim}")
155
+ finally:
156
+ pf.close()
@@ -122,6 +122,25 @@ def _add_serve_model_args(parser: argparse.ArgumentParser) -> None:
122
122
  "to the spheron-pinned queue. Unpinned is the default and unchanged."
123
123
  ),
124
124
  )
125
+ # THE CPU DECLARATION'S PARSER HALF (owner design 2026-09-30, #353):
126
+ # the handler/shim/app halves shipped in #353 but this flag never did —
127
+ # argparse rejected `--cpu` before serve_model could honor it, so every
128
+ # shared-reconciler CPU row (phonon-2) died with "unrecognized
129
+ # arguments" (ENG-599, observed live on dev's ENG-584 sweep). Mirrors
130
+ # `urun scratch run`'s --cpu shape (cli.py); the handler validates the
131
+ # placement rules (CPU-only rows; --gpu <SKU> + --cpu is refused there).
132
+ parser.add_argument(
133
+ "--cpu",
134
+ dest="cpu",
135
+ type=int,
136
+ default=None,
137
+ metavar="N",
138
+ help=(
139
+ "CPU declaration for a gpu-less (CPU) placement: N cores "
140
+ "(shape cpu:N). Only valid with `--gpu none` or a CPU variant "
141
+ "— the shared reconciler's CPU rows pass it with --gpu none."
142
+ ),
143
+ )
125
144
  parser.add_argument(
126
145
  "--exo-class",
127
146
  dest="exo_class",
@@ -265,10 +284,32 @@ def serve_model(args: argparse.Namespace) -> int:
265
284
  "--cpu is the CPU declaration and only applies to a CPU (gpu-less) row; "
266
285
  "drop --cpu or resolve a CPU variant."
267
286
  )
287
+ # --cpu rides the generated shim's URUN_SERVE_CPUS channel, exactly like
288
+ # every other shim-transported pin (--warm, --urun-wheel, the provider pin,
289
+ # the EXO pin) — so it cannot be combined with --entrypoint: a local app is
290
+ # deployed as-is and nothing would carry the declaration (the pin would
291
+ # be a silent no-op; review finding round 1).
292
+ if requested_cpu is not None and getattr(args, "entrypoint", None):
293
+ raise UrunError(
294
+ "--cpu cannot be combined with --entrypoint. The CPU declaration "
295
+ "is set by the generated serve shim; a local --entrypoint app is "
296
+ "deployed as-is, so the declaration would silently do nothing. "
297
+ "Set cpus in your own app's @app.function(cpus=...) instead."
298
+ )
299
+ # `--cpu 0` / `--cpu -1` must fail from the args alone, before any catalog
300
+ # fetch — the shim's late rejection names the CATALOG ROW ("has no
301
+ # positive 'cpu' cores"), blaming the wrong input; the USER supplied the
302
+ # value (CodeRabbit finding).
303
+ if requested_cpu is not None and requested_cpu < 1:
304
+ raise UrunError(
305
+ f"--cpu must declare >= 1 core, got {requested_cpu}; "
306
+ "drop --cpu or pass a positive core count."
307
+ )
268
308
  client = resolve_catalog_client(args)
269
309
  rows = client.select_rows()
270
310
  requested_gpu = requested_gpu_raw
271
- if requested_gpu == "none":
311
+ explicit_none_gpu = requested_gpu == "none"
312
+ if explicit_none_gpu:
272
313
  requested_gpu = None
273
314
  # PARSE THE PIN, THEN VALIDATE THE GPU, THEN THE REST FLOWS THROUGH (review:
274
315
  # Keegan, 2026-09-15). The provider rides IN the --gpu class because the
@@ -287,6 +328,11 @@ def serve_model(args: argparse.Namespace) -> int:
287
328
  # non-enumerated-but-sufficient SKU is ALLOWED (with a warn) instead of rejected.
288
329
  # "error" -> fail before deploy; "warn" -> proceed untuned (synthesize a deploy
289
330
  # row pinned to the requested gpu_spec); enumerated SKU -> clean (resolve normally).
331
+ # - EXPLICIT `--gpu none` (CodeRabbit finding): the caller named the CPU
332
+ # placement, so select the variant's gpu_spec-NULL row DIRECTLY — collapsing
333
+ # it to an omitted --gpu would hand resolve_row "first placement", which
334
+ # selects the GPU row first whenever a variant carries BOTH placements.
335
+ # An omitted --gpu keeps the first-placement path untouched.
290
336
  if requested_gpu:
291
337
  level, message = validate_gpu_against_row(rows, model_id, variant, requested_gpu)
292
338
  if level == "error":
@@ -299,6 +345,27 @@ def serve_model(args: argparse.Namespace) -> int:
299
345
  row = dataclasses.replace(base, gpu_spec=requested_gpu)
300
346
  else:
301
347
  row = resolve_row(rows, model_id, variant, requested_gpu, searched=client.base_url)
348
+ elif explicit_none_gpu:
349
+ # Resolve the DEFAULT VARIANT first (CodeRabbit finding): `urun serve
350
+ # <id> --gpu none` arrives with variant=None, and filtering rows on
351
+ # `variant == None` matches nothing — resolve_row owns the
352
+ # first-listed-variant default AND the unknown-model/unknown-variant
353
+ # errors, so resolve through it, then filter the resolved variant's
354
+ # placements for the CPU row.
355
+ default_row = resolve_row(rows, model_id, variant, None, searched=client.base_url)
356
+ for_variant = [
357
+ r
358
+ for r in rows
359
+ if r.model_id == default_row.model_id and r.variant == default_row.variant
360
+ ]
361
+ cpu_rows = [r for r in for_variant if r.gpu_spec is None]
362
+ if not cpu_rows:
363
+ known = ", ".join(sorted(str(g) for g in {r.gpu_spec for r in for_variant})) or "(none)"
364
+ raise UrunError(
365
+ f"--gpu none names the CPU placement, but {default_row.model_id}:{default_row.variant} has "
366
+ f"no gpu_spec-NULL row. Available placements: {known}"
367
+ )
368
+ row = cpu_rows[0]
302
369
  else:
303
370
  row = resolve_row(rows, model_id, variant, None, searched=client.base_url)
304
371
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes