urun-cli 0.7.0__tar.gz → 0.7.2__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 (34) hide show
  1. {urun_cli-0.7.0 → urun_cli-0.7.2}/CHANGELOG.md +18 -0
  2. {urun_cli-0.7.0 → urun_cli-0.7.2}/PKG-INFO +1 -1
  3. {urun_cli-0.7.0 → urun_cli-0.7.2}/pyproject.toml +1 -1
  4. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/cli.py +33 -1
  5. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/client_tokens.py +68 -3
  6. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/dev.py +14 -0
  7. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/errors.py +54 -0
  8. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/launch_auth.py +92 -12
  9. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/retry.py +18 -0
  10. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/rust.py +7 -3
  11. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/serve.py +49 -7
  12. {urun_cli-0.7.0 → urun_cli-0.7.2}/.gitignore +0 -0
  13. {urun_cli-0.7.0 → urun_cli-0.7.2}/LICENSE +0 -0
  14. {urun_cli-0.7.0 → urun_cli-0.7.2}/README.md +0 -0
  15. {urun_cli-0.7.0 → urun_cli-0.7.2}/SECURITY.md +0 -0
  16. {urun_cli-0.7.0 → urun_cli-0.7.2}/hatch_build.py +0 -0
  17. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/__init__.py +0 -0
  18. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/_bin/README.md +0 -0
  19. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/api.py +0 -0
  20. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/buildlogs.py +0 -0
  21. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/catalog.py +0 -0
  22. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/config.py +0 -0
  23. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/deps.py +0 -0
  24. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/devconsole.py +0 -0
  25. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/devstream.py +0 -0
  26. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/discovery.py +0 -0
  27. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/fleet.py +0 -0
  28. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/frontend_launch.py +0 -0
  29. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/manifest.py +0 -0
  30. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/nodes.py +0 -0
  31. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/openai_proxy_bin.py +0 -0
  32. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/repo_deps.py +0 -0
  33. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/runtime_health.py +0 -0
  34. {urun_cli-0.7.0 → urun_cli-0.7.2}/src/urun/scratch.py +0 -0
@@ -1,3 +1,21 @@
1
+ ## 0.7.2
2
+
3
+ - Fix the generated serve shim: the `--warm N` pin assigns `URUN_SERVE_WARM`
4
+ as a STRING. The 0.7.1 shim assigned an int literal, which raises
5
+ `TypeError: str expected, not int` at `os.encode` inside any build/import —
6
+ the exact first-build failure the agents-a1-fp8 shared deploy hit.
7
+
8
+ ## 0.7.1
9
+
10
+ - `urun serve --warm N` — an idle replica floor on a serve deploy (the
11
+ catalog's `warm` marker for platform-operated shared endpoints; absent =
12
+ the unchanged scale-to-zero default). Pins `URUN_SERVE_WARM` into the
13
+ generated shim so the bundled app's `@app.function(warm=...)` reads it.
14
+ - `urun serve --exo-class / --exo-image` — pin a deploy's provider on the EXO rail.
15
+ - Deploy Python and Rust functions in one app release.
16
+ - Compat: bundle the proxy with the queued session identity; stop reporting
17
+ an unconverged roll as ready and stop dropping the server's error message.
18
+
1
19
  ## 0.6.3
2
20
 
3
21
  - Bump the vendored `urun-openai` binary pin to `@urun-sh/openai@0.5.0` — the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urun-cli
3
- Version: 0.7.0
3
+ Version: 0.7.2
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.0"
23
+ version = "0.7.2"
24
24
  description = "End-user CLI for deploying apps to urun"
25
25
  readme = "README.md"
26
26
  requires-python = ">=3.11"
@@ -2675,7 +2675,7 @@ def deploy_once(args: argparse.Namespace) -> DeployOutcome:
2675
2675
  # showing" and hides a broken build-log path (owner, 2026-07-17).
2676
2676
  if not tailer.emitted_any:
2677
2677
  print(f"{BUILD_LOG_PREFIX}{NO_BUILD_LOGS_NOTE}")
