diff --git a/.gitattributes b/.gitattributes index 762adc23b..d18bb4f8b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ # Apply LF to shell scripts automatically *.sh text eol=lf +.github/workflows/runtime-failure-observer-http text eol=lf .github/workflows/*.lock.yml linguist-generated=true \ No newline at end of file diff --git a/.github/workflows/runtime-failure-observer-http b/.github/workflows/runtime-failure-observer-http new file mode 100755 index 000000000..14be25af7 --- /dev/null +++ b/.github/workflows/runtime-failure-observer-http @@ -0,0 +1,538 @@ +#!/usr/bin/env python3 + +import argparse +import json +import os +import re +import secrets +import stat +import sys +import urllib.error +import urllib.parse +import urllib.request +import uuid +from pathlib import Path + + +OUTPUT_ROOT = Path("/tmp/gh-aw/agent") +TIMEOUT_SECONDS = 30 +JSON_LIMIT = 16 * 1024 * 1024 +LOG_LIMIT = 64 * 1024 * 1024 +CHUNK_SIZE = 64 * 1024 +DEFINITION_IDS = (154, 223, 224, 225, 226, 228, 260, 261, 265) +AZDO_HOST = "dev.azure.com" +AZDO_PATH_PREFIX = "/dnceng-public/public/_apis/build/" +HELIX_HOST = "helix.dot.net" +USER_AGENT = "xharness-runtime-failure-observer/1.0" + +_AZDO_BUILD_PATH = re.compile(r"^/dnceng-public/public/_apis/build/builds$") +_AZDO_TIMELINE_PATH = re.compile( + r"^/dnceng-public/public/_apis/build/builds/[1-9][0-9]*/timeline$" +) +_AZDO_LOG_PATH = re.compile( + r"^/dnceng-public/public/_apis/build/builds/[1-9][0-9]*/logs/[1-9][0-9]*$" +) +_HELIX_WORK_ITEMS_PATH = re.compile( + r"^/api/jobs/[0-9a-f-]{36}/workitems$", re.IGNORECASE +) +_HELIX_CONSOLE_PATH = re.compile( + r"^/api/(?:2019-06-17/)?jobs/[0-9a-f-]{36}/workitems/" + r"[^/]+/files/console(?:\.[A-Za-z0-9_-]+)?\.log$", + re.IGNORECASE, +) +_BLOB_CONSOLE_PATH = re.compile( + r"^/(?:[^/]+/)+console(?:\.[A-Za-z0-9_-]+)?\.log$", + re.IGNORECASE, +) +_WORK_ITEM_NAME = re.compile(r"^[^\x00-\x1f\x7f]{1,512}$") +_BLOB_QUERY_KEYS = { + "helixlogtype", + "rscc", + "rscd", + "rsce", + "rscl", + "rsct", + "se", + "si", + "sig", + "sip", + "ske", + "skoid", + "sks", + "skt", + "sktid", + "skv", + "sp", + "spr", + "sr", + "st", + "sv", +} + + +class TransportError(RuntimeError): + pass + + +def _single_query_values(url: str) -> tuple[urllib.parse.SplitResult, dict[str, str]]: + parts = urllib.parse.urlsplit(url) + if parts.scheme != "https": + raise TransportError("only HTTPS URLs are allowed") + if parts.username is not None or parts.password is not None: + raise TransportError("URL credentials are not allowed") + if parts.fragment: + raise TransportError("URL fragments are not allowed") + try: + if parts.port not in (None, 443): + raise TransportError("only the default HTTPS port is allowed") + except ValueError as error: + raise TransportError("invalid URL port") from error + + values: dict[str, str] = {} + try: + query_values = ( + urllib.parse.parse_qsl( + parts.query, keep_blank_values=True, strict_parsing=True + ) + if parts.query + else () + ) + except ValueError as error: + raise TransportError("invalid URL query") from error + for key, value in query_values: + if key in values: + raise TransportError(f"duplicate query parameter: {key}") + values[key] = value + return parts, values + + +def _validate_azdo_url(parts: urllib.parse.SplitResult, query: dict[str, str]) -> None: + if parts.hostname != AZDO_HOST or not parts.path.startswith(AZDO_PATH_PREFIX): + raise TransportError("URL is not a permitted dnceng-public build API endpoint") + + if _AZDO_BUILD_PATH.fullmatch(parts.path): + expected_keys = { + "api-version", + "branchName", + "definitions", + "resultFilter", + "statusFilter", + "$top", + } + if set(query) != expected_keys: + raise TransportError("unexpected Azure DevOps build-list query parameters") + try: + definition = int(query["definitions"]) + top = int(query["$top"]) + except ValueError as error: + raise TransportError("invalid Azure DevOps build-list query") from error + if definition not in DEFINITION_IDS or not 1 <= top <= 10: + raise TransportError("Azure DevOps build-list query is outside observer bounds") + if ( + query["api-version"] != "7.1" + or query["branchName"] != "refs/heads/main" + or query["statusFilter"] != "completed" + or query["resultFilter"] != "failed,partiallySucceeded" + ): + raise TransportError("Azure DevOps build-list filters are not permitted") + return + + if ( + _AZDO_TIMELINE_PATH.fullmatch(parts.path) + or _AZDO_LOG_PATH.fullmatch(parts.path) + ) and query == {"api-version": "7.1"}: + return + + raise TransportError("URL is not a permitted Azure DevOps build endpoint") + + +def _validate_helix_api_url( + parts: urllib.parse.SplitResult, query: dict[str, str] +) -> str: + if parts.hostname != HELIX_HOST: + raise TransportError("URL is not a permitted Helix endpoint") + if _HELIX_WORK_ITEMS_PATH.fullmatch(parts.path) and query == { + "api-version": "2019-06-17" + }: + return "helix-work-items" + if _HELIX_CONSOLE_PATH.fullmatch(parts.path) and not query: + return "helix-console" + raise TransportError("URL is not a permitted Helix endpoint") + + +def _validate_blob_console_url( + parts: urllib.parse.SplitResult, query: dict[str, str] +) -> None: + hostname = parts.hostname or "" + if ( + not hostname.startswith("helix") + or not hostname.endswith(".blob.core.windows.net") + or not _BLOB_CONSOLE_PATH.fullmatch(parts.path) + ): + raise TransportError("URL is not a permitted Helix console blob") + if not set(query).issubset(_BLOB_QUERY_KEYS): + raise TransportError("unexpected Helix console blob query parameters") + + +def _validate_url(url: str, allowed_families: set[str]) -> None: + parts, query = _single_query_values(url) + if parts.hostname == AZDO_HOST: + _validate_azdo_url(parts, query) + family = "azdo" + elif parts.hostname == HELIX_HOST: + family = _validate_helix_api_url(parts, query) + elif (parts.hostname or "").endswith(".blob.core.windows.net"): + _validate_blob_console_url(parts, query) + family = "helix-console" + else: + raise TransportError("URL host is not permitted") + + if family not in allowed_families: + raise TransportError(f"redirect escaped the permitted {sorted(allowed_families)} endpoints") + + +class _ValidatingRedirectHandler(urllib.request.HTTPRedirectHandler): + def __init__(self, allowed_families: set[str]) -> None: + self._allowed_families = allowed_families + + def redirect_request(self, req, fp, code, msg, headers, newurl): + _validate_url(newurl, self._allowed_families) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def _read_response(response, limit: int) -> bytes: + content_length = response.headers.get("Content-Length") + if content_length is not None: + try: + if int(content_length) > limit: + raise TransportError(f"response exceeds the {limit}-byte limit") + except ValueError as error: + raise TransportError("invalid Content-Length header") from error + + chunks: list[bytes] = [] + total = 0 + while True: + chunk = response.read(min(CHUNK_SIZE, limit - total + 1)) + if not chunk: + return b"".join(chunks) + total += len(chunk) + if total > limit: + raise TransportError(f"response exceeds the {limit}-byte limit") + chunks.append(chunk) + + +def _request_bytes( + url: str, + allowed_families: set[str], + limit: int, + opener=None, +) -> bytes: + _validate_url(url, allowed_families) + if opener is None: + opener = urllib.request.build_opener( + _ValidatingRedirectHandler(allowed_families) + ) + request = urllib.request.Request(url, method="GET", headers={"User-Agent": USER_AGENT}) + try: + with opener.open(request, timeout=TIMEOUT_SECONDS) as response: + status = getattr(response, "status", 200) + if not 200 <= status < 300: + raise TransportError(f"HTTP request failed with status {status}") + return _read_response(response, limit) + except urllib.error.HTTPError as error: + raise TransportError(f"HTTP request failed with status {error.code}") from error + except urllib.error.URLError as error: + raise TransportError(f"HTTP request failed: {error.reason}") from error + except TimeoutError as error: + raise TransportError("HTTP request timed out") from error + + +def _validate_output_path(value: str, suffixes: tuple[str, ...]) -> Path: + candidate = Path(value) + if not candidate.is_absolute(): + raise TransportError("output path must be absolute") + + root = Path(os.path.abspath(OUTPUT_ROOT)) + candidate = Path(os.path.abspath(candidate)) + try: + candidate.relative_to(root) + except ValueError as error: + raise TransportError(f"output path must be under {OUTPUT_ROOT}/") from error + + resolved_root = root.resolve() + resolved = candidate.resolve(strict=False) + try: + resolved.relative_to(resolved_root) + except ValueError as error: + raise TransportError(f"output path must be under {OUTPUT_ROOT}/") from error + if candidate == root or candidate.suffix.lower() not in suffixes: + raise TransportError(f"output path must end in one of: {', '.join(suffixes)}") + if candidate.is_symlink(): + raise TransportError("output path must not be a symlink") + if candidate.exists() and not candidate.is_file(): + raise TransportError("output path must be a regular file") + return candidate + + +def _open_output_parent(output: Path) -> tuple[int, str]: + root = Path(os.path.abspath(OUTPUT_ROOT)) + relative = output.relative_to(root) + root.mkdir(parents=True, exist_ok=True) + + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + current_fd = -1 + try: + current_fd = os.open(root, flags) + for component in relative.parts[:-1]: + try: + os.mkdir(component, mode=0o700, dir_fd=current_fd) + except FileExistsError: + pass + next_fd = os.open(component, flags, dir_fd=current_fd) + os.close(current_fd) + current_fd = next_fd + return current_fd, relative.name + except OSError as error: + if current_fd >= 0: + os.close(current_fd) + raise TransportError("output parent must be a real directory") from error + + +def _reject_invalid_output(parent_fd: int, output_name: str) -> None: + try: + output_stat = os.stat(output_name, dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + return + if not stat.S_ISREG(output_stat.st_mode): + raise TransportError("output path must be a regular file") + + +def _write_output(data: bytes, value: str, suffixes: tuple[str, ...]) -> None: + output = _validate_output_path(value, suffixes) + parent_fd, output_name = _open_output_parent(output) + temporary_name = f".{output_name}.{secrets.token_hex(8)}.tmp" + try: + _reject_invalid_output(parent_fd, output_name) + descriptor = os.open( + temporary_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, + dir_fd=parent_fd, + ) + with os.fdopen(descriptor, "wb") as temporary: + temporary.write(data) + temporary.flush() + os.fsync(temporary.fileno()) + os.replace( + temporary_name, + output_name, + src_dir_fd=parent_fd, + dst_dir_fd=parent_fd, + ) + except OSError as error: + try: + os.unlink(temporary_name, dir_fd=parent_fd) + except FileNotFoundError: + pass + raise TransportError("could not write output") from error + finally: + os.close(parent_fd) + print(f"wrote {len(data)} bytes to {output}") + + +def _positive_int(value: str) -> int: + try: + parsed = int(value) + except ValueError as error: + raise argparse.ArgumentTypeError("must be an integer") from error + if parsed <= 0: + raise argparse.ArgumentTypeError("must be positive") + return parsed + + +def _definition_id(value: str) -> int: + parsed = _positive_int(value) + if parsed not in DEFINITION_IDS: + raise argparse.ArgumentTypeError( + f"must be one of: {', '.join(str(item) for item in DEFINITION_IDS)}" + ) + return parsed + + +def _top(value: str) -> int: + parsed = _positive_int(value) + if parsed > 10: + raise argparse.ArgumentTypeError("must be between 1 and 10") + return parsed + + +def _job_id(value: str) -> str: + try: + return str(uuid.UUID(value)) + except ValueError as error: + raise argparse.ArgumentTypeError("must be a UUID") from error + + +def _work_item(value: str) -> str: + if not _WORK_ITEM_NAME.fullmatch(value) or value in {".", ".."}: + raise argparse.ArgumentTypeError("invalid work-item name") + return value + + +def _azdo_builds_url(definition: int, top: int) -> str: + query = urllib.parse.urlencode( + { + "definitions": definition, + "branchName": "refs/heads/main", + "statusFilter": "completed", + "resultFilter": "failed,partiallySucceeded", + "$top": top, + "api-version": "7.1", + }, + safe=",/", + ) + return f"https://{AZDO_HOST}{AZDO_PATH_PREFIX}builds?{query}" + + +def _helix_work_items_url(job_id: str) -> str: + return ( + f"https://{HELIX_HOST}/api/jobs/{job_id}/workitems" + "?api-version=2019-06-17" + ) + + +def _json_items(payload: bytes) -> list[dict]: + try: + document = json.loads(payload) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise TransportError("Helix work-item response was not valid JSON") from error + + if isinstance(document, list): + items = document + elif isinstance(document, dict): + items = next( + ( + document[key] + for key in ("value", "WorkItems", "workItems") + if isinstance(document.get(key), list) + ), + None, + ) + else: + items = None + if items is None or not all(isinstance(item, dict) for item in items): + raise TransportError("Helix work-item response had an unexpected shape") + return items + + +def _case_insensitive_field(item: dict, field_name: str): + for key, value in item.items(): + if key.casefold() == field_name.casefold(): + return value + return None + + +def _console_url(payload: bytes, work_item: str) -> str: + matches = [ + item + for item in _json_items(payload) + if ( + _case_insensitive_field(item, "Name") + or _case_insensitive_field(item, "WorkItemName") + ) + == work_item + ] + if len(matches) != 1: + raise TransportError( + f"expected exactly one Helix work item named {work_item!r}, found {len(matches)}" + ) + console_url = _case_insensitive_field(matches[0], "ConsoleOutputUri") + if not isinstance(console_url, str) or not console_url: + raise TransportError("Helix work item did not contain a console output URL") + _validate_url(console_url, {"helix-console"}) + return console_url + + +def _run(args: argparse.Namespace) -> None: + if args.command == "azdo-builds": + data = _request_bytes( + _azdo_builds_url(args.definition, args.top), {"azdo"}, JSON_LIMIT + ) + _write_output(data, args.output, (".json",)) + elif args.command == "azdo-timeline": + url = ( + f"https://{AZDO_HOST}{AZDO_PATH_PREFIX}builds/{args.build_id}/timeline" + "?api-version=7.1" + ) + _write_output( + _request_bytes(url, {"azdo"}, JSON_LIMIT), args.output, (".json",) + ) + elif args.command == "azdo-log": + url = ( + f"https://{AZDO_HOST}{AZDO_PATH_PREFIX}builds/{args.build_id}/logs/" + f"{args.log_id}?api-version=7.1" + ) + _write_output( + _request_bytes(url, {"azdo"}, LOG_LIMIT), args.output, (".log", ".txt") + ) + elif args.command == "helix-work-items": + data = _request_bytes( + _helix_work_items_url(args.job_id), {"helix-work-items"}, JSON_LIMIT + ) + _write_output(data, args.output, (".json",)) + elif args.command == "helix-console": + work_items = _request_bytes( + _helix_work_items_url(args.job_id), {"helix-work-items"}, JSON_LIMIT + ) + console_url = _console_url(work_items, args.work_item) + _write_output( + _request_bytes(console_url, {"helix-console"}, LOG_LIMIT), + args.output, + (".log", ".txt"), + ) + else: + raise AssertionError(f"unhandled command: {args.command}") + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Constrained HTTP reader for the runtime failure observer" + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + builds = subparsers.add_parser("azdo-builds") + builds.add_argument("--definition", required=True, type=_definition_id) + builds.add_argument("--top", type=_top, default=10) + builds.add_argument("--output", required=True) + + timeline = subparsers.add_parser("azdo-timeline") + timeline.add_argument("--build-id", required=True, type=_positive_int) + timeline.add_argument("--output", required=True) + + log = subparsers.add_parser("azdo-log") + log.add_argument("--build-id", required=True, type=_positive_int) + log.add_argument("--log-id", required=True, type=_positive_int) + log.add_argument("--output", required=True) + + work_items = subparsers.add_parser("helix-work-items") + work_items.add_argument("--job-id", required=True, type=_job_id) + work_items.add_argument("--output", required=True) + + console = subparsers.add_parser("helix-console") + console.add_argument("--job-id", required=True, type=_job_id) + console.add_argument("--work-item", required=True, type=_work_item) + console.add_argument("--output", required=True) + return parser + + +def main() -> int: + try: + _run(_parser().parse_args()) + return 0 + except TransportError as error: + print(f"runtime-failure-observer-http: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/runtime-failure-observer.agent.lock.yml b/.github/workflows/runtime-failure-observer.agent.lock.yml index 47ff76e1d..b1c9dc640 100644 --- a/.github/workflows/runtime-failure-observer.agent.lock.yml +++ b/.github/workflows/runtime-failure-observer.agent.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"39b954f83d3d485407a77598fdad3b9e634fb74a496ace788304348c89e81cf6","body_hash":"f98d03fb473fb8b19f1597785a743403b07acff9ef928f73823808033bb5019c","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","agent_model":"gpt-5.6-terra","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"699375c44e22543ac2e4a75b7be5dfd58d813422c0d360a63725b86217024ecb","body_hash":"e64e80e55bbf385a0e3815ea65520117303069b0042cefb210007d7e1b48d293","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","agent_model":"gpt-5.6-terra","engine_versions":{"copilot":"1.0.79"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_PAT_0","COPILOT_PAT_1","COPILOT_PAT_2","COPILOT_PAT_3","COPILOT_PAT_4","COPILOT_PAT_5","COPILOT_PAT_6","COPILOT_PAT_7","COPILOT_PAT_8","COPILOT_PAT_9","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}]} # This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -504,6 +504,9 @@ jobs: env: GH_AW_SKILL_DIR: ".github/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" + - name: Install constrained observer HTTP helper + run: "install -D -m 0555 .github/workflows/runtime-failure-observer-http \"${RUNNER_TEMP}/gh-aw/observer-tools/bin/runtime-failure-observer-http\"\nprintf '%s\\n' \"${RUNNER_TEMP}/gh-aw/observer-tools/bin\" >> \"$GITHUB_PATH\"" + - name: Download container images run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e - name: Generate Safe Outputs Config @@ -807,7 +810,6 @@ jobs: # --allow-tool shell(awk) # --allow-tool shell(basename) # --allow-tool shell(cat) - # --allow-tool shell(curl:*) # --allow-tool shell(cut) # --allow-tool shell(date) # --allow-tool shell(dirname) @@ -832,6 +834,7 @@ jobs: # --allow-tool shell(mkdir) # --allow-tool shell(printf) # --allow-tool shell(pwd) + # --allow-tool shell(runtime-failure-observer-http:*) # --allow-tool shell(safeoutputs:*) # --allow-tool shell(sed) # --allow-tool shell(sort) @@ -890,7 +893,7 @@ jobs: fi # shellcheck disable=SC1003,SC2016,SC2086 awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST:+--docker-host "$GH_AW_DOCKER_HOST"} --env-all --exclude-env ACTIONS_ID_TOKEN_REQUEST_TOKEN --exclude-env ACTIONS_ID_TOKEN_REQUEST_URL --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --mount /tmp/gh-aw:/tmp/gh-aw:rw --log-level info --skip-pull \ - -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(awk)'\'' --allow-tool '\''shell(basename)'\'' --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(curl:*)'\'' --allow-tool '\''shell(cut)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(dirname)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(env)'\'' --allow-tool '\''shell(find)'\'' --allow-tool '\''shell(gh:*)'\'' --allow-tool '\''shell(git add:*)'\'' --allow-tool '\''shell(git branch:*)'\'' --allow-tool '\''shell(git checkout:*)'\'' --allow-tool '\''shell(git commit:*)'\'' --allow-tool '\''shell(git merge:*)'\'' --allow-tool '\''shell(git rm:*)'\'' --allow-tool '\''shell(git status)'\'' --allow-tool '\''shell(git switch:*)'\'' --allow-tool '\''shell(git:*)'\'' --allow-tool '\''shell(github:*)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(mkdir)'\'' --allow-tool '\''shell(printf)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(safeoutputs:*)'\'' --allow-tool '\''shell(sed)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(tee)'\'' --allow-tool '\''shell(test)'\'' --allow-tool '\''shell(tr)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(xargs)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && : "${RUNNER_TOOL_CACHE:?RUNNER_TOOL_CACHE must be set}"; GH_AW_TOOL_CACHE="$RUNNER_TOOL_CACHE"; export PATH="$(find "$GH_AW_TOOL_CACHE" -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true; [ -n "$ERLANG_HOME" ] && export PATH="$ERLANG_HOME/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" "${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs" "${RUNNER_TEMP}/gh-aw/bin/copilot" --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(awk)'\'' --allow-tool '\''shell(basename)'\'' --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(cut)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(dirname)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(env)'\'' --allow-tool '\''shell(find)'\'' --allow-tool '\''shell(gh:*)'\'' --allow-tool '\''shell(git add:*)'\'' --allow-tool '\''shell(git branch:*)'\'' --allow-tool '\''shell(git checkout:*)'\'' --allow-tool '\''shell(git commit:*)'\'' --allow-tool '\''shell(git merge:*)'\'' --allow-tool '\''shell(git rm:*)'\'' --allow-tool '\''shell(git status)'\'' --allow-tool '\''shell(git switch:*)'\'' --allow-tool '\''shell(git:*)'\'' --allow-tool '\''shell(github:*)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(mkdir)'\'' --allow-tool '\''shell(printf)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(runtime-failure-observer-http:*)'\'' --allow-tool '\''shell(safeoutputs:*)'\'' --allow-tool '\''shell(sed)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(tee)'\'' --allow-tool '\''shell(test)'\'' --allow-tool '\''shell(tr)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(xargs)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool write --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: AWF_REFLECT_ENABLED: 1 COPILOT_AGENT_RUNNER_TYPE: STANDALONE diff --git a/.github/workflows/runtime-failure-observer.agent.md b/.github/workflows/runtime-failure-observer.agent.md index 3f50a168b..4010893e8 100644 --- a/.github/workflows/runtime-failure-observer.agent.md +++ b/.github/workflows/runtime-failure-observer.agent.md @@ -53,6 +53,12 @@ network: - helix.dot.net - "*.blob.core.windows.net" +pre-agent-steps: + - name: Install constrained observer HTTP helper + run: | + install -D -m 0555 .github/workflows/runtime-failure-observer-http "${RUNNER_TEMP}/gh-aw/observer-tools/bin/runtime-failure-observer-http" + printf '%s\n' "${RUNNER_TEMP}/gh-aw/observer-tools/bin" >> "$GITHUB_PATH" + # The conclusion job still receives the structured signal after this step fails. post-steps: - name: Fail incomplete observer scan @@ -66,7 +72,7 @@ post-steps: tools: github: toolsets: [repos, pull_requests, issues, search] - bash: ["git", "find", "ls", "cat", "grep", "head", "tail", "wc", "curl", "jq", "tee", "sed", "awk", "tr", "cut", "sort", "uniq", "xargs", "echo", "date", "mkdir", "test", "env", "basename", "dirname", "gh", "printf"] + bash: ["git", "find", "ls", "cat", "grep", "head", "tail", "wc", "jq", "tee", "sed", "awk", "tr", "cut", "sort", "uniq", "xargs", "echo", "date", "mkdir", "test", "env", "basename", "dirname", "gh", "printf", "runtime-failure-observer-http:*"] edit: checkout: @@ -122,7 +128,7 @@ The agent reads `dotnet/runtime` and the failing build logs. It never writes to 8. **Same-run dedup cache.** Persist `(exit_code, command, signature_norm)` keys in `/tmp/gh-aw/agent/filed.tsv`. On hit: `dup-this-run`, skip. 9. **All state under `/tmp/gh-aw/agent/`.** 10. **AzDO API: anonymous only.** Stay on `https://dev.azure.com/dnceng-public/public/_apis/build/...`. -11. **Start every shell command with an allow-listed program (`curl`, `jq`, `gh`, `grep`, `printf`, ...).** The harness authorizes by first token only, so a command beginning with `url=...`, `key=...`, or `for` is denied with `Permission denied and could not request permission from user` even when the firewall allows the domain. Inline each URL into a single `curl ... -o ` (keep `%24` for `$top`); never pre-bind URLs to variables or loop over `curl`. +11. **Use only `runtime-failure-observer-http` for AzDO and Helix HTTP reads.** A deterministic pre-agent step installs the repository-owned executable on `PATH` from gh-aw's read-only runtime mount, and the harness authorizes that command by first token. Never invoke the editable workspace copy, `curl`, `python`, or `python3`, and never construct HTTP URLs in shell. The helper is GET-only, constructs the permitted API URLs from constrained IDs, follows only allow-listed redirects, and writes only below `/tmp/gh-aw/agent/`. 12. **`noop` means a successful scan found no actionable candidate.** Emit it only after all required scan inputs were fetched and evaluated successfully and no PR was produced. Never use `noop` for a blocked or incomplete scan. ## Pipelines to scan @@ -156,23 +162,37 @@ These exit codes from `src/Microsoft.DotNet.XHarness.Common/CLI/ExitCode.cs` are Exit codes outside this table: record `skipped: exit code not in improvement table` and stop. +## HTTP helper commands + +The helper exposes only the traversal needed by this observer: + +```text +runtime-failure-observer-http azdo-builds --definition ID [--top 1..10] --output /tmp/gh-aw/agent/NAME.json +runtime-failure-observer-http azdo-timeline --build-id ID --output /tmp/gh-aw/agent/NAME.json +runtime-failure-observer-http azdo-log --build-id ID --log-id ID --output /tmp/gh-aw/agent/NAME.log +runtime-failure-observer-http helix-work-items --job-id UUID --output /tmp/gh-aw/agent/NAME.json +runtime-failure-observer-http helix-console --job-id UUID --work-item NAME --output /tmp/gh-aw/agent/NAME.log +``` + +Always invoke it by the `runtime-failure-observer-http` command name; do not invoke the editable workspace file or its Python interpreter directly. `helix-console` resolves the console URI from the named work item itself so signed blob URLs never need to appear in an agent-generated command. + ## Step 0. Preflight: confirm network egress -Prove the harness will let `curl` reach the public AzDO API before scanning (rule 11): +Prove the repository-owned helper can reach the public AzDO API before scanning (rule 11): ```bash -curl -s "https://dev.azure.com/dnceng-public/public/_apis/build/builds?definitions=154&branchName=refs/heads/main&statusFilter=completed&resultFilter=failed,partiallySucceeded&%24top=1&api-version=7.1" -o /tmp/gh-aw/agent/preflight.json +runtime-failure-observer-http azdo-builds --definition 154 --top 1 --output /tmp/gh-aw/agent/preflight.json jq -r '.count' /tmp/gh-aw/agent/preflight.json ``` -Valid non-empty JSON: continue. If `curl` itself is denied or unavailable, that is the harness rejecting the command form, not the firewall (`.dev.azure.com` and `.helix.dot.net` are allow-listed). Emit `missing_tool` with `tool: curl` and the denial as the reason, then stop without a PR or `noop`. If `curl` executes but the response is empty, malformed, or lacks the required build data, emit `missing_data` and stop. Never blame the firewall allowlist. +Valid non-empty JSON: continue. If `runtime-failure-observer-http` is denied, unavailable, or cannot execute, emit `missing_tool` with `tool: runtime-failure-observer-http` and its exact stderr as the reason, then stop without a PR or `noop`. If the helper executes but its output is empty, malformed, or lacks the required build data, emit `missing_data` and stop. Never substitute another HTTP client or blame the firewall allowlist. ## Step 1. Set up -Run one inlined `curl` per definition id in `154 223 224 225 226 228 260 261 265`, substituting the id in the URL and the `-o` path: +Run one helper command per definition id in `154 223 224 225 226 228 260 261 265`, substituting the id and output path: ```bash -curl -s "https://dev.azure.com/dnceng-public/public/_apis/build/builds?definitions=154&branchName=refs/heads/main&statusFilter=completed&resultFilter=failed,partiallySucceeded&%24top=10&api-version=7.1" -o /tmp/gh-aw/agent/builds-154.json +runtime-failure-observer-http azdo-builds --definition 154 --top 10 --output /tmp/gh-aw/agent/builds-154.json jq -r '.value[] | "\(.id) \(.result) \(.finishTime)"' /tmp/gh-aw/agent/builds-154.json | head ``` @@ -185,7 +205,7 @@ Every definition's build-list request is required. Apply rule 6 to a denied/unav For each `source` (inline the build id in place of `SRCID`): ```bash -curl -s "https://dev.azure.com/dnceng-public/public/_apis/build/builds/SRCID/timeline?api-version=7.1" -o "/tmp/gh-aw/agent/timeline-SRCID.json" +runtime-failure-observer-http azdo-timeline --build-id SRCID --output "/tmp/gh-aw/agent/timeline-SRCID.json" ``` Reconstruct `Stage -> Phase -> Job -> Task` via `parentId`. A failed leaf with non-null `log.id` is a candidate. @@ -193,17 +213,21 @@ Reconstruct `Stage -> Phase -> Job -> Task` via `parentId`. A failed leaf with n Filter to Helix work items only. xharness runs inside Helix work items, not on the AzDO agent. From the `Send to Helix` task log, extract `Sent Helix Job: `: ```bash -curl -s "" -o /tmp/gh-aw/agent/helix-send.log +runtime-failure-observer-http azdo-log --build-id SRCID --log-id LOGID --output /tmp/gh-aw/agent/helix-send.log grep -oE 'Sent Helix Job: [a-f0-9-]+' /tmp/gh-aw/agent/helix-send.log ``` For each Helix job, list failing work items (inline the job id in place of `JOBID`): ```bash -curl -s "https://helix.dot.net/api/jobs/JOBID/workitems?api-version=2019-06-17" -o "/tmp/gh-aw/agent/helix-JOBID.json" +runtime-failure-observer-http helix-work-items --job-id JOBID --output "/tmp/gh-aw/agent/helix-JOBID.json" ``` -A work item is an xharness invocation candidate if `ConsoleOutputUri` contains an xharness command (`xharness apple`, `xharness android`, `xharness wasm`, or `dotnet exec .../Microsoft.DotNet.XHarness.CLI.dll`). Fetch the console and scan for: +A work item is an xharness invocation candidate if its console contains an xharness command (`xharness apple`, `xharness android`, `xharness wasm`, or `dotnet exec .../Microsoft.DotNet.XHarness.CLI.dll`). Fetch each failing work item's console by its exact `Name`, then scan it: + +```bash +runtime-failure-observer-http helix-console --job-id JOBID --work-item "WORKITEM" --output "/tmp/gh-aw/agent/console-JOBID.log" +``` - An `xharness` command line (find the last "Running command" line if present, otherwise the launcher script invocation). - An exit code line: `Exit code: ` or `exited with code ` or `ExitCode=`. @@ -221,7 +245,7 @@ For each work-item failure, extract: If `exit_code` is not in the improvement table: `skipped: exit code not in improvement table`. -Look back at the previous 5 builds on the same definition. The same `(exit_code, command, signature_norm)` tuple must appear in `>= 2` of them to be considered stable. Otherwise: `skipped: weak signature`. +Look back at the previous 5 builds on the same definition using `azdo-builds --top 5`, then use the same `azdo-timeline`, `azdo-log`, `helix-work-items`, and `helix-console` traversal. The same `(exit_code, command, signature_norm)` tuple must appear in `>= 2` of them to be considered stable. Otherwise: `skipped: weak signature`. The history needed for this stability check is required. Apply rule 6 if any required historical build, timeline, work-item, or console request fails; use `skipped: weak signature` only when the successfully fetched history contains fewer than 2 matches. diff --git a/.github/workflows/tests/test_runtime_failure_observer_http.py b/.github/workflows/tests/test_runtime_failure_observer_http.py new file mode 100644 index 000000000..457aadee6 --- /dev/null +++ b/.github/workflows/tests/test_runtime_failure_observer_http.py @@ -0,0 +1,221 @@ +import importlib.machinery +import importlib.util +import io +import tempfile +import unittest +import urllib.error +from pathlib import Path + + +SCRIPT = Path(__file__).parents[1] / "runtime-failure-observer-http" +LOADER = importlib.machinery.SourceFileLoader("observer_http", str(SCRIPT)) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +observer_http = importlib.util.module_from_spec(SPEC) +LOADER.exec_module(observer_http) + + +class FakeResponse: + def __init__(self, data=b"ok", status=200, content_length=None): + self._stream = io.BytesIO(data) + self.status = status + self.headers = {} + if content_length is not None: + self.headers["Content-Length"] = str(content_length) + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def read(self, size): + return self._stream.read(size) + + +class FakeOpener: + def __init__(self, response=None, error=None): + self.response = response + self.error = error + self.requests = [] + + def open(self, request, timeout): + self.requests.append((request, timeout)) + if self.error: + raise self.error + return self.response + + +class UrlValidationTests(unittest.TestCase): + def test_accepts_observer_azdo_build_list(self): + url = observer_http._azdo_builds_url(154, 10) + observer_http._validate_url(url, {"azdo"}) + + def test_rejects_unlisted_definition(self): + url = observer_http._azdo_builds_url(999, 10) + with self.assertRaises(observer_http.TransportError): + observer_http._validate_url(url, {"azdo"}) + + def test_rejects_credentials_and_arbitrary_hosts(self): + for url in ( + "https://user:password@dev.azure.com/dnceng-public/public/_apis/build/builds", + "https://example.com/", + ): + with self.subTest(url=url): + with self.assertRaises(observer_http.TransportError): + observer_http._validate_url(url, {"azdo"}) + + def test_accepts_only_helix_console_blobs(self): + valid = ( + "https://helixre107v0xdeko0k025g8.blob.core.windows.net/" + "dotnet-runtime-refs-heads-main/job/1/console.1234.log" + "?sv=2020-01-01&sr=c&sig=signature&se=2030-01-01&sp=rl" + ) + observer_http._validate_url(valid, {"helix-console"}) + with self.assertRaises(observer_http.TransportError): + observer_http._validate_url( + "https://other.blob.core.windows.net/container/secrets.txt", + {"helix-console"}, + ) + + def test_rejects_undocumented_blob_query_parameter(self): + url = ( + "https://helixre107v0xdeko0k025g8.blob.core.windows.net/" + "dotnet-runtime-refs-heads-main/job/1/console.1234.log?sk=value" + ) + with self.assertRaisesRegex( + observer_http.TransportError, "unexpected Helix console blob" + ): + observer_http._validate_url(url, {"helix-console"}) + + def test_helix_work_items_use_specific_family(self): + url = observer_http._helix_work_items_url( + "00000000-0000-0000-0000-000000000000" + ) + observer_http._validate_url(url, {"helix-work-items"}) + + def test_redirect_handler_rejects_family_escape(self): + handler = observer_http._ValidatingRedirectHandler({"azdo"}) + with self.assertRaises(observer_http.TransportError): + handler.redirect_request( + None, + None, + 302, + "Found", + {}, + "https://helix.dot.net/api/jobs/" + "00000000-0000-0000-0000-000000000000/workitems" + "?api-version=2019-06-17", + ) + + +class OutputPathTests(unittest.TestCase): + def setUp(self): + self.original_root = observer_http.OUTPUT_ROOT + self.temp = tempfile.TemporaryDirectory() + observer_http.OUTPUT_ROOT = Path(self.temp.name) + + def tearDown(self): + observer_http.OUTPUT_ROOT = self.original_root + self.temp.cleanup() + + def test_accepts_output_below_root(self): + output = Path(self.temp.name) / "metadata" / "builds.json" + self.assertEqual( + observer_http._validate_output_path(str(output), (".json",)), + output.resolve(), + ) + + def test_rejects_output_outside_root_and_wrong_suffix(self): + cases = ( + (str(Path(self.temp.name).parent / "outside.json"), (".json",)), + (str(Path(self.temp.name) / "file.tsv"), (".json",)), + ) + for output, suffixes in cases: + with self.subTest(output=output): + with self.assertRaises(observer_http.TransportError): + observer_http._validate_output_path(output, suffixes) + + def test_rejects_existing_directory(self): + output = Path(self.temp.name) / "directory.json" + output.mkdir() + with self.assertRaisesRegex( + observer_http.TransportError, "regular file" + ): + observer_http._validate_output_path(str(output), (".json",)) + + def test_rejects_symlink_parent_during_write(self): + real_parent = Path(self.temp.name) / "real-parent" + real_parent.mkdir() + symlink_parent = Path(self.temp.name) / "symlink-parent" + symlink_parent.symlink_to(real_parent, target_is_directory=True) + + with self.assertRaisesRegex( + observer_http.TransportError, "real directory" + ): + observer_http._write_output( + b"data", str(symlink_parent / "output.json"), (".json",) + ) + + +class RequestBehaviorTests(unittest.TestCase): + def setUp(self): + self.url = observer_http._azdo_builds_url(154, 1) + + def test_get_only_with_fixed_timeout_and_user_agent(self): + opener = FakeOpener(FakeResponse(b"{}")) + self.assertEqual( + observer_http._request_bytes( + self.url, {"azdo"}, observer_http.JSON_LIMIT, opener + ), + b"{}", + ) + request, timeout = opener.requests[0] + self.assertEqual(request.get_method(), "GET") + self.assertEqual(timeout, observer_http.TIMEOUT_SECONDS) + self.assertEqual(request.get_header("User-agent"), observer_http.USER_AGENT) + + def test_rejects_content_length_and_stream_over_limit(self): + for response in ( + FakeResponse(b"small", content_length=11), + FakeResponse(b"01234567890"), + ): + with self.subTest(response=response): + with self.assertRaises(observer_http.TransportError): + observer_http._request_bytes( + self.url, {"azdo"}, 10, FakeOpener(response) + ) + + def test_surfaces_http_errors(self): + error = urllib.error.HTTPError(self.url, 503, "Unavailable", {}, None) + with self.assertRaisesRegex(observer_http.TransportError, "status 503"): + observer_http._request_bytes( + self.url, {"azdo"}, 10, FakeOpener(error=error) + ) + + +class HelixTraversalTests(unittest.TestCase): + def test_console_url_is_selected_by_exact_work_item_name(self): + payload = b"""[ + { + "Name": "runtime-tests", + "ConsoleOutputUri": "https://helixre107v0xdeko0k025g8.blob.core.windows.net/dotnet-runtime/job/console.1.log?helixlogtype=result" + } + ]""" + self.assertIn( + "console.1.log", + observer_http._console_url(payload, "runtime-tests"), + ) + + def test_console_url_rejects_untrusted_metadata(self): + payload = b"""[ + { + "Name": "runtime-tests", + "ConsoleOutputUri": "https://example.com/console.log" + } + ]""" + with self.assertRaises(observer_http.TransportError): + observer_http._console_url(payload, "runtime-tests") + + +if __name__ == "__main__": + unittest.main()