snowflake-sandbox-python 0.2.2a4__py3-none-any.whl → 0.2.2a6__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 (61) hide show
  1. snowflake/cli_sandbox/_egress_flags.py +34 -25
  2. snowflake/cli_sandbox/_git_repo_flags.py +58 -0
  3. snowflake/cli_sandbox/_lifecycle_command.py +108 -0
  4. snowflake/cli_sandbox/_remote_command.py +168 -0
  5. snowflake/cli_sandbox/_run_command.py +64 -3
  6. snowflake/cli_sandbox/_shell_command.py +116 -38
  7. snowflake/cli_sandbox/_upload_plan.py +1 -1
  8. snowflake/cli_sandbox/commands.py +86 -48
  9. snowflake/sandbox/__init__.py +13 -0
  10. snowflake/sandbox/__main__.py +4 -5
  11. snowflake/sandbox/_args.py +9 -65
  12. snowflake/sandbox/_bundle.py +2 -2
  13. snowflake/sandbox/_code_delivery.py +88 -0
  14. snowflake/sandbox/_deploy_spec.py +13 -5
  15. snowflake/sandbox/_diagnostics.py +5 -2
  16. snowflake/sandbox/_env.py +1 -1
  17. snowflake/sandbox/_fs_ops.py +3 -4
  18. snowflake/sandbox/_preview.py +173 -0
  19. snowflake/sandbox/_proto/__init__.py +0 -0
  20. snowflake/sandbox/_proto/sandbox_pb2.py +175 -0
  21. snowflake/sandbox/_proto/sandbox_pb2.pyi +704 -0
  22. snowflake/sandbox/_retry.py +15 -8
  23. snowflake/sandbox/_runtime/_shims.py +32 -4
  24. snowflake/sandbox/_sandbox_state.py +83 -18
  25. snowflake/sandbox/_session.py +143 -0
  26. snowflake/sandbox/_sse.py +2 -2
  27. snowflake/sandbox/_ssh.py +232 -0
  28. snowflake/sandbox/_sync_transport.py +23 -18
  29. snowflake/sandbox/_transport.py +50 -54
  30. snowflake/sandbox/_tunnel.py +185 -0
  31. snowflake/sandbox/_upload_plan.py +1 -1
  32. snowflake/sandbox/_version.py +1 -1
  33. snowflake/sandbox/_wire.py +64 -6
  34. snowflake/sandbox/app.py +2 -2
  35. snowflake/sandbox/client.py +558 -352
  36. snowflake/sandbox/connect.py +14 -14
  37. snowflake/sandbox/deploy.py +122 -22
  38. snowflake/sandbox/egress.py +92 -23
  39. snowflake/sandbox/endpoint.py +515 -0
  40. snowflake/sandbox/exceptions.py +54 -5
  41. snowflake/sandbox/failure.py +16 -17
  42. snowflake/sandbox/git_repo.py +191 -0
  43. snowflake/sandbox/image.py +542 -18
  44. snowflake/sandbox/jobs.py +59 -34
  45. snowflake/sandbox/log_stream.py +333 -129
  46. snowflake/sandbox/mcp.py +2 -2
  47. snowflake/sandbox/secret.py +88 -22
  48. snowflake/sandbox/session_app.py +18 -3
  49. snowflake/sandbox/shell.py +2 -3
  50. snowflake/sandbox/snowflake_connection.py +83 -0
  51. snowflake/sandbox/sync_client.py +555 -348
  52. snowflake/sandbox/sync_log_stream.py +79 -111
  53. snowflake/sandbox/sync_shell.py +2 -3
  54. snowflake/sandbox/warm_session.py +7 -7
  55. {snowflake_sandbox_python-0.2.2a4.dist-info → snowflake_sandbox_python-0.2.2a6.dist-info}/METADATA +55 -1
  56. snowflake_sandbox_python-0.2.2a6.dist-info/RECORD +95 -0
  57. {snowflake_sandbox_python-0.2.2a4.dist-info → snowflake_sandbox_python-0.2.2a6.dist-info}/WHEEL +1 -1
  58. snowflake_sandbox_python-0.2.2a4.dist-info/RECORD +0 -81
  59. {snowflake_sandbox_python-0.2.2a4.dist-info → snowflake_sandbox_python-0.2.2a6.dist-info}/entry_points.txt +0 -0
  60. {snowflake_sandbox_python-0.2.2a4.dist-info → snowflake_sandbox_python-0.2.2a6.dist-info}/licenses/LICENSE +0 -0
  61. {snowflake_sandbox_python-0.2.2a4.dist-info → snowflake_sandbox_python-0.2.2a6.dist-info}/top_level.txt +0 -0
