diff --git a/coding-agent/docs/remote-sandbox-production.md b/coding-agent/docs/remote-sandbox-production.md index d9c6452fc..5ec81036c 100644 --- a/coding-agent/docs/remote-sandbox-production.md +++ b/coding-agent/docs/remote-sandbox-production.md @@ -244,6 +244,9 @@ from `origin`. It streams one prompt, leaves the remote session open, and prints follow-up commands for inspecting, exporting, or publishing the result. It does not overwrite the local checkout by default. +Use `remote run --no-workspace` when the server has no cloud workspace support +and the session should be created without any server-side workspace provisioning. + Set the remote tool approval policy explicitly when automation cannot answer approval prompts: diff --git a/coding-agent/src/coding_agent/cli/remote_commands.py b/coding-agent/src/coding_agent/cli/remote_commands.py index f4216ebbe..b68290903 100644 --- a/coding-agent/src/coding_agent/cli/remote_commands.py +++ b/coding-agent/src/coding_agent/cli/remote_commands.py @@ -115,6 +115,11 @@ def remote_remove(name: str) -> None: is_flag=True, help="Create an empty server-side Docker workspace.", ) +@click.option( + "--no-workspace", + is_flag=True, + help="Create a session without provisioning a workspace; use for servers without cloud workspace support.", +) @click.option( "--runtime", "runtime_profile", @@ -141,6 +146,7 @@ def remote_repl( name: str, repo: str | None, empty_workspace: bool, + no_workspace: bool, runtime_profile: str | None, goal: str, approval_policy: str, @@ -151,6 +157,7 @@ def remote_repl( name=name, repo=repo, empty_workspace=empty_workspace, + no_workspace=no_workspace, runtime_profile=runtime_profile, goal=goal, approval_policy=approval_policy, @@ -172,6 +179,11 @@ def remote_repl( is_flag=True, help="Create an empty server-side Docker workspace.", ) +@click.option( + "--no-workspace", + is_flag=True, + help="Create a session without provisioning a workspace; use for servers without cloud workspace support.", +) @click.option( "--runtime", "runtime_profile", @@ -209,6 +221,7 @@ def remote_run( name: str, repo: str | None, empty_workspace: bool, + no_workspace: bool, runtime_profile: str | None, goal: str, approval_policy: str, @@ -221,6 +234,7 @@ def remote_run( name=name, repo=repo, empty_workspace=empty_workspace, + no_workspace=no_workspace, runtime_profile=runtime_profile, goal=goal, approval_policy=approval_policy, @@ -236,6 +250,7 @@ def _remote_run_once( name: str, repo: str | None, empty_workspace: bool, + no_workspace: bool, runtime_profile: str | None, goal: str, approval_policy: str, @@ -255,16 +270,24 @@ def _remote_run_once( create_workspace_archive_base64, ) - if repo is not None and empty_workspace: + workspace_mode_count = ( + int(repo is not None) + int(empty_workspace) + int(no_workspace) + ) + if workspace_mode_count > 1: raise click.ClickException( - "Pass either --repo to use a local repo or --empty-workspace, not both." + "Pass only one of --repo, --empty-workspace, or --no-workspace." ) - if repo is None and not empty_workspace: + if workspace_mode_count == 0: raise click.ClickException( - "Pass --repo to use a local repo or --empty-workspace to create a blank remote workspace." + "Pass --repo to use a local repo, --empty-workspace to create a blank remote workspace, " + "or --no-workspace to create a session without a workspace for servers without cloud workspace support." ) if download_results and repo is None: raise click.ClickException("Pass --repo with --download.") + if no_workspace and runtime_profile is not None: + raise click.ClickException( + "Pass --runtime only when creating a remote workspace." + ) endpoint = get_remote(name) headers = auth_headers(endpoint) @@ -290,6 +313,7 @@ def _remote_run_once( endpoint, snapshot_archive_base64=snapshot_archive_base64, workspace_source=workspace_source, + no_workspace=no_workspace, approval_policy=approval_policy, runtime_profile=None if workspace_source is not None else runtime_profile, ) diff --git a/coding-agent/src/coding_agent/remote/client.py b/coding-agent/src/coding_agent/remote/client.py index 2b5cbaccd..9670acd43 100644 --- a/coding-agent/src/coding_agent/remote/client.py +++ b/coding-agent/src/coding_agent/remote/client.py @@ -250,6 +250,7 @@ def create_remote_session( *, snapshot_archive_base64: str | None = None, workspace_source: dict[str, object] | None = None, + no_workspace: bool = False, approval_policy: str = "auto", runtime_profile: str | None = None, observation_sink: ObservationSink | None = None, @@ -260,23 +261,34 @@ def create_remote_session( raise click.ClickException( "Pass either workspace_source or snapshot_archive_base64, not both." ) - if workspace_source is None: - workspace_source = {"kind": "docker"} - if snapshot_archive_base64 is not None: - workspace_source["snapshot_archive_base64"] = snapshot_archive_base64 - else: - workspace_source = dict(workspace_source) - if runtime_profile is not None: - workspace_source["runtime_profile"] = runtime_profile - payload: dict[str, object] = { - "workspace_source": workspace_source, - "approval_policy": approval_policy, - } + if no_workspace and ( + workspace_source is not None + or snapshot_archive_base64 is not None + or runtime_profile is not None + ): + raise click.ClickException( + "Pass either no_workspace or remote workspace options, not both." + ) + payload: dict[str, object] = {"approval_policy": approval_policy} + workspace_ref_kind = "none" + if not no_workspace: + if workspace_source is None: + workspace_source = {"kind": "docker"} + if snapshot_archive_base64 is not None: + workspace_source["snapshot_archive_base64"] = snapshot_archive_base64 + if runtime_profile is not None: + workspace_source["runtime_profile"] = runtime_profile + else: + workspace_source = dict(workspace_source) + if runtime_profile is not None: + workspace_source["runtime_profile"] = runtime_profile + payload["workspace_source"] = workspace_source + workspace_ref_kind = _workspace_ref_kind(workspace_source) with record_span( "remote.workspace.provision", sink=observation_sink, attributes={ - "workspace_ref_kind": _workspace_ref_kind(workspace_source), + "workspace_ref_kind": workspace_ref_kind, "remote_status": "started", }, ) as span: @@ -1704,9 +1716,24 @@ def _raise_remote_http_error(response: httpx.Response, action: str) -> None: _ = response.raise_for_status() except httpx.HTTPStatusError as exc: detail = _response_error_detail(response) + if action == "create remote session" and _looks_like_cloud_workspace_disabled( + detail + ): + detail = ( + f"{detail}. Use --no-workspace to create a session without " + "server-side workspace provisioning." + ) raise click.ClickException(f"Failed to {action}: {detail}") from exc +def _looks_like_cloud_workspace_disabled(detail: str) -> bool: + normalized = detail.lower() + return ( + "cloud_workspace.enabled=true" in normalized + or "cloud workspace provisioning" in normalized + ) + + def _response_error_detail(response: httpx.Response) -> str: fallback = _response_status_detail(response) body = _read_response_body_for_error_detail(response) diff --git a/coding-agent/tests/cli/test_remote_client.py b/coding-agent/tests/cli/test_remote_client.py index 7903b8fae..a6b442017 100644 --- a/coding-agent/tests/cli/test_remote_client.py +++ b/coding-agent/tests/cli/test_remote_client.py @@ -3070,6 +3070,116 @@ def fake_stream_prompt( ] +def test_remote_run_no_workspace_omits_workspace_source( + tmp_path: Path, monkeypatch +) -> None: + config_path = tmp_path / "remotes.json" + monkeypatch.setenv("CODING_AGENT_REMOTES_FILE", str(config_path)) + runner = CliRunner() + runner.invoke( + main, + ["remote", "add", "dev", "http://agent.example", "--token", "secret-token"], + catch_exceptions=False, + ) + calls: list[tuple[str, str, dict[str, object] | None, dict[str, str]]] = [] + + class FakeResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return {"session_id": "sess-none"} + + class FakeClient: + def __init__(self, **kwargs: object) -> None: + headers = kwargs.get("headers") + self.headers = headers if isinstance(headers, dict) else {} + + def __enter__(self) -> FakeClient: + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + def post( + self, path: str, json: dict[str, object] | None = None + ) -> FakeResponse: + calls.append(("post", path, json, self.headers)) + return FakeResponse() + + def fake_stream_prompt( + *, base_url: str, session_id: str, prompt: str, headers: dict[str, str] + ) -> int: + calls.append( + ( + "stream", + f"/sessions/{session_id}/prompt", + {"prompt": prompt, "base_url": base_url}, + headers, + ) + ) + return 0 + + monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FakeClient) + monkeypatch.setattr("coding_agent.remote.client.stream_prompt", fake_stream_prompt) + + result = runner.invoke( + main, + ["remote", "run", "dev", "--no-workspace", "--goal", "hello"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "Created one-shot remote session sess-none on remote dev" in result.output + assert calls == [ + ( + "post", + "/sessions", + {"approval_policy": "auto"}, + {"Authorization": "Bearer secret-token"}, + ), + ( + "stream", + "/sessions/sess-none/prompt", + {"prompt": "hello", "base_url": "http://agent.example"}, + {"Authorization": "Bearer secret-token"}, + ), + ] + + +def test_remote_run_no_workspace_is_mutually_exclusive_with_repo( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path = tmp_path / "remotes.json" + monkeypatch.setenv("CODING_AGENT_REMOTES_FILE", str(config_path)) + runner = CliRunner() + runner.invoke( + main, + ["remote", "add", "dev", "http://agent.example"], + catch_exceptions=False, + ) + + result = runner.invoke( + main, + [ + "remote", + "run", + "dev", + "--repo", + str(tmp_path), + "--no-workspace", + "--goal", + "hello", + ], + ) + + assert result.exit_code != 0 + assert ( + "Pass only one of --repo, --empty-workspace, or --no-workspace." + in result.output + ) + + def test_remote_run_sends_runtime_profile_in_workspace_source( tmp_path: Path, monkeypatch ) -> None: @@ -4012,8 +4122,15 @@ def get(self, path: str) -> _RemoteFakeResponse: assert applied == [(repo_path, "result-archive")] +@pytest.mark.parametrize( + "error_detail", + [ + "cloud workspace provisioning requires cloud_workspace.enabled=true", + "production requires cloud_workspace.enabled=true", + ], +) def test_remote_repl_reports_http_session_create_error( - tmp_path: Path, monkeypatch + tmp_path: Path, monkeypatch, error_detail: str ) -> None: import httpx @@ -4041,9 +4158,7 @@ def post( ) -> httpx.Response: del path, json request = httpx.Request("POST", "http://agent.example/sessions") - return httpx.Response( - 400, json={"detail": "cloud workspace disabled"}, request=request - ) + return httpx.Response(400, json={"detail": error_detail}, request=request) monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FailingClient) @@ -4054,7 +4169,8 @@ def post( assert result.exit_code != 0 assert "Failed to create remote session" in result.output - assert "cloud workspace disabled" in result.output + assert error_detail in result.output + assert "--no-workspace" in result.output def test_remote_repl_requires_repo_or_empty_workspace( @@ -4075,7 +4191,9 @@ def test_remote_repl_requires_repo_or_empty_workspace( ) assert result.exit_code != 0 - assert "Pass --repo to use a local repo or --empty-workspace" in result.output + assert "Pass --repo to use a local repo" in result.output + assert "--empty-workspace" in result.output + assert "--no-workspace" in result.output def test_remote_diff_prints_changed_files(tmp_path: Path, monkeypatch) -> None: @@ -5391,6 +5509,103 @@ def fake_stream_prompt( ) +def test_remote_run_snapshot_fallback_sends_runtime_profile( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path = tmp_path / "remotes.json" + repo_path = tmp_path / "repo" + repo_path.mkdir() + monkeypatch.setenv("CODING_AGENT_REMOTES_FILE", str(config_path)) + runner = CliRunner() + runner.invoke( + main, + ["remote", "add", "dev", "http://agent.example", "--token", "secret-token"], + catch_exceptions=False, + ) + calls: list[tuple[str, str, dict[str, object] | None, dict[str, str]]] = [] + + class FakeResponse: + def __init__(self, payload: dict[str, object]) -> None: + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return self._payload + + class FakeClient: + def __init__(self, **kwargs: object) -> None: + headers = kwargs.get("headers") + self.headers = headers if isinstance(headers, dict) else {} + + def __enter__(self) -> FakeClient: + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + def post( + self, path: str, json: dict[str, object] | None = None + ) -> FakeResponse: + calls.append(("post", path, json, self.headers)) + if path == "/sessions": + return FakeResponse({"session_id": "sess-snapshot"}) + raise AssertionError(f"unexpected post {path}") + + def fake_stream_prompt( + *, base_url: str, session_id: str, prompt: str, headers: dict[str, str] + ) -> int: + calls.append( + ( + "stream", + f"/sessions/{session_id}/prompt", + {"prompt": prompt, "base_url": base_url}, + headers, + ) + ) + return 0 + + monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FakeClient) + monkeypatch.setattr("coding_agent.remote.client.stream_prompt", fake_stream_prompt) + monkeypatch.setattr( + "coding_agent.environment.archive.create_workspace_archive_base64", + lambda path: "archive-encoded", + ) + + result = runner.invoke( + main, + [ + "remote", + "run", + "dev", + "--repo", + str(repo_path), + "--snapshot-fallback", + "--runtime", + "universal", + "--goal", + "hello", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert calls[0] == ( + "post", + "/sessions", + { + "workspace_source": { + "kind": "docker", + "snapshot_archive_base64": "archive-encoded", + "runtime_profile": "universal", + }, + "approval_policy": "auto", + }, + {"Authorization": "Bearer secret-token"}, + ) + + def test_remote_run_with_download_keeps_explicit_archive_overwrite( tmp_path: Path, monkeypatch ) -> None: