From c3c99efb63a7b0398c2bc7fe270f76c99fc23bdd Mon Sep 17 00:00:00 2001 From: Dzmitry Talkach Date: Mon, 22 Jun 2026 10:43:43 +0200 Subject: [PATCH 1/3] feat(application): add --share-token to run describe CLI command (PYSDK-145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recipients holding a share token secret can now describe a run without OAuth login by passing --share-token to `application run describe`. The token is used directly as the Bearer token for platform API requests. - Adds `--share-token` option to `run describe`; when set, creates a `Client(token_provider=…)` bypassing OAuth, with `hide_platform_queue_position=True` - Catches `UnauthorizedException` and `ForbiddenException` when using a share token and surfaces a clear "Access denied" message with exit code 1 - Adds 5 integration tests covering success (text + JSON), not-found, unauthorized, and forbidden paths Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 3 + src/aignostics/application/_cli.py | 59 ++++++- src/aignostics/application/_service.py | 12 +- src/aignostics/platform/resources/runs.py | 57 +++++-- tests/aignostics/application/cli_test.py | 190 ++++++++++++++++++++++ uv.lock | 24 ++- 6 files changed, 318 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dcbd087b0..11295728b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,7 @@ dependencies = [ "httpx>=0.28.1,<1", "idc-index-data==24.0.3", "ijson>=3.4.0.post0,<4", + "joserfc>=1.6.7", "jsf>=0.11.2,<1", "jsonschema[format-nongpl]>=4.25.1,<5", "loguru>=0.7.3,<1", @@ -114,6 +115,8 @@ dependencies = [ "pyarrow>=23.0.1,<24; python_version >= '3.14'", "pyjwt[crypto]>=2.13.0,<3", # CVE-2026-32597 requires >=2.12.0 (Renovate #475) "python-dateutil>=2.9.0.post0,<3", + "python-engineio>=4.13.3", + "python-socketio>=5.16.3", # "pywebview[qt6]>=5.4,<6; sys_platform == 'linux'", "requests>=2.33.0,<3", # CVE-2026-25645 requires >= 2.33.0 "requests-oauthlib>=2.0.0,<3", diff --git a/src/aignostics/application/_cli.py b/src/aignostics/application/_cli.py index f28cf4382..aa2b9321c 100644 --- a/src/aignostics/application/_cli.py +++ b/src/aignostics/application/_cli.py @@ -956,7 +956,7 @@ def run_list( # noqa: PLR0913, PLR0917 @run_app.command("describe") -def run_describe( +def run_describe( # noqa: PLR0912 run_id: Annotated[str, typer.Argument(help="Id of the run to describe")], format: Annotated[ # noqa: A002 str, @@ -970,13 +970,20 @@ def run_describe( help="Show only run and item status summary (external ID, state, error message)", ), ] = False, + share_token: Annotated[ + str | None, + typer.Option( + help="Share token secret for link-based access. When provided, OAuth login is not required.", + ), + ] = None, ) -> None: """Describe run.""" logger.trace("Describing run with ID '{}'", run_id) try: user_info = PlatformService.get_user_info() - run = Service().application_run(run_id) + run = Service().application_run(run_id, share_token=share_token) + if format == "json": # Get run details and items, output as JSON run_details = run.details(hide_platform_queue_position=not user_info.is_internal_user) @@ -995,6 +1002,16 @@ def run_describe( else: console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") sys.exit(2) + except ForbiddenException: + logger.warning("Access denied for run '{}'", run_id) + msg = f"Access denied for run '{run_id}'." + if share_token is not None: + msg += " The share token may be invalid, expired, or revoked." + if format == "json": + print(json.dumps({"error": "access_denied", "message": msg}), file=sys.stderr) + else: + console.print(f"[error]Error:[/error] {msg}") + sys.exit(1) except Exception as e: logger.exception(f"Failed to retrieve and print run details for ID '{run_id}'") if format == "json": @@ -1008,12 +1025,16 @@ def run_describe( def run_dump_metadata( run_id: Annotated[str, typer.Argument(help="Id of the run to dump custom metadata for")], pretty: Annotated[bool, typer.Option(help="Pretty print JSON output with indentation")] = False, + share_token: Annotated[ + str | None, + typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."), + ] = None, ) -> None: """Dump custom metadata of a run as JSON to stdout.""" logger.trace("Dumping custom metadata for run with ID '{}'", run_id) try: - run = Service().application_run(run_id).details() + run = Service().application_run(run_id, share_token=share_token).details() custom_metadata = run.custom_metadata if hasattr(run, "custom_metadata") else {} # Output JSON to stdout @@ -1027,6 +1048,13 @@ def run_dump_metadata( logger.warning(f"Run with ID '{run_id}' not found.") console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") sys.exit(2) + except ForbiddenException: + logger.warning("Access denied for run '{}'", run_id) + msg = f"Access denied for run '{run_id}'." + if share_token is not None: + msg += " The share token may be invalid, expired, or revoked." + console.print(f"[error]Error:[/error] {msg}") + sys.exit(1) except Exception as e: logger.exception(f"Failed to dump custom metadata for run with ID '{run_id}'") console.print(f"[error]Error:[/error] Failed to dump custom metadata for run with ID '{run_id}': {e}") @@ -1038,12 +1066,16 @@ def run_dump_item_metadata( run_id: Annotated[str, typer.Argument(help="Id of the run containing the item")], external_id: Annotated[str, typer.Argument(help="External ID of the item to dump custom metadata for")], pretty: Annotated[bool, typer.Option(help="Pretty print JSON output with indentation")] = False, + share_token: Annotated[ + str | None, + typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."), + ] = None, ) -> None: """Dump custom metadata of an item as JSON to stdout.""" logger.trace("Dumping custom metadata for item '{}' in run with ID '{}'", external_id, run_id) try: - run = Service().application_run(run_id) + run = Service().application_run(run_id, share_token=share_token) # Find the item with the matching external_id in the results item = None @@ -1073,6 +1105,13 @@ def run_dump_item_metadata( logger.warning(f"Run with ID '{run_id}' not found.") print(f"Warning: Run with ID '{run_id}' not found.", file=sys.stderr) sys.exit(2) + except ForbiddenException: + logger.warning("Access denied for run '{}'", run_id) + msg = f"Access denied for run '{run_id}'." + if share_token is not None: + msg += " The share token may be invalid, expired, or revoked." + print(f"Error: {msg}", file=sys.stderr) + sys.exit(1) except Exception as e: logger.exception(f"Failed to dump custom metadata for item '{external_id}' in run with ID '{run_id}'") print( @@ -1561,6 +1600,10 @@ def result_download( # noqa: C901, PLR0913, PLR0915, PLR0917 'Run uvx --with "aignostics[qupath]" aignostics qupath install' ), ] = False, + share_token: Annotated[ + str | None, + typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."), + ] = None, ) -> None: """Download results of a run.""" logger.trace( @@ -1708,6 +1751,7 @@ def update_progress(progress: DownloadProgress) -> None: # noqa: C901 wait_for_completion=wait_for_completion, qupath_project=qupath_project, download_progress_callable=update_progress, + share_token=share_token, ) main_download_progress_ui.update(main_task, completed=100, total=100) @@ -1723,6 +1767,13 @@ def update_progress(progress: DownloadProgress) -> None: # noqa: C901 logger.warning(f"Bad input to download results of run with ID '{run_id}': {e}") console.print(f"[warning]Warning:[/warning] Bad input to download results of run with ID '{run_id}': {e}") sys.exit(2) + except ForbiddenException: + logger.warning("Access denied for run '{}'", run_id) + msg = f"Access denied for run '{run_id}'." + if share_token is not None: + msg += " The share token may be invalid, expired, or revoked." + console.print(f"[error]Error:[/error] {msg}") + sys.exit(1) except Exception as e: logger.exception(f"Failed to download results of run with ID '{run_id}'") console.print( diff --git a/src/aignostics/application/_service.py b/src/aignostics/application/_service.py index c104e2f79..de1f9fa3a 100644 --- a/src/aignostics/application/_service.py +++ b/src/aignostics/application/_service.py @@ -791,11 +791,13 @@ def application_runs( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, PLR0917 logger.exception(message) raise RuntimeError(message) from e - def application_run(self, run_id: str) -> Run: + def application_run(self, run_id: str, share_token: str | None = None) -> Run: """Select a run by its ID. Args: - run_id (str): The ID of the run to find + run_id (str): The ID of the run to find. + share_token (str | None): Optional share token secret. When provided the run + is accessed via the ``share_token`` query parameter without OAuth. Returns: Run: The run that can be fetched using the .details() call. @@ -804,6 +806,8 @@ def application_run(self, run_id: str) -> Run: RuntimeError: If initializing the client fails or the run cannot be retrieved. """ try: + if share_token is not None: + return Run.for_run_id(run_id, share_token=share_token) return self._get_platform_client().run(run_id) except Exception as e: message = f"Failed to retrieve application run with ID '{run_id}': {e}" @@ -1595,6 +1599,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, qupath_project: bool = False, download_progress_queue: Any | None = None, # noqa: ANN401 download_progress_callable: Callable | None = None, # type: ignore[type-arg] + share_token: str | None = None, ) -> Path: """Download application run results with progress tracking. @@ -1611,6 +1616,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, of the destination directory. download_progress_queue (Queue | None): Queue for GUI progress updates. download_progress_callable (Callable | None): Callback for CLI progress updates. + share_token (str | None): Optional share token secret for unauthenticated access. Returns: Path: The directory containing downloaded results. @@ -1641,7 +1647,7 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0914, PLR0915, progress = DownloadProgress() update_progress(progress, download_progress_callable, download_progress_queue) - application_run = self.application_run(run_id) + application_run = self.application_run(run_id, share_token=share_token) final_destination_directory = destination_directory try: details = application_run.details() diff --git a/src/aignostics/platform/resources/runs.py b/src/aignostics/platform/resources/runs.py index a2097db64..98f5faf9e 100644 --- a/src/aignostics/platform/resources/runs.py +++ b/src/aignostics/platform/resources/runs.py @@ -105,17 +105,20 @@ class Artifact(_AuthenticatedResource): ``GET /api/v1/runs/{run_id}/artifacts/{artifact_id}/file`` endpoint. """ - def __init__(self, api: _AuthenticatedApi, run_id: str, artifact_id: str) -> None: + def __init__(self, api: _AuthenticatedApi, run_id: str, artifact_id: str, share_token: str | None = None) -> None: """Initializes an Artifact instance. Args: api (_AuthenticatedApi): The configured API client. run_id (str): The ID of the parent run. artifact_id (str): The ID of the output artifact. + share_token (str | None): Optional share token secret forwarded as the + ``share_token`` query parameter on the /file endpoint request. """ super().__init__(api) self.run_id = run_id self.artifact_id = artifact_id + self._share_token = share_token def get_download_url(self) -> str: """Resolve a fresh presigned download URL for this artifact. @@ -143,6 +146,8 @@ def get_download_url(self) -> str: configuration = self._api.api_client.configuration host = configuration.host.rstrip("/") endpoint_url = f"{host}/api/v1/runs/{self.run_id}/artifacts/{self.artifact_id}/file" + if self._share_token: + endpoint_url += f"?share_token={self._share_token}" proxy = getattr(configuration, "proxy", None) ssl_ca_cert = getattr(configuration, "ssl_ca_cert", None) verify_ssl = getattr(configuration, "verify_ssl", True) @@ -248,30 +253,52 @@ class Run(_AuthenticatedResource): Provides operations to check status, retrieve results, and download artifacts. """ - def __init__(self, api: _AuthenticatedApi, run_id: str) -> None: + def __init__(self, api: _AuthenticatedApi, run_id: str, share_token: str | None = None) -> None: """Initializes a Run instance. Args: api (_AuthenticatedApi): The configured API client. run_id (str): The ID of the application run. + share_token (str | None): Optional share token secret. When supplied the + token is forwarded as the ``share_token`` query parameter on every API + request, granting access without an OAuth Bearer token. """ super().__init__(api) self.run_id = run_id + self._share_token = share_token @classmethod - def for_run_id(cls, run_id: str, cache_token: bool = True) -> "Run": - """Creates an Run instance for an existing run. + def for_run_id(cls, run_id: str, cache_token: bool = True, share_token: str | None = None) -> "Run": + """Creates a Run instance for an existing run. + + When *share_token* is provided the run is accessed via the ``share_token`` + query parameter on every API request without an OAuth Bearer token. Args: run_id (str): The ID of the application run. - cache_token (bool): Whether to cache the API token. + cache_token (bool): Whether to use the cached OAuth token. Ignored + when *share_token* is supplied. + share_token (str | None): Optional share token secret. When provided + no OAuth login is required. Returns: Run: The initialized Run instance. + + Example:: + + # Authenticated access + run = Run.for_run_id("run-abc123") + + # Share-token access (no OAuth required) + run = Run.for_run_id("run-abc123", share_token="shr_xxxx") + details = run.details() + for item in run.results(): + print(item.external_id) """ from aignostics.platform._client import Client # noqa: PLC0415 - return cls(Client.get_api_client(cache_token=cache_token), run_id) + api = Client.get_api_client(cache_token=cache_token) + return cls(api, run_id, share_token=share_token) def details(self, nocache: bool = False, hide_platform_queue_position: bool = False) -> RunData: """Retrieves the current status of the application run. @@ -292,9 +319,10 @@ def details(self, nocache: bool = False, hide_platform_queue_position: bool = Fa NotFoundException: If the run is not found after retries. Exception: If the API request fails. """ + share_token = self._share_token @cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) - def details_with_retry(run_id: str) -> RunData: + def details_with_retry(run_id: str, _share_token: str | None = None) -> RunData: def _fetch() -> RunData: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), @@ -307,6 +335,7 @@ def _fetch() -> RunData: )( lambda: self._api.get_run_v1_runs_run_id_get( run_id, + share_token=_share_token, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, ) @@ -321,7 +350,7 @@ def _fetch() -> RunData: reraise=True, )(_fetch) - run_data: RunData = details_with_retry(self.run_id, nocache=nocache) # type: ignore[call-arg] + run_data: RunData = details_with_retry(self.run_id, _share_token=share_token, nocache=nocache) # type: ignore[call-arg] if hide_platform_queue_position: run_data = run_data.model_copy(deep=True) run_data.num_preceding_items_platform = None @@ -386,11 +415,12 @@ def results( # noqa: PLR0913 Raises: Exception: If the API request fails. """ + share_token = self._share_token # Create a wrapper function that applies retry logic and caching to each API call # Caching at this level ensures having a fresh iterator on cache hits @cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) - def results_with_retry(run_id: str, **kwargs: object) -> list[ItemResultData]: + def results_with_retry(run_id: str, _share_token: str | None = None, **kwargs: object) -> list[ItemResultData]: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), stop=stop_after_attempt(settings().run_retry_attempts), @@ -400,6 +430,7 @@ def results_with_retry(run_id: str, **kwargs: object) -> list[ItemResultData]: )( lambda: self._api.list_run_items_v1_runs_run_id_items_get( run_id=run_id, + share_token=_share_token, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, **kwargs, # pyright: ignore[reportArgumentType] @@ -418,7 +449,11 @@ def results_with_retry(run_id: str, **kwargs: object) -> list[ItemResultData]: if custom_metadata is not None: filter_kwargs["custom_metadata"] = custom_metadata - return paginate(lambda **kwargs: results_with_retry(self.run_id, nocache=nocache, **filter_kwargs, **kwargs)) + return paginate( + lambda **kwargs: results_with_retry( + self.run_id, _share_token=share_token, nocache=nocache, **filter_kwargs, **kwargs + ) + ) def download_to_folder( # noqa: C901 self, @@ -511,7 +546,7 @@ def artifact(self, artifact_id: str) -> Artifact: Returns: Artifact: A handle bound to this run and the given artifact. """ - return Artifact(self._api, self.run_id, artifact_id) + return Artifact(self._api, self.run_id, artifact_id, share_token=self._share_token) def get_artifact_download_url(self, artifact_id: str) -> str: """Resolve a fresh presigned download URL for an artifact of this run. diff --git a/tests/aignostics/application/cli_test.py b/tests/aignostics/application/cli_test.py index 1b62e713a..b1d976156 100644 --- a/tests/aignostics/application/cli_test.py +++ b/tests/aignostics/application/cli_test.py @@ -981,6 +981,196 @@ def test_cli_run_describe_json_includes_items(runner: CliRunner) -> None: assert item["termination_reason"] == "SUCCEEDED" +def _make_mock_run(run_id: str = "run-shared-001", custom_metadata: dict | None = None) -> MagicMock: + """Build a mock Run that returns a minimal RunReadResponse from details().""" + mock_run_data = RunReadResponse( + run_id=run_id, + application_id="test-app", + version_number="1.0.0", + state=RunState.TERMINATED, + output=RunOutput.FULL, + termination_reason=RunTerminationReason.ALL_ITEMS_PROCESSED, + error_code=None, + error_message=None, + statistics=RunItemStatistics( + item_count=0, + item_pending_count=0, + item_processing_count=0, + item_user_error_count=0, + item_system_error_count=0, + item_skipped_count=0, + item_succeeded_count=0, + ), + custom_metadata=custom_metadata, + submitted_at=datetime(2025, 1, 1, tzinfo=UTC), + submitted_by="test-user", + terminated_at=datetime(2025, 1, 1, 0, 1, tzinfo=UTC), + ) + mock_run = MagicMock() + mock_run.details.return_value = mock_run_data + mock_run.results.return_value = iter([]) + return mock_run + + +@pytest.mark.integration +def test_cli_run_describe_with_share_token_success(runner: CliRunner, record_property: object) -> None: + """Run describe --share-token succeeds without OAuth and returns run details.""" + record_property("tested-item-id", "PYSDK-145") + mock_run = _make_mock_run("run-shared-001") + + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.return_value = mock_run + result = runner.invoke(cli, ["application", "run", "describe", "run-shared-001", "--share-token", "s3cr3t"]) + + assert result.exit_code == 0, f"Unexpected exit: {result.output}" + mock_svc_cls.return_value.application_run.assert_called_once_with("run-shared-001", share_token="s3cr3t") # noqa: S106 + assert "run-shared-001" in normalize_output(result.output) + + +@pytest.mark.integration +def test_cli_run_describe_with_share_token_json(runner: CliRunner, record_property: object) -> None: + """Run describe --share-token --format json returns valid JSON without OAuth.""" + record_property("tested-item-id", "PYSDK-145") + mock_run = _make_mock_run("run-shared-002") + + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.return_value = mock_run + result = runner.invoke( + cli, + ["application", "run", "describe", "run-shared-002", "--share-token", "s3cr3t", "--format", "json"], + ) + + assert result.exit_code == 0, f"Unexpected exit: {result.output}" + data = json.loads(result.stdout) + assert data["run_id"] == "run-shared-002" + assert "items" in data + + +@pytest.mark.integration +def test_cli_run_describe_with_share_token_not_found(runner: CliRunner, record_property: object) -> None: + """Run describe --share-token exits 2 when run does not exist.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.side_effect = ApiNotFound(status=404, reason="Not Found") + result = runner.invoke(cli, ["application", "run", "describe", "bad-run-id", "--share-token", "s3cr3t"]) + + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + +@pytest.mark.integration +def test_cli_run_describe_with_share_token_forbidden(runner: CliRunner, record_property: object) -> None: + """Run describe --share-token exits 1 when token is invalid, expired, or has no access.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.side_effect = ForbiddenException(status=403, reason="Forbidden") + result = runner.invoke(cli, ["application", "run", "describe", "run-id", "--share-token", "bad-token"]) + + assert result.exit_code == 1 + assert "Access denied" in normalize_output(result.output) + + +@pytest.mark.integration +def test_cli_run_dump_metadata_with_share_token_success(runner: CliRunner, record_property: object) -> None: + """Run dump-metadata --share-token returns custom metadata JSON without OAuth.""" + record_property("tested-item-id", "PYSDK-145") + mock_run = _make_mock_run("run-001", custom_metadata={"key": "value"}) + + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.return_value = mock_run + result = runner.invoke(cli, ["application", "run", "dump-metadata", "run-001", "--share-token", "s3cr3t"]) + + assert result.exit_code == 0, f"Unexpected exit: {result.output}" + mock_svc_cls.return_value.application_run.assert_called_once_with("run-001", share_token="s3cr3t") # noqa: S106 + assert "key" in result.output + + +@pytest.mark.integration +def test_cli_run_dump_metadata_with_share_token_forbidden(runner: CliRunner, record_property: object) -> None: + """Run dump-metadata --share-token exits 1 on forbidden.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.side_effect = ForbiddenException(status=403, reason="Forbidden") + result = runner.invoke(cli, ["application", "run", "dump-metadata", "run-id", "--share-token", "bad"]) + + assert result.exit_code == 1 + assert "Access denied" in normalize_output(result.output) + + +@pytest.mark.integration +def test_cli_run_dump_item_metadata_with_share_token_success(runner: CliRunner, record_property: object) -> None: + """Run dump-item-metadata --share-token finds an item and returns its metadata without OAuth.""" + record_property("tested-item-id", "PYSDK-145") + mock_item = MagicMock() + mock_item.external_id = "slide-001.svs" + mock_item.custom_metadata = {"slide": "meta"} + + mock_run = MagicMock() + mock_run.results.return_value = iter([mock_item]) + + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.return_value = mock_run + result = runner.invoke( + cli, + ["application", "run", "dump-item-metadata", "run-001", "slide-001.svs", "--share-token", "s3cr3t"], + ) + + assert result.exit_code == 0, f"Unexpected exit: {result.output}" + mock_svc_cls.return_value.application_run.assert_called_once_with("run-001", share_token="s3cr3t") # noqa: S106 + assert "slide" in result.output + + +@pytest.mark.integration +def test_cli_run_dump_item_metadata_with_share_token_forbidden(runner: CliRunner, record_property: object) -> None: + """Run dump-item-metadata --share-token exits 1 on forbidden.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.side_effect = ForbiddenException(status=403, reason="Forbidden") + result = runner.invoke( + cli, ["application", "run", "dump-item-metadata", "run-id", "item-id", "--share-token", "bad"] + ) + + assert result.exit_code == 1 + assert "Access denied" in normalize_output(result.output) + + +@pytest.mark.integration +def test_cli_result_download_with_share_token_passes_token_to_service( + runner: CliRunner, tmp_path: Path, record_property: object +) -> None: + """Result download --share-token forwards the token to application_run_download.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_download.return_value = tmp_path + result = runner.invoke( + cli, + ["application", "run", "result", "download", "run-001", str(tmp_path), "--share-token", "s3cr3t"], + ) + + assert result.exit_code == 0, f"Unexpected exit: {result.output}" + call_kwargs = mock_svc_cls.return_value.application_run_download.call_args.kwargs + assert call_kwargs.get("share_token") == "s3cr3t" + + +@pytest.mark.integration +def test_cli_result_download_with_share_token_forbidden( + runner: CliRunner, tmp_path: Path, record_property: object +) -> None: + """Result download --share-token exits 1 on forbidden.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_download.side_effect = ForbiddenException( + status=403, reason="Forbidden" + ) + result = runner.invoke( + cli, + ["application", "run", "result", "download", "run-id", str(tmp_path), "--share-token", "bad"], + ) + + assert result.exit_code == 1 + assert "Access denied" in normalize_output(result.output) + + @pytest.mark.e2e def test_cli_run_cancel_invalid_run_id(runner: CliRunner, record_property) -> None: """Check run cancel command fails as expected on run not found.""" diff --git a/uv.lock b/uv.lock index 2ac82b69b..385b61a04 100644 --- a/uv.lock +++ b/uv.lock @@ -54,6 +54,7 @@ dependencies = [ { name = "humanize" }, { name = "idc-index-data" }, { name = "ijson" }, + { name = "joserfc" }, { name = "jsf" }, { name = "jsonschema", extra = ["format-nongpl"] }, { name = "loguru" }, @@ -78,7 +79,9 @@ dependencies = [ { name = "pygments" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-dateutil" }, + { name = "python-engineio" }, { name = "python-multipart" }, + { name = "python-socketio" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "pyyaml" }, { name = "requests" }, @@ -198,6 +201,7 @@ requires-dist = [ { name = "idc-index-data", specifier = "==24.0.3" }, { name = "ijson", specifier = ">=3.4.0.post0,<4" }, { name = "ipython", marker = "extra == 'marimo'", specifier = ">=9.8.0,<10" }, + { name = "joserfc", specifier = ">=1.6.7" }, { name = "jsf", specifier = ">=0.11.2,<1" }, { name = "jsonschema", extras = ["format-nongpl"], specifier = ">=4.25.1,<5" }, { name = "jupyter", marker = "extra == 'jupyter'", specifier = ">=1.1.1,<2" }, @@ -231,7 +235,9 @@ requires-dist = [ { name = "pyinstaller", marker = "extra == 'pyinstaller'", specifier = ">=6.14.0,<7" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.13.0,<3" }, { name = "python-dateutil", specifier = ">=2.9.0.post0,<3" }, + { name = "python-engineio", specifier = ">=4.13.3" }, { name = "python-multipart", specifier = ">=0.0.26" }, + { name = "python-socketio", specifier = ">=5.16.3" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=312,<313" }, { name = "pyyaml", specifier = ">=6.0.3,<7" }, { name = "requests", specifier = ">=2.33.0,<3" }, @@ -3004,14 +3010,14 @@ wheels = [ [[package]] name = "joserfc" -version = "1.6.5" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/26/abe1ad855eb334b5ebc9c6495d4798e12bee70e5e8e815d54570710b8312/joserfc-1.7.2.tar.gz", hash = "sha256:537ffb8888b2df039cb5b6d017d7cff6f09d521ce65d89cc9b8ab752b1cff947", size = 233183, upload-time = "2026-06-29T09:03:10.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" }, + { url = "https://files.pythonhosted.org/packages/13/80/d1b30336582cced4dce0dae776508a6011723e32f907bc7a702c0b25890a/joserfc-1.7.2-py3-none-any.whl", hash = "sha256:ddd818c0ca9b4f17bbc2d72cb3966e6ded7502be089316c62c3cc64ae86132b5", size = 70426, upload-time = "2026-06-29T09:03:09.393Z" }, ] [[package]] @@ -6230,14 +6236,14 @@ wheels = [ [[package]] name = "python-engineio" -version = "4.13.1" +version = "4.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "simple-websocket" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/a0/f75491f942184d9960b15e763270f765fe9f239745ca5f9e16289011aed4/python_engineio-4.13.3.tar.gz", hash = "sha256:572b7783e341fed21edbc7cea297ccd378dad79265fdde96aa4664420a7c06c9", size = 79734, upload-time = "2026-06-20T22:53:52.197Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" }, + { url = "https://files.pythonhosted.org/packages/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl", hash = "sha256:1f60ecaf1358190f0e26c48c578a60428dc02a8f1295bc3dbf53d1b31116821f", size = 59993, upload-time = "2026-06-20T22:53:50.775Z" }, ] [[package]] @@ -6260,15 +6266,15 @@ wheels = [ [[package]] name = "python-socketio" -version = "5.16.1" +version = "5.16.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bidict" }, { name = "python-engineio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508, upload-time = "2026-02-06T23:42:07Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/2d/ffce71017c106b75099fea569df6518c63fee5d6202ce0cfe7b01e6f22c3/python_socketio-5.16.3.tar.gz", hash = "sha256:89b136f677ae65607a84cecda9b4d6c5377b40a97582c504c25df89af16d520e", size = 128095, upload-time = "2026-06-15T22:07:04.003Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" }, + { url = "https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl", hash = "sha256:e7ad14202a5e6448824c7c2f86161d04e13dec05992257df5c709e6a2798c041", size = 82087, upload-time = "2026-06-15T22:07:02.498Z" }, ] [package.optional-dependencies] From 8bad63102dd7dcc32356e2ff4d6bea0efa57ee00 Mon Sep 17 00:00:00 2001 From: Dzmitry Talkach Date: Wed, 1 Jul 2026 11:17:35 +0200 Subject: [PATCH 2/3] Update the docstring --- src/aignostics/application/_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aignostics/application/_cli.py b/src/aignostics/application/_cli.py index aa2b9321c..5f33b9206 100644 --- a/src/aignostics/application/_cli.py +++ b/src/aignostics/application/_cli.py @@ -973,7 +973,7 @@ def run_describe( # noqa: PLR0912 share_token: Annotated[ str | None, typer.Option( - help="Share token secret for link-based access. When provided, OAuth login is not required.", + help="Share token secret for link-based access.", ), ] = None, ) -> None: From d8936d0d2bd749f25013d9837bb97e1ad7e66a9f Mon Sep 17 00:00:00 2001 From: Oliver Meyer Date: Mon, 3 Aug 2026 16:23:18 +0200 Subject: [PATCH 3/3] feat(application): add --share-token to run commands Adds a --share-token option to `application run describe / dump-metadata / dump-item-metadata / result download` (PYSDK-145), granting an authenticated user access to a run that has been shared with them. - Share-token access still authenticates as the calling user: the platform requires an OAuth Bearer token on every request. The share_token is forwarded as the `share_token` query parameter (on details/results and the artifact /file endpoint) and elevates that user's access to the shared run. Run.for_run_id and the artifact download path therefore keep the normal authenticated client and always send the Bearer header. - 403 handling: re-raise ForbiddenException before the generic ApiException->RuntimeError wrapper in application_run_download so the CLI surfaces the share-token access-denied message. - Percent-encode the share_token in the artifact /file URL so reserved characters cannot corrupt the URL or inject extra query parameters. - Remove three unrelated runtime dependencies (joserfc, python-engineio, python-socketio) that were added but never imported; re-lock. - DRY: extract share_token_access_denied_message() helper and a single ShareTokenOption alias (consistent help text across all four commands). - Rename the misleading _share_token cache-key parameter to share_token. - Add resource-, CLI-, and service-layer tests covering token forwarding, URL encoding, the Bearer header being sent alongside the share_token, authenticated-client wiring, exit-2 not-found paths, and the 403 re-raise. Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 3 - src/aignostics/application/_cli.py | 50 ++----- src/aignostics/application/_service.py | 4 + src/aignostics/application/_utils.py | 22 ++++ src/aignostics/platform/resources/runs.py | 52 +++++--- tests/aignostics/application/cli_test.py | 57 ++++++++ tests/aignostics/application/service_test.py | 20 ++- .../platform/resources/runs_test.py | 122 +++++++++++++++++- uv.lock | 6 - 9 files changed, 270 insertions(+), 66 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5a79ecff9..9c20f8c49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,7 +105,6 @@ dependencies = [ "httpx>=0.28.1,<1", "idc-index-data==24.0.3", "ijson>=3.4.0.post0,<4", - "joserfc>=1.6.7", "jsf>=0.11.2,<1", "jsonschema[format-nongpl]>=4.25.1,<5", "loguru>=0.7.3,<1", @@ -119,8 +118,6 @@ dependencies = [ "pyarrow>=23.0.1,<24; python_version >= '3.14'", "pyjwt[crypto]>=2.13.0,<3", # CVE-2026-32597 requires >=2.12.0 (Renovate #475) "python-dateutil>=2.9.0.post0,<3", - "python-engineio>=4.13.3", - "python-socketio>=5.16.3", # "pywebview[qt6]>=5.4,<6; sys_platform == 'linux'", "requests>=2.33.0,<3", # CVE-2026-25645 requires >= 2.33.0 "requests-oauthlib>=2.0.0,<3", diff --git a/src/aignostics/application/_cli.py b/src/aignostics/application/_cli.py index bb9aa305a..edb40188e 100644 --- a/src/aignostics/application/_cli.py +++ b/src/aignostics/application/_cli.py @@ -38,6 +38,7 @@ print_runs_verbose, read_metadata_csv_to_dict, retrieve_and_print_run_details, + share_token_access_denied_message, validate_mappings, write_metadata_dict_to_csv, ) @@ -121,6 +122,10 @@ int, typer.Option(help="Timeout for acquiring compute nodes in minutes (1-3600).", min=1, max=3600), ] +ShareTokenOption = Annotated[ + str | None, + typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."), +] cli = typer.Typer(name="application", help="List and inspect applications on Aignostics Platform.") @@ -970,12 +975,7 @@ def run_describe( help="Show only run and item status summary (external ID, state, error message)", ), ] = False, - share_token: Annotated[ - str | None, - typer.Option( - help="Share token secret for link-based access.", - ), - ] = None, + share_token: ShareTokenOption = None, ) -> None: """Describe run.""" logger.trace("Describing run with ID '{}'", run_id) @@ -1003,10 +1003,7 @@ def run_describe( console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") sys.exit(2) except ForbiddenException: - logger.warning("Access denied for run '{}'", run_id) - msg = f"Access denied for run '{run_id}'." - if share_token is not None: - msg += " The share token may be invalid, expired, or revoked." + msg = share_token_access_denied_message(run_id, share_token) if format == "json": print(json.dumps({"error": "access_denied", "message": msg}), file=sys.stderr) else: @@ -1025,10 +1022,7 @@ def run_describe( def run_dump_metadata( run_id: Annotated[str, typer.Argument(help="Id of the run to dump custom metadata for")], pretty: Annotated[bool, typer.Option(help="Pretty print JSON output with indentation")] = False, - share_token: Annotated[ - str | None, - typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."), - ] = None, + share_token: ShareTokenOption = None, show_checksum: Annotated[ bool, typer.Option( @@ -1065,11 +1059,7 @@ def run_dump_metadata( console.print(f"[warning]Warning:[/warning] Run with ID '{run_id}' not found.") sys.exit(2) except ForbiddenException: - logger.warning("Access denied for run '{}'", run_id) - msg = f"Access denied for run '{run_id}'." - if share_token is not None: - msg += " The share token may be invalid, expired, or revoked." - console.print(f"[error]Error:[/error] {msg}") + console.print(f"[error]Error:[/error] {share_token_access_denied_message(run_id, share_token)}") sys.exit(1) except Exception as e: logger.exception(f"Failed to dump custom metadata for run with ID '{run_id}'") @@ -1082,10 +1072,7 @@ def run_dump_item_metadata( run_id: Annotated[str, typer.Argument(help="Id of the run containing the item")], external_id: Annotated[str, typer.Argument(help="External ID of the item to dump custom metadata for")], pretty: Annotated[bool, typer.Option(help="Pretty print JSON output with indentation")] = False, - share_token: Annotated[ - str | None, - typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."), - ] = None, + share_token: ShareTokenOption = None, show_checksum: Annotated[ bool, typer.Option( @@ -1139,11 +1126,7 @@ def run_dump_item_metadata( print(f"Warning: Run with ID '{run_id}' not found.", file=sys.stderr) sys.exit(2) except ForbiddenException: - logger.warning("Access denied for run '{}'", run_id) - msg = f"Access denied for run '{run_id}'." - if share_token is not None: - msg += " The share token may be invalid, expired, or revoked." - print(f"Error: {msg}", file=sys.stderr) + print(f"Error: {share_token_access_denied_message(run_id, share_token)}", file=sys.stderr) sys.exit(1) except Exception as e: logger.exception(f"Failed to dump custom metadata for item '{external_id}' in run with ID '{run_id}'") @@ -1700,10 +1683,7 @@ def result_download( # noqa: C901, PLR0913, PLR0915 'Run uvx --with "aignostics[qupath]" aignostics qupath install' ), ] = False, - share_token: Annotated[ - str | None, - typer.Option(help="Share token secret for link-based access. When provided, OAuth login is not required."), - ] = None, + share_token: ShareTokenOption = None, ) -> None: """Download results of a run.""" logger.trace( @@ -1868,11 +1848,7 @@ def update_progress(progress: DownloadProgress) -> None: # noqa: C901 console.print(f"[warning]Warning:[/warning] Bad input to download results of run with ID '{run_id}': {e}") sys.exit(2) except ForbiddenException: - logger.warning("Access denied for run '{}'", run_id) - msg = f"Access denied for run '{run_id}'." - if share_token is not None: - msg += " The share token may be invalid, expired, or revoked." - console.print(f"[error]Error:[/error] {msg}") + console.print(f"[error]Error:[/error] {share_token_access_denied_message(run_id, share_token)}") sys.exit(1) except Exception as e: logger.exception(f"Failed to download results of run with ID '{run_id}'") diff --git a/src/aignostics/application/_service.py b/src/aignostics/application/_service.py index 0ebe57240..776f2f7df 100644 --- a/src/aignostics/application/_service.py +++ b/src/aignostics/application/_service.py @@ -1735,6 +1735,10 @@ def application_run_download( # noqa: C901, PLR0912, PLR0913, PLR0915 message = f"Application run with ID '{run_id}' not found: {e}" logger.warning(message) raise NotFoundException(message) from e + except ForbiddenException: + # Propagate 403 unchanged so the CLI can surface a share-token-specific + # "access denied" message; do not wrap it into RuntimeError below. + raise except ApiException as e: if e.status == HTTPStatus.UNPROCESSABLE_ENTITY: message = f"Run ID '{run_id}' invalid: {e!s}." diff --git a/src/aignostics/application/_utils.py b/src/aignostics/application/_utils.py index 9c52d0647..9cfd0cc3f 100644 --- a/src/aignostics/application/_utils.py +++ b/src/aignostics/application/_utils.py @@ -599,3 +599,25 @@ def get_supported_extensions_for_application(application_id: str) -> set[str]: message = f"Unsupported application {application_id}" logger.critical(message) raise RuntimeError(message) + + +def share_token_access_denied_message(run_id: str, share_token: str | None) -> str: + """Compose the operator-facing "access denied" message for a run and log a warning. + + Centralizes the wording, share-token hint, and warning log shared by the run CLI + commands that support ``--share-token``. The caller owns the output sink (console, + stderr, or JSON) and the exit code. + + Args: + run_id (str): The run access was denied for. + share_token (str | None): The share token supplied, if any. When set, a hint + that the token may be invalid, expired, or revoked is appended. + + Returns: + str: The composed message. + """ + logger.warning("Access denied for run '{}'", run_id) + message = f"Access denied for run '{run_id}'." + if share_token is not None: + message += " The share token may be invalid, expired, or revoked." + return message diff --git a/src/aignostics/platform/resources/runs.py b/src/aignostics/platform/resources/runs.py index 9d388f472..3aa82351a 100644 --- a/src/aignostics/platform/resources/runs.py +++ b/src/aignostics/platform/resources/runs.py @@ -12,6 +12,7 @@ from pathlib import Path from time import sleep from typing import Any, cast +from urllib.parse import urlencode import requests from aignx.codegen.exceptions import ApiException, NotFoundException, ServiceException @@ -148,8 +149,10 @@ def get_download_url(self) -> str: configuration = self._api.api_client.configuration host = configuration.host.rstrip("/") endpoint_url = f"{host}/api/v1/runs/{self.run_id}/artifacts/{self.artifact_id}/file" - if self._share_token: - endpoint_url += f"?share_token={self._share_token}" + if self._share_token is not None: + # Percent-encode the secret so reserved characters (& # = space) cannot + # corrupt the URL or inject extra query parameters. + endpoint_url += f"?{urlencode({'share_token': self._share_token})}" proxy = getattr(configuration, "proxy", None) ssl_ca_cert = getattr(configuration, "ssl_ca_cert", None) verify_ssl = getattr(configuration, "verify_ssl", True) @@ -197,6 +200,10 @@ def _fetch_redirect_url( RuntimeError: 3xx without a Location header, or unexpected non-3xx status. """ try: + # Always send the OAuth Bearer token: the platform requires an + # authenticated account on every request. When a share_token is present + # it is carried as a query parameter on ``endpoint_url`` and elevates + # that authenticated user's access to the shared resource. with requests.get( endpoint_url, headers={ @@ -263,7 +270,7 @@ def __init__(self, api: _AuthenticatedApi, run_id: str, share_token: str | None run_id (str): The ID of the application run. share_token (str | None): Optional share token secret. When supplied the token is forwarded as the ``share_token`` query parameter on every API - request, granting access without an OAuth Bearer token. + request, elevating the authenticated user's access to the shared run. """ super().__init__(api) self.run_id = run_id @@ -273,25 +280,25 @@ def __init__(self, api: _AuthenticatedApi, run_id: str, share_token: str | None def for_run_id(cls, run_id: str, cache_token: bool = True, share_token: str | None = None) -> "Run": """Creates a Run instance for an existing run. - When *share_token* is provided the run is accessed via the ``share_token`` - query parameter on every API request without an OAuth Bearer token. + When *share_token* is provided it is forwarded as the ``share_token`` query + parameter on every API request, elevating the calling (OAuth-authenticated) + user's access to a run they would otherwise not be able to read. Args: run_id (str): The ID of the application run. - cache_token (bool): Whether to use the cached OAuth token. Ignored - when *share_token* is supplied. - share_token (str | None): Optional share token secret. When provided - no OAuth login is required. + cache_token (bool): Whether to use the cached OAuth token. + share_token (str | None): Optional share token secret. The caller must + still be authenticated; the token grants access to the shared run. Returns: Run: The initialized Run instance. Example:: - # Authenticated access + # Authenticated access to a run you own run = Run.for_run_id("run-abc123") - # Share-token access (no OAuth required) + # Share-token access to a run shared with you (still authenticated) run = Run.for_run_id("run-abc123", share_token="shr_xxxx") details = run.details() for item in run.results(): @@ -299,6 +306,10 @@ def for_run_id(cls, run_id: str, cache_token: bool = True, share_token: str | No """ from aignostics.platform._client import Client # noqa: PLC0415 + # Share-token access still authenticates as the calling user: the platform + # requires an OAuth Bearer token on every request. The share_token is + # forwarded as a query parameter (see details/results/Artifact) and elevates + # the authenticated user's access to the shared resource. api = Client.get_api_client(cache_token=cache_token) return cls(api, run_id, share_token=share_token) @@ -323,8 +334,11 @@ def details(self, nocache: bool = False, hide_platform_queue_position: bool = Fa """ share_token = self._share_token + # share_token is threaded as an explicit argument (not a closure capture) so it + # participates in the operation cache key, keeping share-token reads isolated + # from authenticated reads of the same run_id. @cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) - def details_with_retry(run_id: str, _share_token: str | None = None) -> RunData: + def details_with_retry(run_id: str, share_token: str | None = None) -> RunData: def _fetch() -> RunData: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), @@ -337,7 +351,7 @@ def _fetch() -> RunData: )( lambda: self._api.get_run_v1_runs_run_id_get( run_id, - share_token=_share_token, + share_token=share_token, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, ) @@ -352,7 +366,7 @@ def _fetch() -> RunData: reraise=True, )(_fetch) - run_data: RunData = details_with_retry(self.run_id, _share_token=share_token, nocache=nocache) # type: ignore[call-arg] + run_data: RunData = details_with_retry(self.run_id, share_token=share_token, nocache=nocache) # type: ignore[call-arg] if hide_platform_queue_position: run_data = run_data.model_copy(deep=True) run_data.num_preceding_items_platform = None @@ -420,9 +434,11 @@ def results( # noqa: PLR0913 share_token = self._share_token # Create a wrapper function that applies retry logic and caching to each API call - # Caching at this level ensures having a fresh iterator on cache hits + # Caching at this level ensures having a fresh iterator on cache hits. + # share_token is an explicit argument (not a closure capture) so it participates + # in the operation cache key, isolating share-token reads from authenticated reads. @cached_operation(ttl=settings().run_cache_ttl, token_provider=self._api.token_provider) - def results_with_retry(run_id: str, _share_token: str | None = None, **kwargs: object) -> list[ItemResultData]: + def results_with_retry(run_id: str, share_token: str | None = None, **kwargs: object) -> list[ItemResultData]: return Retrying( retry=retry_if_exception_type(exception_types=RETRYABLE_EXCEPTIONS), stop=stop_after_attempt(settings().run_retry_attempts), @@ -432,7 +448,7 @@ def results_with_retry(run_id: str, _share_token: str | None = None, **kwargs: o )( lambda: self._api.list_run_items_v1_runs_run_id_items_get( run_id=run_id, - share_token=_share_token, + share_token=share_token, _request_timeout=settings().run_timeout, _headers={"User-Agent": user_agent()}, **kwargs, # pyright: ignore[reportArgumentType] @@ -453,7 +469,7 @@ def results_with_retry(run_id: str, _share_token: str | None = None, **kwargs: o return paginate( lambda **kwargs: results_with_retry( - self.run_id, _share_token=share_token, nocache=nocache, **filter_kwargs, **kwargs + self.run_id, share_token=share_token, nocache=nocache, **filter_kwargs, **kwargs ) ) diff --git a/tests/aignostics/application/cli_test.py b/tests/aignostics/application/cli_test.py index 2439d345e..efaf706e6 100644 --- a/tests/aignostics/application/cli_test.py +++ b/tests/aignostics/application/cli_test.py @@ -1041,6 +1041,7 @@ def test_cli_run_describe_with_share_token_json(runner: CliRunner, record_proper ) assert result.exit_code == 0, f"Unexpected exit: {result.output}" + mock_svc_cls.return_value.application_run.assert_called_once_with("run-shared-002", share_token="s3cr3t") # noqa: S106 data = json.loads(result.stdout) assert data["run_id"] == "run-shared-002" assert "items" in data @@ -1171,6 +1172,62 @@ def test_cli_result_download_with_share_token_forbidden( assert "Access denied" in normalize_output(result.output) +@pytest.mark.integration +def test_cli_run_describe_without_share_token_passes_none(runner: CliRunner, record_property: object) -> None: + """Run describe without --share-token calls the service with share_token=None (no leakage).""" + record_property("tested-item-id", "PYSDK-145") + mock_run = _make_mock_run("run-noshare") + + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.return_value = mock_run + result = runner.invoke(cli, ["application", "run", "describe", "run-noshare"]) + + assert result.exit_code == 0, f"Unexpected exit: {result.output}" + mock_svc_cls.return_value.application_run.assert_called_once_with("run-noshare", share_token=None) + + +@pytest.mark.integration +def test_cli_run_dump_metadata_with_share_token_not_found(runner: CliRunner, record_property: object) -> None: + """Run dump-metadata --share-token exits 2 when run does not exist.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.side_effect = ApiNotFound(status=404, reason="Not Found") + result = runner.invoke(cli, ["application", "run", "dump-metadata", "bad-run-id", "--share-token", "s3cr3t"]) + + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + +@pytest.mark.integration +def test_cli_run_dump_item_metadata_with_share_token_not_found(runner: CliRunner, record_property: object) -> None: + """Run dump-item-metadata --share-token exits 2 when run does not exist.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run.side_effect = ApiNotFound(status=404, reason="Not Found") + result = runner.invoke( + cli, ["application", "run", "dump-item-metadata", "bad-run-id", "item-id", "--share-token", "s3cr3t"] + ) + + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + +@pytest.mark.integration +def test_cli_result_download_with_share_token_not_found( + runner: CliRunner, tmp_path: Path, record_property: object +) -> None: + """Result download --share-token exits 2 when run does not exist.""" + record_property("tested-item-id", "PYSDK-145") + with patch(APPLICATION_CLI_SERVICE_PATCH_TARGET) as mock_svc_cls: + mock_svc_cls.return_value.application_run_download.side_effect = ApiNotFound(status=404, reason="Not Found") + result = runner.invoke( + cli, ["application", "run", "result", "download", "bad-run-id", str(tmp_path), "--share-token", "s3cr3t"] + ) + + assert result.exit_code == 2 + assert "not found" in normalize_output(result.output).lower() + + @pytest.mark.e2e def test_cli_run_cancel_invalid_run_id(runner: CliRunner, record_property) -> None: """Check run cancel command fails as expected on run not found.""" diff --git a/tests/aignostics/application/service_test.py b/tests/aignostics/application/service_test.py index 4ab026072..5b5b4ec75 100644 --- a/tests/aignostics/application/service_test.py +++ b/tests/aignostics/application/service_test.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch import pytest -from aignx.codegen.exceptions import ApiException +from aignx.codegen.exceptions import ApiException, ForbiddenException from aignx.codegen.models import SubjectType from typer.testing import CliRunner @@ -1070,3 +1070,21 @@ def test_application_run_revoke_share_token_not_found(mock_get_client: MagicMock with pytest.raises(NotFoundException, match="No grant found"): ApplicationService().application_run_revoke_share_token("run-123", "tok-missing") + + +@pytest.mark.unit +def test_application_run_download_reraises_forbidden(tmp_path, record_property: object) -> None: + """A 403 from run.details() propagates as ForbiddenException, not wrapped into RuntimeError. + + Guards the CLI's share-token 'access denied' handler: the download path must not + swallow ForbiddenException into RuntimeError via its generic ApiException branch. + """ + record_property("tested-item-id", "PYSDK-145") + mock_run = MagicMock() + mock_run.details.side_effect = ForbiddenException(status=403, reason="Forbidden") + + with ( + patch.object(ApplicationService, "application_run", return_value=mock_run), + pytest.raises(ForbiddenException), + ): + ApplicationService().application_run_download("run-id", tmp_path, share_token="s3cr3t") # noqa: S106 diff --git a/tests/aignostics/platform/resources/runs_test.py b/tests/aignostics/platform/resources/runs_test.py index 3a0975834..867cf1af5 100644 --- a/tests/aignostics/platform/resources/runs_test.py +++ b/tests/aignostics/platform/resources/runs_test.py @@ -19,7 +19,12 @@ ) from aignostics.platform._api import _AuthenticatedApi -from aignostics.platform.resources.runs import LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, Artifact, Run, Runs +from aignostics.platform.resources.runs import ( + LIST_APPLICATION_RUNS_MAX_PAGE_SIZE, + Artifact, + Run, + Runs, +) from aignostics.platform.resources.utils import PAGE_SIZE _PLATFORM_HOST = "https://platform-staging.aignostics.com" @@ -1402,3 +1407,118 @@ def test_update_item_custom_metadata_no_enrich_skips_sdk_metadata_builders(app_r request = call_kwargs["custom_metadata_update_request"] assert request.custom_metadata == {"key": "value"} assert "sdk" not in request.custom_metadata + + +# ───────────────────────────────────────────────────────────────────────────── +# share_token: forwarding to the API layer + OAuth-less wiring (PYSDK-145) +# ───────────────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +def test_details_forwards_share_token(mock_api, record_property) -> None: + """Run.details() forwards a configured share_token to the get-run API call.""" + record_property("tested-item-id", "PYSDK-145") + mock_api.get_run_v1_runs_run_id_get.return_value = RunReadResponse.model_construct(run_id="test-run-id") + + Run(mock_api, "test-run-id", share_token="s3cr3t").details() # noqa: S106 + + assert mock_api.get_run_v1_runs_run_id_get.call_args.kwargs["share_token"] == "s3cr3t" # noqa: S105 + + +@pytest.mark.unit +def test_details_share_token_none_when_not_supplied(app_run, mock_api, record_property) -> None: + """Run.details() sends share_token=None when no share token was supplied (no leakage).""" + record_property("tested-item-id", "PYSDK-145") + mock_api.get_run_v1_runs_run_id_get.return_value = RunReadResponse.model_construct(run_id="test-run-id") + + app_run.details() + + assert mock_api.get_run_v1_runs_run_id_get.call_args.kwargs["share_token"] is None + + +@pytest.mark.unit +def test_results_forwards_share_token(mock_api, record_property) -> None: + """Run.results() forwards a configured share_token to the list-items API call on every page.""" + record_property("tested-item-id", "PYSDK-145") + mock_api.list_run_items_v1_runs_run_id_items_get.return_value = [] + + list(Run(mock_api, "test-run-id", share_token="s3cr3t").results()) # noqa: S106 + + assert mock_api.list_run_items_v1_runs_run_id_items_get.call_args.kwargs["share_token"] == "s3cr3t" # noqa: S105 + + +@pytest.mark.unit +def test_results_share_token_none_when_not_supplied(app_run, mock_api, record_property) -> None: + """Run.results() sends share_token=None when no share token was supplied (no leakage).""" + record_property("tested-item-id", "PYSDK-145") + mock_api.list_run_items_v1_runs_run_id_items_get.return_value = [] + + list(app_run.results()) + + assert mock_api.list_run_items_v1_runs_run_id_items_get.call_args.kwargs["share_token"] is None + + +@pytest.mark.unit +def test_artifact_get_download_url_appends_share_token(configured_api, record_property) -> None: + """Artifact.get_download_url() adds the share_token as a query parameter on the /file URL.""" + record_property("tested-item-id", "PYSDK-145") + art = Artifact(configured_api, _RUN_ID, _ARTIFACT_ID, share_token="s3cr3t") # noqa: S106 + response = _redirect_response(_PRESIGNED_URL) + + with patch(_PATCH_GET_TOKEN, return_value="t"), patch(_PATCH_REQUESTS_GET, return_value=response) as mock_get: + art.get_download_url() + + assert "share_token=s3cr3t" in mock_get.call_args.args[0] + + +@pytest.mark.unit +def test_artifact_get_download_url_percent_encodes_share_token(configured_api, record_property) -> None: + """A share_token with reserved characters is percent-encoded, not injected as extra query params.""" + record_property("tested-item-id", "PYSDK-145") + art = Artifact(configured_api, _RUN_ID, _ARTIFACT_ID, share_token="a b&x=1") # noqa: S106 + response = _redirect_response(_PRESIGNED_URL) + + with patch(_PATCH_GET_TOKEN, return_value="t"), patch(_PATCH_REQUESTS_GET, return_value=response) as mock_get: + art.get_download_url() + + url = mock_get.call_args.args[0] + assert "share_token=a+b%26x%3D1" in url + # The raw "&" must not have created a second query parameter. + assert url.count("?") == 1 + assert "&" not in url.split("?", 1)[1] + + +@pytest.mark.unit +def test_artifact_get_download_url_sends_bearer_with_share_token(configured_api, record_property) -> None: + """The /file request always sends the OAuth Bearer header alongside the share_token query param.""" + record_property("tested-item-id", "PYSDK-145") + art = Artifact(configured_api, _RUN_ID, _ARTIFACT_ID, share_token="s3cr3t") # noqa: S106 + response = _redirect_response(_PRESIGNED_URL) + + with patch(_PATCH_GET_TOKEN, return_value="t"), patch(_PATCH_REQUESTS_GET, return_value=response) as mock_get: + art.get_download_url() + + assert mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer t" + assert "share_token=s3cr3t" in mock_get.call_args.args[0] + + +@pytest.mark.unit +def test_for_run_id_with_share_token_uses_authenticated_client(mock_api, record_property) -> None: + """Run.for_run_id(share_token=...) uses the normal authenticated client and stores the token.""" + record_property("tested-item-id", "PYSDK-145") + with patch("aignostics.platform._client.Client.get_api_client", return_value=mock_api) as mock_get_api_client: + run = Run.for_run_id("run-abc", share_token="s3cr3t") # noqa: S106 + + mock_get_api_client.assert_called_once_with(cache_token=True) + assert run._share_token == "s3cr3t" # noqa: S105 + + +@pytest.mark.unit +def test_for_run_id_without_share_token_uses_oauth(mock_api, record_property) -> None: + """Run.for_run_id() without a share token uses the normal cached OAuth client.""" + record_property("tested-item-id", "PYSDK-145") + with patch("aignostics.platform._client.Client.get_api_client", return_value=mock_api) as mock_get_api_client: + run = Run.for_run_id("run-abc", cache_token=True) + + mock_get_api_client.assert_called_once_with(cache_token=True) + assert run._share_token is None diff --git a/uv.lock b/uv.lock index 1a3b03070..ac1daee4f 100644 --- a/uv.lock +++ b/uv.lock @@ -60,7 +60,6 @@ dependencies = [ { name = "humanize" }, { name = "idc-index-data" }, { name = "ijson" }, - { name = "joserfc" }, { name = "jsf" }, { name = "jsonschema", extra = ["format-nongpl"] }, { name = "loguru" }, @@ -85,9 +84,7 @@ dependencies = [ { name = "pygments" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-dateutil" }, - { name = "python-engineio" }, { name = "python-multipart" }, - { name = "python-socketio" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "pyyaml" }, { name = "requests" }, @@ -207,7 +204,6 @@ requires-dist = [ { name = "idc-index-data", specifier = "==24.0.3" }, { name = "ijson", specifier = ">=3.4.0.post0,<4" }, { name = "ipython", marker = "extra == 'marimo'", specifier = ">=9.8.0,<10" }, - { name = "joserfc", specifier = ">=1.6.7" }, { name = "jsf", specifier = ">=0.11.2,<1" }, { name = "jsonschema", extras = ["format-nongpl"], specifier = ">=4.25.1,<5" }, { name = "jupyter", marker = "extra == 'jupyter'", specifier = ">=1.1.1,<2" }, @@ -241,9 +237,7 @@ requires-dist = [ { name = "pyinstaller", marker = "extra == 'pyinstaller'", specifier = ">=6.14.0,<7" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.13.0,<3" }, { name = "python-dateutil", specifier = ">=2.9.0.post0,<3" }, - { name = "python-engineio", specifier = ">=4.13.3" }, { name = "python-multipart", specifier = ">=0.0.26" }, - { name = "python-socketio", specifier = ">=5.16.3" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=312,<313" }, { name = "pyyaml", specifier = ">=6.0.3,<7" }, { name = "requests", specifier = ">=2.33.0,<3" },