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,480 @@
1
+ """External MCP servers exposed to the sandbox.
2
+
3
+ An **EXTERNAL MCP SERVER** is a Snowflake schema-level object (``DB.SCHEMA.NAME``)
4
+ that points at a remote MCP endpoint through an API INTEGRATION whose
5
+ ``api_provider`` is ``external_mcp``. Declaring one on a sandbox is addressing
6
+ only — you name the object, and Snowflake resolves everything else:
7
+
8
+ from snowflake.sandbox import Sandbox
9
+
10
+ with Sandbox.create(mcp_servers=["MYDB.MYSCHEMA.GITHUB_MCP_SERVER"]) as sb:
11
+ sb.exec(["python", "agent.py"])
12
+
13
+ That is the same shape every other Snowflake surface takes — Cortex Agents accept
14
+ ``mcp_servers`` as a list of FQN strings and Snowflake resolves them server-side, per
15
+ invoking user. Pass `McpServer` instead of a bare string only to override the
16
+ client-facing ``name`` or the transport ``profile``:
17
+
18
+ from snowflake.sandbox import McpServer
19
+
20
+ gh = McpServer.external("MYDB.MYSCHEMA.GITHUB_MCP_SERVER", name="github")
21
+
22
+ **What the SDK sends, and what it must never send.** The wire entry has seven
23
+ fields, and the SDK populates three of them (``kind``, ``name``, ``fqn``) plus
24
+ ``profile``. The other three — ``url``, ``api_integration``, ``secret_entity_id``
25
+ — are filled in by Snowflake *after* it has resolved the server under the calling user's
26
+ own identity. They are not caller input: accepting them would let one user point
27
+ a sandbox at another user's OAuth secret, so the SDK rejects them up front and
28
+ the platform rejects them again with a 400. That double rejection is the security
29
+ boundary of this feature, and `validate_mcp_servers` is the client half of it.
30
+
31
+ The per-user OAuth token itself never enters the container: the platform injects a
32
+ dummy into ``SANDBOX_MCP_TOKEN_<NAME>`` (see `mcp_token_env_var`) and the egress
33
+ proxy swaps in the real token on the wire, the same mechanism `Secret` uses.
34
+
35
+ Use `list_mcp_servers` to see what this account has and whether *you* have
36
+ authorized it — connecting is a one-time per-user OAuth flow done in Snowsight,
37
+ and a server you have not authorized is silently omitted from the sandbox rather
38
+ than handed over with an empty token.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import re
44
+ from collections.abc import Mapping, Sequence
45
+ from dataclasses import dataclass
46
+ from typing import Literal, NoReturn, cast, get_args
47
+
48
+ from snowflake.sandbox._mcp_discovery import ( # noqa: F401
49
+ _auth_statuses,
50
+ _auth_statuses_call,
51
+ _column_index,
52
+ _is_privilege_error,
53
+ _mcp_discovery_error,
54
+ _server_row,
55
+ list_mcp_servers,
56
+ )
57
+ from snowflake.sandbox._mcp_status import ( # noqa: F401
58
+ MCP_STATUS_LIVE,
59
+ MCP_STATUS_PATH,
60
+ MCP_STATUS_RECONNECT_REQUIRED,
61
+ MCP_STATUS_UNKNOWN,
62
+ McpConnectionStatus,
63
+ _load_status_doc,
64
+ _status_entry,
65
+ mcp_server_status,
66
+ )
67
+ from snowflake.sandbox.exceptions import SandboxError
68
+
69
+ # `list_mcp_servers` and its SQL helpers now live in `_mcp_discovery`; the
70
+ # in-sandbox status surface (`mcp_server_status`, `McpConnectionStatus`, the
71
+ # ``MCP_STATUS_*`` vocabulary) lives in `_mcp_status`. Both are re-exported above
72
+ # so ``from snowflake.sandbox.mcp import ...`` and __init__'s ``_LAZY`` map keep
73
+ # resolving them here unchanged. The re-export edge is one-way — those modules
74
+ # reach back for mcp's value objects only inside function bodies — which keeps
75
+ # the package's import graph acyclic (see test_module_layout).
76
+
77
+ __all__ = [
78
+ "McpServer",
79
+ "McpServerInfo",
80
+ "McpConnectionStatus",
81
+ "MCP_SERVER_KIND",
82
+ "DEFAULT_MCP_PROFILE",
83
+ "MCP_STATUS_PATH",
84
+ "MCP_STATUS_LIVE",
85
+ "MCP_STATUS_RECONNECT_REQUIRED",
86
+ "MCP_STATUS_UNKNOWN",
87
+ "list_mcp_servers",
88
+ "mcp_server_status",
89
+ "mcp_token_env_var",
90
+ "validate_mcp_servers",
91
+ ]
92
+
93
+
94
+ # The only kind on the wire. There is no "nova" kind: Nova is at most a provider
95
+ # behind an API INTEGRATION, never the abstraction a caller addresses.
96
+ MCP_SERVER_KIND: Literal["external_mcp"] = "external_mcp"
97
+
98
+ # Transport profile the runtime renders into ``mcp.json``. Streamable HTTP is what
99
+ # the shipped runtime speaks; the field exists so a future transport does not need
100
+ # a new SDK release.
101
+ DEFAULT_MCP_PROFILE = "streamablehttp"
102
+
103
+ # Prefix for the dummy-token env var, per the SANDBOX_MCP_SERVERS contract. The
104
+ # SANDBOX_ prefix is server-reserved on the create body's `env` MAP and permitted
105
+ # only on `sandbox_env`, which is where the server delivers these — see
106
+ # `_env._PLATFORM_ENV_PREFIXES`.
107
+ _MCP_TOKEN_ENV_PREFIX = "SANDBOX_MCP_TOKEN_"
108
+
109
+ # Fields Snowflake resolves per user, server-side. A caller cannot know them (the OAuth
110
+ # SECRET is an invisible entity in a parentless per-user schema with a
111
+ # deliberately meaningless name) and must not be able to assert them.
112
+ _GS_RESOLVED_FIELDS: tuple[str, ...] = ("url", "api_integration", "secret_entity_id")
113
+
114
+
115
+ # One FQN part: an unquoted Snowflake identifier or a "quoted identifier".
116
+ _FQN_PART = re.compile(r'^(?:[A-Za-z_][A-Za-z0-9_$]*|"[^"]+")$')
117
+
118
+ # Client-facing name: what mcp.json keys on and what the dummy env var is derived
119
+ # from. Kept to a charset that survives that derivation legibly.
120
+ _MCP_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.\-]*$")
121
+
122
+ # Transport profile: a lowercase token, no punctuation beyond _ and -.
123
+ _MCP_PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_\-]*$")
124
+
125
+
126
+ def mcp_token_env_var(name: str) -> str:
127
+ """The environment variable an MCP server's dummy token lands in.
128
+
129
+ Non-alphanumeric characters become underscores and the name is uppercased, so
130
+ a server named ``github`` gets ``SANDBOX_MCP_TOKEN_GITHUB`` and one named
131
+ ``jira-cloud`` gets ``SANDBOX_MCP_TOKEN_JIRA_CLOUD``. The value in the
132
+ container is a dummy; the egress proxy substitutes the real per-user OAuth
133
+ token on requests to that server's host.
134
+ """
135
+ return _MCP_TOKEN_ENV_PREFIX + re.sub(r"[^A-Za-z0-9]", "_", name).upper()
136
+
137
+
138
+ def _reject_removed(where: str, keys: Sequence[str]) -> None:
139
+ """Raise for any Snowflake-resolved field the caller must not assert.
140
+
141
+ ``url`` / ``api_integration`` / ``secret_entity_id`` are filled in server-side from
142
+ the EXTERNAL MCP SERVER named by ``fqn``, under the calling user's own OAuth
143
+ authorization — accepting them from a caller would let one user point a sandbox at
144
+ another user's secret. Every other unrecognized keyword is caught by the strict
145
+ constructor / mapping validators (a plain "unexpected keyword" / "unknown field"),
146
+ so superseded spellings like ``secret_fqn=`` / ``connector=`` now fail there.
147
+ """
148
+ for key in keys:
149
+ if key in _GS_RESOLVED_FIELDS:
150
+ raise SandboxError(
151
+ f"{where}: {key!r} is resolved by Snowflake, not by the caller. "
152
+ f"url, api_integration, and secret_entity_id are filled in "
153
+ f"server-side from the EXTERNAL MCP SERVER named by fqn, under the "
154
+ f"calling user's own OAuth authorization — accepting them from a "
155
+ f"caller would let one user point a sandbox at another user's "
156
+ f"secret, so the platform rejects them with a 400 as well. Pass only "
157
+ f"fqn (and optionally name / profile)."
158
+ )
159
+
160
+
161
+ def _validate_fqn(fqn: str) -> str:
162
+ """Return *fqn* if it is a three-part Snowflake object name, else raise."""
163
+ raw = fqn.strip()
164
+ if not raw:
165
+ raise SandboxError("MCP server fqn must not be empty")
166
+ parts = raw.split(".")
167
+ if len(parts) != 3 or not all(_FQN_PART.match(p) for p in parts):
168
+ raise SandboxError(
169
+ f"MCP server fqn {fqn!r} is not a fully-qualified Snowflake object name: "
170
+ "expected DB.SCHEMA.SERVER (letters, digits, '_' or '$' per part; a "
171
+ 'double-quoted "identifier" is also accepted). Run '
172
+ "snowflake.sandbox.list_mcp_servers() to see the FQNs this account has."
173
+ )
174
+ return raw
175
+
176
+
177
+ def _name_from_fqn(fqn: str) -> str:
178
+ """Derive a client-facing name from an FQN's last part.
179
+
180
+ Unquoted, lowercased, and squashed to the name charset — a quoted identifier
181
+ can hold spaces and punctuation that an explicitly-passed name is not allowed
182
+ to, and a derived name failing validation would report a name the caller never
183
+ typed. Pass ``name=`` to choose your own.
184
+ """
185
+ last = fqn.split(".")[-1]
186
+ if last.startswith('"') and last.endswith('"'):
187
+ last = last[1:-1]
188
+ squashed = re.sub(r"[^A-Za-z0-9_.\-]", "_", last.lower()).lstrip("_.-")
189
+ return squashed or "mcp"
190
+
191
+
192
+ @dataclass(frozen=True, init=False)
193
+ class McpServer:
194
+ """An EXTERNAL MCP SERVER to expose to the sandbox, referenced by FQN.
195
+
196
+ A bare FQN string is the common case and needs none of this —
197
+ ``mcp_servers=["MYDB.MYSCHEMA.GITHUB_MCP_SERVER"]`` is equivalent to
198
+ ``mcp_servers=[McpServer.external("MYDB.MYSCHEMA.GITHUB_MCP_SERVER")]``.
199
+ Construct one explicitly to override the client-facing `name` (which
200
+ ``mcp.json`` keys on, and from which the dummy-token env var is derived) or the
201
+ transport `profile`.
202
+
203
+ Example:
204
+ gh = McpServer.external(
205
+ "MYDB.MYSCHEMA.GITHUB_MCP_SERVER",
206
+ name="github", # -> SANDBOX_MCP_TOKEN_GITHUB
207
+ )
208
+ with Sandbox.create(mcp_servers=[gh]) as sb:
209
+ sb.exec(["python", "agent.py"])
210
+
211
+ `url`, `api_integration`, and `secret_entity_id` are deliberately absent: Snowflake
212
+ resolves them per calling user and the SDK is not allowed to assert them.
213
+ """
214
+
215
+ fqn: str
216
+ name: str | None = None
217
+ profile: str = DEFAULT_MCP_PROFILE
218
+
219
+ def __init__(
220
+ self,
221
+ fqn: str,
222
+ name: str | None = None,
223
+ profile: str = DEFAULT_MCP_PROFILE,
224
+ **removed: object,
225
+ ) -> None:
226
+ # Hand-written so a superseded or Snowflake-resolved keyword raises the message
227
+ # that names its replacement, rather than a bare TypeError from the
228
+ # generated __init__ (or, worse, being accepted and dropped).
229
+ _reject_removed("McpServer", list(removed))
230
+ if removed:
231
+ raise SandboxError(
232
+ f"McpServer got unexpected keyword(s) {sorted(removed)!r}: it takes "
233
+ "fqn, name, and profile only."
234
+ )
235
+ object.__setattr__(self, "fqn", _validate_fqn(fqn))
236
+ object.__setattr__(self, "name", name)
237
+ object.__setattr__(self, "profile", profile)
238
+ self._validate()
239
+
240
+ def _validate(self) -> None:
241
+ name = self.name if self.name is not None else _name_from_fqn(self.fqn)
242
+ if not _MCP_NAME_RE.match(name):
243
+ raise SandboxError(
244
+ f"MCP server name {name!r} is not usable: expected letters, digits, "
245
+ "'_', '.' or '-', starting with a letter or digit. The name keys "
246
+ "mcp.json and is uppercased into the dummy-token env var "
247
+ f"({_MCP_TOKEN_ENV_PREFIX}<NAME>)."
248
+ )
249
+ object.__setattr__(self, "name", name)
250
+ if not _MCP_PROFILE_RE.match(self.profile):
251
+ raise SandboxError(
252
+ f"MCP server profile {self.profile!r} is not a transport profile: "
253
+ f'expected a lowercase token such as "{DEFAULT_MCP_PROFILE}".'
254
+ )
255
+
256
+ @property
257
+ def kind(self) -> Literal["external_mcp"]:
258
+ """The wire kind — always ``external_mcp``."""
259
+ return MCP_SERVER_KIND
260
+
261
+ @property
262
+ def token_env_var(self) -> str:
263
+ """The env var this server's dummy token lands in inside the container."""
264
+ return mcp_token_env_var(str(self.name))
265
+
266
+ @staticmethod
267
+ def external(
268
+ fqn: str,
269
+ *,
270
+ name: str | None = None,
271
+ profile: str = DEFAULT_MCP_PROFILE,
272
+ **removed: object,
273
+ ) -> McpServer:
274
+ """Declare an EXTERNAL MCP SERVER by fully-qualified name.
275
+
276
+ Parameters
277
+ ----------
278
+ fqn:
279
+ ``DB.SCHEMA.SERVER`` — the EXTERNAL MCP SERVER object. This is the only
280
+ required field, and the only addressing the platform accepts; the URL,
281
+ API integration, and per-user OAuth secret behind it are resolved
282
+ server-side under the calling user's identity.
283
+ name:
284
+ Client-facing name: the key in ``mcp.json`` and the source of the
285
+ dummy-token env var. Defaults to the FQN's last part, lowercased.
286
+ profile:
287
+ Transport profile. Defaults to ``"streamablehttp"``.
288
+ """
289
+ _reject_removed("McpServer.external", list(removed))
290
+ if removed:
291
+ raise SandboxError(
292
+ f"McpServer.external got unexpected keyword(s) "
293
+ f"{sorted(removed)!r}: it takes fqn, name, and profile only."
294
+ )
295
+ return McpServer(fqn=fqn, name=name, profile=profile)
296
+
297
+ @staticmethod
298
+ def nova(*_args: object, **_kwargs: object) -> NoReturn:
299
+ """Removed — raises `SandboxError` naming the replacement."""
300
+ raise SandboxError(
301
+ "McpServer.nova() has been removed: 'nova' was never the object a caller "
302
+ "addresses (it is at most a provider behind an API INTEGRATION), and its "
303
+ "secret_fqn= argument was impossible to populate. Use "
304
+ 'McpServer.external("DB.SCHEMA.SERVER") — or just pass the FQN string: '
305
+ 'mcp_servers=["DB.SCHEMA.SERVER"].'
306
+ )
307
+
308
+ @staticmethod
309
+ def account(*_args: object, **_kwargs: object) -> NoReturn:
310
+ """Removed — raises `SandboxError` naming the replacement."""
311
+ raise SandboxError(
312
+ "McpServer.account() has been removed: its database/schema/server triple "
313
+ 'is the one FQN this surface takes. Use McpServer.external("DB.SCHEMA.'
314
+ 'SERVER") — or just pass the FQN string: mcp_servers=["DB.SCHEMA.SERVER"].'
315
+ )
316
+
317
+ def to_wire(self) -> dict[str, str]:
318
+ """Serialize to the caller-supplied half of the ``SANDBOX_MCP_SERVERS`` entry.
319
+
320
+ Exactly four keys: ``kind``, ``name``, ``fqn``, ``profile``. Snowflake adds
321
+ ``url``, ``api_integration``, and ``secret_entity_id`` after resolving the
322
+ server for the calling user; the SDK never sends those.
323
+ """
324
+ return {
325
+ "kind": MCP_SERVER_KIND,
326
+ "name": str(self.name),
327
+ "fqn": self.fqn,
328
+ "profile": self.profile,
329
+ }
330
+
331
+
332
+ def _server_from_mapping(srv: Mapping[str, object]) -> McpServer:
333
+ """Build an `McpServer` from one mapping entry of ``mcp_servers=``.
334
+
335
+ Only ``kind``/``fqn``/``name``/``profile`` are accepted, and ``kind`` only as
336
+ the single supported value: a mapping is the form a caller is most likely to
337
+ hand-assemble (or paste from another surface), so an unrecognised key is an
338
+ error rather than something silently dropped.
339
+ """
340
+ keys = [str(k) for k in srv]
341
+ _reject_removed("mcp_servers entry", keys)
342
+ unknown = [k for k in keys if k not in ("kind", "fqn", "name", "profile")]
343
+ if unknown:
344
+ raise SandboxError(
345
+ f"mcp_servers entry has unknown field(s) {sorted(unknown)!r}: an "
346
+ "entry carries fqn (required) and optionally name and profile."
347
+ )
348
+ kind = srv.get("kind", MCP_SERVER_KIND)
349
+ if kind != MCP_SERVER_KIND:
350
+ raise SandboxError(
351
+ f"mcp_servers entry kind {kind!r} is not supported: the only kind "
352
+ f"is {MCP_SERVER_KIND!r} (an EXTERNAL MCP SERVER named by fqn)."
353
+ )
354
+ fqn = srv.get("fqn")
355
+ if not isinstance(fqn, str) or not fqn:
356
+ raise SandboxError(
357
+ "mcp_servers entry requires fqn='DB.SCHEMA.SERVER' (the EXTERNAL "
358
+ "MCP SERVER object; everything else is resolved server-side)."
359
+ )
360
+ raw_name = srv.get("name")
361
+ raw_profile = srv.get("profile", DEFAULT_MCP_PROFILE)
362
+ return McpServer(
363
+ fqn=fqn,
364
+ name=str(raw_name) if raw_name is not None else None,
365
+ profile=str(raw_profile),
366
+ )
367
+
368
+
369
+ def validate_mcp_servers(
370
+ servers: Sequence[str | McpServer | Mapping[str, object]],
371
+ ) -> list[dict[str, str]]:
372
+ """Validate ``mcp_servers=`` and convert it to wire entries.
373
+
374
+ Accepts a bare FQN string (the common case), an `McpServer`, or a mapping with
375
+ ``fqn`` and optionally ``name`` / ``profile``. Raises `SandboxError` for a
376
+ field the caller cannot legitimately supply — the Snowflake-resolved ``url`` /
377
+ ``api_integration`` / ``secret_entity_id`` — and for any other unrecognized
378
+ keyword (a plain "unexpected keyword" / "unknown field"), which is how the
379
+ superseded ``secret_fqn=`` / ``connector=`` / ``database=`` spellings now fail.
380
+
381
+ Duplicate names, duplicate FQNs, and two names that collapse to the same dummy
382
+ env var are all rejected: each would be a last-one-wins overwrite in the
383
+ container rather than an error.
384
+ """
385
+ entries: list[dict[str, str]] = []
386
+ names_seen: set[str] = set()
387
+ fqns_seen: set[str] = set()
388
+ env_vars_seen: dict[str, str] = {}
389
+
390
+ for srv in servers:
391
+ if isinstance(srv, McpServer):
392
+ entry = srv.to_wire()
393
+ elif isinstance(srv, str):
394
+ entry = McpServer(fqn=srv).to_wire()
395
+ elif isinstance(srv, Mapping):
396
+ entry = _server_from_mapping(srv).to_wire()
397
+ else:
398
+ raise SandboxError(
399
+ f"mcp_servers entry must be an FQN string, McpServer, or mapping, got "
400
+ f"{type(srv).__name__}"
401
+ )
402
+
403
+ name = entry["name"]
404
+ if name in names_seen:
405
+ raise SandboxError(f"duplicate MCP server name: {name!r}")
406
+ names_seen.add(name)
407
+ if entry["fqn"] in fqns_seen:
408
+ raise SandboxError(f"duplicate MCP server fqn: {entry['fqn']!r}")
409
+ fqns_seen.add(entry["fqn"])
410
+ env_var = mcp_token_env_var(name)
411
+ clash = env_vars_seen.get(env_var)
412
+ if clash is not None:
413
+ raise SandboxError(
414
+ f"MCP server names {clash!r} and {name!r} both derive the dummy-token "
415
+ f"env var {env_var}, so one token would overwrite the other in the "
416
+ "container. Give them names that differ by more than punctuation."
417
+ )
418
+ env_vars_seen[env_var] = name
419
+
420
+ entries.append(entry)
421
+
422
+ return entries
423
+
424
+
425
+ # --------------------------------------------------------------------------- #
426
+ # Discovery
427
+ # --------------------------------------------------------------------------- #
428
+
429
+ # Statuses SYSTEM$GET_USER_INTEGRATION_AUTHORIZATIONS returns, plus the SDK's own
430
+ # "could not determine" value.
431
+ MCP_AUTH_CONNECTED: Literal["CONNECTED"] = "CONNECTED"
432
+ MCP_AUTH_NOT_CONNECTED: Literal["NOT_CONNECTED"] = "NOT_CONNECTED"
433
+ MCP_AUTH_NEEDS_REAUTH: Literal["NEEDS_REAUTH"] = "NEEDS_REAUTH"
434
+ MCP_AUTH_INTEGRATION_NOT_FOUND: Literal["INTEGRATION_NOT_FOUND"] = "INTEGRATION_NOT_FOUND"
435
+ MCP_AUTH_UNKNOWN: Literal["UNKNOWN"] = "UNKNOWN"
436
+
437
+ McpAuthStatus = Literal[
438
+ "CONNECTED", "NOT_CONNECTED", "NEEDS_REAUTH", "INTEGRATION_NOT_FOUND", "UNKNOWN"
439
+ ]
440
+ """Per-user authorization status of an EXTERNAL MCP SERVER (`McpServerInfo.status`)."""
441
+
442
+
443
+ def _coerce_auth_status(raw: object) -> McpAuthStatus:
444
+ """Clamp a wire authorization value to the known set; unknown -> ``UNKNOWN``."""
445
+ return cast("McpAuthStatus", raw) if raw in get_args(McpAuthStatus) else MCP_AUTH_UNKNOWN
446
+
447
+
448
+ @dataclass(frozen=True)
449
+ class McpServerInfo:
450
+ """One EXTERNAL MCP SERVER in the account, with *your* authorization status.
451
+
452
+ `status` is per calling user, because the OAuth authorization is: the same
453
+ server reads ``CONNECTED`` for one user and ``NOT_CONNECTED`` for the next.
454
+ Values are ``CONNECTED``, ``NOT_CONNECTED``, ``NEEDS_REAUTH``,
455
+ ``INTEGRATION_NOT_FOUND``, or ``UNKNOWN`` when the status could not be read
456
+ (no API integration on the server, or the account does not expose the
457
+ ``api_integration`` column of ``SHOW EXTERNAL MCP SERVERS``).
458
+
459
+ This is the *account authorization* view, and it can lag reality: a grant
460
+ revoked on the provider side still reads ``CONNECTED`` here until Snowflake
461
+ next tries to use the token. From inside a sandbox, `mcp_server_status` reads
462
+ the hostagent's own most-recent fetch result instead, which is the signal
463
+ that actually tells you whether a tool call will work right now.
464
+ """
465
+
466
+ name: str
467
+ fqn: str
468
+ api_integration: str | None
469
+ status: McpAuthStatus
470
+ enabled: bool | None = None
471
+ comment: str | None = None
472
+
473
+ @property
474
+ def connected(self) -> bool:
475
+ """True when this user has a live OAuth authorization for the server."""
476
+ return self.status == MCP_AUTH_CONNECTED
477
+
478
+ def as_mcp_server(self, *, name: str | None = None) -> McpServer:
479
+ """The `McpServer` to pass to ``mcp_servers=`` for this entry."""
480
+ return McpServer.external(self.fqn, name=name)
@@ -0,0 +1,161 @@
1
+ """FUSE stage/workspace mounts.
2
+
3
+ ``StageMount`` is a Snowflake stage or workspace mounted into the container's
4
+ filesystem. Its counterpart in the other direction — ``Bundle``, the local
5
+ project tree shipped *up* to a stage — used to live here as well; it now sits in
6
+ ``_assemble`` next to the code that packages it.
7
+
8
+ from snowflake.sandbox import StageMount
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass, field
15
+
16
+ from snowflake.sandbox.exceptions import SandboxError
17
+
18
+ __all__ = ["StageMount", "validate_stage_mounts"]
19
+
20
+ # Container paths a stage must not be mounted over: a writable FUSE mount here
21
+ # would shadow the TLS trust store / CA bundle (defeating egress verification),
22
+ # the connector config + Snowflake credential dir, or core system binaries — i.e.
23
+ # turn a stage mount into a container-integrity / privilege-escalation primitive.
24
+ # The platform already rejects "/" and ".." for *secret file* mounts, but stage
25
+ # mounts have no such guard server-side. Reject the root itself and any path
26
+ # beneath it.
27
+ _DENIED_MOUNT_ROOTS: tuple[str, ...] = (
28
+ "/etc",
29
+ "/usr",
30
+ "/bin",
31
+ "/sbin",
32
+ "/lib",
33
+ "/lib64",
34
+ "/proc",
35
+ "/sys",
36
+ "/dev",
37
+ "/boot",
38
+ "/snowflake",
39
+ )
40
+
41
+
42
+ def _normalize_mount_path(mount_path: str) -> str:
43
+ """Validate a stage ``mount_path`` and return its normalized form.
44
+
45
+ Requires an absolute POSIX path with no ``..`` component and no NUL/newline,
46
+ and refuses the filesystem root and the system roots in
47
+ `_DENIED_MOUNT_ROOTS` (and anything beneath them). Raises `SandboxError`.
48
+ Callers pass only a non-empty path (empty means "let the platform default").
49
+ """
50
+ from posixpath import normpath
51
+
52
+ if any(c in mount_path for c in "\x00\n\r"):
53
+ raise SandboxError("stage mount_path must not contain NUL or newline characters")
54
+ if not mount_path.startswith("/"):
55
+ raise SandboxError(
56
+ f"stage mount_path {mount_path!r} must be an absolute path (start with '/')"
57
+ )
58
+ if ".." in mount_path.split("/"):
59
+ raise SandboxError(f"stage mount_path {mount_path!r} must not contain a '..' component")
60
+ # posixpath.normpath preserves exactly two leading slashes ('//etc' stays
61
+ # '//etc'), which Linux resolves to '/etc' — so collapse leading slashes before
62
+ # the root/denylist checks, or '//etc' / '//' would slip past them.
63
+ norm = "/" + normpath(mount_path).lstrip("/")
64
+ if norm == "/":
65
+ raise SandboxError("stage mount_path must not be the filesystem root '/'")
66
+ lowered = norm.lower()
67
+ for root in _DENIED_MOUNT_ROOTS:
68
+ if lowered == root or lowered.startswith(root + "/"):
69
+ raise SandboxError(
70
+ f"stage mount_path {mount_path!r} is under the reserved system path "
71
+ f"{root!r}; mounting a stage there would shadow system files "
72
+ "(TLS trust store, credentials, or binaries). Choose a path like "
73
+ "'/workspace' or '/data'."
74
+ )
75
+ return norm
76
+
77
+
78
+ def validate_stage_mounts(mounts: Sequence[StageMount]) -> None:
79
+ """Reject two mounts that target the same ``mount_path``.
80
+
81
+ Each `StageMount` already validates its own path at construction; this is the
82
+ cross-mount check (two stages mounted at the same point is a silent
83
+ last-one-wins on the platform). Raises `SandboxError`.
84
+ """
85
+ seen: dict[str, str] = {}
86
+ for m in mounts:
87
+ if not m.mount_path:
88
+ continue
89
+ norm = _normalize_mount_path(m.mount_path)
90
+ if norm in seen:
91
+ raise SandboxError(
92
+ f"stage mounts collide on mount_path {norm!r}: {seen[norm]!r} and "
93
+ f"{m.stage_name!r} would mount at the same point"
94
+ )
95
+ seen[norm] = m.stage_name
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class StageMount:
100
+ """A Snowflake stage or workspace FUSE-mounted into the container.
101
+
102
+ The mount is fully ready before the container command starts.
103
+
104
+ Example:
105
+ mount = StageMount.from_workspace("MY_WS", mount_path="/workspace")
106
+ data = StageMount.from_stage("MY_STAGE", mount_path="/data", readonly=True)
107
+ """
108
+
109
+ stage_name: str
110
+ mount_path: str = ""
111
+ readonly: bool = False
112
+ # Internal fields — set by named constructors, not exposed to users.
113
+ _is_vstage: bool = field(default=False, repr=False)
114
+ _vstage_namespace: str = field(default="", repr=False)
115
+ _vstage_version: str = field(default="live", repr=False)
116
+
117
+ def __post_init__(self) -> None:
118
+ # A non-empty mount_path is validated at construction so an unsafe target
119
+ # (system root, ``..``, non-absolute) fails at author time rather than
120
+ # mounting a writable stage over the TLS store / credentials / binaries.
121
+ # An empty mount_path is left for the platform to default.
122
+ if self.mount_path:
123
+ _normalize_mount_path(self.mount_path)
124
+
125
+ @staticmethod
126
+ def from_stage(name: str, *, mount_path: str = "", readonly: bool = False) -> StageMount:
127
+ """Mount a regular Snowflake stage."""
128
+ return StageMount(stage_name=name, mount_path=mount_path, readonly=readonly)
129
+
130
+ @staticmethod
131
+ def from_workspace(name: str, *, mount_path: str = "", readonly: bool = False) -> StageMount:
132
+ """Mount a Snowflake Workspace.
133
+
134
+ ``readonly=False`` (default) ensures a writable live version exists
135
+ before mounting. ``readonly=True`` mounts the head version read-only.
136
+ """
137
+ return StageMount(
138
+ stage_name=name,
139
+ mount_path=mount_path,
140
+ readonly=readonly,
141
+ _is_vstage=True,
142
+ _vstage_namespace="workspace",
143
+ _vstage_version="head" if readonly else "live",
144
+ )
145
+
146
+ def to_api_dict(self) -> dict[str, object]:
147
+ """Serialize to the REST request shape."""
148
+ d: dict[str, object] = {"stage_name": self.stage_name}
149
+ if self._is_vstage:
150
+ d["is_vstage"] = True
151
+ d["vstage_namespace"] = self._vstage_namespace
152
+ d["vstage_version"] = self._vstage_version
153
+ if self.mount_path:
154
+ d["mount_path"] = self.mount_path
155
+ if self.readonly:
156
+ # Omitted when False so the request shape is unchanged for the common
157
+ # case. Sending it when True is load-bearing: the flag was once accepted
158
+ # here and never emitted, so a mount asked to be read-only was mounted
159
+ # writable.
160
+ d["readonly"] = True
161
+ return d
File without changes