snowflake-sandbox-python 0.2.1a1__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.
Files changed (80) hide show
  1. snowflake/cli_sandbox/__init__.py +13 -0
  2. snowflake/cli_sandbox/_adapter.py +170 -0
  3. snowflake/cli_sandbox/_common.py +77 -0
  4. snowflake/cli_sandbox/_egress_flags.py +121 -0
  5. snowflake/cli_sandbox/_get_command.py +109 -0
  6. snowflake/cli_sandbox/_run_command.py +1091 -0
  7. snowflake/cli_sandbox/_shell_command.py +666 -0
  8. snowflake/cli_sandbox/_upload_plan.py +187 -0
  9. snowflake/cli_sandbox/commands.py +556 -0
  10. snowflake/cli_sandbox/plugin_spec.py +28 -0
  11. snowflake/cli_sandbox/py.typed +0 -0
  12. snowflake/sandbox/__init__.py +317 -0
  13. snowflake/sandbox/__main__.py +225 -0
  14. snowflake/sandbox/_ansi.py +206 -0
  15. snowflake/sandbox/_args.py +208 -0
  16. snowflake/sandbox/_assemble.py +256 -0
  17. snowflake/sandbox/_bundle.py +240 -0
  18. snowflake/sandbox/_connection_resolve.py +328 -0
  19. snowflake/sandbox/_deploy_spec.py +56 -0
  20. snowflake/sandbox/_diagnostics.py +501 -0
  21. snowflake/sandbox/_env.py +143 -0
  22. snowflake/sandbox/_files_mixin.py +280 -0
  23. snowflake/sandbox/_fs_ops.py +304 -0
  24. snowflake/sandbox/_globs.py +176 -0
  25. snowflake/sandbox/_hosts.py +110 -0
  26. snowflake/sandbox/_mcp_discovery.py +288 -0
  27. snowflake/sandbox/_mcp_status.py +183 -0
  28. snowflake/sandbox/_retry.py +94 -0
  29. snowflake/sandbox/_runtime/__init__.py +42 -0
  30. snowflake/sandbox/_runtime/_fs_helper.py +93 -0
  31. snowflake/sandbox/_runtime/_job_runner.py +111 -0
  32. snowflake/sandbox/_runtime/_protocol.py +53 -0
  33. snowflake/sandbox/_runtime/_shims.py +267 -0
  34. snowflake/sandbox/_sandbox_state.py +303 -0
  35. snowflake/sandbox/_session_registry.py +222 -0
  36. snowflake/sandbox/_sse.py +160 -0
  37. snowflake/sandbox/_stage.py +270 -0
  38. snowflake/sandbox/_sync_files_mixin.py +272 -0
  39. snowflake/sandbox/_sync_fs_ops.py +185 -0
  40. snowflake/sandbox/_sync_transport.py +737 -0
  41. snowflake/sandbox/_sync_watch.py +99 -0
  42. snowflake/sandbox/_transport.py +1366 -0
  43. snowflake/sandbox/_transport_errors.py +270 -0
  44. snowflake/sandbox/_upload_plan.py +497 -0
  45. snowflake/sandbox/_version.py +37 -0
  46. snowflake/sandbox/_watch.py +164 -0
  47. snowflake/sandbox/_wire.py +348 -0
  48. snowflake/sandbox/app.py +256 -0
  49. snowflake/sandbox/client.py +2356 -0
  50. snowflake/sandbox/config.py +1133 -0
  51. snowflake/sandbox/connect.py +288 -0
  52. snowflake/sandbox/deploy.py +499 -0
  53. snowflake/sandbox/egress.py +388 -0
  54. snowflake/sandbox/exceptions.py +253 -0
  55. snowflake/sandbox/exec_stream.py +264 -0
  56. snowflake/sandbox/files.py +547 -0
  57. snowflake/sandbox/function.py +567 -0
  58. snowflake/sandbox/image.py +46 -0
  59. snowflake/sandbox/jobs.py +649 -0
  60. snowflake/sandbox/lifecycle.py +67 -0
  61. snowflake/sandbox/log_stream.py +219 -0
  62. snowflake/sandbox/mcp.py +480 -0
  63. snowflake/sandbox/mount.py +161 -0
  64. snowflake/sandbox/py.typed +0 -0
  65. snowflake/sandbox/secret.py +244 -0
  66. snowflake/sandbox/session_app.py +244 -0
  67. snowflake/sandbox/shell.py +556 -0
  68. snowflake/sandbox/sync_client.py +2245 -0
  69. snowflake/sandbox/sync_exec_stream.py +238 -0
  70. snowflake/sandbox/sync_files.py +377 -0
  71. snowflake/sandbox/sync_log_stream.py +142 -0
  72. snowflake/sandbox/sync_shell.py +413 -0
  73. snowflake/sandbox/types.py +193 -0
  74. snowflake/sandbox/warm_session.py +700 -0
  75. snowflake_sandbox_python-0.2.1a1.dist-info/METADATA +339 -0
  76. snowflake_sandbox_python-0.2.1a1.dist-info/RECORD +80 -0
  77. snowflake_sandbox_python-0.2.1a1.dist-info/WHEEL +5 -0
  78. snowflake_sandbox_python-0.2.1a1.dist-info/entry_points.txt +2 -0
  79. snowflake_sandbox_python-0.2.1a1.dist-info/licenses/LICENSE +202 -0
  80. snowflake_sandbox_python-0.2.1a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,700 @@
