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,183 @@
1
+ """In-sandbox MCP connection status: "can I use this connector right now?"
2
+
3
+ The client-facing surface here — `McpConnectionStatus`, `mcp_server_status`, and
4
+ the ``MCP_STATUS_*`` vocabulary — is re-exported from `snowflake.sandbox.mcp` (and
5
+ the package root), which is where it is documented and imported from. It lives in
6
+ its own module because it shares nothing with the discovery/SQL half of `mcp`:
7
+ this side reads a JSON file the hostagent writes inside the container and never
8
+ opens a Snowflake connection.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass
17
+ from typing import Any, Literal, cast, get_args, overload
18
+
19
+ # Where the hostagent writes the per-server fetch outcome INSIDE the container.
20
+ # This is the authoritative "can I use this connector right now?" signal, and it
21
+ # is deliberately not the account-status view `list_mcp_servers` reads:
22
+ # SYSTEM$GET_USER_INTEGRATION_AUTHORIZATIONS keeps reporting CONNECTED after the
23
+ # OAuth provider revokes a grant, because the revocation happens IdP-side and
24
+ # Snowflake only learns of it when it next tries to use the token. The hostagent
25
+ # does try -- on every refresh cycle -- so its own fetch result is the only
26
+ # source that stays truthful mid-life.
27
+ #
28
+ # The file is a single JSON document keyed by the client-facing server name (the
29
+ # same <NAME> as SANDBOX_MCP_TOKEN_<NAME>), each value carrying
30
+ # {status, fqn, gs_error_code, detail, last_checked_at}. The path, the key, and
31
+ # the status vocabulary below are the contract shared with the hostagent half;
32
+ # change them only in lockstep with it.
33
+ MCP_STATUS_PATH = "/snowflake/mcp/status.json"
34
+
35
+ # The connector is usable: the last per-user token fetch succeeded.
36
+ MCP_STATUS_LIVE: Literal["live"] = "live"
37
+ # The connector needs the user to re-consent in Snowsight. The hostagent maps a
38
+ # Snowflake policy verdict of ACCESS_DENIED or SECRET_NOT_FOUND to this -- the OAuth
39
+ # grant was revoked, or the secret can no longer be resolved for this user, and
40
+ # no retry inside the sandbox will fix it.
41
+ MCP_STATUS_RECONNECT_REQUIRED: Literal["reconnect_required"] = "reconnect_required"
42
+ # The status could not be determined: no status file (an older runtime, or the
43
+ # feature not enabled) or no entry for this name. Fail-soft -- absence is never
44
+ # reported as an outage.
45
+ MCP_STATUS_UNKNOWN: Literal["unknown"] = "unknown"
46
+
47
+ McpLiveStatus = Literal["live", "reconnect_required", "unknown"]
48
+ """In-sandbox connection status of an EXTERNAL MCP SERVER (`McpConnectionStatus.status`)."""
49
+
50
+
51
+ def _coerce_live_status(raw: str | None) -> McpLiveStatus:
52
+ """Clamp a status-file value to the known set; anything else -> ``unknown``."""
53
+ return cast("McpLiveStatus", raw) if raw in get_args(McpLiveStatus) else MCP_STATUS_UNKNOWN
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class McpConnectionStatus:
58
+ """The live, in-sandbox connection status of one EXTERNAL MCP SERVER.
59
+
60
+ This is what code running *inside* a sandbox should consult before it relies
61
+ on a connector. It reflects the platform's most recent attempt to fetch
62
+ *your* OAuth token for the server, so it is the one signal that stays truthful
63
+ after a provider-side revocation. `McpServerInfo.status` (from
64
+ `list_mcp_servers`) is the account/authorization view and can still read
65
+ ``CONNECTED`` when the grant has actually lapsed; when the two disagree, this
66
+ one is right about whether a tool call will succeed.
67
+
68
+ ``status`` is one of ``live``, ``reconnect_required``, or ``unknown``. When it
69
+ is ``reconnect_required`` the fix is a one-time re-consent in Snowsight (not a
70
+ retry): ``gs_error_code`` carries the verdict that classified it
71
+ (``ACCESS_DENIED`` / ``SECRET_NOT_FOUND``) and ``detail`` a human-readable
72
+ line. ``unknown`` means no signal was available and must not be treated as an
73
+ outage.
74
+ """
75
+
76
+ name: str
77
+ status: McpLiveStatus
78
+ fqn: str | None = None
79
+ gs_error_code: str | None = None
80
+ detail: str | None = None
81
+ last_checked_at: str | None = None
82
+
83
+ @property
84
+ def needs_reconnect(self) -> bool:
85
+ """True only when the server is known to need a re-consent."""
86
+ return self.status == MCP_STATUS_RECONNECT_REQUIRED
87
+
88
+ @property
89
+ def live(self) -> bool:
90
+ """True only when the last token fetch for this server succeeded."""
91
+ return self.status == MCP_STATUS_LIVE
92
+
93
+
94
+ def _status_entry(name: str, raw: object) -> McpConnectionStatus:
95
+ """Build one `McpConnectionStatus` from a status-file value, fail-soft.
96
+
97
+ Any missing or non-string field degrades to ``None`` / ``unknown`` rather
98
+ than raising, so a shape the platform tightens later cannot break a caller.
99
+ """
100
+ if not isinstance(raw, Mapping):
101
+ return McpConnectionStatus(name=name, status=MCP_STATUS_UNKNOWN)
102
+
103
+ def _s(key: str) -> str | None:
104
+ value = raw.get(key)
105
+ return value if isinstance(value, str) and value else None
106
+
107
+ return McpConnectionStatus(
108
+ name=name,
109
+ status=_coerce_live_status(_s("status")),
110
+ fqn=_s("fqn"),
111
+ gs_error_code=_s("gs_error_code"),
112
+ detail=_s("detail"),
113
+ last_checked_at=_s("last_checked_at"),
114
+ )
115
+
116
+
117
+ def _load_status_doc(path: str | os.PathLike[str]) -> dict[str, Any] | None:
118
+ """Read and parse the status file, or None if absent / unreadable / malformed."""
119
+ try:
120
+ with open(path, encoding="utf-8") as fh:
121
+ doc = json.load(fh)
122
+ except (OSError, ValueError):
123
+ # Missing file (older runtime / feature off), unreadable, or invalid JSON.
124
+ return None
125
+ return doc if isinstance(doc, dict) else None
126
+
127
+
128
+ @overload
129
+ def mcp_server_status(name: str, *, path: str | os.PathLike[str] = ...) -> McpConnectionStatus: ...
130
+
131
+
132
+ @overload
133
+ def mcp_server_status(
134
+ name: None = ..., *, path: str | os.PathLike[str] = ...
135
+ ) -> list[McpConnectionStatus]: ...
136
+
137
+
138
+ def mcp_server_status(
139
+ name: str | None = None,
140
+ *,
141
+ path: str | os.PathLike[str] = MCP_STATUS_PATH,
142
+ ) -> McpConnectionStatus | list[McpConnectionStatus]:
143
+ """Read the live connection status the platform publishes inside the sandbox.
144
+
145
+ Call this from code running *in* a sandbox to learn whether an MCP connector
146
+ is usable right now, or whether the user must re-consent to it. Unlike
147
+ `list_mcp_servers` -- which queries the account and can report a revoked grant
148
+ as still ``CONNECTED`` -- this reads the platform's own most-recent
149
+ token-fetch result, rewritten each refresh cycle, so it catches a
150
+ provider-side revocation the account view misses.
151
+
152
+ With a ``name`` it returns that one server's `McpConnectionStatus` (the client
153
+ name used in ``mcp_servers=``, i.e. the ``<NAME>`` in
154
+ ``SANDBOX_MCP_TOKEN_<NAME>``); with no name it returns one for every server
155
+ the sandbox currently has a signal for. A missing file or a missing entry
156
+ yields status ``unknown`` rather than raising -- absence is never an outage.
157
+
158
+ Example:
159
+ from snowflake.sandbox import mcp_server_status
160
+
161
+ gh = mcp_server_status("github")
162
+ if gh.needs_reconnect:
163
+ raise SystemExit(
164
+ f"Reconnect {gh.fqn} in Snowsight before running: {gh.detail}"
165
+ )
166
+
167
+ Parameters
168
+ ----------
169
+ name:
170
+ The client-facing server name to look up. Omit to get the full list.
171
+ path:
172
+ Status-file path; defaults to the runtime location and is a parameter
173
+ only so tests need not write to an absolute system path.
174
+ """
175
+ doc = _load_status_doc(path)
176
+ if name is not None:
177
+ raw = doc.get(name) if doc is not None else None
178
+ if raw is None:
179
+ return McpConnectionStatus(name=name, status=MCP_STATUS_UNKNOWN)
180
+ return _status_entry(name, raw)
181
+ if not doc:
182
+ return []
183
+ return [_status_entry(str(key), value) for key, value in doc.items()]
@@ -0,0 +1,94 @@
1
+ """Backoff + jitter helper for the SDK retry loop.
2
+
3
+ Retry policy:
4
+
5
+ - Cold-start retries on ``503`` or ``409 not_ready``: exponential backoff
6
+ ``1, 4, 16, 64s`` capped at 4 attempts.
7
+ - ``429`` honors the ``Retry-After`` header.
8
+ - ``500`` retries once, then surfaces ``SandboxTransportError``.
9
+ - Retries computed here jitter ±25%. The one exception is a ``429`` that carries
10
+ a ``Retry-After``: ``_transport._rate_limit_delay`` jitters that hint *upward
11
+ only*, because retrying before the moment the server named would re-hammer a
12
+ gateway that just asked us to back off.
13
+
14
+ Every one of those is gated on **idempotency**: for a non-idempotent method
15
+ (POST) a retry is issued only when the failure proves the server never ran the
16
+ request. See ``_transport._IDEMPOTENT_METHODS`` for the rule and why.
17
+
18
+ The helper here only computes delays; the wiring into specific HTTP status
19
+ codes lives in ``_transport.py``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import random
25
+
26
+ __all__ = [
27
+ "compute_backoff",
28
+ "DEFAULT_COLD_START_DELAYS_S",
29
+ "DEFAULT_500_DELAYS_S",
30
+ "DEFAULT_LOGS_500_DELAYS_S",
31
+ "DEFAULT_NOT_FOUND_DELAYS_S",
32
+ "DEFAULT_START_FAILURE_DELAYS_S",
33
+ "is_retryable_start_failure",
34
+ ]
35
+
36
+ DEFAULT_COLD_START_DELAYS_S: tuple[float, ...] = (1.0, 4.0, 16.0, 64.0)
37
+ """Delays before attempt 1, 2, 3, 4 of cold-start retries (4 attempts total)."""
38
+
39
+ DEFAULT_500_DELAYS_S: tuple[float, ...] = (1.0,)
40
+ """Single retry budget for 500s, then surface as SandboxTransportError."""
41
+
42
+ DEFAULT_NOT_FOUND_DELAYS_S: tuple[float, ...] = (0.5, 1.0, 2.0, 4.0)
43
+ """Retries for a 404 right after create: a freshly-created container is not
44
+ yet routable for a moment. Bounded (~7.5s) so a genuinely-missing container
45
+ still surfaces SandboxNotFoundError quickly."""
46
+
47
+ DEFAULT_LOGS_500_DELAYS_S: tuple[float, ...] = (1.0, 2.0, 4.0, 8.0)
48
+ """Longer 500 budget (~15s) for the read-only logs route. Fetching logs while a
49
+ container is mid-run can hit a transient upstream 500 that outlasts the single
50
+ default 500 retry; a GET is idempotent so extending the budget is safe. Bounded
51
+ so a persistent 500 still surfaces SandboxTransportError."""
52
+
53
+ DEFAULT_START_FAILURE_DELAYS_S: tuple[float, ...] = (2.0, 8.0)
54
+ """Backoff before re-creating a container after a *retryable* StartApp failure
55
+ (2 re-creates → 3 create attempts total). The CNG controller has already
56
+ exhausted its own in-place StartApp attempts and tagged the failure retryable, so
57
+ a fresh container may land on a healthy hostagent. Kept small so create() never
58
+ hangs on a persistent condition."""
59
+
60
+
61
+ def is_retryable_start_failure(status: str, error_message: str | None) -> bool:
62
+ """True when a create settled on a terminal ``failed`` the server tagged retryable.
63
+
64
+ There is no structured ``retryable`` flag on the wire today, so this matches the
65
+ ``(retryable)`` marker CNG's controller emits after it exhausts its own StartApp
66
+ attempts. Scoped to ``create()``'s own readiness wait: the message is hydrated
67
+ from *this* attempt's failed-container poll immediately before the raise, so the
68
+ staleness that makes ``_error_message`` unusable elsewhere does not apply here.
69
+ """
70
+ return status == "failed" and "(retryable)" in (error_message or "")
71
+
72
+
73
+ def compute_backoff(
74
+ attempt: int,
75
+ *,
76
+ delays: tuple[float, ...] = DEFAULT_COLD_START_DELAYS_S,
77
+ jitter: float = 0.25,
78
+ rng: random.Random | None = None,
79
+ ) -> float | None:
80
+ """Return the delay in seconds before the given retry attempt.
81
+
82
+ ``attempt`` is 1-indexed (the first retry is ``attempt=1``). Returns
83
+ ``None`` if the attempt is past the budget — the caller surfaces the
84
+ terminal exception.
85
+
86
+ Jitter is full-symmetric: the returned delay is uniformly sampled from
87
+ ``[base * (1 - jitter), base * (1 + jitter)]``.
88
+ """
89
+ if attempt < 1 or attempt > len(delays):
90
+ return None
91
+ base = delays[attempt - 1]
92
+ r = rng if rng is not None else random.Random()
93
+ spread = base * jitter
94
+ return max(0.0, base + r.uniform(-spread, spread))
@@ -0,0 +1,42 @@
1
+ """Code that runs on the **far side** of the sandbox boundary.
2
+
3
+ Everything in this subpackage executes *inside* a sandbox container, not in the
4
+ calling process. That is the whole reason it has a home of its own: the boundary
5
+ is otherwise invisible. These are not helpers the SDK calls — they are programs
6
+ the SDK *writes out and ships*, to be run later, by a different interpreter, on
7
+ another machine, with only the base image's stdlib available.
8
+
9
+ What follows from that, and is easy to forget when the same text sits inline in a
10
+ client-side module:
11
+
12
+ * **No SDK imports.** The container's base image ships (at most) the *published*
13
+ SDK, which may be older than the one deploying. In-container programs stand on
14
+ the stdlib alone — every value they need is baked into their source as a
15
+ literal, never imported at run time.
16
+ * **The programs are not type-checked or linted.** These modules hold their
17
+ source as *strings*; mypy and ruff see the quotes, not the program. A typo
18
+ inside one surfaces as a container that dies on start, hundreds of milliseconds
19
+ of network away from the traceback that would have named it.
20
+ * **Every string is a wire protocol.** The stdout sentinels, the mailbox path and
21
+ the marker-attribute names are read by the *other* side. Changing one is a
22
+ compatibility break against sandboxes already running, not a rename — which is
23
+ why they are defined once, in `_protocol`, and imported by both halves.
24
+
25
+ Layout:
26
+
27
+ * `_protocol` — the constants both sides must agree on (sentinels, mailbox path,
28
+ nonce env var, marker attributes, inotify mask bits).
29
+ * `_job_runner` — the bash runner ``deploy_async`` injects to wrap a detached run.
30
+ * `_shims` — the generated python entrypoints: the ``@app.session`` daemon, the
31
+ ``@app.function`` runner, and the detached-command wrapper.
32
+ * `_fs_helper` — the ``python3 -c`` programs behind the directory ops and the
33
+ ``inotify(7)`` file watcher.
34
+
35
+ The one piece of in-container code deliberately *not* here is
36
+ `snowflake.sandbox.warm_session.session_loop`. It runs in the container too, but it is
37
+ public API the *user* imports and calls from their own entrypoint — ordinary
38
+ typed, linted, documented library code — so it keeps a public home. What lives
39
+ here is the code the SDK generates and the caller never sees.
40
+ """
41
+
42
+ from __future__ import annotations
@@ -0,0 +1,93 @@
1
+ """The ``python3 -c`` programs behind the filesystem surface.
2
+
3
+ Two of the three filesystem mechanisms have no server route, so they run as
4
+ source text in the container over ``exec`` / ``exec_stream`` instead:
5
+
6
+ * the **directory ops** (``list_files`` / ``stat`` / ``make_directory`` /
7
+ ``remove``) — the ``/files`` route transfers bytes only, so these are ordinary
8
+ in-container filesystem calls that print structured JSON back;
9
+ * the **file watcher** — a ``ctypes``-driven ``inotify(7)`` monitor, reaching a
10
+ kernel facility through ``libc`` so it needs no container package (the image
11
+ ships neither an ``inotifywait`` binary nor a python inotify library).
12
+
13
+ Both embed their arguments as ``repr``'d Python literals or pass them as separate
14
+ argv entries — never shell-interpolated — so a path containing spaces, quotes or
15
+ shell metacharacters cannot break out of the program.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from snowflake.sandbox._runtime._protocol import (
21
+ _FS_SENTINEL,
22
+ _IN_CLOSE_WRITE,
23
+ _IN_CREATE,
24
+ _IN_DELETE,
25
+ _IN_MODIFY,
26
+ _IN_MOVED_FROM,
27
+ _IN_MOVED_TO,
28
+ _WATCH_READY,
29
+ _WATCH_SENTINEL,
30
+ )
31
+
32
+ __all__ = ["_FS_HELPER_PREAMBLE", "_watch_program"]
33
+
34
+
35
+ _FS_HELPER_PREAMBLE = f"""\
36
+ import json, os, shutil, sys
37
+ def _emit(obj):
38
+ sys.stdout.write({_FS_SENTINEL!r} + json.dumps(obj) + "\\n")
39
+ """
40
+
41
+
42
+ def _watch_program() -> str:
43
+ """The in-container inotify monitor, as a python3 ``-c`` source string.
44
+
45
+ Prints ``_WATCH_READY`` once armed, then one ``_WATCH_SENTINEL{json}`` line
46
+ per event (``{"mask": int, "path": abs}``). argv: ``[root, recursive01,
47
+ timeout_or_empty]``. inotify is not natively recursive, so with recursion it
48
+ walks the tree at start and adds a watch for each directory created
49
+ afterward, keeping the watch set complete.
50
+ """
51
+ mask = _IN_CREATE | _IN_MOVED_TO | _IN_DELETE | _IN_MOVED_FROM | _IN_MODIFY | _IN_CLOSE_WRITE
52
+ # IN_ISDIR rides the high bit of an event mask when the subject is a dir.
53
+ return f"""\
54
+ import ctypes, ctypes.util, os, struct, select, sys, time, json
55
+ root = sys.argv[1]
56
+ recursive = sys.argv[2] == "1"
57
+ timeout = float(sys.argv[3]) if sys.argv[3] else None
58
+ IN_ISDIR = 0x40000000
59
+ MASK = {mask}
60
+ libc = ctypes.CDLL(ctypes.util.find_library("c") or "libc.so.6", use_errno=True)
61
+ fd = libc.inotify_init1(os.O_NONBLOCK)
62
+ if fd < 0:
63
+ sys.stderr.write("inotify_init1 failed errno=%d\\n" % ctypes.get_errno()); sys.exit(2)
64
+ wds = {{}}
65
+ def add(path):
66
+ wd = libc.inotify_add_watch(fd, path.encode(), MASK)
67
+ if wd >= 0:
68
+ wds[wd] = path
69
+ return wd
70
+ if add(root) < 0:
71
+ sys.stderr.write("inotify_add_watch failed for %r errno=%d\\n" % (root, ctypes.get_errno())); sys.exit(3)
72
+ if recursive:
73
+ for dp, dns, _fns in os.walk(root):
74
+ for d in dns:
75
+ add(os.path.join(dp, d))
76
+ sys.stdout.write({_WATCH_READY!r} + "\\n"); sys.stdout.flush()
77
+ deadline = None if timeout is None else time.time() + timeout
78
+ while deadline is None or time.time() < deadline:
79
+ wait = 1.0 if deadline is None else max(0.0, deadline - time.time())
80
+ if not select.select([fd], [], [], wait)[0]:
81
+ continue
82
+ buf = os.read(fd, 8192)
83
+ i = 0
84
+ while i + 16 <= len(buf):
85
+ wd, m, _cookie, ln = struct.unpack_from("iIII", buf, i); i += 16
86
+ name = buf[i:i+ln].split(b"\\x00", 1)[0].decode("utf-8", "replace"); i += ln
87
+ base = wds.get(wd, root)
88
+ full = os.path.join(base, name) if name else base
89
+ if recursive and (m & {_IN_CREATE}) and (m & IN_ISDIR):
90
+ add(full)
91
+ sys.stdout.write({_WATCH_SENTINEL!r} + json.dumps({{"mask": m, "path": full}}) + "\\n")
92
+ sys.stdout.flush()
93
+ """
@@ -0,0 +1,111 @@
1
+ """The bash runner ``deploy_async`` injects to wrap a detached run.
2
+
3
+ Written into the bundle as ``_sandbox_job_runner.sh`` and made the container's
4
+ entry, so it — not the user's command — is PID 1's job. It runs the manifest's
5
+ original ``[build].entry`` as a *subprocess* (not ``exec``) precisely so it
6
+ regains control afterwards and can report the outcome: print the result sentinel
7
+ to stdout, and best-effort POST the webhook.
8
+
9
+ Bash, not python, because this is the layer that must work before anything is
10
+ known about the workload. It assumes only what the base image guarantees: bash,
11
+ python3 (for json/base64, so no dependency on a ``base64`` CLI flavour) and curl.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from snowflake.sandbox._runtime._protocol import _RESULT_SENTINEL
17
+
18
+ __all__ = ["_SANDBOX_JOB_RUNNER"]
19
+
20
+
21
+ # The ``__RESULT_SENTINEL__`` token below is substituted with _RESULT_SENTINEL at
22
+ # import time so the sentinel prefix stays defined in exactly one place.
23
+ _SANDBOX_JOB_RUNNER = r"""#!/bin/bash
24
+ # Injected by deploy_async. Runs the inner entry, captures its exit code and
25
+ # $SANDBOX_RESULT artifact, prints the result sentinel to stdout, and best-effort
26
+ # POSTs the webhook. Preserves the inner exit code. No stage write and no
27
+ # Snowflake calls: the webhook needs only egress.
28
+ set -u
29
+
30
+ SANDBOX_RESULT="${SANDBOX_RESULT:-/sandbox/result.json}"
31
+ export SANDBOX_RESULT
32
+ _SANDBOX_WEBHOOK_PAYLOAD="/tmp/_sandbox_webhook_payload.json"
33
+
34
+ # Take the per-run nonce out of the environment before running the inner entry,
35
+ # so the untrusted workload cannot read it and forge its own result sentinel.
36
+ _SANDBOX_JOB_NONCE="${SANDBOX_JOB_NONCE:-}"
37
+ unset SANDBOX_JOB_NONCE
38
+
39
+ # The inner entry writes its structured result to $SANDBOX_RESULT; make sure its
40
+ # parent dir exists (the default /sandbox is not present in the base image), and
41
+ # is writable, so the inner write doesn't FileNotFoundError. Best-effort: if the
42
+ # default location can't be created, fall back to a tmp path.
43
+ if ! mkdir -p "$(dirname "$SANDBOX_RESULT")" 2>/dev/null; then
44
+ SANDBOX_RESULT="/tmp/_sandbox_result.json"
45
+ export SANDBOX_RESULT
46
+ mkdir -p "$(dirname "$SANDBOX_RESULT")" 2>/dev/null || true
47
+ fi
48
+
49
+ # Decode the JSON-encoded inner entry argv into the _inner array.
50
+ mapfile -t _inner < <(printf '%s' "${SANDBOX_JOB_INNER_ENTRY:-[]}" | python3 -c '
51
+ import json, sys
52
+ for a in json.load(sys.stdin):
53
+ print(a)
54
+ ')
55
+
56
+ if [ "${#_inner[@]}" -eq 0 ]; then
57
+ echo "sandbox-job-runner: empty SANDBOX_JOB_INNER_ENTRY" >&2
58
+ exit 2
59
+ fi
60
+
61
+ # Run the inner entry as a subprocess (not exec) so we regain control to emit
62
+ # the sentinel + webhook after it completes.
63
+ "${_inner[@]}"
64
+ inner_exit=$?
65
+
66
+ if [ "$inner_exit" -eq 0 ]; then
67
+ _status="succeeded"
68
+ else
69
+ _status="failed"
70
+ fi
71
+
72
+ # Build the result JSON (parse $SANDBOX_RESULT if present, else {exit_code:N}),
73
+ # print the sentinel line to stdout (the log channel the SDK scans), and write
74
+ # the webhook payload to a file for the curl below.
75
+ SANDBOX_JOB_STATUS="$_status" \
76
+ SANDBOX_JOB_EXIT="$inner_exit" \
77
+ SANDBOX_RESULT="$SANDBOX_RESULT" \
78
+ SANDBOX_JOB_SENTINEL="__RESULT_SENTINEL__" \
79
+ SANDBOX_JOB_NONCE="${_SANDBOX_JOB_NONCE:-}" \
80
+ SANDBOX_JOB_WEBHOOK_PAYLOAD="$_SANDBOX_WEBHOOK_PAYLOAD" \
81
+ python3 -c '
82
+ import base64, json, os
83
+ status = os.environ["SANDBOX_JOB_STATUS"]
84
+ exit_code = int(os.environ["SANDBOX_JOB_EXIT"])
85
+ result = None
86
+ try:
87
+ with open(os.environ["SANDBOX_RESULT"]) as fh:
88
+ result = json.load(fh)
89
+ except Exception:
90
+ result = None
91
+ if not isinstance(result, dict):
92
+ result = {"exit_code": exit_code}
93
+ core = {"status": status, "exit_code": exit_code, "result": result,
94
+ "nonce": os.environ.get("SANDBOX_JOB_NONCE", "")}
95
+ print(os.environ["SANDBOX_JOB_SENTINEL"] + base64.b64encode(json.dumps(core).encode()).decode())
96
+ webhook = dict(core)
97
+ webhook["job_id"] = os.environ.get("SANDBOX_JOB_ID", "")
98
+ with open(os.environ["SANDBOX_JOB_WEBHOOK_PAYLOAD"], "w") as fh:
99
+ json.dump(webhook, fh)
100
+ '
101
+
102
+ # Best-effort webhook POST (failures swallowed). Needs only egress.
103
+ if [ -n "${SANDBOX_JOB_WEBHOOK:-}" ] && [ -f "$_SANDBOX_WEBHOOK_PAYLOAD" ]; then
104
+ curl -m 10 -s -X POST \
105
+ -H 'Content-Type: application/json' \
106
+ -d @"$_SANDBOX_WEBHOOK_PAYLOAD" \
107
+ "$SANDBOX_JOB_WEBHOOK" >/dev/null 2>&1 || true
108
+ fi
109
+
110
+ exit "$inner_exit"
111
+ """.replace("__RESULT_SENTINEL__", _RESULT_SENTINEL)
@@ -0,0 +1,53 @@
1
+ """The values both sides of the sandbox boundary must agree on.
2
+
3
+ Each of these is read by a *different process* from the one that writes it: the
4
+ in-container programs in this package bake them into their source as literals,
5
+ and the client-side modules use the same names to parse what comes back. Defining
6
+ them once, here, is what keeps the two halves in step — the alternative is a
7
+ literal repeated in a source string, where no test and no type checker can notice
8
+ it drifting.
9
+
10
+ They are therefore a **compatibility surface**, not internal names. A sandbox that
11
+ was deployed by an older SDK is still running the older text; changing a value
12
+ here stops the current client from recognising that container's output.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ # The stdout sentinel the in-sandbox runner prints on completion. The payload
18
+ # after the prefix is base64(json) where json = {status, exit_code, result}.
19
+ _RESULT_SENTINEL = "__SANDBOX_RESULT__"
20
+
21
+ # The mailbox file the daemon tails inside the container.
22
+ _MAILBOX_PATH = "/tmp/sandbox_mailbox.jsonl"
23
+
24
+ # The stdout sentinel the in-container daemon prints per reply. The payload is
25
+ # ``<msg_id>__<nonce>__<base64(json(reply))>`` so a reply is matched to its message
26
+ # id AND authenticated by the per-session nonce, which keeps the untrusted
27
+ # workload from forging a reply for someone else's message.
28
+ _REPLY_SENTINEL = "__SANDBOX_REPLY__"
29
+
30
+ # Attribute the marker stamps onto a method so the generated session shim can
31
+ # discover the cold-start hook by introspection (mirrors function-mode's
32
+ # discovery of fn.__name__). Kept as a plain attribute flag — no wrapper class —
33
+ # so the method is still a normal bound method on the user's instance.
34
+ _ENTER_FLAG = "__sandbox_session_enter__"
35
+
36
+ # The stdout sentinel the in-container filesystem helper prefixes its JSON with.
37
+ _FS_SENTINEL = "__SBX_FS__"
38
+
39
+ # The stdout sentinels the in-container inotify monitor prints: one READY line
40
+ # once the watch is armed, then one prefixed JSON line per event.
41
+ _WATCH_SENTINEL = "__SBX_WATCH__"
42
+ _WATCH_READY = _WATCH_SENTINEL + "READY"
43
+
44
+ # Raw inotify(7) mask bits (uapi/linux/inotify.h) -- hard-coded so the in-container
45
+ # program needs no imports beyond the stdlib.
46
+ _IN_MODIFY = 0x00000002
47
+ _IN_CLOSE_WRITE = 0x00000008
48
+ _IN_MOVED_FROM = 0x00000040
49
+ _IN_MOVED_TO = 0x00000080
50
+ _IN_CREATE = 0x00000100
51
+ _IN_DELETE = 0x00000200
52
+ _IN_ACCESS = 0x00000001
53
+ _IN_OPEN = 0x00000020