snowflake-sandbox-python 0.2.2a2__py3-none-any.whl → 0.2.2a4__py3-none-any.whl

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.
@@ -106,7 +106,12 @@ def build_egress(
106
106
 
107
107
  egress = Egress(
108
108
  external_access_integrations=integrations,
109
- **({"allow_default_egress": False} if no_default_egress else {}),
109
+ # Uses the current parameter name. The CLI FLAG stays `--no-default-egress`: it is
110
+ # released, renaming it would break users' scripts, and that is a separate decision
111
+ # from the library parameter's name. So the flag spelling and the parameter spelling
112
+ # differ here deliberately — the alternative is the CLI depending on a deprecated
113
+ # parameter, which is worse.
114
+ **({"allow_public_package_registries": False} if no_default_egress else {}),
110
115
  )
111
116
  # Validate now, not at create time. `compile_egress` is pure and is what the client
112
117
  # runs anyway -- but it runs *after* connecting, so a mistyped host pair otherwise
@@ -312,7 +312,13 @@ def _print_inferred_plan(plan: _RunPlan) -> None:
312
312
  typer.echo(f"command: {plan.command or '(image default)'}")
313
313
  if plan.egress is not None:
314
314
  eais = getattr(plan.egress, "external_access_integrations", ())
315
- closed = getattr(plan.egress, "allow_default_egress", None) is False
315
+ # Read the RESOLVER, not any one spelling. There are three accepted names for this
316
+ # control and reading a specific field goes silently blind when a caller (or this
317
+ # CLI) uses a different one — which is exactly what happened: switching the flag's
318
+ # construction to allow_public_package_registries made this summary stop reporting
319
+ # "default-egress=closed" while the sandbox was still correctly closed. A dry run
320
+ # that under-reports the egress posture is worse than no dry run.
321
+ closed = getattr(plan.egress, "default_egress_allowed", None) is False
316
322
  bits = [f"eai={','.join(eais)}" if eais else "", "default-egress=closed" if closed else ""]
317
323
  if plan.secrets:
318
324
  bits.append(f"secrets={len(plan.secrets)}")
@@ -127,19 +127,19 @@ def preflight_checks(spec: DeploySpec, bundle_root: Path | None = None) -> list[
127
127
  problems.append(Problem("error", str(exc)))
128
128
 
129
129
  # A secret's `host` and Snowflake reachability are different controls: the first is
130
- # enforced at the credential swap, the second by allow_internet / the group
131
- # booleans. A secret scoped to a host the sandbox cannot reach on Snowflake is
130
+ # enforced at the credential swap, the second by the egress level
131
+ # (allow_public_package_registries) / the group booleans. A secret scoped to a host the sandbox cannot reach on Snowflake is
132
132
  # silently useless, so say so. (eai_hosts is the SPCS perimeter and does not
133
133
  # affect Snowflake reachability, so it is deliberately not consulted here.)
134
134
  # Only warn when egress was *explicitly* closed AND no EAI was granted:
135
- # - allow_internet is tri-state: left unset the platform applies its permissive
135
+ # - the egress level is tri-state: left unset the platform applies its permissive
136
136
  # default, and actual reachability is then an Istio question we cannot answer
137
137
  # from here — warning on it would be noise on the common path.
138
138
  # - allow_github / allow_pypi are NOT credited here, and now never could be: both
139
139
  # are inert, so neither makes a host reachable in any mode. The retired
140
140
  # allowed_egress_hosts is likewise not consulted — hosts named there are ignored
141
141
  # platform-side.
142
- # - Under allow_internet=False the only grant left is an EAI, whose network rules
142
+ # - Under allow_public_package_registries=False the only grant left is an EAI, whose rules
143
143
  # live in Snowflake and cannot be resolved from here — so an EAI being present is
144
144
  # taken as "the caller has said how" (loop-invariant, hence hoisted out below).
145
145
  eg = spec.egress
@@ -153,13 +153,15 @@ def preflight_checks(spec: DeploySpec, bundle_root: Path | None = None) -> list[
153
153
  hint = (
154
154
  f"grant {h!r} with an External Access Integration and pass "
155
155
  f'external_access_integrations=("MY_EAI",) — under '
156
- f"allow_internet=False that is the only grant the platform applies"
156
+ f"allow_public_package_registries=False that is the only grant the "
157
+ f"platform applies"
157
158
  )
158
159
  problems.append(
159
160
  Problem(
160
161
  "warn",
161
162
  f"secret {s.fqn!r} is scoped to host {h!r}, which the sandbox cannot "
162
- f"reach with allow_internet=False — the credential could never be used",
163
+ f"reach with allow_public_package_registries=False — the credential "
164
+ f"could never be used",
163
165
  hint,
164
166
  )
165
167
  )
@@ -29,7 +29,7 @@ __all__ = ["__version__"]
29
29
  _DIST_NAME = "snowflake-sandbox-python"
30
30
 
31
31
  # Keep in sync with [project].version in pyproject.toml.
32
- _FALLBACK_VERSION = "0.2.2a2"
32
+ _FALLBACK_VERSION = "0.2.2a4"
33
33
 
34
34
  try:
35
35
  __version__: str = _pkg_version(_DIST_NAME)
@@ -15,6 +15,7 @@ from collections.abc import Mapping, Sequence
15
15
  from datetime import timedelta
16
16
  from typing import TYPE_CHECKING, Any, get_args
17
17
 
18
+ from snowflake.sandbox._runtime._protocol import _REPLY_SENTINEL, _RESULT_SENTINEL
18
19
  from snowflake.sandbox.exceptions import SandboxContractWarning, SandboxError
19
20
  from snowflake.sandbox.types import MemoryTier, SandboxStatus
20
21
 
@@ -23,6 +24,10 @@ if TYPE_CHECKING:
23
24
 
24
25
  __all__ = [
25
26
  "_parse_status",
27
+ "_is_internal_sentinel_line",
28
+ "_visible_log_lines",
29
+ "_logs_overfetch",
30
+ "_LOGS_OVERFETCH_CAP",
26
31
  "_warn_if_truncated",
27
32
  "_exec_session_id",
28
33
  "_resolve_exec_budget",
@@ -428,3 +433,58 @@ def _exec_reconnect_exhausted(attempt: int, *, delivered: bool) -> bool:
428
433
  never trips this.
429
434
  """
430
435
  return not delivered and attempt >= len(_EXEC_RECONNECT_DELAYS)
436
+
437
+
438
+ # --- internal protocol lines that must never reach a caller's logs() -------------
439
+ #
440
+ # The in-sandbox runner and session daemon report over STDOUT, so their markers land in
441
+ # the log stream alongside the workload's own output: `__SANDBOX_RESULT__<b64>` on
442
+ # managed-process exit and `__SANDBOX_REPLY__<...>` per session reply. They are transport,
443
+ # not output -- `logs()` suppressing them is what LogStream already does for the result
444
+ # marker ("suppress the internal marker from output").
445
+ #
446
+ # They CANNOT be stripped server-side: they are an SDK-level protocol the API knows
447
+ # nothing about, and `_protocol.py` calls their values a compatibility surface (a sandbox
448
+ # launched by an older SDK is still running the older wrapper text), so the wrapper cannot
449
+ # stop printing them either. Internal consumers therefore read the UNFILTERED text via
450
+ # `_logs_raw()`; only the public `logs()` filters.
451
+ _INTERNAL_SENTINELS = (_RESULT_SENTINEL, _REPLY_SENTINEL)
452
+
453
+ # How many extra lines to ask the server for when a caller wants the last `tail` VISIBLE
454
+ # lines. `tail` is applied SERVER-side, before filtering, so asking for exactly `tail`
455
+ # and then dropping a sentinel returns fewer lines than requested -- the bug that made
456
+ # logs(tail=2) answer ['line5', '__SANDBOX_RESULT__...'] and then, once filtered,
457
+ # ['line5'] alone. The fetch grows until enough visible lines come back, the server runs
458
+ # out of log, or the cap is hit; the cap keeps a session whose recent history is mostly
459
+ # reply markers from turning one logs() call into an unbounded fetch.
460
+ _LOGS_OVERFETCH_MARGIN = 8
461
+ _LOGS_OVERFETCH_CAP = 4096
462
+
463
+
464
+ def _is_internal_sentinel_line(text: str) -> bool:
465
+ """True when a log line carries an SDK-internal protocol marker."""
466
+ return any(sentinel in text for sentinel in _INTERNAL_SENTINELS)
467
+
468
+
469
+ def _visible_log_lines(raw_text: str) -> list[str]:
470
+ r"""The caller-visible lines of a raw log snapshot, internal markers removed.
471
+
472
+ An empty snapshot yields no lines rather than one empty line, so
473
+ ``"\n".join(...)`` round-trips "" instead of inventing a blank line.
474
+ """
475
+ if not raw_text:
476
+ return []
477
+ return [ln for ln in raw_text.split("\n") if not _is_internal_sentinel_line(ln)]
478
+
479
+
480
+ def _logs_overfetch(tail: int, previous: int = 0) -> int:
481
+ """Server-side `tail` to request in order to end up with `tail` VISIBLE lines.
482
+
483
+ `tail=0` means "everything", which needs no widening. Otherwise start a margin above
484
+ the request and double on each retry, capped -- see `_LOGS_OVERFETCH_CAP`.
485
+ """
486
+ if tail <= 0:
487
+ return 0
488
+ if previous <= 0:
489
+ return tail + _LOGS_OVERFETCH_MARGIN
490
+ return min(previous * 2, _LOGS_OVERFETCH_CAP)
@@ -57,6 +57,7 @@ from snowflake.sandbox._transport import (
57
57
  from snowflake.sandbox._wire import (
58
58
  _EXEC_RECONNECT_DELAYS,
59
59
  _EXEC_TIMEOUT_GRACE_S,
60
+ _LOGS_OVERFETCH_CAP,
60
61
  _POLL_UNKNOWN_EXIT_CODE,
61
62
  _PROBE_BUDGET_S,
62
63
  _PROBE_NOT_FOUND_RETRY_S,
@@ -66,8 +67,10 @@ from snowflake.sandbox._wire import (
66
67
  _exec_session_id,
67
68
  _fresh_exec_body,
68
69
  _idle_suspend_minutes,
70
+ _logs_overfetch,
69
71
  _parse_status,
70
72
  _resolve_exec_budget,
73
+ _visible_log_lines,
71
74
  _warn_if_truncated,
72
75
  )
73
76
  from snowflake.sandbox.egress import Egress, compile_egress
@@ -129,7 +132,7 @@ async def _coerce_image(image: str | Image, transport: Transport | None) -> str:
129
132
 
130
133
 
131
134
  class AsyncSandbox(_FilesMixin, _SandboxState):
132
- """A Snowflake sandbox container (async API).
135
+ """A Cortex Sandbox (async API).
133
136
 
134
137
  ``await create()`` is the factory, and the only creation path -- ``AsyncSandbox(...)``
135
138
  raises. A constructor cannot be awaited, which is the deeper reason it was never the
@@ -766,15 +769,69 @@ class AsyncSandbox(_FilesMixin, _SandboxState):
766
769
  return
767
770
  await self._wait_until_ready(timeout=timeout)
768
771
 
769
- async def logs(self, *, tail: int = 0, since_ts_ms: int = 0) -> str:
772
+ async def logs(
773
+ self, *, tail: int = 0, since_ts_ms: int = 0, source: str | None = None, pid: int = 0
774
+ ) -> str:
770
775
  """Return the container's captured stdout and stderr as a string.
771
776
 
772
777
  `tail` limits to the last N lines. Only meaningful for command containers
773
778
  (created with `command=`).
774
779
 
780
+ `source` selects which stream to read, defaulting to the server's `all` -- the
781
+ entrypoint plus the `command=` process:
782
+
783
+ - ``"exec"`` -- what the commands you ran with `exec()` printed, which no other
784
+ source includes. `exec()` also returns that output directly; this is how you
785
+ read it again afterwards.
786
+ - ``"base"`` / ``"managed"`` -- one of the two long-lived streams on its own.
787
+
788
+ `pid` narrows an exec read to one producer. Without it every exec is merged and
789
+ the returned text carries no per-line attribution; `pid=` is how you read one
790
+ command's output on its own.
791
+
792
+ SDK-internal protocol markers are suppressed: the in-sandbox runner reports a
793
+ run's outcome as `__SANDBOX_RESULT__<b64>` on stdout and the session daemon
794
+ reports replies as `__SANDBOX_REPLY__<...>`, so both land in the log stream
795
+ beside the workload's own output. They are transport, not output. `tail` counts
796
+ VISIBLE lines, so `logs(tail=2)` on a command printing `line1..line5` answers the two
797
+ lines `line4` and `line5`, never spending one of the two on a marker.
798
+
775
799
  Example:
776
800
  output = await sb.logs(tail=100)
777
801
  print(output)
802
+
803
+ # What the commands you ran with `exec()` printed.
804
+ print(await sb.logs(source="exec"))
805
+ """
806
+ fetch = _logs_overfetch(tail)
807
+ while True:
808
+ raw = await self._logs_raw(tail=fetch, since_ts_ms=since_ts_ms, source=source, pid=pid)
809
+ raw_count = len(raw.split("\n")) if raw else 0
810
+ visible = _visible_log_lines(raw)
811
+ if tail <= 0 or len(visible) >= tail:
812
+ break
813
+ # Short of `tail` visible lines. Only a WIDER fetch can help, and only while
814
+ # the server still has more to give: `raw_count < fetch` means we already hold
815
+ # the whole log, so filtering -- not the window -- is why there are fewer.
816
+ if raw_count < fetch or fetch >= _LOGS_OVERFETCH_CAP:
817
+ break
818
+ fetch = _logs_overfetch(tail, fetch)
819
+ # `tail > 0`, not `if tail`: a NEGATIVE tail is truthy and visible[-(-1):] is
820
+ # visible[1:], which would silently drop the FIRST lines instead of returning
821
+ # everything. `_logs_overfetch` already treats tail <= 0 as "no window".
822
+ if tail > 0:
823
+ visible = visible[-tail:]
824
+ return "\n".join(visible)
825
+
826
+ async def _logs_raw(
827
+ self, *, tail: int = 0, since_ts_ms: int = 0, source: str | None = None, pid: int = 0
828
+ ) -> str:
829
+ """The log snapshot EXACTLY as the server returns it, markers included.
830
+
831
+ Internal consumers that read the runner's or daemon's stdout protocol
832
+ (`wait()`, `Job.get`/`Job.status`, `send()`) must use this, not `logs()` --
833
+ `logs()` strips the very lines they parse. Not public: a caller wanting the
834
+ sandbox's output wants `logs()`.
778
835
  """
779
836
  if not self._created:
780
837
  await self._ensure_created()
@@ -787,6 +844,14 @@ class AsyncSandbox(_FilesMixin, _SandboxState):
787
844
  q.append(f"tail={tail}")
788
845
  if since_ts_ms:
789
846
  q.append(f"since={since_ts_ms}")
847
+ # Forwarded verbatim rather than validated here: the server owns the vocabulary
848
+ # and names the valid values in its rejection, so a client-side allowlist would go
849
+ # stale as soon as a source is added and would turn a 400 that explains itself
850
+ # into one that does not.
851
+ if source:
852
+ q.append(f"source={source}")
853
+ if pid:
854
+ q.append(f"pid={pid}")
790
855
  path = f"containers/{self._id}/logs" + ("?" + "&".join(q) if q else "")
791
856
  try:
792
857
  resp = await self._transport.request(
@@ -1852,7 +1917,7 @@ class AsyncSandbox(_FilesMixin, _SandboxState):
1852
1917
  # it (the connect() reconnect path) fall through to the server-reported
1853
1918
  # exit code rather than accepting a forgeable, unauthenticated line.
1854
1919
  if self._result_nonce is not None:
1855
- parsed = _parse_result_sentinel(await self.logs(), nonce=self._result_nonce)
1920
+ parsed = _parse_result_sentinel(await self._logs_raw(), nonce=self._result_nonce)
1856
1921
  if parsed is not None:
1857
1922
  parsed.logs_ref = self._id
1858
1923
  return parsed
@@ -1910,7 +1975,7 @@ class AsyncSandbox(_FilesMixin, _SandboxState):
1910
1975
  await self.exec(_mailbox_append_cmd(msg_id, message))
1911
1976
  deadline = _time.monotonic() + timeout
1912
1977
  while True:
1913
- reply = _parse_reply_sentinel(await self.logs(), msg_id, nonce=self._reply_nonce)
1978
+ reply = _parse_reply_sentinel(await self._logs_raw(), msg_id, nonce=self._reply_nonce)
1914
1979
  if reply is not None:
1915
1980
  return reply
1916
1981
  # A terminal container will never reply; surface that rather than spending
@@ -1918,7 +1983,9 @@ class AsyncSandbox(_FilesMixin, _SandboxState):
1918
1983
  # the cached status) is the only live read of the container's lifecycle.
1919
1984
  if await self.refresh() in TERMINAL_STATUSES:
1920
1985
  # One last read: the daemon may have replied just before exiting.
1921
- reply = _parse_reply_sentinel(await self.logs(), msg_id, nonce=self._reply_nonce)
1986
+ reply = _parse_reply_sentinel(
1987
+ await self._logs_raw(), msg_id, nonce=self._reply_nonce
1988
+ )
1922
1989
  if reply is not None:
1923
1990
  return reply
1924
1991
  raise SandboxError(_ended_before_reply(self.status, msg_id))
@@ -251,7 +251,7 @@ def _make_connection(**kwargs: Any) -> Any:
251
251
 
252
252
 
253
253
  def get_snowflake_connection(**kwargs: Any) -> Any:
254
- """Return a Snowflake connection for code running inside a Snowflake sandbox.
254
+ """Return a Snowflake connection for code running inside a Cortex Sandbox.
255
255
 
256
256
  Resolves the credential the way the platform provides it, in order: the
257
257
  ``SNOWFLAKE_PAT``/``SNOWFLAKE_TOKEN`` env value; a token file named by
@@ -48,7 +48,7 @@ def _retired_host_list_message(spelling: str) -> str:
48
48
  f"locally instead: the sandbox would start healthy and silently fail to reach "
49
49
  f"them.\n\n"
50
50
  f"Grant the hosts with an External Access Integration and name it instead:\n"
51
- f' Egress(allow_default_egress=False, external_access_integrations=("MY_EAI",))\n\n'
51
+ f' Egress(allow_public_package_registries=False, external_access_integrations=("MY_EAI",))\n\n'
52
52
  f"Creating one (the network rule cannot live in a personal USER$ database):\n"
53
53
  f" CREATE NETWORK RULE db.schema.my_rule MODE = EGRESS TYPE = HOST_PORT\n"
54
54
  f" VALUE_LIST = ('example.com:443');\n"
@@ -71,11 +71,30 @@ def _nonempty_host_value(raw: object) -> bool:
71
71
  return len(raw) > 0
72
72
 
73
73
 
74
- # The REST spelling of the default-egress tri-state. Named once: the SDK parameter was
75
- # renamed to allow_default_egress, the wire key was not, and two places translate between
76
- # them (Egress.to_wire and compile_egress's raw-dict path).
74
+ # The REST spelling this client SENDS for the egress tri-state. Named once because the
75
+ # client parameter and the wire key have diverged and two places translate between them
76
+ # (Egress.to_wire and compile_egress's raw-dict path).
77
+ #
78
+ # DELIBERATELY still "allow_internet", even though sandbox-api now has
79
+ # allow_public_package_registries (sandbox-api#349) and every tier accepts it (measured
80
+ # 2026-09-24). Do NOT "fix" this to the new key as a tidy-up — that couples a client-side
81
+ # naming improvement to a deployment risk:
82
+ #
83
+ # - sandbox-api keeps honouring allow_internet deliberately, for pinned SDKs exactly like
84
+ # this one (containers/egress.go: "the superseded one and still means exactly the same
85
+ # thing"; containers/models.go names "every pinned sandbox-sdk" as the reason).
86
+ # - A prod rollback to a pre-#349 build would 400 every create carrying the NEW key, while
87
+ # the old key keeps working against every build, reverted or not.
88
+ #
89
+ # Switching it is its own decision, gated on the prod rollback window closing — not on the
90
+ # field being accepted. Until then this is the one spelling that is safe everywhere.
77
91
  _DEFAULT_EGRESS_WIRE_KEY = "allow_internet"
78
92
 
93
+ # The current, honest client parameter name; it matches sandbox-api's field and the Snowsight
94
+ # dialog copy ("Snowflake and public package registries"). The two older spellings below stay
95
+ # accepted because they shipped.
96
+ _EGRESS_LEVEL_PARAM = "allow_public_package_registries"
97
+
79
98
 
80
99
  @dataclass(frozen=True)
81
100
  class Egress:
@@ -87,8 +106,8 @@ class Egress:
87
106
 
88
107
  | level | how | reaches |
89
108
  |---|---|---|
90
- | baseline | the default, or `allow_default_egress=True` | Snowflake, the cloud-storage stages, and ~43 package-manager hosts |
91
- | closed | `allow_default_egress=False` / `Egress.only()` | Snowflake and its stages only |
109
+ | baseline | the default, or `allow_public_package_registries=True` | Snowflake, the cloud-storage stages, and ~43 package-manager hosts |
110
+ | closed | `allow_public_package_registries=False` / `Egress.only()` | Snowflake and its stages only |
92
111
  | granted | `external_access_integrations=(...)` | the above, plus whatever the EAI's network rules resolve to |
93
112
 
94
113
  **The baseline is not the internet.** It is a curated host set the platform
@@ -96,17 +115,30 @@ class Egress:
96
115
  list requires an External Access Integration whichever level you pick. A
97
116
  caller-supplied host list used to be the third option and is now retired.
98
117
 
99
- `allow_internet` is the former name of `allow_default_egress` and still works, with a
100
- DeprecationWarning. It was misleading in both directions: it never granted the whole
101
- internet, and False does not stop egress because an EAI still grants.
118
+ **The parameter is `allow_public_package_registries`.** Two earlier spellings still work
119
+ and are deprecated — `allow_default_egress` and, before it, `allow_internet`. Setting more
120
+ than one to *different* values raises, since they are one control.
121
+
122
+ Neither older name survived contact with what the field does. `allow_internet` was wrong
123
+ in both directions: it never granted the internet (the baseline is a curated ~43-host set,
124
+ GET/HEAD only), and `False` does not stop egress because an EAI still grants.
125
+ `allow_default_egress` then named the value's *provenance* rather than what it grants, and
126
+ self-contradicts the moment the platform default changes — at which point
127
+ `default_egress=False` would *be* the default. The current name says what is granted.
128
+
129
+ Neither older name emits a DeprecationWarning yet. That is deliberate and not an
130
+ oversight: the warning would fire on construction, so adding it means migrating every
131
+ test and example that still uses an old name, for no behaviour change. It arrives with the
132
+ release that removes them.
102
133
 
103
134
  Example:
104
135
  egress = Egress() # baseline
105
136
  egress = Egress.only() # closed: nothing arbitrary
106
- egress = Egress(allow_default_egress=False, external_access_integrations=("MY_EAI",))
137
+ egress = Egress(allow_public_package_registries=False,
138
+ external_access_integrations=("MY_EAI",))
107
139
 
108
- `allow_github` and `allow_pypi` are both **deprecated and inert**. `allow_internet` is
109
- the only egress control. Public PyPI is already reachable via the package-managers
140
+ `allow_github` and `allow_pypi` are both **deprecated and inert**; the egress level is the
141
+ only control that does anything. Public PyPI is already reachable via the package-managers
110
142
  group, and the six GitHub/dbt hosts now need an External Access Integration like any
111
143
  other host outside that group. Both flags will be removed in a future release.
112
144
  """
@@ -118,8 +150,8 @@ class Egress:
118
150
  # baseline and disables the group flags below.
119
151
  allow_internet: bool | None = None
120
152
  # Deprecated and inert. It set EgressConfig.allow_egress_to_github_and_dbt, the six-host
121
- # GitHub/dbt group, which the platform is removing — leaving allow_internet as the only
122
- # egress control. Those hosts need an External Access Integration now. Setting it does
153
+ # GitHub/dbt group, which the platform is removing — leaving the egress level as the only
154
+ # control. Those hosts need an External Access Integration now. Setting it does
123
155
  # nothing and, unlike allow_pypi, does not even warn (see __new__); to_wire() no longer
124
156
  # sends it. Retained and still defaulting True, so existing callers keep constructing
125
157
  # without a TypeError and no value of it reads as meaningful; remove in a later major.
@@ -139,7 +171,25 @@ class Egress:
139
171
  # replaces. Appended last so positional Egress(None, True, False, (...)) callers are
140
172
  # unaffected. The WIRE key is still allow_internet — this is a client-side rename, so
141
173
  # no server change is needed and old and new SDKs interoperate.
174
+ #
175
+ # SUPERSEDED by allow_public_package_registries below. Kept working, not removed: it
176
+ # shipped in v0.2.1a1 / v0.2.2.dev1 / v0.2.2a2 / v0.2.2a3, so deleting it would break
177
+ # callers who adopted it.
142
178
  allow_default_egress: bool | None = None
179
+ # The current name, and the one to use. Matches sandbox-api's request field
180
+ # (allow_public_package_registries, sandbox-api#349) and the Snowsight dialog's wording,
181
+ # so the client, the wire contract and the UI finally agree on what this knob is called.
182
+ #
183
+ # Why the two names above are not it. "internet" was wrong in both directions: it never
184
+ # granted the internet (the baseline is ~43 curated hosts, GET/HEAD only) and False does
185
+ # not stop egress, because an EAI still grants. "default egress" then described the
186
+ # value's PROVENANCE rather than what it grants, and self-contradicts the moment the
187
+ # platform default changes — at which point "default_egress=False" would BE the default.
188
+ # This name says what is actually granted: access to public package registries.
189
+ #
190
+ # Appended last so positional Egress(None, True, False, (...), None) callers are
191
+ # unaffected. Tri-state exactly like the fields it supersedes.
192
+ allow_public_package_registries: bool | None = None
143
193
 
144
194
  def __post_init__(self) -> None:
145
195
  # A dataclass enforces no types, so Egress(allow_internet="false") kept the string and
@@ -148,6 +198,7 @@ class Egress:
148
198
  for name, nullable in (
149
199
  ("allow_internet", True),
150
200
  ("allow_default_egress", True),
201
+ ("allow_public_package_registries", True),
151
202
  ("allow_github", False),
152
203
  ("allow_pypi", False),
153
204
  ):
@@ -158,17 +209,21 @@ class Egress:
158
209
  raise SandboxError(
159
210
  f"Egress({name}=) must be True or False, got {type(value).__name__} {value!r}"
160
211
  )
161
- # Both spellings set and disagreeing is ambiguous, and picking one silently would
212
+ # Any two spellings set and disagreeing is ambiguous, and picking one silently would
162
213
  # apply an egress posture the caller did not ask for. Raise at the call instead.
163
- if (
164
- self.allow_internet is not None
165
- and self.allow_default_egress is not None
166
- and self.allow_internet != self.allow_default_egress
167
- ):
214
+ # Checked pairwise across all THREE names, not just the newest pair: a caller
215
+ # mid-migration can easily have the oldest and newest both set.
216
+ spellings = [
217
+ ("allow_public_package_registries", self.allow_public_package_registries),
218
+ ("allow_default_egress", self.allow_default_egress),
219
+ ("allow_internet", self.allow_internet),
220
+ ]
221
+ given = [(n, v) for n, v in spellings if v is not None]
222
+ if len({v for _, v in given}) > 1:
223
+ shown = ", ".join(f"{n}={v!r}" for n, v in given)
168
224
  raise SandboxError(
169
- "Egress(allow_internet=) and Egress(allow_default_egress=) disagree "
170
- f"({self.allow_internet!r} vs {self.allow_default_egress!r}). They are the "
171
- "same control — pass only allow_default_egress."
225
+ f"Egress spellings of the egress level disagree ({shown}). They are all the "
226
+ f"same control — pass only {_EGRESS_LEVEL_PARAM}."
172
227
  )
173
228
  # NOT warned on deliberately. The suite runs filterwarnings=error, so emitting a
174
229
  # DeprecationWarning here fails all 21 tests that construct Egress(allow_internet=)
@@ -194,7 +249,8 @@ class Egress:
194
249
  def only() -> Egress:
195
250
  """Close egress — ``Egress.only()``, with no arguments.
196
251
 
197
- Sets ``allow_default_egress=False``, which withholds the platform baseline as well: no
252
+ Sets ``allow_public_package_registries=False``, which withholds the platform baseline as
253
+ well: no
198
254
  arbitrary internet host is reachable, not even the package-manager set the default
199
255
  mode grants. Snowflake and its stages stay reachable regardless — see the danger
200
256
  note below, which is why this is not described as total confinement.
@@ -203,7 +259,7 @@ class Egress:
203
259
  caller-supplied host list; to confine and then grant a specific host, pair
204
260
  confinement with an External Access Integration:
205
261
 
206
- Egress(allow_default_egress=False, external_access_integrations=("MY_EAI",))
262
+ Egress(allow_public_package_registries=False, external_access_integrations=("MY_EAI",))
207
263
 
208
264
  !!! danger "Not an exfiltration boundary for Snowflake destinations"
209
265
  ``only()`` does **not** confine the sandbox to *just* a chosen set of hosts.
@@ -220,9 +276,9 @@ class Egress:
220
276
  restriction. See *Reachability is not credential scope* above and the
221
277
  ``Secret`` docs.
222
278
  """
223
- # New spelling deliberately: only() must not emit a DeprecationWarning at a
224
- # caller who never touched the old name.
225
- return Egress(allow_default_egress=False)
279
+ # Current spelling deliberately: only() must not attribute a deprecated name to a
280
+ # caller who never typed one.
281
+ return Egress(allow_public_package_registries=False)
226
282
 
227
283
  @property
228
284
  def default_egress_allowed(self) -> bool | None:
@@ -232,6 +288,8 @@ class Egress:
232
288
  look "unset" to logic that checks the old one, which is the bug a plain alias
233
289
  would introduce.
234
290
  """
291
+ if self.allow_public_package_registries is not None:
292
+ return self.allow_public_package_registries
235
293
  if self.allow_default_egress is not None:
236
294
  return self.allow_default_egress
237
295
  return self.allow_internet
@@ -246,7 +304,11 @@ class Egress:
246
304
  if effective is not None:
247
305
  # Sent even when False: the platform reads an absent key as its permissive
248
306
  # default, so omitting it would silently discard a request to close egress.
249
- # The key stays allow_internet — the rename is client-side only.
307
+ #
308
+ # EXACTLY ONE spelling goes on the wire. sandbox-api refuses a request carrying
309
+ # two spellings that disagree (containers/egress.go), so a client that sent both
310
+ # could produce a refusal that reads as a server bug. See
311
+ # _DEFAULT_EGRESS_WIRE_KEY for why that one spelling is still allow_internet.
250
312
  body[_DEFAULT_EGRESS_WIRE_KEY] = effective
251
313
  # Neither group flag is sent: the platform is removing the GitHub/dbt flag and already
252
314
  # reserved the one allow_pypi fed, so either would describe a grant that never happens.
@@ -302,8 +364,10 @@ def compile_egress(
302
364
  the two now coincide: the only meaningful field is the default-egress tri-state, whose
303
365
  default is the platform's own, so a default `Egress` serialises to nothing. A dict is
304
366
  taken as authored, since that path is the raw-wire escape hatch — except that
305
- ``allow_default_egress`` is translated to the wire key, because a raw-dict caller who
306
- used the name the docs now teach would otherwise have it silently ignored.
367
+ ``allow_public_package_registries`` and the older ``allow_default_egress`` are both
368
+ translated to the single wire key this client sends, because a raw-dict caller who used
369
+ the name the docs teach would otherwise have it silently ignored — and because sandbox-api
370
+ refuses a request that carries two disagreeing spellings of the same control.
307
371
  """
308
372
  if isinstance(egress, Egress):
309
373
  body = egress.to_wire()
@@ -312,18 +376,28 @@ def compile_egress(
312
376
  else:
313
377
  body = dict(egress)
314
378
  # A raw dict is otherwise passed through verbatim, which would make
315
- # {"allow_default_egress": False} a silent no-op: the server sees no
316
- # allow_internet key and applies its PERMISSIVE default, so a caller asking to
317
- # close egress would get the baseline instead. Translate to the wire key rather
318
- # than refuse, matching what the typed path accepts.
319
- if "allow_default_egress" in body:
320
- renamed = body.pop("allow_default_egress")
379
+ # {"allow_public_package_registries": False} or {"allow_default_egress": False} a
380
+ # silent no-op ON A CLIENT PATH THAT SENDS THE OLD KEY: the server would see no
381
+ # allow_internet key and apply its PERMISSIVE default, so a caller asking to close
382
+ # egress would get the baseline instead. Translate to the one wire key this client
383
+ # sends, rather than refuse, matching what the typed path accepts.
384
+ #
385
+ # EXACTLY ONE spelling may survive. sandbox-api refuses a request carrying two
386
+ # spellings that disagree, so collapsing them here turns a confusing server refusal
387
+ # into a local error naming the caller's own keys. Note allow_public_package_registries
388
+ # is BOTH a client name and a real wire key the server accepts — it is still collapsed
389
+ # onto _DEFAULT_EGRESS_WIRE_KEY so that one client cannot emit two spellings at once.
390
+ _level_aliases = ("allow_public_package_registries", "allow_default_egress")
391
+ for _alias in _level_aliases:
392
+ if _alias not in body:
393
+ continue
394
+ renamed = body.pop(_alias)
321
395
  existing = body.get(_DEFAULT_EGRESS_WIRE_KEY)
322
396
  if existing is not None and renamed is not None and existing != renamed:
323
397
  raise SandboxError(
324
- f"egress dict sets both {_DEFAULT_EGRESS_WIRE_KEY!r} and "
325
- f"'allow_default_egress' to different values ({existing!r} vs "
326
- f"{renamed!r}). They are the same control — pass one."
398
+ f"egress dict sets both {_DEFAULT_EGRESS_WIRE_KEY!r} and {_alias!r} to "
399
+ f"different values ({existing!r} vs {renamed!r}). They are the same "
400
+ f"control — pass one, and {_EGRESS_LEVEL_PARAM!r} is the current name."
327
401
  )
328
402
  if renamed is not None:
329
403
  body[_DEFAULT_EGRESS_WIRE_KEY] = renamed
snowflake/sandbox/jobs.py CHANGED
@@ -211,7 +211,7 @@ class Job(_JobHandle):
211
211
  terminal state and server-reported exit code, never a forgeable line."""
212
212
  sb = await get_sandbox(self.id, connection=self._connection)
213
213
  if self.nonce is not None:
214
- parsed = _parse_result_sentinel(await sb.logs(), nonce=self.nonce)
214
+ parsed = _parse_result_sentinel(await sb._logs_raw(), nonce=self.nonce)
215
215
  if parsed is not None:
216
216
  # A trusted sentinel reports succeeded/failed; status() is a live
217
217
  # lifecycle verdict where timed_out is not a member, so fold it in.
@@ -226,7 +226,8 @@ class Job(_JobHandle):
226
226
  async def get(self, timeout: float | None = None, poll_s: float = 3.0) -> RunResult:
227
227
  """Poll for the Job's result.
228
228
 
229
- Fetches ``get_sandbox(id).logs()`` and scans for the LAST sentinel;
229
+ Fetches ``get_sandbox(id)._logs_raw()`` -- the UNFILTERED channel, because the
230
+ public ``logs()`` strips the very sentinel this scans for -- and takes the LAST one;
230
231
  returns the decoded `RunResult` as soon as one appears. If the
231
232
  container reaches a terminal state (``dead`` or ``failed``) with no sentinel, returns
232
233
  ``RunResult(status="failed", ...)``. On *timeout* (when set) before
@@ -239,7 +240,7 @@ class Job(_JobHandle):
239
240
  # Only trust the stdout sentinel when we hold the run's nonce;
240
241
  # a from_id() handle has none, so fall through to the server state.
241
242
  if self.nonce is not None:
242
- parsed = _parse_result_sentinel(await sb.logs(), nonce=self.nonce)
243
+ parsed = _parse_result_sentinel(await sb._logs_raw(), nonce=self.nonce)
243
244
  if parsed is not None:
244
245
  parsed.logs_ref = self.id
245
246
  return parsed
@@ -288,7 +289,7 @@ class SyncJob(_JobHandle):
288
289
  container's own terminal state."""
289
290
  sb = get_sandbox_sync(self.id, connection=self._connection)
290
291
  if self.nonce is not None:
291
- parsed = _parse_result_sentinel(sb.logs(), nonce=self.nonce)
292
+ parsed = _parse_result_sentinel(sb._logs_raw(), nonce=self.nonce)
292
293
  if parsed is not None:
293
294
  # See Job.status: fold the non-live timed_out into failed.
294
295
  return "failed" if parsed.status == "timed_out" else parsed.status
@@ -308,7 +309,7 @@ class SyncJob(_JobHandle):
308
309
  while True:
309
310
  sb = get_sandbox_sync(self.id, connection=self._connection)
310
311
  if self.nonce is not None:
311
- parsed = _parse_result_sentinel(sb.logs(), nonce=self.nonce)
312
+ parsed = _parse_result_sentinel(sb._logs_raw(), nonce=self.nonce)
312
313
  if parsed is not None:
313
314
  parsed.logs_ref = self.id
314
315
  return parsed
@@ -24,6 +24,7 @@ from collections.abc import AsyncIterator
24
24
  from typing import TYPE_CHECKING, Any, Self
25
25
 
26
26
  from snowflake.sandbox._runtime._protocol import _RESULT_SENTINEL
27
+ from snowflake.sandbox._wire import _is_internal_sentinel_line
27
28
  from snowflake.sandbox.exceptions import SandboxConflictError, SandboxError
28
29
  from snowflake.sandbox.types import TERMINAL_STATUSES, StreamName
29
30
 
@@ -105,6 +106,13 @@ class LogStream(AsyncIterator[str]):
105
106
  if _RESULT_SENTINEL in text:
106
107
  self._process_done = True
107
108
  continue
109
+ # Any OTHER internal marker (the session daemon's __SANDBOX_REPLY__) is
110
+ # suppressed too, but is NOT end-of-stream -- a session keeps running after a
111
+ # reply. Checked after the result marker so that one still sets the flag.
112
+ # Without this the streamed view leaked a marker the snapshot logs() hides:
113
+ # same sandbox, two different answers about what its output was.
114
+ if _is_internal_sentinel_line(text):
115
+ continue
108
116
  ts = int(ln.get("ts") or 0)
109
117
  if ts:
110
118
  # Compare against the batch-start watermark, not one mutated
@@ -55,6 +55,7 @@ from snowflake.sandbox._sync_transport import (
55
55
  from snowflake.sandbox._wire import (
56
56
  _EXEC_RECONNECT_DELAYS,
57
57
  _EXEC_TIMEOUT_GRACE_S,
58
+ _LOGS_OVERFETCH_CAP,
58
59
  _POLL_UNKNOWN_EXIT_CODE,
59
60
  _PROBE_BUDGET_S,
60
61
  _PROBE_NOT_FOUND_RETRY_S,
@@ -64,8 +65,10 @@ from snowflake.sandbox._wire import (
64
65
  _exec_session_id,
65
66
  _fresh_exec_body,
66
67
  _idle_suspend_minutes,
68
+ _logs_overfetch,
67
69
  _parse_status,
68
70
  _resolve_exec_budget,
71
+ _visible_log_lines,
69
72
  _warn_if_truncated,
70
73
  )
71
74
  from snowflake.sandbox.egress import Egress, compile_egress
@@ -146,7 +149,7 @@ def _raise_if_stream_terminated(abort: StreamAbort) -> None:
146
149
 
147
150
 
148
151
  class Sandbox(_FilesMixin, _SandboxState):
149
- """A Snowflake sandbox container (sync API).
152
+ """A Cortex Sandbox (sync API).
150
153
 
151
154
  `create()` is the factory, and the only creation path -- ``Sandbox(...)`` raises.
152
155
  Reconnect to an existing sandbox with ``Sandbox.connect(name)`` / `get_sandbox` /
@@ -765,15 +768,69 @@ class Sandbox(_FilesMixin, _SandboxState):
765
768
  return
766
769
  self._wait_until_ready(timeout=timeout)
767
770
 
768
- def logs(self, *, tail: int = 0, since_ts_ms: int = 0) -> str:
771
+ def logs(
772
+ self, *, tail: int = 0, since_ts_ms: int = 0, source: str | None = None, pid: int = 0
773
+ ) -> str:
769
774
  """Return the container's captured stdout and stderr as a string.
770
775
 
771
776
  `tail` limits to the last N lines. Only meaningful for command containers
772
777
  (created with `command=`).
773
778
 
779
+ `source` selects which stream to read, defaulting to the server's `all` -- the
780
+ entrypoint plus the `command=` process:
781
+
782
+ - ``"exec"`` -- what the commands you ran with `exec()` printed, which no other
783
+ source includes. `exec()` also returns that output directly; this is how you
784
+ read it again afterwards.
785
+ - ``"base"`` / ``"managed"`` -- one of the two long-lived streams on its own.
786
+
787
+ `pid` narrows an exec read to one producer. Without it every exec is merged and
788
+ the returned text carries no per-line attribution; `pid=` is how you read one
789
+ command's output on its own.
790
+
791
+ SDK-internal protocol markers are suppressed: the in-sandbox runner reports a
792
+ run's outcome as `__SANDBOX_RESULT__<b64>` on stdout and the session daemon
793
+ reports replies as `__SANDBOX_REPLY__<...>`, so both land in the log stream
794
+ beside the workload's own output. They are transport, not output. `tail` counts
795
+ VISIBLE lines, so `logs(tail=2)` on a command printing `line1..line5` answers the two
796
+ lines `line4` and `line5`, never spending one of the two on a marker.
797
+
774
798
  Example:
775
799
  output = sb.logs(tail=100)
776
800
  print(output)
801
+
802
+ # What the commands you ran with `exec()` printed.
803
+ print(sb.logs(source="exec"))
804
+ """
805
+ fetch = _logs_overfetch(tail)
806
+ while True:
807
+ raw = self._logs_raw(tail=fetch, since_ts_ms=since_ts_ms, source=source, pid=pid)
808
+ raw_count = len(raw.split("\n")) if raw else 0
809
+ visible = _visible_log_lines(raw)
810
+ if tail <= 0 or len(visible) >= tail:
811
+ break
812
+ # Short of `tail` visible lines. Only a WIDER fetch can help, and only while
813
+ # the server still has more to give: `raw_count < fetch` means we already hold
814
+ # the whole log, so filtering -- not the window -- is why there are fewer.
815
+ if raw_count < fetch or fetch >= _LOGS_OVERFETCH_CAP:
816
+ break
817
+ fetch = _logs_overfetch(tail, fetch)
818
+ # `tail > 0`, not `if tail`: a NEGATIVE tail is truthy and visible[-(-1):] is
819
+ # visible[1:], which would silently drop the FIRST lines instead of returning
820
+ # everything. `_logs_overfetch` already treats tail <= 0 as "no window".
821
+ if tail > 0:
822
+ visible = visible[-tail:]
823
+ return "\n".join(visible)
824
+
825
+ def _logs_raw(
826
+ self, *, tail: int = 0, since_ts_ms: int = 0, source: str | None = None, pid: int = 0
827
+ ) -> str:
828
+ """The log snapshot EXACTLY as the server returns it, markers included.
829
+
830
+ Internal consumers that read the runner's or daemon's stdout protocol
831
+ (`wait()`, `Job.get`/`Job.status`, `send()`) must use this, not `logs()` --
832
+ `logs()` strips the very lines they parse. Not public: a caller wanting the
833
+ sandbox's output wants `logs()`.
777
834
  """
778
835
  if not self._created:
779
836
  self._ensure_created()
@@ -786,6 +843,14 @@ class Sandbox(_FilesMixin, _SandboxState):
786
843
  q.append(f"tail={tail}")
787
844
  if since_ts_ms:
788
845
  q.append(f"since={since_ts_ms}")
846
+ # Forwarded verbatim rather than validated here: the server owns the vocabulary
847
+ # and names the valid values in its rejection, so a client-side allowlist would go
848
+ # stale as soon as a source is added and would turn a 400 that explains itself
849
+ # into one that does not.
850
+ if source:
851
+ q.append(f"source={source}")
852
+ if pid:
853
+ q.append(f"pid={pid}")
789
854
  path = f"containers/{self._id}/logs" + ("?" + "&".join(q) if q else "")
790
855
  try:
791
856
  resp = self._transport.request("GET", path, retry_500_delays=DEFAULT_LOGS_500_DELAYS_S)
@@ -1801,7 +1866,7 @@ class Sandbox(_FilesMixin, _SandboxState):
1801
1866
  # it (the connect() reconnect path) fall through to the server-reported
1802
1867
  # exit code rather than accepting a forgeable, unauthenticated line.
1803
1868
  if self._result_nonce is not None:
1804
- parsed = _parse_result_sentinel(self.logs(), nonce=self._result_nonce)
1869
+ parsed = _parse_result_sentinel(self._logs_raw(), nonce=self._result_nonce)
1805
1870
  if parsed is not None:
1806
1871
  parsed.logs_ref = self._id
1807
1872
  return parsed
@@ -1858,7 +1923,7 @@ class Sandbox(_FilesMixin, _SandboxState):
1858
1923
  self.exec(_mailbox_append_cmd(msg_id, message))
1859
1924
  deadline = _time.monotonic() + timeout
1860
1925
  while True:
1861
- reply = _parse_reply_sentinel(self.logs(), msg_id, nonce=self._reply_nonce)
1926
+ reply = _parse_reply_sentinel(self._logs_raw(), msg_id, nonce=self._reply_nonce)
1862
1927
  if reply is not None:
1863
1928
  return reply
1864
1929
  # A terminal container will never reply; surface that rather than spending
@@ -1866,7 +1931,7 @@ class Sandbox(_FilesMixin, _SandboxState):
1866
1931
  # the cached status) is the only live read of the container's lifecycle.
1867
1932
  if self.refresh() in TERMINAL_STATUSES:
1868
1933
  # One last read: the daemon may have replied just before exiting.
1869
- reply = _parse_reply_sentinel(self.logs(), msg_id, nonce=self._reply_nonce)
1934
+ reply = _parse_reply_sentinel(self._logs_raw(), msg_id, nonce=self._reply_nonce)
1870
1935
  if reply is not None:
1871
1936
  return reply
1872
1937
  raise SandboxError(_ended_before_reply(self.status, msg_id))
@@ -23,6 +23,7 @@ from collections.abc import Iterator
23
23
  from typing import TYPE_CHECKING, Self
24
24
 
25
25
  from snowflake.sandbox._runtime._protocol import _RESULT_SENTINEL
26
+ from snowflake.sandbox._wire import _is_internal_sentinel_line
26
27
  from snowflake.sandbox.log_stream import (
27
28
  _DRAIN_AFTER_TERMINAL_S,
28
29
  _POLL_INTERVAL_S,
@@ -90,6 +91,13 @@ class SyncLogStream(Iterator[str]):
90
91
  if _RESULT_SENTINEL in text:
91
92
  self._process_done = True
92
93
  continue
94
+ # Any OTHER internal marker (the session daemon's __SANDBOX_REPLY__) is
95
+ # suppressed too, but is NOT end-of-stream -- a session keeps running after a
96
+ # reply. Checked after the result marker so that one still sets the flag.
97
+ # Without this the streamed view leaked a marker the snapshot logs() hides:
98
+ # same sandbox, two different answers about what its output was.
99
+ if _is_internal_sentinel_line(text):
100
+ continue
93
101
  ts = int(ln.get("ts") or 0)
94
102
  if ts:
95
103
  if ts < start_ts:
@@ -28,7 +28,8 @@ redeployed, never adopted — see `agent_session`.
28
28
 
29
29
  * `Session.send()` mints a ``msg_id`` (uuid hex), execs a shell-quoted
30
30
  ``printf … >> /var/tmp/sandbox_mailbox.jsonl`` to append the message, then polls
31
- ``logs()`` until the matching ``__SANDBOX_REPLY__<msg_id>__<b64>`` line
31
+ ``_logs_raw()`` -- the UNFILTERED channel, since the public ``logs()`` suppresses
32
+ internal markers -- until the matching ``__SANDBOX_REPLY__<msg_id>__<b64>`` line
32
33
  appears, base64-decodes it, and returns the reply string. A timeout raises
33
34
  `SandboxExecTimeoutError`.
34
35
 
@@ -304,7 +305,8 @@ class Session:
304
305
 
305
306
  Mints a ``msg_id``, execs a shell-quoted append into
306
307
  ``/var/tmp/sandbox_mailbox.jsonl`` inside the live container, then polls
307
- ``logs()`` until the daemon prints ``__SANDBOX_REPLY__<msg_id>__<b64>``.
308
+ ``_logs_raw()`` (the unfiltered channel; public ``logs()`` hides the marker)
309
+ until the daemon prints ``__SANDBOX_REPLY__<msg_id>__<b64>``.
308
310
  Base64-decodes the payload and returns the reply string.
309
311
 
310
312
  Raises `SandboxError` if the container reaches a terminal state without
@@ -316,7 +318,7 @@ class Session:
316
318
 
317
319
  deadline = time.monotonic() + timeout
318
320
  while True:
319
- log_text = await sb.logs()
321
+ log_text = await sb._logs_raw()
320
322
  reply = _parse_reply_sentinel(log_text, msg_id, nonce=self.nonce)
321
323
  if reply is not None:
322
324
  return reply
@@ -327,7 +329,7 @@ class Session:
327
329
  if await sb.refresh() in TERMINAL_STATUSES:
328
330
  # One last read first -- the daemon may have replied just before
329
331
  # exiting, and that reply is the answer, not an error.
330
- reply = _parse_reply_sentinel(await sb.logs(), msg_id, nonce=self.nonce)
332
+ reply = _parse_reply_sentinel(await sb._logs_raw(), msg_id, nonce=self.nonce)
331
333
  if reply is not None:
332
334
  return reply
333
335
  raise SandboxError(_ended_before_reply(sb.status, msg_id))
@@ -342,7 +344,8 @@ class Session:
342
344
  Same mailbox round-trip as `send`, blocking instead of awaiting: mints a
343
345
  ``msg_id``, execs the shell-quoted append into
344
346
  ``/var/tmp/sandbox_mailbox.jsonl`` inside the live container (via the
345
- synchronous `Sandbox`), then polls ``logs()`` until the daemon prints
347
+ synchronous `Sandbox`), then polls ``_logs_raw()`` -- the unfiltered channel,
348
+ since public ``logs()`` hides the marker -- until the daemon prints
346
349
  ``__SANDBOX_REPLY__<msg_id>__<b64>``, base64-decodes it, and returns the
347
350
  reply string. The request built and the reply parsed are byte-identical
348
351
  to `send`'s — both go through the shared ``_mailbox_append_cmd`` /
@@ -365,14 +368,14 @@ class Session:
365
368
 
366
369
  deadline = time.monotonic() + timeout
367
370
  while True:
368
- log_text = sb.logs()
371
+ log_text = sb._logs_raw()
369
372
  reply = _parse_reply_sentinel(log_text, msg_id, nonce=self.nonce)
370
373
  if reply is not None:
371
374
  return reply
372
375
  # As in `send`: a terminal container is not a timeout, and refresh() is
373
376
  # the only live read of its status.
374
377
  if sb.refresh() in TERMINAL_STATUSES:
375
- reply = _parse_reply_sentinel(sb.logs(), msg_id, nonce=self.nonce)
378
+ reply = _parse_reply_sentinel(sb._logs_raw(), msg_id, nonce=self.nonce)
376
379
  if reply is not None:
377
380
  return reply
378
381
  raise SandboxError(_ended_before_reply(sb.status, msg_id))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowflake-sandbox-python
3
- Version: 0.2.2a2
3
+ Version: 0.2.2a4
4
4
  Summary: Snowflake sandboxes — Python SDK and CLI.
5
5
  Author: Snowflake, Inc.
6
6
  License:
@@ -1,9 +1,9 @@
1
1
  snowflake/cli_sandbox/__init__.py,sha256=9a_pevabd3fTnoLRIrjdu9Ff3M_E5N_H4qW6i_oq_70,591
2
2
  snowflake/cli_sandbox/_adapter.py,sha256=nQHcfTVI5hxjuOhM2CtMASmndou0a2SVa5ti3-OuY7Y,8480
3
3
  snowflake/cli_sandbox/_common.py,sha256=2SINRgB820KpQ4aIBUeVZy-9npqYcfyBPb83WTqK850,3159
4
- snowflake/cli_sandbox/_egress_flags.py,sha256=GupRm-BABGz9RId_SJoPFKCYRcv-3AWD9gvC6puynOg,5321
4
+ snowflake/cli_sandbox/_egress_flags.py,sha256=4iOMPxwYcxnhIrDUl2yRybklqSOiuWnHfLUn5PKuFFE,5737
5
5
  snowflake/cli_sandbox/_get_command.py,sha256=ICMP_hfufKUTRiG2UKJU9Okk8nOyjGabbPUahl6o2cE,3397
6
- snowflake/cli_sandbox/_run_command.py,sha256=hevLhwVSy4lnmB1htZ-YlSFu09jjGvQ6TmRkYfd4K-M,44469
6
+ snowflake/cli_sandbox/_run_command.py,sha256=BZpGFUKBAdPQOAyPX2bd9tc1php0rzD4XJ4yIxR-JU4,45000
7
7
  snowflake/cli_sandbox/_shell_command.py,sha256=VuLxAzcD3GuUIA6CRy95xfJe-hIJICqkPSK2wenoN5Y,26094
8
8
  snowflake/cli_sandbox/_upload_plan.py,sha256=JFZuRLQ0vgX82ugXr9UhAT1T-eIoYgFwGPOCyECVyts,7316
9
9
  snowflake/cli_sandbox/commands.py,sha256=IjJ0DRiwI4ljNtKlhavxlZoY66EP0Dd8vRJCEulwaeU,21720
@@ -17,7 +17,7 @@ snowflake/sandbox/_assemble.py,sha256=x52FpfRKJ_8j_hhS8qPDb2pZcmxbg1sQrxs31Upjmw
17
17
  snowflake/sandbox/_bundle.py,sha256=UclyMJ_NNOnR0cGM5G3ayTaJ3VDMy6aFh89Y_-fR6Kg,10019
18
18
  snowflake/sandbox/_connection_resolve.py,sha256=yIed7EtamXw13EpVvc6yvyo3km_dRigH2vARkreCW0M,16216
19
19
  snowflake/sandbox/_deploy_spec.py,sha256=YoKLL_9gf9NM-X0OEfW4VRU8oG2jy34Pd29-JGJnoFI,2225
20
- snowflake/sandbox/_diagnostics.py,sha256=58Gt5jv6I9PRRrDWHQrsWQupPUgaS0d-2A8a7EGwXQ8,20816
20
+ snowflake/sandbox/_diagnostics.py,sha256=weA4vxiITQTHmMDZSk1zPHbdFH3otr-zs79wqOSgIvI,20949
21
21
  snowflake/sandbox/_env.py,sha256=7Afvpm8Ppg4LTQbMv99HJKn8QWOrMD5sMhBtSJwolME,6033
22
22
  snowflake/sandbox/_files_mixin.py,sha256=WZUlw40bNLtTLIsRi2iHJylTghegxz9NSDWFur3hATs,12986
23
23
  snowflake/sandbox/_fs_ops.py,sha256=_yOGwYNPkM9uq5-jDz8zuvwIlYkTPEgiDPhrfpDvSFk,11498
@@ -37,45 +37,45 @@ snowflake/sandbox/_sync_watch.py,sha256=_-62oyqH6o57O3FaYgfiRy1iyezhh_khYaHwJslS
37
37
  snowflake/sandbox/_transport.py,sha256=pqly1AjpTJTSbCQZfV2Oqi2K7OfYG1dGhQfSfXplkB0,63466
38
38
  snowflake/sandbox/_transport_errors.py,sha256=8-87F_4XT84kzHlPlrE83jgZVyRaRXgw12fGrQO1mzU,11776
39
39
  snowflake/sandbox/_upload_plan.py,sha256=4MTaLdLr0_QUuEObxJ4XmuH-lXoL7ixO0Y1wU5XkfPU,19758
40
- snowflake/sandbox/_version.py,sha256=WxQNdz-rkwfRcANlWa9MGMsiZF22teg0k23Q5pGVpZs,1519
40
+ snowflake/sandbox/_version.py,sha256=XHrdqmMCueQVU5dzw6xaZyIVnK_hpxfD7QMEwhpUC0Q,1519
41
41
  snowflake/sandbox/_watch.py,sha256=mWYhEP7z4ETE_eXU149EBtHB1UVaRmU3RTja3S8cgCM,6919
42
- snowflake/sandbox/_wire.py,sha256=aOUadr-6r11uMbmkjMISOZpmJ5XTei7lFeMEXDRy64I,19594
42
+ snowflake/sandbox/_wire.py,sha256=eBJp3F1hHj7sojphAVR0H4HfJIYZ6aAZNjenLbdl2Bc,22467
43
43
  snowflake/sandbox/app.py,sha256=czuPnGme5P2BMMoEpgNto96iC0KSSOF5aSsNkpTh3r4,9404
44
- snowflake/sandbox/client.py,sha256=EXk7pqrGQHITbqjN_KWKnUmHEDgn2uaCRO6HsqxAlWg,121063
44
+ snowflake/sandbox/client.py,sha256=E5D6ybrPwJQhoQIx-dhg6zqZXE8PC4rY7tv1tnUz4k8,124511
45
45
  snowflake/sandbox/config.py,sha256=1iSC5mAwgo_dkBu7hhzM1-X9N2pXZFv46QjgOV8v37M,55491
46
- snowflake/sandbox/connect.py,sha256=gozwmkbLjdVda8CrmVmk8jXHVYb26U3N4O04mkVXbLc,13830
46
+ snowflake/sandbox/connect.py,sha256=vE9tI3-MSS_1gJ1-mA37ntGsOvIqECvcgDrnkdZlMf0,13827
47
47
  snowflake/sandbox/deploy.py,sha256=4tBUFgC0BccMniT8XLOtFoPgGHrhQ6pPHYtXtNgtPe4,21574
48
- snowflake/sandbox/egress.py,sha256=JQACDQAC1i5UkzwZdoECcYT2eenW6Ew6a6BcqFKbSJo,21527
48
+ snowflake/sandbox/egress.py,sha256=h-KlZAop8rtmO9AlGfSl6aDVw4ACX74za-dAGwsiuZc,26603
49
49
  snowflake/sandbox/exceptions.py,sha256=XDYENzKqouJHRawMNG9_2ueC9wK9y4Yu72ClQp8RdkU,12319
50
50
  snowflake/sandbox/exec_stream.py,sha256=KaU0X5UuhwBRj2ZYOVpSDojbjicOGS_VlOuRnEEf4fo,10902
51
51
  snowflake/sandbox/failure.py,sha256=V6izCcpwptn46OQ2DogWsiFr8RLF5pfzsapL8UkKAeY,2632
52
52
  snowflake/sandbox/files.py,sha256=yX1pdu6iDvVkRUJwWjf_F1jJaKZmoXCsLb4LfxZQR9A,22283
53
53
  snowflake/sandbox/function.py,sha256=p11PKf96-Rav2ujj0FNX_sy6bTML-hKVxqmqGI3_qFY,23885
54
54
  snowflake/sandbox/image.py,sha256=5x1rc0Nf8rUOR5tKcdSUoPtSy31cr0zctpaeBvVMU9A,1476
55
- snowflake/sandbox/jobs.py,sha256=0l89gpjk4yzPtCV_mjcKR2kZJQ1CBvpJ2SDww6zlcdE,28777
55
+ snowflake/sandbox/jobs.py,sha256=mIJCL9mUlePLPfTaa5f6L8_EsGb2PW_N7fOmQ3aKkAI,28901
56
56
  snowflake/sandbox/lifecycle.py,sha256=1swNVWPBDGNGLVYWNa28eWuvQBONsLq6Wqky3d4Hzno,3287
57
- snowflake/sandbox/log_stream.py,sha256=jswT4Hh9aVnJN4CCQXdi6KIxCj-CI9YYnaG1Rq5sN8c,10030
57
+ snowflake/sandbox/log_stream.py,sha256=5R6HIpRjttBUbtyoQ54jFeHCW0H0HQcECjCKSy5LQtU,10592
58
58
  snowflake/sandbox/mcp.py,sha256=khrfbn3VzsU7d7kAhouyChj3ql1i7rBQMYGRXKUoTGI,20661
59
59
  snowflake/sandbox/mount.py,sha256=I5jGvh07hgl7xabSN3VkphsB8aJcJCzjiQ4AdjeeA6k,6521
60
60
  snowflake/sandbox/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
61
61
  snowflake/sandbox/secret.py,sha256=LQjbhOLPkNDGewFgjuWr4Z9AE0gUtf6YdA_0VzD7ID4,9869
62
62
  snowflake/sandbox/session_app.py,sha256=awg3HyOPQMTMvoTVUk0Gp6ngsv7rIEJvPwpN_8H6VyI,9862
63
63
  snowflake/sandbox/shell.py,sha256=iaouTkm_gS7so8H5xjm15APBBwyI-JU6nxBX0O34nnU,24965
64
- snowflake/sandbox/sync_client.py,sha256=qYu_7ALXA68U-EBfh8_3KGwGKtUaJguQ7tckULUKSv8,114739
64
+ snowflake/sandbox/sync_client.py,sha256=tInHCaYD0qWJWknLCs_TdZSXtlsgNzUQsTX7Bb4blyk,118131
65
65
  snowflake/sandbox/sync_exec_stream.py,sha256=V3G149X2nOGjwiKVRDTmQ5PR20saD-mYYsqCbaKVGJI,9199
66
66
  snowflake/sandbox/sync_files.py,sha256=zuAe81PLDl4mIOyxJfygJ9F9OFmw-r4Pdime9QqsPHc,12849
67
- snowflake/sandbox/sync_log_stream.py,sha256=9gsHIBntHzPFf_aN9WN8w62vm_ocjpI4EYdgKW9nJZw,5719
67
+ snowflake/sandbox/sync_log_stream.py,sha256=PxsTDA0yevB19ckyUclnG7XNjso8IwqNO-xukaBAMas,6281
68
68
  snowflake/sandbox/sync_shell.py,sha256=tQVhahzraXMnckKT_j42-5CKy2rKDKrwCma3YEiUfkM,18287
69
69
  snowflake/sandbox/types.py,sha256=foDUzp8K21HaCdjPOyTsnDYbBEgwTCWn5Kmtnwu2oqg,7775
70
- snowflake/sandbox/warm_session.py,sha256=kgmskWutApKbuWNq72_fslufkCUYPMN9oWg1qY7QBCM,60161
70
+ snowflake/sandbox/warm_session.py,sha256=EZRBafnBC0dO9emJHF6HmtjE4CDW4mCua4MaXvfcyJs,60432
71
71
  snowflake/sandbox/_runtime/__init__.py,sha256=hvkrEARQOaMGvmx-jrjawcdDYTz8_0_mQqpyCUZG1F0,2346
72
72
  snowflake/sandbox/_runtime/_fs_helper.py,sha256=JQ-ODrESW-5vVCPUfike5Ghuvz0HgpSCgdcSyG2H0Yc,3655
73
73
  snowflake/sandbox/_runtime/_job_runner.py,sha256=ZdWaUGnD01bVFS87nOaciHhSSyj9nV7hPpT1Mfbmx2M,4335
74
74
  snowflake/sandbox/_runtime/_protocol.py,sha256=vpmweaMO3cFe3oS8r8sXzZ7qB3UO3FUi3x9sn0CoCl8,3786
75
75
  snowflake/sandbox/_runtime/_shims.py,sha256=fhirq9SQwRBeujYLobMaseUrElSYhGjEncDuX-X8pxE,14357
76
- snowflake_sandbox_python-0.2.2a2.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
77
- snowflake_sandbox_python-0.2.2a2.dist-info/METADATA,sha256=hY84JbddevWvT9VQ3_wKIg_tSdlIz4PcNjip5pGd5_s,19495
78
- snowflake_sandbox_python-0.2.2a2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
79
- snowflake_sandbox_python-0.2.2a2.dist-info/entry_points.txt,sha256=0U-_vAHe7ZSTIi191p653ObERfBI6Ik9QTvSc1cJkDc,80
80
- snowflake_sandbox_python-0.2.2a2.dist-info/top_level.txt,sha256=TY0gFSHKDdZy3THb0FGomyikWQasEGldIR1O0HGOHVw,10
81
- snowflake_sandbox_python-0.2.2a2.dist-info/RECORD,,
76
+ snowflake_sandbox_python-0.2.2a4.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
77
+ snowflake_sandbox_python-0.2.2a4.dist-info/METADATA,sha256=6-ATo4-1tAKpIRZYzeCF_pBH-GWK1Ab2ABue7UtiTCA,19495
78
+ snowflake_sandbox_python-0.2.2a4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
79
+ snowflake_sandbox_python-0.2.2a4.dist-info/entry_points.txt,sha256=0U-_vAHe7ZSTIi191p653ObERfBI6Ik9QTvSc1cJkDc,80
80
+ snowflake_sandbox_python-0.2.2a4.dist-info/top_level.txt,sha256=TY0gFSHKDdZy3THb0FGomyikWQasEGldIR1O0HGOHVw,10
81
+ snowflake_sandbox_python-0.2.2a4.dist-info/RECORD,,