1
+ """Warm session engine — the internal machinery behind ``Sandbox.send``.
2
+
3
+ Deploy a project's daemon **detached** (a long-running loop, never
4
+ run-to-completion), get a `Session` handle back, and drive it with
5
+ ``send()`` — a round-trip that injects a message into the live container and
6
+ reads the daemon's reply. It is the stateful sibling of the detached run
7
+ (``jobs.py``): a detached run executes to completion and exits (stateless); a
8
+ session stays alive across many turns with context warm in process memory.
9
+
10
+ The daemon stays warm and pays for idle. ``session.id == container_id``;
11
+ ``key`` → container is tracked in a module-level dict in-process.
12
+
13
+ **The mailbox protocol:**
14
+
15
+ * The daemon (inside the container) runs `session_loop()`, which tails
16
+ ``/tmp/sandbox_mailbox.jsonl``. Each new line is
17
+ ``{"id": "<msg_id>", "msg": "<text>"}``. For each, the loop calls the user's
18
+ ``handle(msg, state)`` and prints to **stdout**:
19
+
20
+ __SANDBOX_REPLY__<msg_id>__<base64(json(reply))>
21
+
22
+ * `Session.send()` mints a ``msg_id`` (uuid hex), execs a shell-quoted
23
+ ``printf … >> /tmp/sandbox_mailbox.jsonl`` to append the message, then polls
24
+ ``logs()`` until the matching ``__SANDBOX_REPLY__<msg_id>__<b64>`` line
25
+ appears, base64-decodes it, and returns the reply string. A timeout raises
26
+ `SandboxExecTimeoutError`.
27
+
28
+ ``state`` is a plain dict that the daemon keeps in process memory across calls —
29
+ this is THE warm context mechanism: it stays warm across turns.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import base64
36
+ import binascii
37
+ import json
38
+ import shlex
39
+ import time
40
+ import uuid
41
+ from collections.abc import Callable
42
+ from contextlib import AbstractContextManager, nullcontext
43
+ from pathlib import Path
44
+ from typing import TYPE_CHECKING, Any
45
+
46
+ from snowflake.sandbox._deploy_spec import DeploySpec
47
+ from snowflake.sandbox._runtime._protocol import _MAILBOX_PATH, _REPLY_SENTINEL
48
+ from snowflake.sandbox.client import get_sandbox
49
+ from snowflake.sandbox.exceptions import SandboxError, SandboxExecTimeoutError
50
+ from snowflake.sandbox.types import TERMINAL_STATUSES
51
+
52
+ if TYPE_CHECKING:
53
+ from snowflake.sandbox.config import ConnectionLike
54
+
55
+ __all__ = ["Session", "agent_session", "agent_session_sync", "session_loop"]
56
+
57
+ # Env var carrying the per-session reply nonce into the daemon. The daemon loop
58
+ # reads it once at startup and unsets it before running any user handler, so the
59
+ # untrusted ``on_message`` code cannot read it and forge an authenticated reply.
60
+ #
61
+ # Defined here rather than in ``_runtime._protocol`` with its siblings: no
62
+ # generated in-container program reads this name from Python (the session shim
63
+ # bakes the literal into its source), while ``client``/``sync`` import it from
64
+ # this module to set the env var on create — and under mypy's
65
+ # no-implicit-reexport a pass-through alias would not satisfy them.
66
+ _SESSION_NONCE_ENV = "SANDBOX_SESSION_NONCE"
67
+
68
+ # In-process key -> container_id tracking. A backend-side registry would let
69
+ # create-or-resume survive process restarts; today this is per-process.
70
+ _SESSIONS: dict[str, str] = {}
71
+ # Parallel key -> reply-nonce map, so a same-process resume re-attaches with
72
+ # the nonce that authenticates the daemon's replies. Lost on process restart, like
73
+ # _SESSIONS itself; a rehydrated handle then falls back to unauthenticated matching.
74
+ _SESSION_NONCES: dict[str, str] = {}
75
+
76
+
77
+ def _parse_reply_sentinel(log_text: str, msg_id: str, *, nonce: str | None = None) -> str | None:
78
+ """Scan *log_text* for the LAST reply sentinel matching *msg_id* and return the
79
+ decoded reply string. Pure + unit-testable.
80
+
81
+ The reply line is ``__SANDBOX_REPLY__<msg_id>__<nonce>__<b64>`` — the daemon
82
+ reads the per-session ``nonce`` from its environment once at startup and
83
+ unsets it before running any handler, so the (untrusted) ``on_message`` code
84
+ cannot learn it and forge a reply. When *nonce* is given, only a sentinel
85
+ carrying that exact nonce is accepted. The older
86
+ ``__SANDBOX_REPLY__<msg_id>__<b64>`` form (a base image shipping a pre-nonce
87
+ SDK) is still decoded when the caller holds no nonce, but is rejected when a
88
+ nonce is required — its reply cannot be authenticated. ``msg_id`` and the
89
+ nonce are uuid hex and standard base64 has no ``_``, so ``__`` splits the
90
+ fields unambiguously.
91
+
92
+ Returns ``None`` if no matching well-formed sentinel is present (a malformed
93
+ base64/json payload is skipped, not raised, so a partial log line never masks
94
+ an earlier good one)."""
95
+ prefix = _REPLY_SENTINEL
96
+ found: str | None = None
97
+ for line in log_text.splitlines():
98
+ idx = line.find(prefix)
99
+ if idx == -1:
100
+ continue
101
+ rest = line[idx + len(prefix) :].strip()
102
+ parts = rest.split("__")
103
+ if len(parts) == 3:
104
+ r_msg_id, r_nonce, b64 = parts
105
+ elif len(parts) == 2:
106
+ r_msg_id, r_nonce, b64 = parts[0], "", parts[1]
107
+ else:
108
+ continue
109
+ if r_msg_id != msg_id or not b64:
110
+ continue
111
+ if nonce is not None and r_nonce != nonce:
112
+ # A reply that cannot prove it came from the trusted daemon loop.
113
+ continue
114
+ try:
115
+ decoded = base64.b64decode(b64, validate=True)
116
+ reply = json.loads(decoded)
117
+ except (binascii.Error, ValueError, UnicodeDecodeError):
118
+ continue
119
+ if not isinstance(reply, str):
120
+ continue
121
+ found = reply
122
+ return found
123
+
124
+
125
+ def _mailbox_append_cmd(msg_id: str, message: str) -> list[str]:
126
+ """Build the shell argv that appends one mailbox line for *msg_id*/*message*.
127
+
128
+ The line is ``{"id": "<msg_id>", "msg": "<text>"}`` (compact JSON). The JSON
129
+ is shell-quoted with `shlex.quote()` and emitted with ``printf '%s\\n'``
130
+ so embedded quotes/newlines/backslashes survive the round-trip into the
131
+ container's mailbox file."""
132
+ payload = json.dumps({"id": msg_id, "msg": message}, separators=(",", ":"))
133
+ quoted = shlex.quote(payload)
134
+ return [
135
+ "/bin/bash",
136
+ "-c",
137
+ f"printf '%s\\n' {quoted} >> {_MAILBOX_PATH}",
138
+ ]
139
+
140
+
141
+ def _ended_before_reply(status: str, msg_id: str) -> str:
142
+ """The message for a container that went terminal mid-round-trip."""
143
+ return (
144
+ f"the sandbox ended (status {status!r}) before replying to message {msg_id}. "
145
+ f"The daemon is gone, so this is not a timeout and retrying send() on this "
146
+ f"session will not help -- start a new one with force_fresh=True, or check "
147
+ f"the container's logs for why it exited."
148
+ )
149
+
150
+
151
+ class Session:
152
+ """A handle to a warm session.
153
+
154
+ ``id == container_id``. Construct via `agent_session()` (which
155
+ deploys the daemon detached) or `from_id()` to rehydrate later.
156
+ """
157
+
158
+ def __init__(
159
+ self,
160
+ container_id: str,
161
+ key: str = "",
162
+ *,
163
+ nonce: str | None = None,
164
+ connection: ConnectionLike | None = None,
165
+ ) -> None:
166
+ self.id = container_id
167
+ self.key = key
168
+ # The Snowflake connection this handle reads its container over. Every method
169
+ # re-fetches by id (``get_sandbox``), and a handle rehydrated in a new process
170
+ # has no ambient connection to inherit.
171
+ self._connection = connection
172
+ # Per-session reply nonce: set by `agent_session` at create time and
173
+ # held only in this process, so `send()` can authenticate that a reply came
174
+ # from the trusted daemon loop rather than a forged stdout line. A handle
175
+ # rehydrated by `from_id()` has none — its replies cannot be authenticated.
176
+ self.nonce = nonce
177
+
178
+ @classmethod
179
+ def from_id(
180
+ cls, container_id: str, key: str = "", *, connection: ConnectionLike | None = None
181
+ ) -> Session:
182
+ """Rehydrate a `Session` handle from a container id.
183
+
184
+ The per-session reply nonce lives only in the process that created the
185
+ session, so a rehydrated handle has none: `send()` matches replies by
186
+ message id but cannot authenticate them.
187
+
188
+ ``connection`` names the Snowflake connection the session lives in. Omit it to
189
+ use the enclosing ``using()`` block, else the default connection."""
190
+ return cls(container_id, key=key, connection=connection)
191
+
192
+ async def send(self, message: str, timeout: float = 120.0, poll_s: float = 1.0) -> str:
193
+ """Send *message* to the daemon and return its reply (the mailbox
194
+ round-trip).
195
+
196
+ Mints a ``msg_id``, execs a shell-quoted append into
197
+ ``/tmp/sandbox_mailbox.jsonl`` inside the live container, then polls
198
+ ``logs()`` until the daemon prints ``__SANDBOX_REPLY__<msg_id>__<b64>``.
199
+ Base64-decodes the payload and returns the reply string.
200
+
201
+ Raises `SandboxError` if the container reaches a terminal state without
202
+ replying, and `SandboxExecTimeoutError` if it is still alive but silent for
203
+ *timeout* seconds."""
204
+ msg_id = uuid.uuid4().hex
205
+ sb = await get_sandbox(self.id, connection=self._connection)
206
+ await sb.exec(_mailbox_append_cmd(msg_id, message))
207
+
208
+ deadline = time.monotonic() + timeout
209
+ while True:
210
+ log_text = await sb.logs()
211
+ reply = _parse_reply_sentinel(log_text, msg_id, nonce=self.nonce)
212
+ if reply is not None:
213
+ return reply
214
+ # A dead daemon otherwise costs the whole timeout and is then reported as
215
+ # one, which points the caller at their message rather than at the gone
216
+ # container. refresh(), not `sb.status`: that is cached from create time
217
+ # and nothing on the container's lifecycle mutates it (see LogStream).
218
+ if await sb.refresh() in TERMINAL_STATUSES:
219
+ # One last read first -- the daemon may have replied just before
220
+ # exiting, and that reply is the answer, not an error.
221
+ reply = _parse_reply_sentinel(await sb.logs(), msg_id, nonce=self.nonce)
222
+ if reply is not None:
223
+ return reply
224
+ raise SandboxError(_ended_before_reply(sb.status, msg_id))
225
+ if time.monotonic() + poll_s >= deadline:
226
+ raise SandboxExecTimeoutError(f"no reply for message {msg_id} within {timeout}s")
227
+ await asyncio.sleep(poll_s)
228
+
229
+ def send_sync(self, message: str, timeout: float = 120.0, poll_s: float = 1.0) -> str:
230
+ """Send *message* to the daemon and return its reply — synchronous
231
+ counterpart of `send`.
232
+
233
+ Same mailbox round-trip as `send`, blocking instead of awaiting: mints a
234
+ ``msg_id``, execs the shell-quoted append into
235
+ ``/tmp/sandbox_mailbox.jsonl`` inside the live container (via the
236
+ synchronous `Sandbox`), then polls ``logs()`` until the daemon prints
237
+ ``__SANDBOX_REPLY__<msg_id>__<b64>``, base64-decodes it, and returns the
238
+ reply string. The request built and the reply parsed are byte-identical
239
+ to `send`'s — both go through the shared ``_mailbox_append_cmd`` /
240
+ ``_parse_reply_sentinel``. Safe to call with no running event loop and
241
+ never starts one, so it does not deadlock inside a notebook.
242
+
243
+ Raises `SandboxError` if the container reaches a terminal state without
244
+ replying, and `SandboxExecTimeoutError` if it is still alive but silent for
245
+ *timeout* seconds.
246
+
247
+ Example:
248
+ session = TriageSession.session_sync(key="triage")
249
+ reply = session.send_sync("what changed in prod yesterday?")
250
+ """
251
+ from snowflake.sandbox.sync_client import get_sandbox as _get_sandbox_sync
252
+
253
+ msg_id = uuid.uuid4().hex
254
+ sb = _get_sandbox_sync(self.id, connection=self._connection)
255
+ sb.exec(_mailbox_append_cmd(msg_id, message))
256
+
257
+ deadline = time.monotonic() + timeout
258
+ while True:
259
+ log_text = sb.logs()
260
+ reply = _parse_reply_sentinel(log_text, msg_id, nonce=self.nonce)
261
+ if reply is not None:
262
+ return reply
263
+ # As in `send`: a terminal container is not a timeout, and refresh() is
264
+ # the only live read of its status.
265
+ if sb.refresh() in TERMINAL_STATUSES:
266
+ reply = _parse_reply_sentinel(sb.logs(), msg_id, nonce=self.nonce)
267
+ if reply is not None:
268
+ return reply
269
+ raise SandboxError(_ended_before_reply(sb.status, msg_id))
270
+ if time.monotonic() + poll_s >= deadline:
271
+ raise SandboxExecTimeoutError(f"no reply for message {msg_id} within {timeout}s")
272
+ time.sleep(poll_s)
273
+
274
+ async def status_async(self) -> str:
275
+ """The lifecycle status from a real server read: ``cold`` | ``running`` |
276
+ ``ended``.
277
+
278
+ ``ended`` when the container is terminal (``dead`` or ``failed``); otherwise ``running``
279
+ (a daemon the platform has checkpointed still reports ``running`` — it is
280
+ restored warm on the next ``send()``), or ``cold`` before an id exists.
281
+ Safe from async code,
282
+ including from inside a running event loop; prefer it over the sync
283
+ `status` in any ``async def``."""
284
+ sb = await get_sandbox(self.id, connection=self._connection)
285
+ if sb.status in TERMINAL_STATUSES:
286
+ return "ended"
287
+ return "running" if sb._id else "cold"
288
+
289
+ def status(self) -> str:
290
+ """The lifecycle status from a real server read: ``cold`` | ``running`` |
291
+ ``ended`` (``ended`` = the container is terminal: ``dead`` or ``failed``).
292
+
293
+ A synchronous convenience wrapper around `status_async`: it drives the
294
+ read on a fresh event loop, so it may be called **only when no event loop
295
+ is running**. Called from *inside* a running loop it cannot block on the
296
+ network read — and fabricating a status from the id alone can never observe
297
+ ``ended`` (it would report a dead session as ``running``, the silent-wrong
298
+ result this method must not produce) — so it raises `SandboxError` instead.
299
+ From async code, ``await session.status_async()``."""
300
+ try:
301
+ asyncio.get_running_loop()
302
+ except RuntimeError:
303
+ return asyncio.run(self.status_async())
304
+ raise SandboxError(
305
+ "Session.status() cannot read the session status from inside a running "
306
+ "event loop: it would have to block on a network read, and reporting a "
307
+ "status from the id alone can never return 'ended' for a dead session. "
308
+ "Use `await session.status_async()` from async code."
309
+ )
310
+
311
+ async def end(self) -> None:
312
+ """End the Session by destroying its container.
313
+
314
+ Also drops the key→container mapping so a later ``agent_session(key=…)``
315
+ cold-starts a fresh daemon rather than resuming a dead one."""
316
+ sb = await get_sandbox(self.id, connection=self._connection)
317
+ await sb.destroy()
318
+ if self.key and _SESSIONS.get(self.key) == self.id:
319
+ _SESSIONS.pop(self.key, None)
320
+ _SESSION_NONCES.pop(self.key, None)
321
+
322
+ def end_sync(self) -> None:
323
+ """End the Session by destroying its container — synchronous counterpart
324
+ of `end`.
325
+
326
+ Blocks instead of awaiting; the same teardown as `end`, and it likewise
327
+ drops the key→container mapping so a later ``agent_session(key=…)`` /
328
+ `agent_session_sync()` cold-starts a fresh daemon rather than resuming a
329
+ dead one. Safe to call with no running event loop and never starts one.
330
+
331
+ Example:
332
+ session = TriageSession.session_sync(key="triage")
333
+ session.end_sync()
334
+ """
335
+ from snowflake.sandbox.sync_client import get_sandbox as _get_sandbox_sync
336
+
337
+ sb = _get_sandbox_sync(self.id, connection=self._connection)
338
+ sb.destroy()
339
+ if self.key and _SESSIONS.get(self.key) == self.id:
340
+ _SESSIONS.pop(self.key, None)
341
+ _SESSION_NONCES.pop(self.key, None)
342
+
343
+
344
+ def _layer_shim(spec: DeploySpec, shim_dir: Path) -> DeploySpec:
345
+ """Return *spec* with the generated shim files copied into its bundle root.
346
+
347
+ ``@app.session`` writes its mailbox-loop shim to a temp dir; the daemon also
348
+ needs the user's module. Copying the shim into a throwaway copy of the bundle
349
+ root keeps both without mutating the user's tree.
350
+ """
351
+ import shutil
352
+ import tempfile
353
+ from dataclasses import replace
354
+
355
+ from snowflake.sandbox._assemble import _DEFAULT_EXCLUDES, Bundle, _copy_tree_into
356
+
357
+ root = Path(spec.bundle.root) if spec.bundle is not None else shim_dir
358
+ exclude = list(spec.bundle.exclude) if spec.bundle is not None else []
359
+ staged = Path(tempfile.mkdtemp(prefix="sbx-session-"))
360
+ if root.is_dir() and root != shim_dir:
361
+ # Route the user's tree through the credential + symlink filters: a bare
362
+ # copytree would ship `.env` / `id_rsa` / `~/.aws` / `*.pem` and ignore the
363
+ # bundle's own excludes. _copy_tree_into applies both.
364
+ _copy_tree_into(root, staged, list(_DEFAULT_EXCLUDES) + exclude)
365
+ for f in shim_dir.iterdir():
366
+ if f.is_file():
367
+ shutil.copy2(f, staged / f.name)
368
+ include = list(spec.bundle.include) if spec.bundle is not None else []
369
+ return replace(
370
+ spec, bundle=Bundle(root=str(staged), include=tuple(include), exclude=tuple(exclude))
371
+ )
372
+
373
+
374
+ def _connection_scope(connection: ConnectionLike | None) -> AbstractContextManager[object]:
375
+ """``using(connection)`` when one was named, else a no-op.
376
+
377
+ Lets an entry point accept ``connection=`` and have it apply to a whole pipeline of
378
+ calls that take no connection argument themselves.
379
+ ``connection`` picks the Snowflake connection for this call; the handle carries it
380
+ onward. See `config.using` for the resolution rules (names, live connections,
381
+ memoisation, and how an enclosing ``using()`` block interacts).
382
+
383
+ """
384
+ if connection is None:
385
+ return nullcontext()
386
+ from snowflake.sandbox.config import using
387
+
388
+ return using(connection)
389
+
390
+
391
+ async def agent_session(
392
+ spec: DeploySpec,
393
+ *,
394
+ key: str,
395
+ shim_dir: str | Path | None = None,
396
+ force_fresh: bool = False,
397
+ timeout: float = 3600.0,
398
+ connection: ConnectionLike | None = None,
399
+ git_repo: str | None = None,
400
+ git_ref: str | None = None,
401
+ git_subdir: str | None = None,
402
+ ) -> Session:
403
+ """Create-or-resume a keep-alive Session keyed by *key*.
404
+
405
+ Deploys the project's daemon **detached** — reusing ``deploy_spec(...,
406
+ detach=True)`` internals exactly like ``deploy_async`` reuses the bundle
407
+ path: the spec's ``entry`` is the user's long-running loop (it should call
408
+ `session_loop()`), and the container's main process is that loop. Returns a
409
+ `Session` whose ``id`` is the daemon's container id.
410
+
411
+ **Create-or-resume by ``key``:** an in-process ``dict[str, str]`` tracks
412
+ ``key → container_id``. If *key* is already
413
+ tracked and that container is alive, the existing Session is returned
414
+ (resume); otherwise a fresh daemon is deployed and ``key → id`` recorded.
415
+ Set ``force_fresh=True`` to bypass resume and always cold-start.
416
+ ``connection`` picks the Snowflake connection for this call; the handle carries it
417
+ onward. See `config.using` for the resolution rules (names, live connections,
418
+ memoisation, and how an enclosing ``using()`` block interacts).
419
+
420
+ """
421
+ # --- resume path: reuse a live daemon already tracked for this key ---
422
+ if not force_fresh:
423
+ existing_id = _SESSIONS.get(key)
424
+ if existing_id is not None:
425
+ try:
426
+ # Same connection the caller named: checking liveness on the DEFAULT
427
+ # connection instead would raise for a container that is alive on
428
+ # theirs, cold-starting a second daemon and leaking the first.
429
+ sb = await get_sandbox(existing_id, connection=connection)
430
+ except SandboxError:
431
+ sb = None
432
+ if sb is not None and sb.status not in TERMINAL_STATUSES:
433
+ return Session(
434
+ existing_id, key=key, nonce=_SESSION_NONCES.get(key), connection=connection
435
+ )
436
+ # Stale/dead -> drop it and cold-start below.
437
+ _SESSIONS.pop(key, None)
438
+ _SESSION_NONCES.pop(key, None)
439
+
440
+ # --- create path: deploy the daemon detached ---
441
+ # Imported here (not at module import) to keep the lazy __init__ contract:
442
+ # bare ``import snowflake.sandbox`` must not pull deploy.py / httpx.
443
+ from snowflake.sandbox.deploy import deploy_spec
444
+
445
+ # The generated daemon shim lives in shim_dir; layer it over the declared
446
+ # bundle so the user's module stays importable alongside it.
447
+ if shim_dir is not None:
448
+ spec = _layer_shim(spec, Path(shim_dir))
449
+
450
+ # Per-session reply nonce: delivered as the SANDBOX_SESSION_NONCE env var
451
+ # the daemon loop reads once at startup and unsets before running any handler,
452
+ # so an untrusted ``on_message`` cannot forge an authenticated reply. Held
453
+ # in-process so ``send()`` can verify it.
454
+ #
455
+ # It rides the `sandbox_env` PLATFORM channel (deploy_spec -> from_local ->
456
+ # Sandbox._add_platform_env), NOT the user `env` map: SANDBOX_ is a
457
+ # server-reserved prefix, and the create path rejects (400) a reserved-prefix
458
+ # key on the `env` map. Enforcement is unchanged: the container still receives
459
+ # SANDBOX_SESSION_NONCE in its environment (the server merges sandbox_env into
460
+ # the app env), and session_loop still pops it before any handler runs.
461
+ nonce = uuid.uuid4().hex
462
+
463
+ # ``connection=`` has to reach the whole deploy pipeline (deploy_spec -> from_local
464
+ # -> create), not just this function, and threading a kwarg through every layer of
465
+ # it would mean every future layer remembering to forward it. A ``using()`` block
466
+ # carries it through code that takes no connection argument at all -- the case the
467
+ # scope exists for. ``contextlib.nullcontext`` when nothing was named, so the
468
+ # ambient/enclosing binding is left exactly as it was.
469
+ with _connection_scope(connection):
470
+ result = await deploy_spec(
471
+ spec,
472
+ git_repo=git_repo,
473
+ git_ref=git_ref,
474
+ git_subdir=git_subdir,
475
+ detach=True,
476
+ force_fresh=force_fresh,
477
+ timeout=timeout,
478
+ platform_env={_SESSION_NONCE_ENV: nonce},
479
+ )
480
+ if not result.deployed or not result.sandbox_id:
481
+ problems = "; ".join(p.message for p in result.problems if p.severity == "error")
482
+ raise SandboxError(
483
+ "agent_session failed to deploy the daemon" + (f": {problems}" if problems else "")
484
+ )
485
+
486
+ _SESSIONS[key] = result.sandbox_id
487
+ _SESSION_NONCES[key] = nonce
488
+ return Session(result.sandbox_id, key=key, nonce=nonce, connection=connection)
489
+
490
+
491
+ def agent_session_sync(
492
+ spec: DeploySpec,
493
+ *,
494
+ key: str,
495
+ shim_dir: str | Path | None = None,
496
+ force_fresh: bool = False,
497
+ timeout: float = 3600.0,
498
+ connection: ConnectionLike | None = None,
499
+ git_repo: str | None = None,
500
+ git_ref: str | None = None,
501
+ git_subdir: str | None = None,
502
+ ) -> Session:
503
+ """Create-or-resume a keep-alive Session keyed by *key* — synchronous
504
+ counterpart of `agent_session`.
505
+
506
+ Same create-or-resume contract, blocking instead of awaiting: an in-process
507
+ ``key -> container_id`` map is consulted first (resume a live daemon), and
508
+ otherwise the daemon is deployed detached via the synchronous deploy core.
509
+ It shares the same `_SESSIONS` registry as `agent_session`, so a session
510
+ opened on either side resumes from the other. Returns a `Session` whose
511
+ ``id`` is the daemon's container id; drive it with ``s.send_sync(msg)``. Safe
512
+ with no running event loop and never starts one.
513
+
514
+ ``connection`` picks the Snowflake connection for this call; the handle carries it
515
+ onward. See `config.using` for the resolution rules (names, live connections,
516
+ memoisation, and how an enclosing ``using()`` block interacts).
517
+
518
+ Example:
519
+ session = agent_session_sync(app_session.deploy_spec(), key="triage")
520
+ reply = session.send_sync("hello")
521
+ """
522
+ from snowflake.sandbox.sync_client import get_sandbox as _get_sandbox_sync
523
+
524
+ # --- resume path: reuse a live daemon already tracked for this key ---
525
+ if not force_fresh:
526
+ existing_id = _SESSIONS.get(key)
527
+ if existing_id is not None:
528
+ try:
529
+ # Same connection the caller named — see the async twin.
530
+ sb = _get_sandbox_sync(existing_id, connection=connection)
531
+ except SandboxError:
532
+ sb = None
533
+ if sb is not None and sb.status not in TERMINAL_STATUSES:
534
+ return Session(
535
+ existing_id, key=key, nonce=_SESSION_NONCES.get(key), connection=connection
536
+ )
537
+ # Stale/dead -> drop it and cold-start below.
538
+ _SESSIONS.pop(key, None)
539
+ _SESSION_NONCES.pop(key, None)
540
+
541
+ # --- create path: deploy the daemon detached (sync deploy core) ---
542
+ # Imported lazily (not at module scope) to keep the lazy __init__ contract:
543
+ # a bare ``import snowflake.sandbox`` must not pull deploy.py / httpx.
544
+ from snowflake.sandbox.deploy import deploy_spec_sync
545
+
546
+ if shim_dir is not None:
547
+ spec = _layer_shim(spec, Path(shim_dir))
548
+
549
+ # Per-session reply nonce: rides the `sandbox_env` PLATFORM channel, not the user
550
+ # `env` map (SANDBOX_ is a server-reserved prefix the create path rejects on `env`).
551
+ # See `agent_session` for the full rationale.
552
+ nonce = uuid.uuid4().hex
553
+
554
+ # ``connection=`` has to reach the whole deploy pipeline (deploy_spec -> from_local
555
+ # -> create), not just this function, and threading a kwarg through every layer of
556
+ # it would mean every future layer remembering to forward it. A ``using()`` block
557
+ # carries it through code that takes no connection argument at all -- the case the
558
+ # scope exists for. ``contextlib.nullcontext`` when nothing was named, so the
559
+ # ambient/enclosing binding is left exactly as it was.
560
+ with _connection_scope(connection):
561
+ result = deploy_spec_sync(
562
+ spec,
563
+ git_repo=git_repo,
564
+ git_ref=git_ref,
565
+ git_subdir=git_subdir,
566
+ detach=True,
567
+ force_fresh=force_fresh,
568
+ timeout=timeout,
569
+ platform_env={_SESSION_NONCE_ENV: nonce},
570
+ )
571
+ if not result.deployed or not result.sandbox_id:
572
+ problems = "; ".join(p.message for p in result.problems if p.severity == "error")
573
+ raise SandboxError(
574
+ "agent_session failed to deploy the daemon" + (f": {problems}" if problems else "")
575
+ )
576
+
577
+ _SESSIONS[key] = result.sandbox_id
578
+ _SESSION_NONCES[key] = nonce
579
+ return Session(result.sandbox_id, key=key, nonce=nonce, connection=connection)
580
+
581
+
582
+ def session_loop(
583
+ handle: Callable[[str, dict[str, Any]], str],
584
+ *,
585
+ mailbox_path: str = _MAILBOX_PATH,
586
+ poll_s: float = 0.5,
587
+ ) -> None:
588
+ """Run the daemon-side message loop (the in-container half of a warm agent).
589
+
590
+ The daemon's entry program imports this so you don't hand-write the
591
+ mailbox loop:
592
+
593
+ from snowflake.sandbox import session_loop
594
+ session_loop(lambda msg, state: ...)
595
+
596
+ *handle* is ``handle(msg, state) -> reply``: *msg* is the incoming message
597
+ text, *state* is a plain dict that **persists across calls in process
598
+ memory** (THE warm context). The reply string is returned to the controller.
599
+
600
+ The loop tails *mailbox_path* for new ``{"id", "msg"}`` lines; for each it
601
+ calls ``handle(msg, state)`` and prints
602
+ ``__SANDBOX_REPLY__<id>__<base64(json(reply))>`` to **stdout** (flushed). It
603
+ runs forever and is robust to malformed lines (a bad line is skipped, not
604
+ fatal) and to handler exceptions (the error string is returned as the reply
605
+ so ``send()`` doesn't hang).
606
+
607
+ **Surviving the platform's idle checkpoint.** Snowflake's idle auto-suspend
608
+ (``runsc checkpoint``/``restore``) sends **SIGUSR1** to the app immediately
609
+ *before* the memory checkpoint and **SIGUSR2** immediately *after* restore.
610
+ SIGUSR1's default action is *terminate*, so this loop sets both to
611
+ ``SIG_IGN`` — without that a checkpoint kills the daemon. In-process *memory*
612
+ (``state``, counters, loaded objects) survives the checkpoint automatically;
613
+ open network sockets (Cortex/Snowflake/TCP) go stale and must be reopened
614
+ lazily by the handler. Ignoring the signals is harmless when the platform
615
+ never checkpoints — they simply never arrive.
616
+ """
617
+ import os
618
+ import signal
619
+
620
+ # Per-session reply nonce: read once and remove it from the environment
621
+ # before any handler runs, so the (untrusted) handler cannot read it and forge
622
+ # an authenticated reply. Empty when the caller's SDK predates the nonce. Uses
623
+ # the module constant (not a literal) so it stays in sync with the producer.
624
+ _reply_nonce = os.environ.pop(_SESSION_NONCE_ENV, "")
625
+
626
+ state: dict[str, Any] = {}
627
+
628
+ # Ignore the SIGUSR1 (pre-checkpoint) / SIGUSR2 (post-restore) signals the
629
+ # platform's idle auto-suspend sends: SIGUSR1's default action is terminate, so
630
+ # a daemon that installs nothing is killed the first time it is checkpointed.
631
+ # Nothing is wired to them beyond survival — every reply is already printed
632
+ # with flush=True, so no output is buffered across the checkpoint. Must run on
633
+ # the main thread (Python requirement); if the loop is driven off-thread we
634
+ # degrade to the default disposition rather than crash.
635
+ try:
636
+ signal.signal(signal.SIGUSR1, signal.SIG_IGN)
637
+ signal.signal(signal.SIGUSR2, signal.SIG_IGN)
638
+ except (ValueError, OSError, AttributeError):
639
+ # Off the main thread, or a platform without SIGUSR1/2 — checkpoint
640
+ # survival is unavailable, but the keep-alive loop still works.
641
+ pass
642
+
643
+ # Track how many bytes we've consumed so we only process new lines, even
644
+ # across file truncation/rotation.
645
+ offset = 0
646
+ # Ensure the mailbox exists so the first send's append has a target and our
647
+ # read doesn't race a missing file.
648
+ try:
649
+ open(mailbox_path, "a").close()
650
+ except OSError:
651
+ pass
652
+
653
+ while True:
654
+ try:
655
+ size = os.path.getsize(mailbox_path)
656
+ except OSError:
657
+ time.sleep(poll_s)
658
+ continue
659
+ if size < offset:
660
+ # File was truncated/rotated — restart from the top.
661
+ offset = 0
662
+ if size == offset:
663
+ time.sleep(poll_s)
664
+ continue
665
+ # Read in BINARY so `offset` is a true byte count: it is compared against
666
+ # os.path.getsize() (bytes) and seeked with, and a text-mode tell() is an
667
+ # opaque cookie that must not be offset-arithmetic'd (doing so seeks to an
668
+ # invalid position on some CPython builds).
669
+ with open(mailbox_path, "rb") as fh:
670
+ fh.seek(offset)
671
+ chunk = fh.read()
672
+ offset += len(chunk)
673
+ # A trailing line with no newline is a partial append still in flight (a
674
+ # large `printf ... >> mailbox` is not always one atomic write). Rewind the
675
+ # byte offset past it and drop it from this batch, so it is re-read whole
676
+ # next poll rather than split into two fragments that each fail json.loads
677
+ # and silently lose the message.
678
+ if chunk and not chunk.endswith(b"\n"):
679
+ partial = chunk.rpartition(b"\n")[2]
680
+ offset -= len(partial)
681
+ chunk = chunk[: len(chunk) - len(partial)]
682
+ new_lines = chunk.decode("utf-8", "replace").splitlines()
683
+ for line in new_lines:
684
+ line = line.strip()
685
+ if not line:
686
+ continue
687
+ try:
688
+ rec = json.loads(line)
689
+ msg_id = rec["id"]
690
+ msg = rec["msg"]
691
+ except (ValueError, KeyError, TypeError):
692
+ continue # malformed line — skip, never fatal
693
+ try:
694
+ reply = handle(msg, state)
695
+ except Exception as exc: # noqa: BLE001 — never let one turn kill the daemon
696
+ reply = f"handler error: {type(exc).__name__}: {exc}"
697
+ if not isinstance(reply, str):
698
+ reply = str(reply)
699
+ b64 = base64.b64encode(json.dumps(reply).encode()).decode()
700
+ print(f"{_REPLY_SENTINEL}{msg_id}__{_reply_nonce}__{b64}", flush=True)