diff --git a/src/dstack/_internal/cli/services/presets/session.py b/src/dstack/_internal/cli/services/presets/session.py index 68bd44446d..fb52b692e4 100644 --- a/src/dstack/_internal/cli/services/presets/session.py +++ b/src/dstack/_internal/cli/services/presets/session.py @@ -30,6 +30,7 @@ from dstack._internal.core.errors import CLIError from dstack._internal.core.models.common import validate_extra_ignore from dstack._internal.core.models.configurations import PresetConfiguration +from dstack._internal.core.models.envs import EnvSentinel from dstack._internal.utils.common import get_dstack_dir if TYPE_CHECKING: @@ -300,9 +301,13 @@ def create_preset_session( ) ) record = configuration.model_dump(mode="json", exclude_none=True) - # Env values may be secrets: the session records only the variable names, - # which read back as passthrough references resolved from the environment. - record["env"] = list(configuration.env) + # Keep literal values so a resumed session does not depend on the + # caller's current environment. Passthrough entries remain names and are + # resolved again on resume. The session manifest is written mode 0600. + record["env"] = [ + key if isinstance(value, EnvSentinel) else f"{key}={value}" + for key, value in configuration.env.items() + ] if not configuration.env: record.pop("env") _write_private_text( diff --git a/src/tests/_internal/cli/services/presets/test_agent.py b/src/tests/_internal/cli/services/presets/test_agent.py index f101258c57..5a8da1f085 100644 --- a/src/tests/_internal/cli/services/presets/test_agent.py +++ b/src/tests/_internal/cli/services/presets/test_agent.py @@ -23,6 +23,7 @@ get_claude_auth, run_preset_agent, ) +from dstack._internal.cli.services.presets.create import load_session_configuration from dstack._internal.cli.services.presets.redaction import ( contains_redacted_value, redact, @@ -49,6 +50,7 @@ from dstack._internal.compat import IS_WINDOWS from dstack._internal.core.errors import CLIError from dstack._internal.core.models.configurations import PresetConfiguration +from dstack._internal.core.models.envs import EnvSentinel from dstack._internal.core.services.configs import ConfigManager from tests._internal.cli.common import get_session_run, get_session_state @@ -223,15 +225,19 @@ def test_creates_private_session_with_log_and_manifest(self, tmp_path, monkeypat assert session.path.stat().st_mode & 0o777 == 0o700 assert session.log_path.stat().st_mode & 0o777 == 0o600 - def test_session_saves_scrubbed_configuration(self, tmp_path, monkeypatch): + def test_session_saves_resumable_configuration(self, tmp_path, monkeypatch): self._home(tmp_path, monkeypatch) session = create_preset_session(self._configuration(), previous=()) data = yaml.safe_load((session.path / "preset.dstack.yml").read_text()) assert data["max_price"] == 0.5 - assert data["env"] == ["HF_TOKEN", "TOKENIZERS_PARALLELISM"] - assert "false" not in (session.path / "preset.dstack.yml").read_text() + assert data["env"] == ["HF_TOKEN", "TOKENIZERS_PARALLELISM=false"] + loaded = load_session_configuration(session) + assert isinstance(loaded.env["HF_TOKEN"], EnvSentinel) + assert loaded.env["TOKENIZERS_PARALLELISM"] == "false" + if not IS_WINDOWS: + assert (session.path / "preset.dstack.yml").stat().st_mode & 0o777 == 0o600 @pytest.mark.parametrize("status", ["success", "failed"]) def test_finish_records_terminal_status(self, tmp_path, monkeypatch, status):