diff --git a/CHANGELOG.md b/CHANGELOG.md index 526bfad45..0a61d357d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project are documented in this file. Format follows For narrative release notes written for operators and product owners, see [RELEASE_NOTES.md](RELEASE_NOTES.md). +## [1.11.1] - 2026-07-24 + +Patch release fixing Markdown artifact downloads that saved the HTML render wrapper instead of the authored `.md` source. Backend-only, no dependency changes, no CDK deploy — ships via `backend.yml` (artifact-render Lambda). Existing artifacts are fixed on download with no re-storage. + +### 🐛 Fixed + +- Downloading a Markdown artifact now saves the authored `.md` source instead of a `.html` file of the render scaffolding — Markdown records keep `content_type=text/markdown` but S3 holds the writer's HTML wrapper (raw Markdown base64-embedded in a `" +) + + +def _extract_markdown_source(wrapper: str) -> str | None: + """Recover the raw Markdown source the writer base64-embedded in its + HTML render wrapper. Returns None when the marker is absent or the + payload can't be decoded, so the caller can fall back to the wrapper + bytes rather than fail the download.""" + match = _MARKDOWN_SOURCE_RE.search(wrapper) + if not match: + return None + try: + return base64.b64decode(match.group("b64").strip()).decode("utf-8") + except (ValueError, UnicodeDecodeError): + return None + + def _security_headers(content_type: str) -> dict[str, str]: return { "content-type": content_type, @@ -154,12 +191,13 @@ def _security_headers(content_type: str) -> dict[str, str]: # File extension to suggest when saving an artifact. Keyed by the bare # (parameter-stripped, lowercased) authored content type. Markdown rows -# hold the writer's HTML render wrapper in S3 (see module docstring), so -# the saved bytes are HTML — extension follows the bytes, not the label. +# hold the writer's HTML render wrapper in S3, but a download recovers the +# embedded raw Markdown source (see `_download_payload`) so the saved file +# is `.md`, matching what the user authored. _DOWNLOAD_EXTENSIONS = { "text/html": "html", - "text/markdown": "html", - "text/x-markdown": "html", + "text/markdown": "md", + "text/x-markdown": "md", "image/svg+xml": "svg", "application/json": "json", "text/css": "css", @@ -175,8 +213,28 @@ def _security_headers(content_type: str) -> dict[str, str]: def _download_extension(stored_content_type: str) -> str: - bare = (stored_content_type or "").split(";")[0].strip().lower() - return _DOWNLOAD_EXTENSIONS.get(bare, "bin") + return _DOWNLOAD_EXTENSIONS.get(_bare_type(stored_content_type), "bin") + + +def _download_payload( + stored_content_type: str, serve_content_type: str, body: str +) -> tuple[str, str, str]: + """Choose the bytes, HTTP content type, and file extension for a save. + + Markdown records serve an HTML render wrapper but download as the raw + Markdown source the writer embedded in it, so the user saves the `.md` + they authored. If the source can't be recovered (marker absent), fall + back to the wrapper bytes as `.html`. Every other type downloads + exactly as served.""" + if _bare_type(stored_content_type) in _MARKDOWN_MIME_TYPES: + source = _extract_markdown_source(body) + if source is not None: + return source, _MARKDOWN_CONTENT_TYPE, "md" + logger.warning( + "markdown download source marker missing; serving wrapper as html" + ) + return body, serve_content_type, "html" + return body, serve_content_type, _download_extension(stored_content_type) def _content_disposition(title: str, ext: str) -> str: @@ -486,14 +544,16 @@ def handler(event: dict[str, Any], _context: Any) -> dict[str, Any]: return _error_response(500, "The artifact service is misconfigured.") if _wants_download(event): - ext = _download_extension(stored_content_type) + download_body, download_type, ext = _download_payload( + stored_content_type, content_type, body + ) headers = _download_headers( - content_type, _content_disposition(title, ext) + download_type, _content_disposition(title, ext) ) return { "statusCode": 200, "headers": headers, - "body": "" if method == "HEAD" else body, + "body": "" if method == "HEAD" else download_body, } if method == "HEAD": diff --git a/backend/tests/lambdas/test_artifact_render.py b/backend/tests/lambdas/test_artifact_render.py index 5817c760e..48fe3de15 100644 --- a/backend/tests/lambdas/test_artifact_render.py +++ b/backend/tests/lambdas/test_artifact_render.py @@ -432,9 +432,9 @@ def test_oversized_content_is_500(aws_env, monkeypatch: pytest.MonkeyPatch) -> N "stored,ext", [ ("text/html; charset=utf-8", "html"), - ("text/markdown", "html"), # S3 body is the HTML render wrapper - ("text/x-markdown", "html"), - ("TEXT/MARKDOWN; charset=utf-8", "html"), + ("text/markdown", "md"), # download recovers the embedded source + ("text/x-markdown", "md"), + ("TEXT/MARKDOWN; charset=utf-8", "md"), ("image/svg+xml", "svg"), ("application/json", "json"), ("text/css", "css"), @@ -492,7 +492,43 @@ def test_download_returns_attachment(aws_env) -> None: assert "content-security-policy" not in resp["headers"] -def test_download_markdown_uses_html_extension(aws_env) -> None: +def _markdown_wrapper(markdown: str) -> str: + """A minimal HTML render wrapper carrying the writer's base64 marker, + matching the `" + "" + ) + + +def test_download_markdown_recovers_source_as_md(aws_env) -> None: + # A Markdown record serves the HTML wrapper for rendering, but a + # download recovers the raw Markdown source embedded in it and saves + # it as a .md file — matching what the user authored. + source = "# Notes\n\nSome **markdown** body with a café.\n" + boto3.client("s3", region_name="us-east-1").put_object( + Bucket=BUCKET, Key=CONTENT_KEY, Body=_markdown_wrapper(source).encode() + ) + _put_record( + aws_env["ddb"], + content_type="text/markdown; charset=utf-8", + title="Notes", + ) + resp = handler.handler( + _event(_mint(_valid_claims()), download=True), None + ) + assert resp["statusCode"] == 200 + assert resp["body"] == source # raw source, not the wrapper + assert resp["headers"]["content-type"] == "text/markdown; charset=utf-8" + assert 'filename="Notes.md"' in resp["headers"]["content-disposition"] + + +def test_download_markdown_falls_back_to_html_when_marker_absent(aws_env) -> None: + # Older render / template drift: no embed marker. The download must + # still succeed, falling back to the wrapper bytes as .html. wrapper = "rendered md" boto3.client("s3", region_name="us-east-1").put_object( Bucket=BUCKET, Key=CONTENT_KEY, Body=wrapper.encode() @@ -511,6 +547,26 @@ def test_download_markdown_uses_html_extension(aws_env) -> None: assert 'filename="Notes.html"' in resp["headers"]["content-disposition"] +def test_head_download_markdown_omits_body_keeps_md_disposition(aws_env) -> None: + # HEAD still advertises the .md attachment even though the body is empty. + boto3.client("s3", region_name="us-east-1").put_object( + Bucket=BUCKET, + Key=CONTENT_KEY, + Body=_markdown_wrapper("# Hi\n").encode(), + ) + _put_record( + aws_env["ddb"], + content_type="text/markdown; charset=utf-8", + title="Notes", + ) + resp = handler.handler( + _event(_mint(_valid_claims()), method="HEAD", download=True), None + ) + assert resp["statusCode"] == 200 + assert resp["body"] == "" + assert 'filename="Notes.md"' in resp["headers"]["content-disposition"] + + def test_download_default_filename_when_title_missing(aws_env) -> None: _put_record(aws_env["ddb"], content_type="application/json") resp = handler.handler( diff --git a/backend/uv.lock b/backend/uv.lock index ee1b2cc65..8ee824716 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "agentcore-stack" -version = "1.11.0" +version = "1.11.1" source = { editable = "." } dependencies = [ { name = "aiofiles" }, diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index 8dffdfabf..ee6f5909a 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai.client", - "version": "1.11.0", + "version": "1.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai.client", - "version": "1.11.0", + "version": "1.11.1", "dependencies": { "@angular/cdk": "21.2.14", "@angular/common": "21.2.17", diff --git a/frontend/ai.client/package.json b/frontend/ai.client/package.json index a175c970d..cba84bcd0 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -1,6 +1,6 @@ { "name": "ai.client", - "version": "1.11.0", + "version": "1.11.1", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/infrastructure/package-lock.json b/infrastructure/package-lock.json index 74167e76b..30be10302 100644 --- a/infrastructure/package-lock.json +++ b/infrastructure/package-lock.json @@ -1,12 +1,12 @@ { "name": "infrastructure", - "version": "1.11.0", + "version": "1.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "infrastructure", - "version": "1.11.0", + "version": "1.11.1", "dependencies": { "aws-cdk-lib": "2.260.0", "constructs": "10.6.0" diff --git a/infrastructure/package.json b/infrastructure/package.json index 22f2830da..78ef4580a 100644 --- a/infrastructure/package.json +++ b/infrastructure/package.json @@ -1,6 +1,6 @@ { "name": "infrastructure", - "version": "1.11.0", + "version": "1.11.1", "bin": { "infrastructure": "bin/infrastructure.js" },