2678
- watch_rollout(
2678
+ rollout = watch_rollout(
2679
2679
  client,
2680
2680
  app=app_name,
2681
2681
  manifest_hash=mh,
@@ -2683,6 +2683,27 @@ def deploy_once(args: argparse.Namespace) -> DeployOutcome:
2683
2683
  on_phase=lambda phase: print(f" rollout: {phase}"),
2684
2684
  )
2685
2685
  mark_phase("roll-wait")
2686
+ # A ROLL THAT DID NOT CONVERGE IS NOT A READY DEPLOYMENT. watch_rollout's
2687
+ # verdict used to be discarded, so a `timeout` printed its own honest
2688
+ # "still not converged" line and was then immediately contradicted by
2689
+ # "Deployment ready" and exit 0 -- the one signal automation reads.
2690
+ # Live receipt, dev-usw2 2026-09-09 `urun serve qwen3.6-27b:bf16`:
2691
+ # `rollout: still not converged after 300s` followed by
2692
+ # `Deployment ready`, exit 0, while the app sat at
2693
+ # `no_ready_gpu_units`. A CI job gating on the exit code called that a
2694
+ # successful deploy. `skipped` (no presence signal for this app at all)
2695
+ # stays exit 0: nothing was observed, so nothing is contradicted.
2696
+ if rollout == "timeout":
2697
+ print(format_phase_timing(phases))
2698
+ print(
2699
+ "Deployment NOT confirmed ready: the build succeeded and the "
2700
+ "roll continues server-side, but this command did not observe "
2701
+ "the new release serving before its deadline. Re-check with "
2702
+ "`urun app status`, or pass --no-wait to finalize without "
2703
+ "waiting.",
2704
+ file=sys.stderr,
2705
+ )
2706
+ return DeployOutcome(exit_code=1, manifest_hash=mh, deps=deps, status=status)
2686
2707
  print(format_phase_timing(phases))
2687
2708
  print_ready(status)
2688
2709
  return DeployOutcome(exit_code=0, manifest_hash=mh, deps=deps, status=status)
@@ -2944,6 +2965,17 @@ def _run_demo_frontend(
2944
2965
  )
2945
2966
  if launch.token_expires_at:
2946
2967
  print(f"Browser token expires: {launch.token_expires_at}")
2968
+ if launch.principal_actor_id:
2969
+ print(
2970
+ f"Browser token identity: bound to your API key's principal "
2971
+ f"(actor {launch.principal_actor_id}) — this session can re-attach "
2972
+ "across `urun dev --attach` runs for this app"
2973
+ )
2974
+ else:
2975
+ print(
2976
+ "Browser token identity: a fresh per-invocation identity (this API "
2977
+ "key has no resolvable principal)"
2978
+ )
2947
2979
  print(
2948
2980
  f"Token endpoint: {launch.token_url} (re-mints silently; a re-mint gates NEW "
2949
2981
  "session starts only — live sessions keep running)"
@@ -158,7 +158,8 @@ def build_client_token_request(
158
158
  allowed_functions: list[str],
159
159
  allowed_origins: list[str] | None,
160
160
  max_session_s: int | None,
161
- subject: str,
161
+ subject: str | None = None,
162
+ bind_principal: bool = False,
162
163
  dev: bool = False,
163
164
  metadata: dict[str, Any] | None = None,
164
165
  ) -> dict[str, Any]:
@@ -175,6 +176,15 @@ def build_client_token_request(
175
176
  the browser can never ask to be resolved. Omitted entirely when false,
176
177
  like every other optional field here: every non-dev mint
177
178
  send a body that does not mention dev mode at all.
179
+
180
+ ``bind_principal`` (ENG-297) asks the server to derive the subject
181
+ itself from the API key's creator (``client-user:<principalActorId>``)
182
+ instead of accepting one from the caller — mutually exclusive with
183
+ ``subject`` (the server 400s if both are sent; this mirrors that
184
+ locally, same convention as every other bound here). Omit both to keep
185
+ minting an unscoped ad-hoc subject the old way (nothing in this CLI
186
+ does that anymore, but the parameter combination stays legal for direct
187
+ callers/tests).
178
188
  """
179
189
  if not isinstance(expires_in, int) or isinstance(expires_in, bool):
180
190
  raise UrunError("client token expires_in must be an integer")
@@ -214,7 +224,14 @@ def build_client_token_request(
214
224
  not isinstance(max_session_s, int) or isinstance(max_session_s, bool) or max_session_s < 1
215
225
  ):
216
226
  raise UrunError("client token max_session_s must be a positive integer")
217
- if not subject or len(subject) > CLIENT_TOKEN_MAX_SUBJECT_LENGTH:
227
+ if not isinstance(bind_principal, bool):
228
+ raise UrunError("client token bind_principal must be a boolean")
229
+ if bind_principal and subject is not None:
230
+ raise UrunError(
231
+ "client token bind_principal and subject are mutually exclusive — "
232
+ "bind_principal derives the subject server-side"
233
+ )
234
+ if not bind_principal and (not subject or len(subject) > CLIENT_TOKEN_MAX_SUBJECT_LENGTH):
218
235
  raise UrunError(
219
236
  f"client token subject must be 1..{CLIENT_TOKEN_MAX_SUBJECT_LENGTH} characters"
220
237
  )
@@ -227,8 +244,11 @@ def build_client_token_request(
227
244
  body: dict[str, Any] = {
228
245
  "expires_in": expires_in,
229
246
  "allowed_functions": list(allowed_functions),
230
- "subject": subject,
231
247
  }
248
+ if subject is not None:
249
+ body["subject"] = subject
250
+ if bind_principal:
251
+ body["bind_principal"] = True
232
252
  if allowed_origins is not None:
233
253
  body["allowed_origins"] = list(allowed_origins)
234
254
  if max_session_s is not None:
@@ -245,6 +265,10 @@ class MintedToken:
245
265
  token: str
246
266
  expires_at: str
247
267
  org_id: str
268
+ # ENG-297: the claim-verified principal actor id, present only when the
269
+ # mint asked for bind_principal and it was resolved server-side. Absence
270
+ # is the normal unbound case, never an error.
271
+ principal_actor_id: str | None = None
248
272
 
249
273
 
250
274
  class ClientTokenMinter:
@@ -294,14 +318,55 @@ class ClientTokenMinter:
294
318
  "client-token response did not include a positive integer expires_in"
295
319
  )
296
320
  expires_at = payload.get("expires_at")
321
+ principal_actor_id = payload.get("principal_actor_id")
322
+ # A bind_principal request answered with a PRESENT but
323
+ # malformed principal_actor_id (empty string, wrong type) is a
324
+ # server contract violation, not "unbound" — silently
325
+ # coercing it to None would make the CLI print "no resolvable
326
+ # principal" for a session the server actually DID bind,
327
+ # masking a bug instead of failing loud like every other
328
+ # contract check above. An ABSENT key is different: it is the
329
+ # expected shape from a pre-ENG-297 server that doesn't know
330
+ # the field at all (ignores bind_principal, mints with its own
331
+ # random subject) — the accepted deploy-lag scenario, not a
332
+ # bug — so it degrades to the unbound case exactly as before.
333
+ if (
334
+ self._body.get("bind_principal") is True
335
+ and "principal_actor_id" in payload
336
+ and (not isinstance(principal_actor_id, str) or not principal_actor_id)
337
+ ):
338
+ raise UrunError(
339
+ "client-token response requested bind_principal but included a "
340
+ "malformed principal_actor_id"
341
+ )
297
342
  self._minted = MintedToken(
298
343
  token=token,
299
344
  expires_at=expires_at if isinstance(expires_at, str) else "",
300
345
  org_id=org_id,
346
+ principal_actor_id=(
347
+ principal_actor_id if isinstance(principal_actor_id, str) else None
348
+ ),
301
349
  )
302
350
  self._remint_at = now + max(expires_in - self._skew, 1)
303
351
  return self._minted
304
352
 
353
+ def replace_body(self, body: dict[str, Any]) -> None:
354
+ """Swap the mint body for THIS minter's remaining life — every future
355
+ (re-)mint uses it, not just the next one.
356
+
357
+ The ONLY caller is launch_auth.py's bind_principal fallback: the
358
+ first mint discovers this API key has no resolvable principal, so
359
+ every mint for the rest of this process must use the legacy nonce
360
+ subject instead — never retry bind_principal again for this launch.
361
+ Forces an immediate re-mint (clears the cached token) so the swap
362
+ takes effect on the very next :meth:`current`, not after some stale
363
+ remint-skew window.
364
+ """
365
+ with self._lock:
366
+ self._body = body
367
+ self._minted = None
368
+ self._remint_at = 0.0
369
+
305
370
 
306
371
  class _TokenRequestHandler(BaseHTTPRequestHandler):
307
372
  """Serve scoped tokens and browser diagnostics for a local demo.
@@ -1442,6 +1442,20 @@ def _maybe_launch_frontend(
1442
1442
  "origins: unrestricted (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
+ mux.line(
1446
+ "frontend | ",
1447
+ (
1448
+ f"browser token identity: bound to this API key's principal "
1449
+ f"(actor {launch.principal_actor_id}) — this session can re-attach "
1450
+ "across `urun dev` runs for this app"
1451
+ )
1452
+ if launch.principal_actor_id
1453
+ else (
1454
+ "browser token identity: a fresh per-invocation identity (this API "
1455
+ "key has no resolvable principal — sessions will not re-attach "
1456
+ "across runs; see the warning above)"
1457
+ ),
1458
+ )
1445
1459
  # Say the quiet part out loud: the scope above names the BASE app while the
1446
1460
  # dev loop deploys to `<app>--dev`, which reads like a mismatch to anyone
1447
1461
  # watching. It is the contract — the page asks for the base name and the
@@ -31,6 +31,55 @@ MAX_ERROR_BODY_CHARS = 500
31
31
  ENDPOINT_HINT = "the endpoint may not be deployed on this server yet"
32
32
 
33
33
 
34
+ # ── credential redaction ──────────────────────────────────────────────────────
35
+ #
36
+ # Server-supplied error text is echoed to terminals and CI logs (the exception
37
+ # string itself, and urun.retry.describe_error's retry line). A PROXIED upstream
38
+ # error can carry credential material verbatim -- a signed URL's query token, an
39
+ # echoed Authorization header, a JWT. Whitespace normalisation and truncation do
40
+ # not prevent it reaching a log, so redact before any of it is rendered.
41
+ #
42
+ # Patterns are TARGETED, never "anything long and hex": a build id is 64 hex
43
+ # chars and is exactly the value an operator needs to keep reading. Redacting it
44
+ # would trade a credential leak for an unusable diagnostic.
45
+ _REDACTIONS: tuple[tuple[re.Pattern[str], str], ...] = (
46
+ # urun API keys, both shapes (cli.API_KEY_RE).
47
+ (re.compile(r"\burun_(?:[0-9a-f]{32}|sk_[A-Za-z0-9_-]{43})\b"), "urun_[redacted]"),
48
+ # Authorization headers echoed back by a proxy.
49
+ (
50
+ re.compile(r"\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}", re.IGNORECASE),
51
+ r"\1 [redacted]",
52
+ ),
53
+ # JWTs (three base64url segments), wherever they appear.
54
+ (
55
+ re.compile(r"\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}"),
56
+ "[redacted-jwt]",
57
+ ),
58
+ # Signed-URL and query-string secrets: ?token=, &sig=, &X-Amz-Signature=, ...
59
+ (
60
+ re.compile(
61
+ r"([?&](?:[A-Za-z0-9_-]*(?:token|signature|sig|secret|password|api[_-]?key|"
62
+ r"access[_-]?key|credential)[A-Za-z0-9_-]*)=)[^&\s\"']+",
63
+ re.IGNORECASE,
64
+ ),
65
+ r"\1[redacted]",
66
+ ),
67
+ )
68
+
69
+
70
+ def redact_credentials(text: str) -> str:
71
+ """Strip credential material from server-supplied text before it is logged.
72
+
73
+ Applied at :class:`ApiError` construction so EVERY render of a server
74
+ message -- the exception string, ``describe_error``'s retry line, anything
75
+ downstream -- is safe by construction rather than by each caller
76
+ remembering.
77
+ """
78
+ for pattern, replacement in _REDACTIONS:
79
+ text = pattern.sub(replacement, text)
80
+ return text
81
+
82
+
34
83
  def sanitize_error_text(text: str, limit: int = MAX_ERROR_BODY_CHARS) -> str:
35
84
  """Make server-supplied text safe to print: no ANSI/control chars, bounded.
36
85
 
@@ -74,6 +123,11 @@ class ApiError(UrunError):
74
123
  method: str | None = None,
75
124
  url: str | None = None,
76
125
  ):
126
+ # Redact FIRST: `message` (and a signed `url`) are server-supplied and
127
+ # end up in this exception's string, in retry lines, and in CI logs.
128
+ message = redact_credentials(message)
129
+ if url:
130
+ url = redact_credentials(url)
77
131
  detail = f"{status} {code}: {message}"
78
132
  if method and url:
79
133
  detail += f" ({method} {url})"
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
 
19
19
  import re
20
20
  import secrets
21
+ import sys
21
22
  import urllib.parse
22
23
  from collections.abc import Callable, Sequence
23
24
  from dataclasses import dataclass, field
@@ -46,12 +47,16 @@ EVENTS_URL_ENV = "NEXT_PUBLIC_URUN_EVENTS_URL"
46
47
  # identity as a pure hash of (v3, orgId, token subject, app, function,
47
48
  # concurrency key) — services/session-api-ts/src/logical-id.ts:86 — and the
48
49
  # SDK sends no explicit id when the app names no sessionKey
49
- # (urun-ts packages/core/src/app.ts:162). The mint subject below is fixed for
50
- # the whole invocation, so the CLI's session and the page it launches derive
51
- # the SAME id and the platform's attach-or-create binds them together. There
52
- # is deliberately NO session-key env var: the launcher has nothing to publish.
53
- # Limitation, stated not hidden: an app passing its OWN sessionKey derives a
54
- # different id and lands on the platform's coalesce path, not a clean attach.
50
+ # (urun-ts packages/core/src/app.ts:162). The mint subject is fixed for the
51
+ # whole invocation — and, for a principal-bound mint (the default, ENG-297),
52
+ # fixed ACROSS invocations too — so the CLI's session and the page it
53
+ # launches derive the SAME id and the platform's attach-or-create binds them
54
+ # together; a principal-bound launch additionally reattaches a PRIOR run's
55
+ # still-live session for the same actor and app, not just the one page in
56
+ # this process. There is deliberately NO session-key env var: the launcher
57
+ # has nothing to publish. Limitation, stated not hidden: an app passing its
58
+ # OWN sessionKey derives a different id and lands on the platform's coalesce
59
+ # path, not a clean attach.
55
60
  # Next.js's built-in dev port, used when a frontend's dev script names none.
56
61
  DEFAULT_FRONTEND_PORT = 3000
57
62
  # The loopback host `urun dev` binds its token endpoint and pins the browser
@@ -62,6 +67,14 @@ DEFAULT_LAUNCH_HOST = "127.0.0.1"
62
67
  # and fails loud — another launch path must be added HERE deliberately.
63
68
  _LAUNCH_COMMANDS = frozenset({"dev"})
64
69
 
70
+ # ENG-297: the session API's structured error code for "bind_principal was
71
+ # requested but this API key's creator has no resolvable principal" (a
72
+ # legacy key minted before member API keys existed, or an admin-minted key
73
+ # with no creator) — urun-infra services/session-api-ts's
74
+ # handleClientTokenMint. A NAMED constant, not a literal, so the catch below
75
+ # and its test pin ONE string — never message matching.
76
+ PRINCIPAL_UNRESOLVABLE_CODE = "principal_not_available"
77
+
65
78
 
66
79
  def launch_auth_label(command: str) -> str:
67
80
  """Inert SDK-side auth-provider label (the session API ignores
@@ -160,7 +173,13 @@ def _display_session_url(url: str) -> str:
160
173
  return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, "", ""))
161
174
 
162
175
 
163
- def mint_launch_client_token(minter: Any, session_url: str, *, command: str | None = None) -> Any:
176
+ def mint_launch_client_token(
177
+ minter: Any,
178
+ session_url: str,
179
+ *,
180
+ command: str | None = None,
181
+ legacy_body_factory: Callable[[], dict[str, Any]] | None = None,
182
+ ) -> Any:
164
183
  """Mint the launch's first scoped client token, failing with a clear story.
165
184
 
166
185
  The mint is the first request the launch sends to the SESSION API, and
@@ -172,6 +191,20 @@ def mint_launch_client_token(minter: Any, session_url: str, *, command: str | No
172
191
  (Owner ruling 2026-08-03: remedy first, keep it short, and never name
173
192
  internal deployment topology — a customer reading this has a support
174
193
  issue, and a urun developer knows which host to point the flag at.)
194
+
195
+ When ``legacy_body_factory`` is given (only
196
+ :func:`prepare_frontend_launch_auth` passes one, when the primary mint
197
+ requested ``bind_principal``), a mint that fails with the server's
198
+ structured "no resolvable principal" 400
199
+ (:data:`PRINCIPAL_UNRESOLVABLE_CODE`) is retried EXACTLY ONCE against a
200
+ freshly-built legacy body (the old per-invocation nonce subject) — this
201
+ API key has no resolvable principal (a legacy or admin-minted key), so
202
+ the launch falls back rather than hard-failing a command that used to
203
+ work. The minter's body is swapped IN PLACE
204
+ (:meth:`ClientTokenMinter.replace_body`), so every LATER silent re-mint
205
+ (the TokenEndpoint's) also uses the legacy subject — bind_principal is
206
+ never retried again for this process. A failure on the retry itself
207
+ propagates raw, exactly like any other mint failure.
175
208
  """
176
209
  try:
177
210
  return minter.current()
@@ -186,6 +219,20 @@ def mint_launch_client_token(minter: Any, session_url: str, *, command: str | No
186
219
  f"The URL tried ({_display_session_url(session_url)}) does not "
187
220
  "serve POST /api/client-tokens."
188
221
  ) from exc
222
+ if (
223
+ legacy_body_factory is not None
224
+ and exc.status == 400
225
+ and exc.code == PRINCIPAL_UNRESOLVABLE_CODE
226
+ ):
227
+ print(
228
+ "warning: this API key has no resolvable principal — the browser "
229
+ "session falls back to a fresh per-invocation identity (it will not "
230
+ "re-attach to a prior `urun dev` run for this app). Re-create the key "
231
+ "as a member API key to fix this.",
232
+ file=sys.stderr,
233
+ )
234
+ minter.replace_body(legacy_body_factory())
235
+ return minter.current()
189
236
  raise
190
237
 
191
238
 
@@ -279,6 +326,10 @@ class FrontendLaunchAuth:
279
326
  allowed_functions: list[str]
280
327
  allowed_origins: list[str]
281
328
  max_session_s: int | None
329
+ # ENG-297: the claim-verified principal this launch's token is bound to,
330
+ # or None when this API key had no resolvable principal (the legacy
331
+ # fallback fired — see mint_launch_client_token).
332
+ principal_actor_id: str | None
282
333
  endpoint: TokenEndpoint
283
334
  # The launch's token minter — the SessionHolder authenticates its HTTP
284
335
  # calls with the same silently-re-minting token the page refreshes from.
@@ -349,20 +400,48 @@ def prepare_frontend_launch_auth(
349
400
  # Dev frontends may be opened through any loopback hostname or port. The
350
401
  # session API treats an absent claim as unrestricted; demo remains pinned.
351
402
  token_allowed_origins = None if dev else endpoint_allowed_origins
403
+
404
+ # The DEFAULT (bind_principal=True) asks the platform to derive the
405
+ # subject itself from the API key's creator (`client-user:<principalActorId>`)
406
+ # — stable across BOTH re-mints AND invocations, so the same actor
407
+ # launching the same app twice reattaches the SAME session (ENG-297)
408
+ # instead of minting a fresh identity every process run. `_legacy_nonce_body`
409
+ # below is the ONLY fallback: an API key with no resolvable principal
410
+ # (legacy or admin-minted) still gets a working launch, just back to the
411
+ # old per-invocation nonce — stable across re-mints for THIS launch,
412
+ # unique per invocation, so two such launches never coalesce their
413
+ # sessions.
352
414
  mint_body = build_client_token_request(
353
415
  expires_in=DEMO_TOKEN_EXPIRES_IN_S,
354
416
  allowed_functions=allowed_functions,
355
417
  allowed_origins=token_allowed_origins,
356
418
  max_session_s=max_session_s,
357
- # Stable across re-mints (same actor keeps gating new starts for THIS
358
- # launch), unique per invocation (two launches never coalesce their
359
- # sessions).
360
- subject=f"{command}:{app_slug}:{secrets.token_hex(4)}",
419
+ bind_principal=True,
361
420
  dev=dev,
362
421
  metadata={"source": "urun-cli", "command": command, "app": app_slug},
363
422
  )
423
+
424
+ def _legacy_nonce_body() -> dict[str, Any]:
425
+ # Rebuilt lazily — only if bind_principal actually fails — from the
426
+ # SAME scope/dev/metadata as the bound attempt; only the subject
427
+ # strategy differs. A fresh secrets.token_hex(4) each call: this is
428
+ # only ever invoked once per process (the fallback retries exactly
429
+ # once), so "fresh" here still means "one nonce for the whole
430
+ # invocation" exactly as before.
431
+ return build_client_token_request(
432
+ expires_in=DEMO_TOKEN_EXPIRES_IN_S,
433
+ allowed_functions=allowed_functions,
434
+ allowed_origins=token_allowed_origins,
435
+ max_session_s=max_session_s,
436
+ subject=f"{command}:{app_slug}:{secrets.token_hex(4)}",
437
+ dev=dev,
438
+ metadata={"source": "urun-cli", "command": command, "app": app_slug},
439
+ )
440
+
364
441
  minter = ClientTokenMinter(create_client_token, mint_body)
365
- minted = mint_launch_client_token(minter, session_url, command=command)
442
+ minted = mint_launch_client_token(
443
+ minter, session_url, command=command, legacy_body_factory=_legacy_nonce_body
444
+ )
366
445
  endpoint = TokenEndpoint(
367
446
  minter,
368
447
  endpoint_allowed_origins,
@@ -385,6 +464,7 @@ def prepare_frontend_launch_auth(
385
464
  allowed_functions=allowed_functions,
386
465
  allowed_origins=token_allowed_origins or [],
387
466
  max_session_s=max_session_s,
467
+ principal_actor_id=minted.principal_actor_id,
388
468
  endpoint=endpoint,
389
469
  minter=minter,
390
470
  )
@@ -157,9 +157,27 @@ def retry_call(
157
157
  sleep(wait)
158
158
 
159
159
 
160
+ #: Cap for the server message echoed by :func:`describe_error`. Long enough for
161
+ #: a real upstream diagnosis ("cube load failed: upstream status 503"), short
162
+ #: enough that a retry line stays one line.
163
+ _DESCRIBE_DETAIL_MAX = 160
164
+
165
+
160
166
  def describe_error(exc: BaseException) -> str:
161
167
  """Short, honest one-line description of a transient failure for logging."""
162
168
  if isinstance(exc, ApiError):
169
+ # The server's MESSAGE is the diagnosis; the code alone is only a label.
170
+ # Dropping it turned a 30-second read into an investigation: a `urun
171
+ # serve` on dev-usw2 2026-09-09 retried the build-log fetch 7 times
172
+ # showing `HTTP 502 logs_upstream_error` and gave up, while the very
173
+ # same response body said `cube load failed: upstream status 503` --
174
+ # i.e. the log backend had no healthy upstream at all. Bounded so a
175
+ # verbose server message cannot turn a retry line into a paragraph.
176
+ detail = " ".join((exc.message or "").split())
177
+ if detail:
178
+ if len(detail) > _DESCRIBE_DETAIL_MAX:
179
+ detail = detail[: _DESCRIBE_DETAIL_MAX - 1].rstrip() + "\u2026"
180
+ return f"HTTP {exc.status} {exc.code}: {detail}"
163
181
  return f"HTTP {exc.status} {exc.code}"
164
182
  if isinstance(exc, urllib.error.HTTPError):
165
183
  return f"HTTP {exc.code} {exc.reason}"
@@ -909,11 +909,15 @@ def _resolve_symlink(logical: Path, ws_root: Path, rel: str) -> Path:
909
909
  def _dir_ignored(rel: str, name: str, patterns: list[str]) -> bool:
910
910
  if rel in _BUILTIN_EXCLUDED_DIRS or name in _BUILTIN_EXCLUDED_DIRS:
911
911
  return True
912
- # gitignore-style directory patterns ("docs/") match the directory itself.
912
+ # gitignore-style directory patterns ("docs/") match the directory itself;
913
+ # a descendant pattern ("cache/**", "cache/*") ignores every entry under
914
+ # the directory, so the directory is skipped as a whole.
913
915
  for pat in patterns:
914
916
  base = pat.rstrip("/")
915
917
  if fnmatch.fnmatch(rel, base) or fnmatch.fnmatch(name, base):
916
918
  return True
919
+ if fnmatch.fnmatch(rel + "/", pat):
920
+ return True
917
921
  return False
918
922
 
919
923
 
@@ -938,6 +942,8 @@ def _collect_files(ws_root: Path, patterns: list[str]) -> dict[str, Path]:
938
942
  f"{rel}: '.urun' is generated exclusively by the builder; "
939
943
  "remove it from the project before deploying"
940
944
  )
945
+ if _dir_ignored(rel, d, patterns):
946
+ continue # an ignored tree never ships, symlinked or not
941
947
  if logical.is_symlink():
942
948
  target = _resolve_symlink(logical, ws_root, rel)
943
949
  raise UrunError(
@@ -945,8 +951,6 @@ def _collect_files(ws_root: Path, patterns: list[str]) -> dict[str, Path]:
945
951
  f"(only file symlinks inside the workspace ship; {rel} resolves "
946
952
  f"to {target})"
947
953
  )
948
- if _dir_ignored(rel, d, patterns):
949
- continue
950
954
  keep.append(d)
951
955
  dirnames[:] = keep
952
956
  for f in sorted(filenames):
@@ -120,6 +120,19 @@ def _add_serve_model_args(parser: argparse.ArgumentParser) -> None:
120
120
  "own this flag pins nothing and is refused)."
121
121
  ),
122
122
  )
123
+ parser.add_argument(
124
+ "--warm",
125
+ dest="warm",
126
+ type=int,
127
+ default=None,
128
+ metavar="N",
129
+ help=(
130
+ "Idle replica floor (app_functions.warm_replicas) for THIS deploy: "
131
+ "N idle replicas stay resident at zero live sessions. Omit = legacy "
132
+ "always-on (NULL). 0 = scale to zero when idle. Shared-endpoint "
133
+ "deploys pass the catalog row's warm (default 0)."
134
+ ),
135
+ )
123
136
  parser.add_argument(
124
137
  "--entrypoint",
125
138
  help=(
@@ -314,14 +327,30 @@ def _build_deploy_args(args: argparse.Namespace, row: CatalogRow) -> argparse.Na
314
327
  The shim does NOT bake the catalog row — it sets ``URUN_SERVE_MODEL`` (the
315
328
  ``<id:variant>`` string), ``URUN_SERVE_GPUS`` (the resolved gpu spec), and
316
329
  ``URUN_SERVE_ENGINE`` (the row's engine) in its own module body and re-exports
317
- ``urun.serve.app:app``. Those land scoped to THIS deploy artifact (NOT a shared
318
- ``URUN_SERVE_CONFIG`` materializer env, the retired coupling bug). The bundled
319
- app resolves engine ARGS by-string at boot; the engine NAME is pinned here only
320
- to select the build-time dependency group (vllm / llama-cpp-python / ...) so the
321
- deps venv carries the engine the adapter imports at load.
330
+ ``urun.serve.app:app``. When ``--warm`` is given, the idle replica floor rides
331
+ the same channel (``URUN_SERVE_WARM``, JSON int) so the bundled app's
332
+ ``@app.function(warm=...)`` declares it at import; an omitted ``--warm``
333
+ renders nothing (byte-identical shim, legacy always-on NULL). Those land
334
+ scoped to THIS deploy artifact (NOT a shared ``URUN_SERVE_CONFIG``
335
+ materializer env, the retired coupling bug). The bundled app resolves engine
336
+ ARGS by-string at boot; the engine NAME is pinned here only to select the
337
+ build-time dependency group (vllm / llama-cpp-python / ...) so the deps venv
338
+ carries the engine the adapter imports at load.
322
339
  """
323
340
  name = getattr(args, "name", None) or _default_app_name(row)
324
341
  exo_class, exo_image = _validated_exo_pin(args)
342
+ # --warm re-validation (argparse guarantees the int): a negative floor is a
343
+ # config contradiction the deployer must see here, never at app import in
344
+ # a build pod minutes later. Warm rides the shim env, so --entrypoint (the
345
+ # user's own app) + --warm cannot combine — validate against entrypoint too.
346
+ warm = getattr(args, "warm", None)
347
+ if warm is not None and warm < 0:
348
+ raise UrunError(f"--warm must be a non-negative integer, got {warm}")
349
+ if warm is not None and getattr(args, "entrypoint", None):
350
+ raise UrunError(
351
+ "--warm only applies to the bundled serve app (it is pinned in the "
352
+ "generated shim); it cannot be combined with --entrypoint."
353
+ )
325
354
 
326
355
  entrypoint = getattr(args, "entrypoint", None)
327
356
  if entrypoint:
@@ -334,7 +363,7 @@ def _build_deploy_args(args: argparse.Namespace, row: CatalogRow) -> argparse.Na
334
363
  shim_root = None
335
364
  else:
336
365
  project_root, rel_entrypoint = _write_serve_shim(
337
- row, exo_class=exo_class, exo_image=exo_image
366
+ row, exo_class=exo_class, exo_image=exo_image, warm=warm
338
367
  )
339
368
  # PLATFORM RELEASE SCOPE (catalog prebuilt instant serve). The shim is
340
369
  # deterministic given the resolved row + gpu, so its manifest hash is an
@@ -399,13 +428,14 @@ def _write_serve_shim(
399
428
  *,
400
429
  exo_class: str | None = None,
401
430
  exo_image: str | None = None,
431
+ warm: int | None = None,
402
432
  ) -> tuple[Path, str]:
403
433
  """Write the bundled-app shim ``app.py`` pinned to ``row`` into a temp root."""
404
434
  project_root = Path(tempfile.mkdtemp(prefix="urun-serve-"))
405
435
  try:
406
436
  app_path = project_root / "app.py"
407
437
  app_path.write_text(
408
- render_serve_shim_py(row, exo_class=exo_class, exo_image=exo_image),
438
+ render_serve_shim_py(row, exo_class=exo_class, exo_image=exo_image, warm=warm),
409
439
  encoding="utf-8",
410
440
  )
411
441
  # Function-engine map for the launcher's PRE-IMPORT topology decision
@@ -523,6 +553,7 @@ def render_serve_shim_py(
523
553
  *,
524
554
  exo_class: str | None = None,
525
555
  exo_image: str | None = None,
556
+ warm: int | None = None,
526
557
  ) -> str:
527
558
  """Render the tiny shim that deploys the BUNDLED urun-python serve app.
528
559
 
@@ -540,6 +571,10 @@ def render_serve_shim_py(
540
571
  ``deps=_deploy_deps()`` selects this engine's dependency group — the build
541
572
  installs vllm / llama-cpp-python / ... into the per-serve deps venv (the
542
573
  engine the adapter lazily imports at load).
574
+ * When ``warm`` is not None, ``URUN_SERVE_WARM`` is set before the import so
575
+ the bundled app's ``@app.function(warm=...)`` declares the idle replica
576
+ floor (app_functions.warm_replicas) at import. Unset renders NOTHING —
577
+ byte-identical shim, legacy always-on.
543
578
 
544
579
  Values are embedded as JSON (NOT interpolated Python) so a weird model string,
545
580
  gpu spec, or engine name can never break the module. The catalog row's
@@ -605,6 +640,7 @@ os.environ["URUN_SERVE_MODEL"] = %(model)s
605
640
  os.environ["URUN_SERVE_GPUS"] = %(gpus)s
606
641
  os.environ["URUN_SERVE_ENGINE"] = %(engine)s
607
642
  %(exo_env)s
643
+ %(warm_env)s
608
644
  %(serve_deps_env)s
609
645
 
610
646
  from urun.serve.app import app # noqa: E402 (bundled urun-python serve app)
@@ -626,6 +662,11 @@ if False: # AST-visible deps declaration; never runs.
626
662
  __all__ = ["app"]
627
663
  '''
628
664
  exo_env = render_exo_pin_env(exo_class, exo_image)
665
+ warm_env = (
666
+ 'os.environ["URUN_SERVE_WARM"] = ' + json.dumps(str(int(warm))) + "\n"
667
+ if warm is not None
668
+ else ""
669
+ )
629
670
  return template % {
630
671
  "entrypoint": BUNDLED_SERVE_ENTRYPOINT,
631
672
  "model": model,
@@ -634,6 +675,7 @@ __all__ = ["app"]
634
675
  "deps_literal": deps_literal,
635
676
  "serve_deps_env": serve_deps_env,
636
677
  "exo_env": exo_env,
678
+ "warm_env": warm_env,
637
679
  }
638
680
 
639
681
 
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