diff --git a/coding-agent/docs/adr/0024-remote-result-publication.md b/coding-agent/docs/adr/0024-remote-result-publication.md index 1445e1812..fa2f0a8ad 100644 --- a/coding-agent/docs/adr/0024-remote-result-publication.md +++ b/coding-agent/docs/adr/0024-remote-result-publication.md @@ -176,6 +176,7 @@ Suggested production configuration shape: enabled = true git_author_name = "coding-agent" git_author_email = "coding-agent@example.com" +allowed_git_hosts = ["github.com"] git_token_env = "CODING_AGENT_GIT_TOKEN" [remote_publication.github] diff --git a/coding-agent/docs/remote-sandbox-production.md b/coding-agent/docs/remote-sandbox-production.md index ebc13680d..b1379edd4 100644 --- a/coding-agent/docs/remote-sandbox-production.md +++ b/coding-agent/docs/remote-sandbox-production.md @@ -77,6 +77,13 @@ allow_request_commands = false network = "none" timeout_seconds = 3600 secret_env_allowlist = [] + +[remote_publication] +enabled = true +git_author_name = "coding-agent" +git_author_email = "coding-agent@example.com" +allowed_git_hosts = ["github.com"] +git_token_env = "CODING_AGENT_GIT_TOKEN" ``` `python:3.11-slim` is enough for basic file operations and pure-Python tools, @@ -148,6 +155,7 @@ Run the service on loopback and place TLS/authenticated ingress in front of it: User=coding-agent Group=coding-agent Environment=CODING_AGENT_BEARER_TOKEN=replace-with-secret +Environment=CODING_AGENT_GIT_TOKEN=replace-with-secret ExecStart=/usr/local/bin/coding-agent serve --config /etc/coding-agent/config.toml Restart=on-failure ``` @@ -197,17 +205,15 @@ Use `remote run` for the recommended one-shot remote workflow: coding-agent remote run team --repo . --goal "fix the failing test" ``` -`remote run --repo` uploads a local snapshot, streams one prompt, downloads the -final workspace through the archive manifest/archive API, overwrites the local -checkout while preserving `.git`, and then closes the remote session. Use -`--yes` only when the local checkout is already recoverable and you want to skip -the manifest confirmation prompt; it does not approve tools. +`remote run --repo` uploads a local snapshot, streams one prompt, leaves the +remote session open, and prints follow-up commands for inspecting or exporting +the result. It does not overwrite the local checkout by default. Set the remote tool approval policy explicitly when automation cannot answer approval prompts: ```bash -coding-agent remote run team --repo . --goal "fix the failing test" --approval yolo --yes +coding-agent remote run team --repo . --goal "fix the failing test" --approval yolo ``` Select a configured runtime profile when the project needs more tooling than the @@ -225,6 +231,37 @@ repeatable bootstrap behavior. Request-provided setup commands remain reserved for the later setup execution slice and currently fail closed instead of being accepted as a silent no-op. +For Git-backed sessions, publish remote changes to a review branch explicitly: + +```bash +coding-agent remote publish team --session --branch coding-agent/session- +``` + +Branch publication requires `[remote_publication]` to be enabled, Git author +identity to be configured, the workspace `remote.origin.url` host to match +`allowed_git_hosts`, and server-side Git credentials. The host allowlist is +checked before token injection or `git push`. When `git_token_env` is set, the +named environment variable must contain a Git token; the server injects it into +the `git push` environment without placing the token in command arguments or +publication responses. If `git_token_env` is omitted, the workspace must rely +on existing Git credential helper state. GitHub PR creation is not part of this +slice. + +Snapshot sessions can still be inspected and exported, but they are not branch +publication inputs because snapshots intentionally exclude `.git`. + +Use `--download` only when you want the old archive-overwrite fallback: + +```bash +coding-agent remote run team --repo . --goal "fix the failing test" --download +``` + +`--download` fetches the final workspace through the archive manifest/archive +API, overwrites the local checkout while preserving `.git`, and then closes the +remote session. Use `--yes` with `--download` only when the local checkout is +already recoverable and you want to skip the manifest confirmation prompt; it +does not approve tools. + `--approval auto` is the default and can still ask for approval for tools such as `bash_run`. `--approval interactive` asks for every tool. `--approval yolo` lets the server approve tool calls for that one remote session, so use it only @@ -242,6 +279,9 @@ coding-agent remote sessions status team coding-agent remote sessions cancel team coding-agent remote sessions close team +coding-agent remote diff team --session +coding-agent remote patch team --session +coding-agent remote publish team --session --branch coding-agent/session- coding-agent remote download team --session --repo . coding-agent remote workspaces list team-admin @@ -279,20 +319,20 @@ providers, or microVM providers. ## Snapshot Transfer Semantics -Remote runs use snapshot round-trip transfer: +Snapshot fallback runs use archive round-trip transfer: 1. The local repo is packed as a bounded `tar.gz` archive encoded as base64. 2. The server extracts that archive into a Docker workspace. 3. The agent executes in the remote workspace. -4. The final remote workspace is downloaded. -5. The local checkout is overwritten while local `.git` is preserved. +4. The final remote workspace remains remote unless explicitly downloaded. +5. Explicit archive download overwrites the local checkout while local `.git` + is preserved. -This is not live sync. P0 does not support incremental patch export, concurrent -local edit merging, efficient large-repo delta sync, or automatic conflict -resolution. +This is not live sync. P0 does not support concurrent local edit merging, +efficient large-repo delta sync, or automatic conflict resolution. -Before using `remote run --repo` or `remote repl --repo`, keep your local work -recoverable with a commit, stash, or backup. +Before using `remote download`, `remote run --download`, or `remote repl +--repo`, keep your local work recoverable with a commit, stash, or backup. ## Production Smoke @@ -310,8 +350,9 @@ provider: ``` 3. Run a small repository through `remote run --repo`. -4. Confirm the downloaded workspace contains the remote change. -5. Confirm successful one-shot runs leave no active sessions or workspaces: +4. Inspect the remote result with `remote diff` and `remote patch`. +5. Export the result explicitly with `remote download` or close the session. +6. Confirm closed sessions leave no active workspaces: ```bash coding-agent remote sessions list team @@ -319,5 +360,5 @@ provider: ``` This verifies production config loading, auth, Docker provider readiness, -workspace execution, approval forwarding, archive manifest/download, local -restore, and session/workspace cleanup. +workspace execution, approval forwarding, result inspection, explicit archive +download, and session/workspace cleanup. diff --git a/coding-agent/src/coding_agent/__main__.py b/coding-agent/src/coding_agent/__main__.py index 13da680e8..7f6812764 100644 --- a/coding-agent/src/coding_agent/__main__.py +++ b/coding-agent/src/coding_agent/__main__.py @@ -1059,6 +1059,38 @@ def remote_patch(name: str, session_id: str) -> None: click.echo(patch, nl=False) +@remote.command("publish") +@click.argument("name") +@click.option("--session", "session_id", required=True, help="Remote session ID") +@click.option( + "--branch", + "branch_name", + required=True, + help="Publish the remote session changes to this Git branch.", +) +def remote_publish(name: str, session_id: str, branch_name: str) -> None: + """Publish remote session results as a Git branch.""" + from coding_agent.remote.client import get_remote, publish_remote_branch + + endpoint = get_remote(name) + publication = publish_remote_branch(endpoint, session_id, branch_name) + published_branch = publication.get("branch_name") + pushed_ref = publication.get("pushed_ref") + commit_sha = publication.get("commit_sha") + remote_url = publication.get("remote_url") + if not isinstance(published_branch, str) or not published_branch: + raise click.ClickException("Remote publish response missing branch_name") + if not isinstance(commit_sha, str) or not commit_sha: + raise click.ClickException("Remote publish response missing commit_sha") + if not isinstance(remote_url, str) or not remote_url: + raise click.ClickException("Remote publish response missing remote_url") + click.echo(f"Published branch {published_branch}") + if isinstance(pushed_ref, str) and pushed_ref: + click.echo(f"Ref: {pushed_ref}") + click.echo(f"Commit: {commit_sha}") + click.echo(f"Remote: {remote_url}") + + @remote.command("download") @click.argument("name") @click.option("--session", "session_id", required=True, help="Remote session ID") diff --git a/coding-agent/src/coding_agent/environment/__init__.py b/coding-agent/src/coding_agent/environment/__init__.py index 38a47cb7a..c8be8633d 100644 --- a/coding-agent/src/coding_agent/environment/__init__.py +++ b/coding-agent/src/coding_agent/environment/__init__.py @@ -12,6 +12,7 @@ CloudWorkspaceClientFactory, CloudWorkspaceSource, WorkspaceArchiveManifest, + WorkspaceBranchPublication, WorkspaceDiff, WorkspaceDiffFile, WorkspaceInventoryEntry, @@ -24,6 +25,7 @@ get_cloud_workspace_from_config, import_workspace_archive_from_config, list_cloud_workspaces_from_config, + publish_workspace_branch_from_config, provision_cloud_binding_from_config, register_workspace_provider, workspace_archive_manifest_from_config, @@ -43,6 +45,7 @@ "cleanup_stale_cloud_workspaces_from_config", "LocalEnvironment", "WorkspaceArchiveManifest", + "WorkspaceBranchPublication", "WorkspaceDiff", "WorkspaceDiffFile", "WorkspaceInventoryEntry", @@ -55,6 +58,7 @@ "get_cloud_workspace_from_config", "import_workspace_archive_from_config", "list_cloud_workspaces_from_config", + "publish_workspace_branch_from_config", "provision_cloud_binding_from_config", "register_workspace_provider", "workspace_archive_manifest_from_config", diff --git a/coding-agent/src/coding_agent/environment/docker_workspace_provider.py b/coding-agent/src/coding_agent/environment/docker_workspace_provider.py index 91be5ee9c..a894ff06d 100644 --- a/coding-agent/src/coding_agent/environment/docker_workspace_provider.py +++ b/coding-agent/src/coding_agent/environment/docker_workspace_provider.py @@ -18,6 +18,7 @@ from pathlib import Path import os from typing import TYPE_CHECKING, cast, override +from urllib.parse import urlsplit, urlunsplit from .cloud import CloudCommandResult from .workspace_provider import ( @@ -25,6 +26,7 @@ CloudWorkspaceSource, WorkspaceProvider, WorkspaceArchiveManifest, + WorkspaceBranchPublication, WorkspaceDiff, WorkspaceDiffFile, WorkspaceDiffStatus, @@ -669,6 +671,32 @@ def workspace_patch( patch=patch, ) + @override + def publish_workspace_branch( + self, + config: dict[str, object], + publication_config: dict[str, object], + workspace_id: str, + branch_name: str, + commit_message: str, + ) -> WorkspaceBranchPublication: + if publication_config.get("enabled") is not True: + raise ValueError("remote_publication.enabled must be true") + provider_config = _docker_workspace_provider_config(config) + workspace_root = _git_workspace_root_for_operation( + provider_config, + workspace_id, + "publication", + ) + return _publish_git_workspace_branch( + provider_config, + publication_config, + workspace_id, + workspace_root, + branch_name, + commit_message, + ) + def _docker_workspace_provider_config( config: dict[str, object], @@ -1116,11 +1144,212 @@ def _git_workspace_diff( ) +def _publish_git_workspace_branch( + provider_config: _DockerWorkspaceProviderConfig, + publication_config: dict[str, object], + workspace_id: str, + workspace_root: Path, + branch_name: str, + commit_message: str, +) -> WorkspaceBranchPublication: + author_name = _required_publication_string( + publication_config, + "git_author_name", + ) + author_email = _required_publication_string( + publication_config, + "git_author_email", + ) + branch_name = branch_name.strip() + if not branch_name: + raise ValueError("branch_name must be non-empty") + commit_message = commit_message.strip() + if not commit_message: + raise ValueError("commit_message must be non-empty") + + status = _run_git_publish_command( + provider_config, + workspace_root, + ["status", "--porcelain=v1", "-z"], + "git status", + ) + if not status: + raise ValueError("workspace publication requires uncommitted changes") + + _run_git_publish_command( + provider_config, + workspace_root, + ["check-ref-format", "--branch", branch_name], + "git check-ref-format", + ) + _run_git_publish_command( + provider_config, + workspace_root, + ["config", "user.name", author_name], + "git config user.name", + ) + _run_git_publish_command( + provider_config, + workspace_root, + ["config", "user.email", author_email], + "git config user.email", + ) + _run_git_publish_command( + provider_config, + workspace_root, + ["add", "-A"], + "git add", + ) + _run_git_publish_command( + provider_config, + workspace_root, + ["commit", "-m", commit_message], + "git commit", + ) + commit_sha = _run_git_publish_command( + provider_config, + workspace_root, + ["rev-parse", "HEAD"], + "git rev-parse", + ).strip() + remote_url = _run_git_publish_command( + provider_config, + workspace_root, + ["config", "--get", "remote.origin.url"], + "git config remote.origin.url", + ).strip() + _validate_publication_remote_url(publication_config, remote_url) + pushed_ref = f"refs/heads/{branch_name}" + push_env = _git_publication_push_env(publication_config) + _run_git_publish_command( + provider_config, + workspace_root, + ["push", "origin", f"HEAD:{pushed_ref}"], + "git push", + extra_env=push_env, + ) + return WorkspaceBranchPublication( + workspace_id=workspace_id, + branch_name=branch_name, + pushed_ref=pushed_ref, + commit_sha=commit_sha, + remote_url=_redact_git_remote_url(remote_url), + ) + + +def _run_git_publish_command( + provider_config: _DockerWorkspaceProviderConfig, + workspace_root: Path, + args: list[str], + operation: str, + *, + extra_env: dict[str, str] | None = None, +) -> str: + try: + return _run_git_workspace_command( + provider_config, + workspace_root, + args, + operation, + extra_env=extra_env, + ) + except subprocess.CalledProcessError as exc: + error = ValueError(f"{operation} failed") + for note in getattr(exc, "__notes__", ()): + error.add_note(note) + raise error from exc + + +def _git_publication_push_env( + publication_config: dict[str, object], +) -> dict[str, str]: + token_env = _optional_string(publication_config.get("git_token_env"), None) + if token_env is None: + return {} + token = os.environ.get(token_env) + if token is None or not token.strip(): + raise ValueError(f"remote_publication.git_token_env is not set: {token_env}") + credential = base64.b64encode( + f"x-access-token:{token.strip()}".encode("utf-8") + ).decode("ascii") + return { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "http.extraheader", + "GIT_CONFIG_VALUE_0": f"Authorization: Basic {credential}", + } + + +def _redact_git_remote_url(remote_url: str) -> str: + parsed = urlsplit(remote_url) + if parsed.username is None and parsed.password is None: + return remote_url + if parsed.hostname is None: + return remote_url + netloc = parsed.hostname + if parsed.port is not None: + netloc = f"{netloc}:{parsed.port}" + return urlunsplit( + ( + parsed.scheme, + netloc, + parsed.path, + parsed.query, + parsed.fragment, + ) + ) + + +def _validate_publication_remote_url( + publication_config: dict[str, object], + remote_url: str, +) -> None: + if not remote_url: + raise ValueError("Git workspace has no remote.origin.url") + parsed = urlsplit(remote_url) + if parsed.scheme not in {"https", "git+https"}: + raise ValueError("Git workspace remote.origin.url must use https") + if parsed.hostname is None: + raise ValueError("Git workspace remote.origin.url must include a host") + allowed_git_hosts = publication_config.get("allowed_git_hosts") + if allowed_git_hosts is None: + raise ValueError("remote_publication.allowed_git_hosts must be configured") + allowed_hosts = _publication_string_list(allowed_git_hosts, key="allowed_git_hosts") + if not allowed_hosts: + raise ValueError("remote_publication.allowed_git_hosts must be configured") + if parsed.hostname not in allowed_hosts: + raise ValueError( + "Git workspace remote.origin.url host is not in remote_publication.allowed_git_hosts" + ) + + +def _publication_string_list(value: object, *, key: str) -> tuple[str, ...]: + if not isinstance(value, list): + raise ValueError(f"remote_publication.{key} must be a list of strings") + result: list[str] = [] + for item in value: + if not isinstance(item, str) or not item.strip(): + raise ValueError(f"remote_publication.{key} must be a list of strings") + result.append(item.strip().lower()) + return tuple(result) + + +def _required_publication_string( + config: dict[str, object], + key: str, +) -> str: + value = config.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"remote_publication.{key} must be configured") + return value.strip() + + def _run_git_workspace_command( provider_config: _DockerWorkspaceProviderConfig, workspace_root: Path, args: list[str], operation: str, + *, + extra_env: dict[str, str] | None = None, ) -> str: command = [ provider_config.git_binary, @@ -1136,7 +1365,7 @@ def _run_git_workspace_command( text=True, timeout=_GIT_OPERATION_TIMEOUT_SECONDS, stdin=subprocess.DEVNULL, - env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0", **(extra_env or {})}, check=True, ) except subprocess.CalledProcessError as exc: diff --git a/coding-agent/src/coding_agent/environment/workspace_provider.py b/coding-agent/src/coding_agent/environment/workspace_provider.py index 001d0022b..9c60a8ffa 100644 --- a/coding-agent/src/coding_agent/environment/workspace_provider.py +++ b/coding-agent/src/coding_agent/environment/workspace_provider.py @@ -69,6 +69,15 @@ class WorkspacePatch: patch: str +@dataclass(frozen=True) +class WorkspaceBranchPublication: + workspace_id: str + branch_name: str + pushed_ref: str + commit_sha: str + remote_url: str + + class WorkspaceProvider(Protocol): def build_cloud_client_factory( self, config: dict[str, object] @@ -155,6 +164,15 @@ def workspace_patch( workspace_id: str, ) -> WorkspacePatch: ... + def publish_workspace_branch( + self, + config: dict[str, object], + publication_config: dict[str, object], + workspace_id: str, + branch_name: str, + commit_message: str, + ) -> WorkspaceBranchPublication: ... + _WORKSPACE_PROVIDERS: dict[str, WorkspaceProvider] = {} @@ -294,6 +312,23 @@ def workspace_patch_from_config( return provider.workspace_patch(config, workspace_id) +def publish_workspace_branch_from_config( + config: dict[str, object], + publication_config: dict[str, object], + workspace_id: str, + branch_name: str, + commit_message: str, +) -> WorkspaceBranchPublication: + provider = _provider_from_config(config) + return provider.publish_workspace_branch( + config, + publication_config, + workspace_id, + branch_name, + commit_message, + ) + + def _provider_from_config(config: dict[str, object]) -> WorkspaceProvider: provider_name = config.get("provider") if not isinstance(provider_name, str) or not provider_name.strip(): diff --git a/coding-agent/src/coding_agent/remote/client.py b/coding-agent/src/coding_agent/remote/client.py index b02f75b56..1fcf74403 100644 --- a/coding-agent/src/coding_agent/remote/client.py +++ b/coding-agent/src/coding_agent/remote/client.py @@ -302,6 +302,22 @@ def download_workspace_patch(endpoint: RemoteEndpoint, session_id: str) -> str: return patch +def publish_remote_branch( + endpoint: RemoteEndpoint, + session_id: str, + branch_name: str, +) -> dict[str, object]: + data = _post_remote_json( + endpoint, + f"/sessions/{session_id}/publish", + "publish remote workspace branch", + json={"mode": "branch", "branch_name": branch_name}, + ) + if data.get("status") != "published": + raise click.ClickException("Remote branch publication did not complete") + return dict(data) + + def _get_remote_json( endpoint: RemoteEndpoint, path: str, @@ -325,6 +341,8 @@ def _post_remote_json( endpoint: RemoteEndpoint, path: str, action: str, + *, + json: dict[str, object] | None = None, ) -> dict[str, object]: try: with httpx.Client( @@ -332,7 +350,9 @@ def _post_remote_json( headers=auth_headers(endpoint), timeout=30.0, ) as client: - response = client.post(path) + response = ( + client.post(path, json=json) if json is not None else client.post(path) + ) _raise_remote_http_error(response, action) data = cast(object, response.json()) except httpx.RequestError as exc: diff --git a/coding-agent/src/coding_agent/ui/http_server.py b/coding-agent/src/coding_agent/ui/http_server.py index 5f6b44c6e..13b05b3e8 100644 --- a/coding-agent/src/coding_agent/ui/http_server.py +++ b/coding-agent/src/coding_agent/ui/http_server.py @@ -33,6 +33,7 @@ export_workspace_archive_from_config, get_cloud_workspace_from_config, list_cloud_workspaces_from_config, + publish_workspace_branch_from_config, provision_cloud_binding_from_config, workspace_archive_manifest_from_config, workspace_diff_from_config, @@ -163,6 +164,10 @@ def _load_runtime_profiles_config() -> dict[str, Any]: return _load_agent_config_section("runtime_profiles") +def _load_remote_publication_config() -> dict[str, Any]: + return _load_agent_config_section("remote_publication") + + def _load_remote_phases_config() -> dict[str, Any]: return _load_agent_config_section("remote_phases") @@ -1828,11 +1833,47 @@ async def publish_session_result( body: PublishSessionRequest, auth_context: AuthContext | None = Depends(auth_context_from_headers), ) -> PublishSessionResponse: - del request, body + del request _ = await _get_visible_session(session_id, auth_context) - raise HTTPException( - status_code=501, - detail="remote result publication is not implemented yet", + if body.mode == "pr": + raise HTTPException( + status_code=501, + detail="remote PR publication is not implemented yet", + ) + branch_name = body.branch_name or f"coding-agent/session-{session_id}" + commit_message = f"Apply coding-agent remote session {session_id} changes" + try: + publication = await session_manager.export_workspace_archive( + session_id, + lambda binding: publish_workspace_branch_from_config( + _load_cloud_workspace_config(), + _load_remote_publication_config(), + binding.workspace_id, + branch_name, + commit_message, + ), + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=_key_error_detail(exc)) from exc + except SessionOwnershipConflictError as exc: + raise _owner_conflict_http_exception(exc, session_id=session_id) from exc + except (ValueError, TypeError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + if str(exc) == "turn already in progress": + raise HTTPException(status_code=409, detail=str(exc)) from exc + raise + + return PublishSessionResponse( + session_id=session_id, + mode="branch", + status="published", + branch_name=publication.branch_name, + pushed_ref=publication.pushed_ref, + commit_sha=publication.commit_sha, + remote_url=publication.remote_url, + pr_url=None, + error=None, ) diff --git a/coding-agent/src/coding_agent/ui/schemas.py b/coding-agent/src/coding_agent/ui/schemas.py index 78bcc0571..61774a3f4 100644 --- a/coding-agent/src/coding_agent/ui/schemas.py +++ b/coding-agent/src/coding_agent/ui/schemas.py @@ -181,6 +181,7 @@ class PublishSessionResponse(BaseModel): mode: Literal["branch", "pr"] status: Literal["published", "unsupported", "failed"] branch_name: str | None = None + pushed_ref: str | None = None commit_sha: str | None = None remote_url: str | None = None pr_url: str | None = None diff --git a/coding-agent/tests/cli/test_remote_client.py b/coding-agent/tests/cli/test_remote_client.py index e51946af7..d2f4a7f62 100644 --- a/coding-agent/tests/cli/test_remote_client.py +++ b/coding-agent/tests/cli/test_remote_client.py @@ -1070,6 +1070,83 @@ def get(self, path: str) -> FakeResponse: ] +def test_remote_publish_branch_prints_publication_result( + 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, 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-123", + "mode": "branch", + "status": "published", + "branch_name": "coding-agent/result", + "pushed_ref": "refs/heads/coding-agent/result", + "commit_sha": "abc123", + "remote_url": "https://github.com/org/repo.git", + "pr_url": None, + "error": 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((path, json, self.headers)) + return FakeResponse() + + monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FakeClient) + + result = runner.invoke( + main, + [ + "remote", + "publish", + "dev", + "--session", + "sess-123", + "--branch", + "coding-agent/result", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "Published branch coding-agent/result" in result.output + assert "Ref: refs/heads/coding-agent/result" in result.output + assert "Commit: abc123" in result.output + assert "Remote: https://github.com/org/repo.git" in result.output + assert calls == [ + ( + "/sessions/sess-123/publish", + {"mode": "branch", "branch_name": "coding-agent/result"}, + {"Authorization": "Bearer secret-token"}, + ) + ] + + def test_remote_repl_with_repo_uploads_snapshot_and_downloads_workspace( tmp_path: Path, monkeypatch ) -> None: diff --git a/coding-agent/tests/coding_agent/environment/test_docker_workspace_provider.py b/coding-agent/tests/coding_agent/environment/test_docker_workspace_provider.py index d5b1af528..4af7b0d57 100644 --- a/coding-agent/tests/coding_agent/environment/test_docker_workspace_provider.py +++ b/coding-agent/tests/coding_agent/environment/test_docker_workspace_provider.py @@ -15,6 +15,7 @@ cleanup_stale_cloud_workspaces_from_config, cloud_client_factory_from_config, cloud_workspace_ready_from_config, + publish_workspace_branch_from_config, workspace_diff_from_config, workspace_patch_from_config, provision_cloud_binding_from_config, @@ -1141,6 +1142,264 @@ def fake_run( assert patch.patch == patch_text +def test_docker_workspace_provider_publishes_git_workspace_branch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + workspace_root = tmp_path / "workspaces" + workspace_dir = workspace_root / "ws-123" + (workspace_dir / ".git").mkdir(parents=True) + commands: list[list[str]] = [] + push_env: dict[str, str] | None = None + monkeypatch.setenv("CODING_AGENT_GIT_TOKEN", "secret-token") + + def fake_run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + nonlocal push_env + assert kwargs["stdin"] is subprocess.DEVNULL + commands.append(command) + assert command[0:3] == ["git", "-C", str(workspace_dir)] + args = command[3:] + if args == ["status", "--porcelain=v1", "-z"]: + return subprocess.CompletedProcess(command, 0, stdout=" M README.md\0") + if args == ["check-ref-format", "--branch", "coding-agent/test"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["config", "user.name", "coding-agent"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["config", "user.email", "coding-agent@example.com"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["add", "-A"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == [ + "commit", + "-m", + "Apply coding-agent remote session sess-123 changes", + ]: + return subprocess.CompletedProcess( + command, 0, stdout="[detached HEAD abc123] msg\n", stderr="" + ) + if args == ["rev-parse", "HEAD"]: + return subprocess.CompletedProcess(command, 0, stdout="abc123\n", stderr="") + if args == ["config", "--get", "remote.origin.url"]: + return subprocess.CompletedProcess( + command, + 0, + stdout="https://user:secret@github.com/org/repo.git\n", + stderr="", + ) + if args == ["push", "origin", "HEAD:refs/heads/coding-agent/test"]: + env = kwargs["env"] + assert isinstance(env, dict) + push_env = {key: env[key] for key in env if key.startswith("GIT_CONFIG_")} + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + raise AssertionError(f"unexpected command: {command}") + + monkeypatch.setattr(subprocess, "run", fake_run) + + publication = publish_workspace_branch_from_config( + _docker_config({"workspace_root": str(workspace_root)}), + { + "enabled": True, + "git_author_name": "coding-agent", + "git_author_email": "coding-agent@example.com", + "git_token_env": "CODING_AGENT_GIT_TOKEN", + "allowed_git_hosts": ["github.com"], + }, + "ws-123", + "coding-agent/test", + "Apply coding-agent remote session sess-123 changes", + ) + + assert publication.workspace_id == "ws-123" + assert publication.branch_name == "coding-agent/test" + assert publication.pushed_ref == "refs/heads/coding-agent/test" + assert publication.commit_sha == "abc123" + assert publication.remote_url == "https://github.com/org/repo.git" + assert push_env is not None + assert push_env["GIT_CONFIG_COUNT"] == "1" + assert push_env["GIT_CONFIG_KEY_0"] == "http.extraheader" + assert push_env["GIT_CONFIG_VALUE_0"].startswith("Authorization: Basic ") + assert push_env["GIT_CONFIG_VALUE_0"] != "Authorization: Basic " + assert "secret-token" not in push_env["GIT_CONFIG_VALUE_0"] + assert commands[-1] == [ + "git", + "-C", + str(workspace_dir), + "push", + "origin", + "HEAD:refs/heads/coding-agent/test", + ] + + +def test_docker_workspace_provider_rejects_unsafe_git_remote_before_push( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + workspace_root = tmp_path / "workspaces" + workspace_dir = workspace_root / "ws-123" + (workspace_dir / ".git").mkdir(parents=True) + + def fake_run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + del kwargs + args = command[3:] + if args == ["status", "--porcelain=v1", "-z"]: + return subprocess.CompletedProcess(command, 0, stdout=" M README.md\0") + if args == ["check-ref-format", "--branch", "coding-agent/test"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["config", "user.name", "coding-agent"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["config", "user.email", "coding-agent@example.com"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["add", "-A"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == [ + "commit", + "-m", + "Apply coding-agent remote session sess-123 changes", + ]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["rev-parse", "HEAD"]: + return subprocess.CompletedProcess(command, 0, stdout="abc123\n", stderr="") + if args == ["config", "--get", "remote.origin.url"]: + return subprocess.CompletedProcess( + command, + 0, + stdout="http://insecure.example.com/org/repo.git\n", + stderr="", + ) + raise AssertionError(f"unexpected command: {command}") + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(ValueError, match="must use https"): + _ = publish_workspace_branch_from_config( + _docker_config({"workspace_root": str(workspace_root)}), + { + "enabled": True, + "git_author_name": "coding-agent", + "git_author_email": "coding-agent@example.com", + "allowed_git_hosts": ["github.com"], + }, + "ws-123", + "coding-agent/test", + "Apply coding-agent remote session sess-123 changes", + ) + + +def test_docker_workspace_provider_requires_git_remote_host_allowlist_before_push( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + workspace_root = tmp_path / "workspaces" + workspace_dir = workspace_root / "ws-123" + (workspace_dir / ".git").mkdir(parents=True) + + def fake_run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + del kwargs + args = command[3:] + if args == ["status", "--porcelain=v1", "-z"]: + return subprocess.CompletedProcess(command, 0, stdout=" M README.md\0") + if args == ["check-ref-format", "--branch", "coding-agent/test"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["config", "user.name", "coding-agent"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["config", "user.email", "coding-agent@example.com"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["add", "-A"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == [ + "commit", + "-m", + "Apply coding-agent remote session sess-123 changes", + ]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["rev-parse", "HEAD"]: + return subprocess.CompletedProcess(command, 0, stdout="abc123\n", stderr="") + if args == ["config", "--get", "remote.origin.url"]: + return subprocess.CompletedProcess( + command, + 0, + stdout="https://github.com/org/repo.git\n", + stderr="", + ) + raise AssertionError(f"unexpected command: {command}") + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises( + ValueError, match=r"remote_publication\.allowed_git_hosts must be configured" + ): + _ = publish_workspace_branch_from_config( + _docker_config({"workspace_root": str(workspace_root)}), + { + "enabled": True, + "git_author_name": "coding-agent", + "git_author_email": "coding-agent@example.com", + }, + "ws-123", + "coding-agent/test", + "Apply coding-agent remote session sess-123 changes", + ) + + +def test_docker_workspace_provider_rejects_unallowlisted_git_remote_host_before_push( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + workspace_root = tmp_path / "workspaces" + workspace_dir = workspace_root / "ws-123" + (workspace_dir / ".git").mkdir(parents=True) + + def fake_run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + del kwargs + args = command[3:] + if args == ["status", "--porcelain=v1", "-z"]: + return subprocess.CompletedProcess(command, 0, stdout=" M README.md\0") + if args == ["check-ref-format", "--branch", "coding-agent/test"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["config", "user.name", "coding-agent"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["config", "user.email", "coding-agent@example.com"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["add", "-A"]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == [ + "commit", + "-m", + "Apply coding-agent remote session sess-123 changes", + ]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + if args == ["rev-parse", "HEAD"]: + return subprocess.CompletedProcess(command, 0, stdout="abc123\n", stderr="") + if args == ["config", "--get", "remote.origin.url"]: + return subprocess.CompletedProcess( + command, + 0, + stdout="https://gitlab.example.com/org/repo.git\n", + stderr="", + ) + raise AssertionError(f"unexpected command: {command}") + + monkeypatch.setattr(subprocess, "run", fake_run) + + with pytest.raises(ValueError, match="host is not in"): + _ = publish_workspace_branch_from_config( + _docker_config({"workspace_root": str(workspace_root)}), + { + "enabled": True, + "git_author_name": "coding-agent", + "git_author_email": "coding-agent@example.com", + "allowed_git_hosts": ["github.com"], + }, + "ws-123", + "coding-agent/test", + "Apply coding-agent remote session sess-123 changes", + ) + + def test_docker_workspace_provider_rejects_patch_for_snapshot_workspace_without_git( tmp_path: Path, ) -> None: diff --git a/coding-agent/tests/ui/test_http_server.py b/coding-agent/tests/ui/test_http_server.py index 3b2b7a8e0..b7926d896 100644 --- a/coding-agent/tests/ui/test_http_server.py +++ b/coding-agent/tests/ui/test_http_server.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import Any from typing import cast -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest from httpx import AsyncClient, ASGITransport @@ -31,6 +31,7 @@ from coding_agent.core.config import settings from coding_agent.environment import CloudCommandResult, CloudEnvironment from coding_agent.environment.workspace_provider import ( + WorkspaceBranchPublication, WorkspaceDiff, WorkspaceDiffFile, WorkspacePatch, @@ -3610,7 +3611,93 @@ def fake_workspace_patch( "patch": "diff --git a/README.md b/README.md\n", } - async def test_publish_operation_fails_closed( + async def test_publish_branch_returns_provider_result( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + create_resp = await client.post( + "/sessions", + json={ + "execution_binding": { + "kind": "cloud", + "workspace_url": "docker://agent-ws-publish/workspace", + "workspace_id": "ws-publish", + }, + }, + ) + session_id = create_resp.json()["session_id"] + + def fake_publish( + cloud_config: dict[str, object], + publication_config: dict[str, object], + workspace_id: str, + branch_name: str, + commit_message: str, + ) -> WorkspaceBranchPublication: + assert cloud_config["provider"] == "docker" + assert publication_config["git_author_name"] == "coding-agent" + assert workspace_id == "ws-publish" + assert branch_name == "coding-agent/result" + assert commit_message == ( + f"Apply coding-agent remote session {session_id} changes" + ) + return WorkspaceBranchPublication( + workspace_id=workspace_id, + branch_name=branch_name, + pushed_ref="refs/heads/coding-agent/result", + commit_sha="abc123", + remote_url="https://github.com/org/repo.git", + ) + + mock_publish = MagicMock(side_effect=fake_publish) + monkeypatch.setattr( + "coding_agent.ui.http_server.publish_workspace_branch_from_config", + mock_publish, + ) + monkeypatch.setattr( + "coding_agent.ui.http_server._load_cloud_workspace_config", + lambda: {"provider": "docker"}, + ) + monkeypatch.setattr( + "coding_agent.ui.http_server._load_remote_publication_config", + lambda: { + "enabled": True, + "git_author_name": "coding-agent", + "git_author_email": "coding-agent@example.com", + "allowed_git_hosts": ["github.com"], + }, + ) + + response = await client.post( + f"/sessions/{session_id}/publish", + json={"mode": "branch", "branch_name": "coding-agent/result"}, + ) + + assert response.status_code == 200 + mock_publish.assert_called_once_with( + {"provider": "docker"}, + { + "enabled": True, + "git_author_name": "coding-agent", + "git_author_email": "coding-agent@example.com", + "allowed_git_hosts": ["github.com"], + }, + "ws-publish", + "coding-agent/result", + f"Apply coding-agent remote session {session_id} changes", + ) + assert response.json() == { + "session_id": session_id, + "mode": "branch", + "status": "published", + "branch_name": "coding-agent/result", + "pushed_ref": "refs/heads/coding-agent/result", + "commit_sha": "abc123", + "remote_url": "https://github.com/org/repo.git", + "pr_url": None, + "error": None, + } + + async def test_publish_pr_fails_closed( self, client: AsyncClient, ) -> None: @@ -3619,14 +3706,67 @@ async def test_publish_operation_fails_closed( response = await client.post( f"/sessions/{session_id}/publish", - json={"mode": "branch"}, + json={"mode": "pr"}, ) assert response.status_code == 501 assert ( - response.json()["detail"] - == "remote result publication is not implemented yet" + response.json()["detail"] == "remote PR publication is not implemented yet" + ) + + async def test_publish_branch_requires_publication_config( + self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + create_resp = await client.post( + "/sessions", + json={ + "execution_binding": { + "kind": "cloud", + "workspace_url": "docker://agent-ws-publish/workspace", + "workspace_id": "ws-publish", + }, + }, + ) + session_id = create_resp.json()["session_id"] + monkeypatch.setattr( + "coding_agent.ui.http_server._load_cloud_workspace_config", + lambda: {"provider": "docker"}, + ) + monkeypatch.setattr( + "coding_agent.ui.http_server._load_remote_publication_config", + lambda: {}, + ) + + response = await client.post( + f"/sessions/{session_id}/publish", + json={"mode": "branch", "branch_name": "coding-agent/result"}, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "remote_publication.enabled must be true" + + async def test_publish_hides_other_user_session( + self, client: AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv( + "CODING_AGENT_SERVER_CONFIG", str(_write_auth_config(tmp_path)) + ) + monkeypatch.setattr(settings, "http_api_key", None) + + created = await client.post( + "/sessions", + headers={"Authorization": "Bearer admin-token"}, + json={}, ) + session_id = created.json()["session_id"] + + response = await client.post( + f"/sessions/{session_id}/publish", + headers={"Authorization": "Bearer user-token-a"}, + json={"mode": "branch", "branch_name": "test"}, + ) + + assert response.status_code == 404 async def test_remote_result_publication_operations_return_404_for_missing_session( self, client: AsyncClient