@@ -10,14 +10,10 @@ Spellings, chosen to match what the CLI already does elsewhere:
10
10
  * ``--eai NAME`` — repeatable, like ``--env``. Names an External Access Integration
11
11
  whose network rules the platform resolves; that is the only way to reach a host
12
12
  outside the built-in baseline.
13
- * ``--secret ENV_VAR=DB.SCHEMA.NAME@host[,host]`` — repeatable. The env var comes
13
+ * ``--secret ENV_VAR=DB.SCHEMA.NAME[@host[,host]]`` — repeatable. The env var comes
14
14
  first because that is the order ``--env KEY=VAL`` established, and it is the half
15
- you read inside the sandbox. Omitting ``ENV_VAR=`` takes the SDK's derived
16
- ``CNG_SECRET_<FQN>`` name. The ``@host`` scopes where the credential may be sent and
17
- is **always required**: the SDK refuses an unscoped credential rather than let it
18
- egress anywhere. (Its error text says ``host=`` becomes optional once an EAI supplies
19
- the hosts — that is not implemented; an EAI does not relax the requirement, verified
20
- against `compile_egress`.)
15
+ you read inside the sandbox. The ``@host`` scopes where the credential may be sent
16
+ and is required unless an ``--eai`` supplies the host grant.
21
17
  * ``--no-default-egress`` — the closed level: Snowflake and its stages only, no
22
18
  baseline package-manager hosts. An ``--eai`` still grants on top of it, which is
23
19
  why this is not called ``--no-egress``.
@@ -27,7 +23,7 @@ from __future__ import annotations
27
23
 
28
24
  import typer
29
25
 
30
- from snowflake.sandbox.egress import Egress, compile_egress
26
+ from snowflake.sandbox.egress import Egress, compile_egress, compile_secrets
31
27
  from snowflake.sandbox.secret import Secret
32
28
 
33
29
  __all__ = ["EAI_HELP", "NO_DEFAULT_EGRESS_HELP", "SECRET_HELP", "build_egress"]
@@ -38,10 +34,17 @@ EAI_HELP = (
38
34
  "readable or the platform refuses the create."
39
35
  )
40
36
 
37
+ OUTBOUND_WIF_HOST_HELP = (
38
+ "Host that gets a Snowflake workload identity token as Authorization: Bearer, so it "
39
+ "can verify the calling account without a stored secret; repeatable. An exact host "
40
+ "or a leading-dot suffix (.example.com). It does not open egress: the host must also "
41
+ "be reachable, e.g. through --eai."
42
+ )
43
+
41
44
  SECRET_HELP = (
42
- "Snowflake SECRET to inject, as ENV_VAR=DB.SCHEMA.NAME@host[,host]; repeatable. "
43
- "The @host scopes where the credential may be sent and is required. Omit ENV_VAR= "
44
- "for the derived CNG_SECRET_<FQN> name."
45
+ "Snowflake SECRET to inject, as ENV_VAR=DB.SCHEMA.NAME[@host[,host]]; repeatable. "
46
+ "The @host scopes where the credential may be sent and is required unless an "
47
+ "--eai supplies the host grant. ENV_VAR= is required."
45
48
  )
46
49
 
47
50
  NO_DEFAULT_EGRESS_HELP = (
@@ -64,6 +67,8 @@ def _parse_secret(raw: str) -> Secret:
64
67
  env_var, spec = env_var.strip(), spec.strip()
65
68
  if not env_var:
66
69
  raise typer.BadParameter(f"empty env var in {raw!r} (expected ENV_VAR=DB.SCHEMA.NAME)")
70
+ else:
71
+ raise typer.BadParameter(f"{raw!r}: expected ENV_VAR=DB.SCHEMA.NAME[@host]")
67
72
 
68
73
  hosts: tuple[str, ...] = ()
69
74
  if "@" in spec:
@@ -89,38 +94,42 @@ def build_egress(
89
94
  eai: list[str] | None,
90
95
  secret: list[str] | None,
91
96
  no_default_egress: bool,
97
+ outbound_wif_host: list[str] | None = None,
92
98
  ) -> tuple[Egress | None, tuple[Secret, ...]]:
93
99
  """`(egress, secrets)` for these flags — both empty when none were given.
94
100
 
95
101
  Two values because that is the shape `Sandbox.create()` takes: secrets are authored
96
- alongside egress but are their own argument (they nest under egress only on the
97
- wire, where `compile_egress` puts them).
102
+ alongside egress and sent as their own top-level create field.
98
103
 
99
104
  `None` rather than an empty `Egress` when nothing was passed, so the create body
100
105
  stays byte-identical to today's for every existing caller.
101
106
  """
102
107
  integrations = tuple(dict.fromkeys(name.strip() for name in (eai or []) if name.strip()))
103
108
  secrets = tuple(_parse_secret(raw) for raw in (secret or []))
104
- if not integrations and not secrets and not no_default_egress:
109
+ wif_hosts = tuple(dict.fromkeys(h.strip() for h in (outbound_wif_host or []) if h.strip()))
110
+ if not integrations and not secrets and not no_default_egress and not wif_hosts:
105
111
  return None, ()
106
112
 
107
- egress = Egress(
108
- external_access_integrations=integrations,
109
- # Uses the current parameter name. The CLI FLAG stays `--no-default-egress`: it is
110
- # released, renaming it would break users' scripts, and that is a separate decision
111
- # from the library parameter's name. So the flag spelling and the parameter spelling
112
- # differ here deliberately — the alternative is the CLI depending on a deprecated
113
- # parameter, which is worse.
114
- **({"allow_public_package_registries": False} if no_default_egress else {}),
115
- )
116
113
  # Validate now, not at create time. `compile_egress` is pure and is what the client
117
114
  # runs anyway -- but it runs *after* connecting, so a mistyped host pair otherwise
118
115
  # costs a Snowflake round trip before saying so. These are usage errors; they should
119
- # cost nothing.
116
+ # cost nothing. Egress() is inside the try too: it rejects a bad --outbound-wif-host.
120
117
  from snowflake.sandbox.exceptions import SandboxError
121
118
 
122
119
  try:
123
- compile_egress(egress, secrets)
120
+ egress = Egress(
121
+ external_access_integrations=integrations,
122
+ # Uses the current parameter name. The CLI FLAG stays `--no-default-egress`: it
123
+ # is released, renaming it would break users' scripts, and that is a separate
124
+ # decision from the library parameter's name. So the flag spelling and the
125
+ # parameter spelling differ here deliberately — the alternative is the CLI
126
+ # depending on a deprecated parameter, which is worse. None is unset, which
127
+ # sends nothing for the level.
128
+ allow_public_package_registries=False if no_default_egress else None,
129
+ outbound_wif_hosts=wif_hosts,
130
+ )
131
+ compile_egress(egress, ())
132
+ compile_secrets(secrets, has_eai=bool(integrations))
124
133
  except SandboxError as exc:
125
134
  raise typer.BadParameter(str(exc)) from exc
126
135
  return egress, secrets
@@ -0,0 +1,58 @@
1
+ """`--git-repo` / `--git-integration`: mount Git repositories into a run's sandbox.
2
+
3
+ * ``--git-repo URL[=MOUNT_PATH]`` — repeatable. The first ``=`` starts the mount path.
4
+ * ``--git-integration NAME`` — the ``EXTERNAL_APPLICATION`` integration every
5
+ ``--git-repo`` is cloned through, one per run.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ import typer
13
+
14
+ if TYPE_CHECKING:
15
+ from snowflake.sandbox.git_repo import GitRepo
16
+
17
+ __all__ = ["GIT_INTEGRATION_HELP", "GIT_REPO_HELP", "build_git_repos"]
18
+
19
+ GIT_REPO_HELP = (
20
+ "Git repository to mount, as an https URL with an optional =MOUNT_PATH "
21
+ "(default /mnt/repos/<repo>); repeatable. Needs --git-integration. The default "
22
+ "branch is mounted writable, and git push from the sandbox has no credential."
23
+ )
24
+
25
+ GIT_INTEGRATION_HELP = (
26
+ "Snowflake EXTERNAL_APPLICATION integration that every --git-repo is cloned "
27
+ "through, with your own authorization of it."
28
+ )
29
+
30
+
31
+ def build_git_repos(raw: list[str] | None, integration: str | None) -> tuple[GitRepo, ...]:
32
+ """The `GitRepo`s for these flags, or a `typer.BadParameter`."""
33
+ from snowflake.sandbox.exceptions import SandboxError
34
+ from snowflake.sandbox.git_repo import GitRepo, validate_git_repos
35
+
36
+ if not raw:
37
+ if integration:
38
+ raise typer.BadParameter("--git-integration needs at least one --git-repo.")
39
+ return ()
40
+ if not integration:
41
+ raise typer.BadParameter(
42
+ "--git-repo needs --git-integration NAME, the EXTERNAL_APPLICATION "
43
+ "integration the repository is cloned through."
44
+ )
45
+ repos: list[GitRepo] = []
46
+ for spec in raw:
47
+ url, _, mount_path = spec.strip().partition("=")
48
+ try:
49
+ repos.append(
50
+ GitRepo(url.strip(), integration=integration, mount_path=mount_path.strip())
51
+ )
52
+ except SandboxError as exc:
53
+ raise typer.BadParameter(f"--git-repo {spec!r}: {exc}") from exc
54
+ try:
55
+ validate_git_repos(repos)
56
+ except SandboxError as exc:
57
+ raise typer.BadParameter(f"--git-repo: {exc}") from exc
58
+ return tuple(repos)
@@ -0,0 +1,108 @@
1
+ """``snow sandbox suspend`` and ``snow sandbox resume``.
2
+
3
+ The CLI counterparts to ``Sandbox.suspend()`` / ``Sandbox.resume()``. Snowflake
4
+ suspends an idle sandbox on its own timer; these let an operator bank a sandbox's
5
+ state on demand, and wake one without running anything in it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import typer
13
+
14
+ # From _common, where these are DEFINED. Importing them via _get_command (which
15
+ # re-exports them) is an implicit re-export and mypy rejects it.
16
+ from snowflake.cli_sandbox._common import (
17
+ _CONN_HELP,
18
+ _USE_SNOW_CONN_HELP,
19
+ _apply_connection,
20
+ _resolve_sandbox_id,
21
+ )
22
+
23
+
24
+ def _suspend_command(
25
+ sandbox: str = typer.Argument(..., help="Sandbox name (or id) to suspend."),
26
+ use_snow_connection: bool = typer.Option(
27
+ True,
28
+ "--use-snow-connection/--no-snow-connection",
29
+ help=_USE_SNOW_CONN_HELP,
30
+ ),
31
+ connection: str | None = typer.Option(
32
+ None,
33
+ "--connection",
34
+ "-c",
35
+ help=_CONN_HELP,
36
+ ),
37
+ **options: Any,
38
+ ) -> None:
39
+ """Suspend a sandbox: snapshot its filesystem and tear the container down.
40
+
41
+ The sandbox keeps its files; the next thing that touches it brings it back on
42
+ that snapshot with a fresh process tree. Already-suspended is success.
43
+
44
+ Example:
45
+ snow sandbox suspend SANDBOX
46
+ """
47
+ from snowflake.cli_sandbox._adapter import sdk_connection
48
+ from snowflake.sandbox.exceptions import SandboxError
49
+ from snowflake.sandbox.sync_client import get_sandbox
50
+
51
+ _apply_connection(use_snow_connection, connection)
52
+ try:
53
+ # The SYNC client: AsyncSandbox.connect is itself a coroutine, so a bare
54
+ # asyncio.run(sb.suspend()) on its result would fail on a coroutine object.
55
+ # get_sandbox(), not Sandbox.connect(): _resolve_sandbox_id already turned a
56
+ # name into a cntr_ id, and connect() resolves its argument as a NAME -- so
57
+ # feeding it an id fails with "no sandbox named cntr_...".
58
+ #
59
+ # The SYNC client throughout: AsyncSandbox.connect is itself a coroutine, so a
60
+ # bare asyncio.run(sb.suspend()) on its result fails on a coroutine object.
61
+ get_sandbox(_resolve_sandbox_id(sandbox), connection=sdk_connection()).suspend()
62
+ except SandboxError as exc:
63
+ typer.echo(f"error: {exc}", err=True)
64
+ raise typer.Exit(1) from exc
65
+ typer.echo(f"suspended {sandbox}")
66
+
67
+
68
+ def _resume_command(
69
+ sandbox: str = typer.Argument(..., help="Sandbox name (or id) to resume."),
70
+ use_snow_connection: bool = typer.Option(
71
+ True,
72
+ "--use-snow-connection/--no-snow-connection",
73
+ help=_USE_SNOW_CONN_HELP,
74
+ ),
75
+ connection: str | None = typer.Option(
76
+ None,
77
+ "--connection",
78
+ "-c",
79
+ help=_CONN_HELP,
80
+ ),
81
+ **options: Any,
82
+ ) -> None:
83
+ """Wake a suspended sandbox without running anything in it.
84
+
85
+ Rarely needed -- anything that touches a sandbox resumes it anyway -- so this
86
+ is for paying the cold start up front. Returns once the resume is under way,
87
+ not once the sandbox is serving.
88
+
89
+ Example:
90
+ snow sandbox resume SANDBOX
91
+ """
92
+ from snowflake.cli_sandbox._adapter import sdk_connection
93
+ from snowflake.sandbox.exceptions import SandboxError
94
+ from snowflake.sandbox.sync_client import get_sandbox
95
+
96
+ _apply_connection(use_snow_connection, connection)
97
+ try:
98
+ # get_sandbox(), not Sandbox.connect(): _resolve_sandbox_id already turned a
99
+ # name into a cntr_ id, and connect() resolves its argument as a NAME.
100
+ get_sandbox(_resolve_sandbox_id(sandbox), connection=sdk_connection()).resume()
101
+ except SandboxError as exc:
102
+ typer.echo(f"error: {exc}", err=True)
103
+ raise typer.Exit(1) from exc
104
+ typer.echo(f"resuming {sandbox}")
105
+
106
+
107
+ _SUSPEND_HELP = "Suspend a sandbox, snapshotting its filesystem.\n\nExample:\n snow sandbox suspend SANDBOX\n"
108
+ _RESUME_HELP = "Resume a suspended sandbox.\n\nExample:\n snow sandbox resume SANDBOX\n"
@@ -0,0 +1,168 @@
1
+ """CLI entry points for SSH, local TCP forwarding and the desktop page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from contextlib import redirect_stdout
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import typer
12
+
13
+ from snowflake.cli_sandbox._common import (
14
+ _CONN_HELP,
15
+ _USE_SNOW_CONN_HELP,
16
+ _apply_connection,
17
+ _resolve_sandbox_id,
18
+ )
19
+ from snowflake.sandbox.exceptions import SandboxError
20
+
21
+ if TYPE_CHECKING:
22
+ from snowflake.sandbox.config import Config
23
+
24
+ _SSH_HELP = "Connect with OpenSSH over an authenticated sandbox tunnel. Requires OpenSSH on PATH.\n\nExample:\n snow sandbox ssh my-sandbox\n snow sandbox ssh my-sandbox --cmd 'uname -a'"
25
+ _PORT_FORWARD_HELP = "Forward a loopback TCP port to a port inside an existing sandbox.\n\nExample:\n snow sandbox port-forward my-sandbox 8080 --local-port 18080"
26
+ _DESKTOP_HELP = "Open the remote desktop page for an existing sandbox.\n\nExample:\n snow sandbox desktop my-sandbox\n snow sandbox desktop my-sandbox --no-open"
27
+
28
+
29
+ def _remote_config(use_snow_connection: bool, connection: str | None) -> Config:
30
+ from snowflake.cli_sandbox._adapter import sdk_connection
31
+ from snowflake.sandbox.config import resolve_config
32
+
33
+ _apply_connection(use_snow_connection, connection=connection)
34
+ return resolve_config(connection=sdk_connection())
35
+
36
+
37
+ def _ssh_command(
38
+ sandbox_id: str = typer.Argument(..., help="Existing sandbox name or app id."),
39
+ user: str = typer.Option("root", "--user", help="SSH login user inside the sandbox."),
40
+ cmd: str | None = typer.Option(
41
+ None, "--cmd", help="Remote command; omit for an interactive SSH session."
42
+ ),
43
+ use_snow_connection: bool = typer.Option(
44
+ True, "--use-snow-connection/--no-snow-connection", help=_USE_SNOW_CONN_HELP
45
+ ),
46
+ connection: str | None = typer.Option(None, "--connection", "-c", help=_CONN_HELP),
47
+ proxy: bool = typer.Option(False, "--proxy", hidden=True),
48
+ identity: Path | None = typer.Option(None, "--identity", hidden=True),
49
+ known_hosts: Path | None = typer.Option(None, "--known-hosts", hidden=True),
50
+ **options: Any,
51
+ ) -> None:
52
+ from snowflake.sandbox._preview import preview_url
53
+ from snowflake.sandbox._ssh import run_ssh, ssh_proxy
54
+ from snowflake.sandbox._tunnel import resolve_remote_url
55
+
56
+ try:
57
+ if sandbox_id.startswith("cntr_"):
58
+ preview_url(sandbox_id, 8079, "org-account.example")
59
+ if proxy and (identity is None or known_hosts is None):
60
+ raise typer.BadParameter("--proxy requires --identity and --known-hosts")
61
+ if proxy or not sandbox_id.startswith("cntr_"):
62
+ # Connector/browser-login notices must not enter the SSH stream.
63
+ with redirect_stdout(sys.stderr):
64
+ config = _remote_config(use_snow_connection, connection)
65
+ sandbox_id = _resolve_sandbox_id(sandbox_id)
66
+ # Validate before key generation or spawning a process.
67
+ preview_url(sandbox_id, 8079, "org-account.example")
68
+ if proxy:
69
+ if identity is None or known_hosts is None:
70
+ raise typer.BadParameter("--proxy requires --identity and --known-hosts")
71
+
72
+ async def pump() -> None:
73
+ with redirect_stdout(sys.stderr):
74
+ url = await resolve_remote_url(sandbox_id, config, service="ssh")
75
+ await ssh_proxy(
76
+ url, config, identity=identity, known_hosts=known_hosts, alias=sandbox_id
77
+ )
78
+
79
+ asyncio.run(pump())
80
+ code = 0
81
+ else:
82
+ code = run_ssh(
83
+ sandbox_id,
84
+ user=user,
85
+ command=cmd,
86
+ connection=connection,
87
+ use_snow_connection=use_snow_connection,
88
+ )
89
+ except KeyboardInterrupt:
90
+ raise typer.Exit(130) from None
91
+ except (SandboxError, OSError) as exc:
92
+ typer.echo(f"error: {exc}", err=True)
93
+ raise typer.Exit(1) from exc
94
+ raise typer.Exit(code)
95
+
96
+
97
+ def _port_forward_command(
98
+ sandbox_id: str = typer.Argument(..., help="Existing sandbox name or app id."),
99
+ port: int = typer.Argument(..., help="TCP port inside the sandbox."),
100
+ local_port: int | None = typer.Option(
101
+ None, "--local-port", help="Loopback port to listen on (default: remote port)."
102
+ ),
103
+ use_snow_connection: bool = typer.Option(
104
+ True, "--use-snow-connection/--no-snow-connection", help=_USE_SNOW_CONN_HELP
105
+ ),
106
+ connection: str | None = typer.Option(None, "--connection", "-c", help=_CONN_HELP),
107
+ **options: Any,
108
+ ) -> None:
109
+ from snowflake.sandbox._preview import validate_preview_port
110
+ from snowflake.sandbox._tunnel import forward_port, remote_url, resolve_remote_url
111
+
112
+ try:
113
+ remote_url(
114
+ sandbox_id if sandbox_id.startswith("cntr_") else "cntr_validation",
115
+ "org-account.example",
116
+ port=port,
117
+ )
118
+ listen_port = validate_preview_port(port if local_port is None else local_port)
119
+ config = _remote_config(use_snow_connection, connection)
120
+ sandbox_id = _resolve_sandbox_id(sandbox_id)
121
+
122
+ async def forward() -> None:
123
+ url = await resolve_remote_url(sandbox_id, config, port=port)
124
+ await forward_port(
125
+ url,
126
+ config,
127
+ port=port,
128
+ local_port=listen_port,
129
+ ready=lambda bound: typer.echo(
130
+ f"Forwarding 127.0.0.1:{bound} to {sandbox_id}:{port}", err=True
131
+ ),
132
+ )
133
+
134
+ asyncio.run(forward())
135
+ except KeyboardInterrupt:
136
+ raise typer.Exit(130) from None
137
+ except (SandboxError, OSError) as exc:
138
+ typer.echo(f"error: {exc}", err=True)
139
+ raise typer.Exit(1) from exc
140
+
141
+
142
+ def _desktop_command(
143
+ sandbox_id: str = typer.Argument(..., help="Existing sandbox name or app id."),
144
+ no_open: bool = typer.Option(
145
+ False, "--no-open", help="Print the desktop URL without opening a browser."
146
+ ),
147
+ use_snow_connection: bool = typer.Option(
148
+ True, "--use-snow-connection/--no-snow-connection", help=_USE_SNOW_CONN_HELP
149
+ ),
150
+ connection: str | None = typer.Option(None, "--connection", "-c", help=_CONN_HELP),
151
+ **options: Any,
152
+ ) -> None:
153
+ import webbrowser
154
+
155
+ from snowflake.sandbox._tunnel import remote_url, resolve_remote_url
156
+
157
+ try:
158
+ if sandbox_id.startswith("cntr_"):
159
+ remote_url(sandbox_id, "org-account.example", service="desktop")
160
+ config = _remote_config(use_snow_connection, connection)
161
+ sandbox_id = _resolve_sandbox_id(sandbox_id)
162
+ url = asyncio.run(resolve_remote_url(sandbox_id, config, service="desktop"))
163
+ typer.echo(url)
164
+ if not no_open:
165
+ webbrowser.open(url)
166
+ except (SandboxError, OSError) as exc:
167
+ typer.echo(f"error: {exc}", err=True)
168
+ raise typer.Exit(1) from exc
@@ -62,9 +62,15 @@ from snowflake.cli_sandbox._common import (
62
62
  from snowflake.cli_sandbox._egress_flags import (
63
63
  EAI_HELP,
64
64
  NO_DEFAULT_EGRESS_HELP,
65
+ OUTBOUND_WIF_HOST_HELP,
65
66
  SECRET_HELP,
66
67
  build_egress,
67
68
  )
69
+ from snowflake.cli_sandbox._git_repo_flags import (
70
+ GIT_INTEGRATION_HELP,
71
+ GIT_REPO_HELP,
72
+ build_git_repos,
73
+ )
68
74
  from snowflake.cli_sandbox._shell_command import (
69
75
  _emit_status,
70
76
  _exec_streaming,
@@ -323,6 +329,10 @@ def _print_inferred_plan(plan: _RunPlan) -> None:
323
329
  if plan.secrets:
324
330
  bits.append(f"secrets={len(plan.secrets)}")
325
331
  typer.echo(f"egress: {' '.join(b for b in bits if b)}")
332
+ for repo in plan.git_repos:
333
+ typer.echo(
334
+ f"git repo: {repo.url} -> {repo.resolved_mount_path} (integration {repo.integration})" # type: ignore[attr-defined]
335
+ )
326
336
  if plan.upload is not None:
327
337
  typer.echo(
328
338
  f"upload: {plan.upload.file_count} file(s), "
@@ -359,6 +369,7 @@ class _RunPlan:
359
369
  env: dict[str, str]
360
370
  egress: object | None # Egress | None -- typed loosely to keep the SDK import lazy
361
371
  secrets: tuple[object, ...]
372
+ git_repos: tuple[object, ...] # GitRepo, typed loosely like egress
362
373
  quiet: bool
363
374
 
364
375
 
@@ -373,6 +384,8 @@ def _reject_app_incompatible_flags(
373
384
  eai: list[str] | None,
374
385
  secret: list[str] | None,
375
386
  nde: bool,
387
+ outbound_wif_host: list[str] | None = None,
388
+ git_repo: list[str] | None = None,
376
389
  ) -> None:
377
390
  """Everything an App module declares for itself, and so cannot be told from a flag.
378
391
 
@@ -399,12 +412,19 @@ def _reject_app_incompatible_flags(
399
412
  raise typer.BadParameter(
400
413
  f"{flag} applies only to local code; an App declares its own bundle."
401
414
  )
402
- for flag, value in (("--eai", eai), ("--secret", secret), ("--no-default-egress", nde)):
415
+ for flag, value in (
416
+ ("--eai", eai),
417
+ ("--secret", secret),
418
+ ("--no-default-egress", nde),
419
+ ("--outbound-wif-host", outbound_wif_host),
420
+ ):
403
421
  if value:
404
422
  raise typer.BadParameter(
405
423
  f"{flag} does not apply to an App module; an App declares its own egress "
406
424
  f"and secrets in code (see @app.function(egress=..., secrets=...))."
407
425
  )
426
+ if git_repo:
427
+ raise typer.BadParameter("--git-repo applies only to an image or local-code run.")
408
428
 
409
429
 
410
430
  def _reject_inapplicable_flags(
@@ -421,6 +441,8 @@ def _reject_inapplicable_flags(
421
441
  eai: list[str] | None = None,
422
442
  secret: list[str] | None = None,
423
443
  nde: bool = False,
444
+ outbound_wif_host: list[str] | None = None,
445
+ git_repo: list[str] | None = None,
424
446
  ) -> None:
425
447
  """Reject flag combinations that cannot take effect, loudly rather than silently.
426
448
 
@@ -438,6 +460,8 @@ def _reject_inapplicable_flags(
438
460
  eai=eai,
439
461
  secret=secret,
440
462
  nde=nde,
463
+ outbound_wif_host=outbound_wif_host,
464
+ git_repo=git_repo,
441
465
  )
442
466
  elif function is not None:
443
467
  raise typer.BadParameter("--function applies only to an App module (--app).")
@@ -540,6 +564,9 @@ def _plan_from_args(
540
564
  eai: list[str] | None = None,
541
565
  secret: list[str] | None = None,
542
566
  no_default_egress: bool = False,
567
+ outbound_wif_host: list[str] | None = None,
568
+ git_repo: list[str] | None = None,
569
+ git_integration: str | None = None,
543
570
  quiet: bool = False,
544
571
  ) -> _RunPlan:
545
572
  """Validate the flags and settle a `_RunPlan` (raising `typer.BadParameter`).
@@ -570,7 +597,10 @@ def _plan_from_args(
570
597
  eai=eai,
571
598
  secret=secret,
572
599
  nde=no_default_egress,
600
+ outbound_wif_host=outbound_wif_host,
601
+ git_repo=git_repo,
573
602
  )
603
+ git_repos = build_git_repos(git_repo, git_integration)
574
604
 
575
605
  env_overrides: dict[str, str] = {}
576
606
  for raw in envs or []:
@@ -581,7 +611,10 @@ def _plan_from_args(
581
611
  # silently overridden rather than merged -- rejected above in
582
612
  # `_reject_inapplicable_flags`, which is why this can run unconditionally.
583
613
  egress_cfg, secret_objs = build_egress(
584
- eai=eai, secret=secret, no_default_egress=no_default_egress
614
+ eai=eai,
615
+ secret=secret,
616
+ no_default_egress=no_default_egress,
617
+ outbound_wif_host=outbound_wif_host,
585
618
  )
586
619
 
587
620
  upload: UploadPlan | None = None
@@ -661,6 +694,7 @@ def _plan_from_args(
661
694
  env=env_overrides,
662
695
  egress=egress_cfg,
663
696
  secrets=secret_objs,
697
+ git_repos=git_repos,
664
698
  quiet=quiet,
665
699
  )
666
700
 
@@ -807,6 +841,7 @@ async def _run_job(plan: _RunPlan) -> int:
807
841
  env=plan.env or None,
808
842
  egress=plan.egress, # type: ignore[arg-type] # Egress | None, lazily typed
809
843
  secrets=plan.secrets, # type: ignore[arg-type]
844
+ git_repos=plan.git_repos, # type: ignore[arg-type]
810
845
  )
811
846
 
812
847
 
@@ -830,6 +865,7 @@ async def _run_detached_image(plan: _RunPlan) -> None:
830
865
  env=plan.env or None,
831
866
  egress=plan.egress, # type: ignore[arg-type] # Egress | None, lazily typed
832
867
  secrets=plan.secrets, # type: ignore[arg-type]
868
+ git_repos=plan.git_repos, # type: ignore[arg-type]
833
869
  name=plan.name,
834
870
  command=command,
835
871
  idle_suspend=plan.idle_suspend,
@@ -867,6 +903,7 @@ async def _run_detached_local(plan: _RunPlan) -> None:
867
903
  env=plan.env or None,
868
904
  egress=plan.egress, # type: ignore[arg-type] # Egress | None, lazily typed
869
905
  secrets=plan.secrets, # type: ignore[arg-type]
906
+ git_repos=plan.git_repos, # type: ignore[arg-type]
870
907
  name=plan.name,
871
908
  idle_suspend=plan.idle_suspend,
872
909
  )
@@ -997,7 +1034,7 @@ def _run_command(
997
1034
  secret: list[str] | None = typer.Option(
998
1035
  None,
999
1036
  "--secret",
1000
- metavar="ENV_VAR=DB.SCHEMA.NAME",
1037
+ metavar="ENV_VAR=DB.SCHEMA.NAME[@host]",
1001
1038
  help=SECRET_HELP,
1002
1039
  ),
1003
1040
  no_default_egress: bool = typer.Option(
@@ -1005,6 +1042,27 @@ def _run_command(
1005
1042
  "--no-default-egress",
1006
1043
  help=NO_DEFAULT_EGRESS_HELP,
1007
1044
  ),
1045
+ outbound_wif_host: list[str] | None = typer.Option(
1046
+ None,
1047
+ "--outbound-wif-host",
1048
+ metavar="HOST",
1049
+ help=OUTBOUND_WIF_HOST_HELP,
1050
+ hidden=True, # unreleased; see CONTRIBUTING "Hiding an unreleased parameter"
1051
+ ),
1052
+ git_repo: list[str] | None = typer.Option(
1053
+ None,
1054
+ "--git-repo",
1055
+ metavar="URL[=MOUNT_PATH]",
1056
+ help=GIT_REPO_HELP,
1057
+ hidden=True, # unreleased; see CONTRIBUTING "Hiding an unreleased parameter"
1058
+ ),
1059
+ git_integration: str | None = typer.Option(
1060
+ None,
1061
+ "--git-integration",
1062
+ metavar="NAME",
1063
+ help=GIT_INTEGRATION_HELP,
1064
+ hidden=True,
1065
+ ),
1008
1066
  allow_large: bool = typer.Option(
1009
1067
  False,
1010
1068
  "--allow-large",
@@ -1076,6 +1134,9 @@ def _run_command(
1076
1134
  eai=eai,
1077
1135
  secret=secret,
1078
1136
  no_default_egress=no_default_egress,
1137
+ outbound_wif_host=outbound_wif_host,
1138
+ git_repo=git_repo,
1139
+ git_integration=git_integration,
1079
1140
  quiet=quiet,
1080
1141
  )
1081
1142
  _hint_app_module(plan)