From c48094027d7e972d655148021bc06c61b2f35cb2 Mon Sep 17 00:00:00 2001 From: kina0630 Date: Wed, 13 May 2026 15:04:27 +0800 Subject: [PATCH 1/2] feat: export git workspace diff and patch --- .../src/coding_agent/environment/__init__.py | 10 + .../environment/docker_workspace_provider.py | 186 ++++++++++++++++++ .../environment/workspace_provider.py | 56 ++++++ .../src/coding_agent/ui/http_server.py | 70 ++++++- .../test_docker_workspace_provider.py | 119 +++++++++++ coding-agent/tests/ui/test_http_server.py | 154 +++++++++++---- 6 files changed, 554 insertions(+), 41 deletions(-) diff --git a/coding-agent/src/coding_agent/environment/__init__.py b/coding-agent/src/coding_agent/environment/__init__.py index 894673b3f..38a47cb7a 100644 --- a/coding-agent/src/coding_agent/environment/__init__.py +++ b/coding-agent/src/coding_agent/environment/__init__.py @@ -12,7 +12,10 @@ CloudWorkspaceClientFactory, CloudWorkspaceSource, WorkspaceArchiveManifest, + WorkspaceDiff, + WorkspaceDiffFile, WorkspaceInventoryEntry, + WorkspacePatch, WorkspaceProvider, cloud_client_factory_from_config, cloud_workspace_ready_from_config, @@ -24,6 +27,8 @@ provision_cloud_binding_from_config, register_workspace_provider, workspace_archive_manifest_from_config, + workspace_diff_from_config, + workspace_patch_from_config, ) __all__ = [ @@ -38,7 +43,10 @@ "cleanup_stale_cloud_workspaces_from_config", "LocalEnvironment", "WorkspaceArchiveManifest", + "WorkspaceDiff", + "WorkspaceDiffFile", "WorkspaceInventoryEntry", + "WorkspacePatch", "WorkspaceProvider", "cloud_client_factory_from_config", "cloud_workspace_ready_from_config", @@ -50,4 +58,6 @@ "provision_cloud_binding_from_config", "register_workspace_provider", "workspace_archive_manifest_from_config", + "workspace_diff_from_config", + "workspace_patch_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 7e01eabd5..91be5ee9c 100644 --- a/coding-agent/src/coding_agent/environment/docker_workspace_provider.py +++ b/coding-agent/src/coding_agent/environment/docker_workspace_provider.py @@ -25,7 +25,11 @@ CloudWorkspaceSource, WorkspaceProvider, WorkspaceArchiveManifest, + WorkspaceDiff, + WorkspaceDiffFile, + WorkspaceDiffStatus, WorkspaceInventoryEntry, + WorkspacePatch, WorkspaceStatus, register_workspace_provider, ) @@ -627,6 +631,44 @@ def workspace_archive_manifest( workspace_root=workspace_root, ) + @override + def workspace_diff( + self, + config: dict[str, object], + workspace_id: str, + ) -> WorkspaceDiff: + provider_config = _docker_workspace_provider_config(config) + workspace_root = _git_workspace_root_for_operation( + provider_config, + workspace_id, + "diff", + ) + return _git_workspace_diff(provider_config, workspace_id, workspace_root) + + @override + def workspace_patch( + self, + config: dict[str, object], + workspace_id: str, + ) -> WorkspacePatch: + provider_config = _docker_workspace_provider_config(config) + workspace_root = _git_workspace_root_for_operation( + provider_config, + workspace_id, + "patch", + ) + patch = _run_git_workspace_command( + provider_config, + workspace_root, + ["diff", "--binary", "HEAD", "--"], + "git diff", + ) + return WorkspacePatch( + workspace_id=workspace_id, + format="unified_diff", + patch=patch, + ) + def _docker_workspace_provider_config( config: dict[str, object], @@ -1026,6 +1068,150 @@ def _docker_workspace_archive_manifest( ) +def _git_workspace_root_for_operation( + provider_config: _DockerWorkspaceProviderConfig, + workspace_id: str, + operation: str, +) -> Path: + _validate_workspace_id(workspace_id) + if not _is_provider_workspace_id(workspace_id): + raise KeyError(f"workspace not found: {workspace_id}") + workspace_root = _workspace_root_for_id( + provider_config.workspace_root, + workspace_id, + ) + if not workspace_root.is_dir(): + raise KeyError(f"workspace not found: {workspace_id}") + if not (workspace_root / ".git").exists(): + raise ValueError(f"workspace {operation} requires a Git workspace") + return workspace_root + + +def _git_workspace_diff( + provider_config: _DockerWorkspaceProviderConfig, + workspace_id: str, + workspace_root: Path, +) -> WorkspaceDiff: + name_status_output = _run_git_workspace_command( + provider_config, + workspace_root, + ["diff", "--name-status", "--find-renames", "-z", "HEAD", "--"], + "git diff --name-status", + ) + numstat_output = _run_git_workspace_command( + provider_config, + workspace_root, + ["diff", "--numstat", "HEAD", "--"], + "git diff --numstat", + ) + numstat = _parse_git_numstat(numstat_output) + files = _parse_git_name_status(name_status_output, numstat) + additions = sum(item[0] for item in numstat.values() if item[0] is not None) + deletions = sum(item[1] for item in numstat.values() if item[1] is not None) + return WorkspaceDiff( + workspace_id=workspace_id, + files=files, + additions=additions, + deletions=deletions, + ) + + +def _run_git_workspace_command( + provider_config: _DockerWorkspaceProviderConfig, + workspace_root: Path, + args: list[str], + operation: str, +) -> str: + command = [ + provider_config.git_binary, + "-C", + str(workspace_root), + *args, + ] + try: + result = subprocess.run( + command, + shell=False, + capture_output=True, + text=True, + timeout=_GIT_OPERATION_TIMEOUT_SECONDS, + stdin=subprocess.DEVNULL, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + check=True, + ) + except subprocess.CalledProcessError as exc: + _add_git_failure_output_notes(exc, operation, exc.stdout, exc.stderr) + raise + return result.stdout + + +def _parse_git_name_status( + output: str, + numstat: dict[str, tuple[int | None, int | None, bool]], +) -> list[WorkspaceDiffFile]: + tokens = [token for token in output.split("\0") if token] + files: list[WorkspaceDiffFile] = [] + index = 0 + while index < len(tokens): + status_and_path = tokens[index] + index += 1 + status_parts = status_and_path.split("\t", 1) + if len(status_parts) != 2: + raise ValueError("git diff name-status output is malformed") + status_token, first_path = status_parts + status_code = status_token[:1] + old_path: str | None = None + if status_code in {"R", "C"}: + if index >= len(tokens): + raise ValueError("git diff name-status output is malformed") + old_path = first_path + path = tokens[index] + index += 1 + status: WorkspaceDiffStatus = "renamed" + else: + path = first_path + status = _workspace_diff_status_from_git_status(status_code) + + additions, deletions, binary = numstat.get(path, (None, None, False)) + files.append( + WorkspaceDiffFile( + path=path, + status=status, + old_path=old_path, + additions=additions, + deletions=deletions, + binary=binary, + ) + ) + return files + + +def _workspace_diff_status_from_git_status(status_code: str) -> WorkspaceDiffStatus: + if status_code == "A": + return "added" + if status_code == "M": + return "modified" + if status_code == "D": + return "deleted" + return "unknown" + + +def _parse_git_numstat(output: str) -> dict[str, tuple[int | None, int | None, bool]]: + result: dict[str, tuple[int | None, int | None, bool]] = {} + for line in output.splitlines(): + if not line: + continue + parts = line.split("\t", 2) + if len(parts) != 3: + raise ValueError("git diff numstat output is malformed") + raw_additions, raw_deletions, path = parts + if raw_additions == "-" or raw_deletions == "-": + result[path] = (None, None, True) + continue + result[path] = (int(raw_additions), int(raw_deletions), False) + return result + + def _active_workspace_ids_from_config(config: dict[str, object]) -> set[str]: raw_ids = config.get("_active_workspace_ids") if raw_ids is None: diff --git a/coding-agent/src/coding_agent/environment/workspace_provider.py b/coding-agent/src/coding_agent/environment/workspace_provider.py index bf2bd9a57..001d0022b 100644 --- a/coding-agent/src/coding_agent/environment/workspace_provider.py +++ b/coding-agent/src/coding_agent/environment/workspace_provider.py @@ -18,6 +18,9 @@ WorkspaceStatus: TypeAlias = Literal[ "active", "stale", "cleaning", "cleaned", "cleanup_failed" ] +WorkspaceDiffStatus: TypeAlias = Literal[ + "added", "modified", "deleted", "renamed", "binary", "unknown" +] @dataclass(frozen=True) @@ -41,6 +44,31 @@ class WorkspaceArchiveManifest: archive_sha256: str | None +@dataclass(frozen=True) +class WorkspaceDiffFile: + path: str + status: WorkspaceDiffStatus + old_path: str | None = None + additions: int | None = None + deletions: int | None = None + binary: bool = False + + +@dataclass(frozen=True) +class WorkspaceDiff: + workspace_id: str + files: list[WorkspaceDiffFile] + additions: int + deletions: int + + +@dataclass(frozen=True) +class WorkspacePatch: + workspace_id: str + format: Literal["unified_diff"] + patch: str + + class WorkspaceProvider(Protocol): def build_cloud_client_factory( self, config: dict[str, object] @@ -115,6 +143,18 @@ def workspace_archive_manifest( session_id: str | None = None, ) -> WorkspaceArchiveManifest: ... + def workspace_diff( + self, + config: dict[str, object], + workspace_id: str, + ) -> WorkspaceDiff: ... + + def workspace_patch( + self, + config: dict[str, object], + workspace_id: str, + ) -> WorkspacePatch: ... + _WORKSPACE_PROVIDERS: dict[str, WorkspaceProvider] = {} @@ -238,6 +278,22 @@ def workspace_archive_manifest_from_config( ) +def workspace_diff_from_config( + config: dict[str, object], + workspace_id: str, +) -> WorkspaceDiff: + provider = _provider_from_config(config) + return provider.workspace_diff(config, workspace_id) + + +def workspace_patch_from_config( + config: dict[str, object], + workspace_id: str, +) -> WorkspacePatch: + provider = _provider_from_config(config) + return provider.workspace_patch(config, workspace_id) + + 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/ui/http_server.py b/coding-agent/src/coding_agent/ui/http_server.py index 32ca1badf..94631e616 100644 --- a/coding-agent/src/coding_agent/ui/http_server.py +++ b/coding-agent/src/coding_agent/ui/http_server.py @@ -35,6 +35,8 @@ list_cloud_workspaces_from_config, provision_cloud_binding_from_config, workspace_archive_manifest_from_config, + workspace_diff_from_config, + workspace_patch_from_config, ) from coding_agent.ui.binding_resolver import DefaultBindingResolver from coding_agent.ui.session_manager import Session, SessionManager @@ -66,6 +68,7 @@ WorkspaceArchiveManifestResponse, WorkspaceCleanupResponse, WorkspaceDiffResponse, + WorkspaceDiffFileSchema, WorkspaceGcResponse, WorkspaceListResponse, WorkspacePatchResponse, @@ -1739,10 +1742,41 @@ async def get_session_workspace_diff( auth_context: AuthContext | None = Depends(auth_context_from_headers), ) -> WorkspaceDiffResponse: del request - _ = await _get_visible_session(session_id, auth_context) - raise HTTPException( - status_code=501, - detail="workspace diff is not implemented yet", + try: + diff = await session_manager.export_workspace_archive( + session_id, + lambda binding: workspace_diff_from_config( + _load_cloud_workspace_config(), + binding.workspace_id, + ), + ) + 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 WorkspaceDiffResponse( + session_id=session_id, + workspace_id=diff.workspace_id, + files=[ + WorkspaceDiffFileSchema( + path=file.path, + status=file.status, + old_path=file.old_path, + additions=file.additions, + deletions=file.deletions, + binary=file.binary, + ) + for file in diff.files + ], + additions=diff.additions, + deletions=diff.deletions, ) @@ -1757,10 +1791,30 @@ async def get_session_workspace_patch( auth_context: AuthContext | None = Depends(auth_context_from_headers), ) -> WorkspacePatchResponse: del request - _ = await _get_visible_session(session_id, auth_context) - raise HTTPException( - status_code=501, - detail="workspace patch export is not implemented yet", + try: + patch = await session_manager.export_workspace_archive( + session_id, + lambda binding: workspace_patch_from_config( + _load_cloud_workspace_config(), + binding.workspace_id, + ), + ) + 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 WorkspacePatchResponse( + session_id=session_id, + workspace_id=patch.workspace_id, + format=patch.format, + patch=patch.patch, ) 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 385d04aee..d5b1af528 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,8 @@ cleanup_stale_cloud_workspaces_from_config, cloud_client_factory_from_config, cloud_workspace_ready_from_config, + workspace_diff_from_config, + workspace_patch_from_config, provision_cloud_binding_from_config, ) from coding_agent.ui.execution_binding import CloudWorkspaceBinding @@ -1035,6 +1037,123 @@ def fake_run( assert list(workspace_root.iterdir()) == [] +def test_docker_workspace_provider_reports_git_workspace_diff( + 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]: + assert kwargs["stdin"] is subprocess.DEVNULL + assert command[0:3] == ["git", "-C", str(workspace_dir)] + if command[3:6] == ["diff", "--name-status", "--find-renames"]: + return subprocess.CompletedProcess( + command, + 0, + stdout=( + "M\tsrc/app.py\0" + "A\tnew.txt\0" + "D\told.txt\0" + "R100\toldname.txt\0newname.txt\0" + "M\tbin.dat\0" + ), + stderr="", + ) + if command[3:5] == ["diff", "--numstat"]: + return subprocess.CompletedProcess( + command, + 0, + stdout=( + "2\t1\tsrc/app.py\n" + "5\t0\tnew.txt\n" + "0\t3\told.txt\n" + "1\t1\tnewname.txt\n" + "-\t-\tbin.dat\n" + ), + stderr="", + ) + raise AssertionError(f"unexpected command: {command}") + + monkeypatch.setattr(subprocess, "run", fake_run) + + diff = workspace_diff_from_config( + _docker_config({"workspace_root": str(workspace_root)}), + "ws-123", + ) + + assert diff.workspace_id == "ws-123" + assert diff.additions == 8 + assert diff.deletions == 5 + assert [ + ( + file.path, + file.status, + file.old_path, + file.additions, + file.deletions, + file.binary, + ) + for file in diff.files + ] == [ + ("src/app.py", "modified", None, 2, 1, False), + ("new.txt", "added", None, 5, 0, False), + ("old.txt", "deleted", None, 0, 3, False), + ("newname.txt", "renamed", "oldname.txt", 1, 1, False), + ("bin.dat", "modified", None, None, None, True), + ] + + +def test_docker_workspace_provider_exports_git_workspace_patch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + workspace_root = tmp_path / "workspaces" + workspace_dir = workspace_root / "ws-123" + (workspace_dir / ".git").mkdir(parents=True) + patch_text = "diff --git a/README.md b/README.md\n" + + def fake_run( + command: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + assert kwargs["stdin"] is subprocess.DEVNULL + assert command == [ + "git", + "-C", + str(workspace_dir), + "diff", + "--binary", + "HEAD", + "--", + ] + return subprocess.CompletedProcess(command, 0, stdout=patch_text, stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + + patch = workspace_patch_from_config( + _docker_config({"workspace_root": str(workspace_root)}), + "ws-123", + ) + + assert patch.workspace_id == "ws-123" + assert patch.format == "unified_diff" + assert patch.patch == patch_text + + +def test_docker_workspace_provider_rejects_patch_for_snapshot_workspace_without_git( + tmp_path: Path, +) -> None: + workspace_root = tmp_path / "workspaces" + (workspace_root / "ws-123").mkdir(parents=True) + + with pytest.raises(ValueError, match="workspace patch requires a Git workspace"): + _ = workspace_patch_from_config( + _docker_config({"workspace_root": str(workspace_root)}), + "ws-123", + ) + + def test_docker_workspace_provider_removes_workspace_when_snapshot_import_fails_before_containers( monkeypatch: pytest.MonkeyPatch, 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 aeb1c5c14..a05270d71 100644 --- a/coding-agent/tests/ui/test_http_server.py +++ b/coding-agent/tests/ui/test_http_server.py @@ -30,6 +30,11 @@ from coding_agent.approval.store import ApprovalStore from coding_agent.core.config import settings from coding_agent.environment import CloudCommandResult, CloudEnvironment +from coding_agent.environment.workspace_provider import ( + WorkspaceDiff, + WorkspaceDiffFile, + WorkspacePatch, +) from coding_agent.ui.execution_binding import ( CloudWorkspaceBinding, LocalExecutionBinding, @@ -3464,48 +3469,131 @@ async def test_session_result_hides_other_user_session( assert response.status_code == 404 - @pytest.mark.parametrize( - ("method", "path_suffix", "json_body", "detail"), - [ - ( - "GET", - "/workspace/diff", - None, - "workspace diff is not implemented yet", - ), - ( - "GET", - "/workspace/patch", - None, - "workspace patch export is not implemented yet", - ), - ( - "POST", - "/publish", - {"mode": "branch"}, - "remote result publication is not implemented yet", - ), - ], - ) - async def test_remote_result_publication_operations_fail_closed( + async def test_workspace_diff_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-diff/workspace", + "workspace_id": "ws-diff", + }, + }, + ) + session_id = create_resp.json()["session_id"] + + def fake_workspace_diff( + config: dict[str, object], workspace_id: str + ) -> WorkspaceDiff: + assert config["provider"] == "docker" + assert workspace_id == "ws-diff" + return WorkspaceDiff( + workspace_id=workspace_id, + files=[ + WorkspaceDiffFile( + path="src/app.py", + status="modified", + additions=2, + deletions=1, + ) + ], + additions=2, + deletions=1, + ) + + monkeypatch.setattr( + "coding_agent.ui.http_server.workspace_diff_from_config", + fake_workspace_diff, + ) + monkeypatch.setattr( + "coding_agent.ui.http_server._load_cloud_workspace_config", + lambda: {"provider": "docker"}, + ) + + response = await client.get(f"/sessions/{session_id}/workspace/diff") + + assert response.status_code == 200 + assert response.json() == { + "session_id": session_id, + "workspace_id": "ws-diff", + "files": [ + { + "path": "src/app.py", + "status": "modified", + "old_path": None, + "additions": 2, + "deletions": 1, + "binary": False, + } + ], + "additions": 2, + "deletions": 1, + } + + async def test_workspace_patch_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-patch/workspace", + "workspace_id": "ws-patch", + }, + }, + ) + session_id = create_resp.json()["session_id"] + + def fake_workspace_patch( + config: dict[str, object], workspace_id: str + ) -> WorkspacePatch: + assert config["provider"] == "docker" + assert workspace_id == "ws-patch" + return WorkspacePatch( + workspace_id=workspace_id, + format="unified_diff", + patch="diff --git a/README.md b/README.md\n", + ) + + monkeypatch.setattr( + "coding_agent.ui.http_server.workspace_patch_from_config", + fake_workspace_patch, + ) + monkeypatch.setattr( + "coding_agent.ui.http_server._load_cloud_workspace_config", + lambda: {"provider": "docker"}, + ) + + response = await client.get(f"/sessions/{session_id}/workspace/patch") + + assert response.status_code == 200 + assert response.json() == { + "session_id": session_id, + "workspace_id": "ws-patch", + "format": "unified_diff", + "patch": "diff --git a/README.md b/README.md\n", + } + + async def test_publish_operation_fails_closed( self, client: AsyncClient, - method: str, - path_suffix: str, - json_body: dict[str, object] | None, - detail: str, ) -> None: create_resp = await client.post("/sessions", json={}) session_id = create_resp.json()["session_id"] - response = await client.request( - method, - f"/sessions/{session_id}{path_suffix}", - json=json_body, + response = await client.post( + f"/sessions/{session_id}/publish", + json={"mode": "branch"}, ) assert response.status_code == 501 - assert response.json()["detail"] == detail + assert ( + response.json()["detail"] + == "remote result publication is not implemented yet" + ) async def test_remote_result_publication_operations_return_404_for_missing_session( self, client: AsyncClient From 280cc86fca77927e91e4c07b29d3b08390f67de8 Mon Sep 17 00:00:00 2001 From: kina0630 Date: Wed, 13 May 2026 15:11:15 +0800 Subject: [PATCH 2/2] fix: guard workspace result access by owner --- .../src/coding_agent/ui/http_server.py | 2 ++ coding-agent/tests/ui/test_http_server.py | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/coding-agent/src/coding_agent/ui/http_server.py b/coding-agent/src/coding_agent/ui/http_server.py index 94631e616..5f6b44c6e 100644 --- a/coding-agent/src/coding_agent/ui/http_server.py +++ b/coding-agent/src/coding_agent/ui/http_server.py @@ -1742,6 +1742,7 @@ async def get_session_workspace_diff( auth_context: AuthContext | None = Depends(auth_context_from_headers), ) -> WorkspaceDiffResponse: del request + _ = await _get_visible_session(session_id, auth_context) try: diff = await session_manager.export_workspace_archive( session_id, @@ -1791,6 +1792,7 @@ async def get_session_workspace_patch( auth_context: AuthContext | None = Depends(auth_context_from_headers), ) -> WorkspacePatchResponse: del request + _ = await _get_visible_session(session_id, auth_context) try: patch = await session_manager.export_workspace_archive( session_id, diff --git a/coding-agent/tests/ui/test_http_server.py b/coding-agent/tests/ui/test_http_server.py index a05270d71..3b2b7a8e0 100644 --- a/coding-agent/tests/ui/test_http_server.py +++ b/coding-agent/tests/ui/test_http_server.py @@ -3469,6 +3469,39 @@ async def test_session_result_hides_other_user_session( assert response.status_code == 404 + async def test_workspace_diff_and_patch_hide_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={ + "execution_binding": { + "kind": "cloud", + "workspace_url": "docker://agent-ws-private/workspace", + "workspace_id": "ws-private", + }, + }, + ) + session_id = created.json()["session_id"] + + diff = await client.get( + f"/sessions/{session_id}/workspace/diff", + headers={"Authorization": "Bearer user-token-a"}, + ) + patch = await client.get( + f"/sessions/{session_id}/workspace/patch", + headers={"Authorization": "Bearer user-token-a"}, + ) + + assert diff.status_code == 404 + assert patch.status_code == 404 + async def test_workspace_diff_returns_provider_result( self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: