From 6c3a3a90dcb7ac9a75420e1746f89728d3dbaaee Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Wed, 13 May 2026 10:35:43 +0200 Subject: [PATCH 1/9] feat: PowerShell preamble for SSH section + --output file flag Preamble: add optional ssh.preamble list (or single string) of PowerShell commands that run after SSH connects but before the agent is uploaded/started. Each command is sent via -EncodedCommand for safe quoting. Non-zero exit aborts the run immediately. Purpose: set up the test environment (create dirs, disable Defender, etc.) using native Windows commands. Output flag: add -o/--output FILE. Silences all stdout during the run; writes only the results summary (and failure details on failure) to the specified file. Useful for CI artefacts where interleaved progress output is unwanted. Also extracts _render_summary() helper and adds 15 unit tests covering both features including edge cases (preamble failure, string vs list, xfail counts, file content on pass vs fail). Co-Authored-By: Claude Sonnet 4.6 --- README.md | 37 ++++++ config.yaml | 3 + pyproject.toml | 6 + run.py | 188 ++++++++++++++++---------- tests/__init__.py | 0 tests/test_features.py | 295 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 459 insertions(+), 70 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_features.py diff --git a/README.md b/README.md index 10e68e2..57622a3 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ adaptix-testing -c config.yaml -t tasks.yaml Both flags default to `config.yaml` / `tasks.yaml` in the current directory if omitted. +Write results to a file instead of stdout (silent run): + +```sh +adaptix-testing -c config.yaml -t tasks.yaml -o results.txt +``` + --- ## config.yaml @@ -118,6 +124,37 @@ When `terminate: true`, the agent process is killed via `taskkill` and its recor Combine with `setup.agent_output` + `ssh.source_path` pointing to the same path to generate and immediately deliver an agent in one run. +#### PowerShell preamble (optional) + +Run PowerShell commands on the target after connecting but before uploading and starting the agent. Use this to prepare the test environment — create directories, disable Defender, set environment variables, etc. + +```yaml +ssh: + host: 192.168.1.100 + username: administrator + source_path: ./agent.exe + agent_path: C:\ci\agent.exe + terminate: true + preamble: + - "New-Item -ItemType Directory -Force -Path C:\\ci" + - "Set-MpPreference -DisableRealtimeMonitoring $true" + - "Add-MpPreference -ExclusionPath C:\\ci" +``` + +Each command runs via PowerShell `-EncodedCommand` so quoting and special characters are handled safely. If any command exits with a non-zero code the run aborts immediately. A single string is also accepted instead of a list. + +--- + +## --output flag + +Write results to a file instead of printing to stdout. Nothing is printed during the run; the file receives only the results section when complete. + +```sh +adaptix-testing -c config.yaml -t tasks.yaml -o results.txt +``` + +On success the file contains just the summary table. On failure it also includes the failure detail panels. This is designed for CI pipelines where you want a clean artefact without interleaved progress output. + --- ## tasks.yaml diff --git a/config.yaml b/config.yaml index f2c624c..596e00c 100644 --- a/config.yaml +++ b/config.yaml @@ -56,3 +56,6 @@ operator: # source_path: ./agent.exe # Optional: upload via SCP before starting # agent_path: C:\Users\administrator\agent.exe # Required: path to execute on target # terminate: true # Optional: kill agent and remove when done +# preamble: # Optional: PowerShell commands to run after +# - "New-Item -ItemType Directory -Force -Path C:\test" # SSH connect, before agent starts. +# - "Set-MpPreference -DisableRealtimeMonitoring $true" # Run as a list or a single string. diff --git a/pyproject.toml b/pyproject.toml index 37bc703..a6cb7ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,3 +18,9 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] include = ["run.py"] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-mock>=3.14", +] diff --git a/run.py b/run.py index 38e3d47..a76baf0 100644 --- a/run.py +++ b/run.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import argparse import base64 +import io import json import os import re @@ -25,10 +26,12 @@ POLL_TIMEOUT = 60 # seconds before declaring a task timed-out console = Console(highlight=False) +# Always write errors to stderr so they show even when console is silenced. +_err_console = Console(stderr=True, highlight=False) def die(msg: str) -> None: - console.print(Panel(f"[bold]{escape(str(msg))}[/bold]", title="[bold red] Error [/bold red]", border_style="red")) + _err_console.print(Panel(f"[bold]{escape(str(msg))}[/bold]", title="[bold red] Error [/bold red]", border_style="red")) sys.exit(1) @@ -271,6 +274,20 @@ def _exe_name(agent_path): return agent_path.replace("\\", "/").split("/")[-1] +def _ps_run(client, cmd): + """Run a PowerShell command over SSH via -EncodedCommand. Returns (exit_code, stdout, stderr).""" + encoded = base64.b64encode(cmd.encode("utf-16-le")).decode() + _, out_ch, err_ch = client.exec_command( + f"powershell -NonInteractive -EncodedCommand {encoded}" + ) + exit_code = out_ch.channel.recv_exit_status() + return ( + exit_code, + out_ch.read().decode(errors="replace").strip(), + err_ch.read().decode(errors="replace").strip(), + ) + + def ssh_start_agent(client, agent_path): # -NoNewWindow attaches the agent to this exec channel's ConPTY. # The infinite sleep keeps the channel—and its ConPTY—alive until we @@ -327,6 +344,21 @@ def ssh_deliver(base_url, headers, ssh_cfg): client = ssh_connect(ssh_cfg) console.print("[green]✓[/green] SSH connected") + preamble = ssh_cfg.get("preamble", []) + if isinstance(preamble, str): + preamble = [preamble] + for cmd in preamble: + console.print(f" [dim]preamble →[/dim] [white]{escape(cmd)}[/white]") + exit_code, out, err = _ps_run(client, cmd) + if out: + for line in out.splitlines(): + console.print(f" [dim]{escape(line)}[/dim]") + if exit_code != 0: + client.close() + die(f"Preamble command failed (exit {exit_code}): {escape(err or 'no stderr')}") + if preamble: + console.print(f"[green]✓[/green] Preamble done ({len(preamble)} command{'s' if len(preamble) != 1 else ''})") + ssh_terminate_agent(client, agent_path) time.sleep(1) remove_agents_by_name(base_url, headers, _exe_name(agent_path)) @@ -417,6 +449,76 @@ def check_output(task_result, expected): return expected.lower() in actual.lower() +# ── Results summary ─────────────────────────────────────────────────────────── + +def _render_summary(c, results): + """Render the results summary table and any failure details to console c. + Returns 0 if all tasks passed, 1 otherwise.""" + n_passed = sum(1 for r in results if r["status"] == "passed") + n_failed = sum(1 for r in results if r["status"] == "failed") + n_dispatch = sum(1 for r in results if r["status"] == "dispatch-failed") + n_timeout = sum(1 for r in results if r["status"] == "timed-out") + n_xfail = sum(1 for r in results if r["status"] == "xfail") + n_bad = n_failed + n_dispatch + n_timeout + + c.print(Rule(style="dim")) + + table = Table(box=None, show_header=False, padding=(0, 2), collapse_padding=True) + table.add_column(justify="right") + table.add_column() + table.add_row(f"[bold]{len(results)}[/bold]", "run") + table.add_row(f"[green]{n_passed}[/green]", "passed") + if n_failed: table.add_row(f"[red]{n_failed}[/red]", "failed") + if n_dispatch: table.add_row(f"[red]{n_dispatch}[/red]", "dispatch-failed") + if n_timeout: table.add_row(f"[yellow]{n_timeout}[/yellow]", "timed out") + if n_xfail: table.add_row(f"[yellow]{n_xfail}[/yellow]", "xfail") + c.print(table) + + if n_bad == 0: + c.print("\n[bold green]All tasks passed.[/bold green]") + return 0 + + for r in [r for r in results if r["status"] == "failed"]: + res = r["result"] or {} + actual = res.get("a_text", "") + res.get("a_message", "") + exp = r["task"].get("expected", "") + + body = Text() + body.append(r["task"]["cmdline"] + "\n\n", style="bold white") + body.append("Expected\n", style="dim") + body.append("─" * 48 + "\n", style="dim") + for line in exp.strip().splitlines(): + body.append(line + "\n", style="yellow") + body.append(f"\nActual ({len(actual):,} chars)\n", style="dim") + body.append("─" * 48 + "\n", style="dim") + for line in actual.splitlines(): + body.append(line + "\n") + c.print(Panel(body, title="[bold red] FAILED [/bold red]", border_style="red")) + + dispatched = [r for r in results if r["status"] == "dispatch-failed"] + if dispatched: + body = Text() + for r in dispatched: + body.append(r["task"]["cmdline"], style="white") + if r.get("err_msg"): + body.append(f" {r['err_msg']}", style="dim") + body.append("\n") + c.print(Panel(body, title="[bold red] DISPATCH FAILED [/bold red]", border_style="red")) + + timeouts = [r for r in results if r["status"] == "timed-out"] + if timeouts: + body = Text() + for r in timeouts: + body.append(r["task"]["cmdline"] + "\n", style="white") + c.print(Panel( + body, + title=f"[bold yellow] TIMED OUT [/bold yellow][dim] (>{POLL_TIMEOUT}s)[/dim]", + border_style="yellow", + )) + + return 1 + + # ── Main ────────────────────────────────────────────────────────────────────── def main(): @@ -425,8 +527,15 @@ def main(): help="Path to config YAML (default: config.yaml)") parser.add_argument("-t", "-tasks", "--tasks", default="tasks.yaml", help="Path to tasks YAML (default: tasks.yaml)") + parser.add_argument("-o", "--output", metavar="FILE", default=None, + help="Write results to FILE; suppress all stdout during the run") args = parser.parse_args() + global console + output_path = args.output + if output_path: + console = Console(file=io.StringIO(), highlight=False) + try: cfg = load_yaml(args.config) tasks = load_yaml(args.tasks)["tasks"] @@ -462,9 +571,9 @@ def main(): listener_profile = _resolve_listener_profile(setup_cfg, project) _create_listener_from_profile(base_url, headers, listener_profile) - output_path = setup_cfg.get("agent_output", "./generated_agent") + output_path_agent = setup_cfg.get("agent_output", "./generated_agent") agent_profile = _resolve_agent_profile(setup_cfg, project, listener_profile["name"]) - _generate_agent_from_profile(base_url, headers, agent_profile, output_path) + _generate_agent_from_profile(base_url, headers, agent_profile, output_path_agent) except requests.exceptions.RequestException as e: die(f"Setup failed: {e}") @@ -556,73 +665,12 @@ def main(): console.print("[green]✓[/green] Agent terminated and removed") ssh_client.close() - # ── Summary ─────────────────────────────────────────────────────────────── - n_passed = sum(1 for r in results if r["status"] == "passed") - n_failed = sum(1 for r in results if r["status"] == "failed") - n_dispatch = sum(1 for r in results if r["status"] == "dispatch-failed") - n_timeout = sum(1 for r in results if r["status"] == "timed-out") - n_xfail = sum(1 for r in results if r["status"] == "xfail") - n_bad = n_failed + n_dispatch + n_timeout - - console.print(Rule(style="dim")) - - table = Table(box=None, show_header=False, padding=(0, 2), collapse_padding=True) - table.add_column(justify="right") - table.add_column() - table.add_row(f"[bold]{len(results)}[/bold]", "run") - table.add_row(f"[green]{n_passed}[/green]", "passed") - if n_failed: table.add_row(f"[red]{n_failed}[/red]", "failed") - if n_dispatch: table.add_row(f"[red]{n_dispatch}[/red]", "dispatch-failed") - if n_timeout: table.add_row(f"[yellow]{n_timeout}[/yellow]", "timed out") - if n_xfail: table.add_row(f"[yellow]{n_xfail}[/yellow]", "xfail") - console.print(table) - - if n_bad == 0: - console.print("\n[bold green]All tasks passed.[/bold green]") - return 0 - - # ── Failed detail ───────────────────────────────────────────────────────── - for r in [r for r in results if r["status"] == "failed"]: - res = r["result"] or {} - actual = res.get("a_text", "") + res.get("a_message", "") - exp = r["task"].get("expected", "") - - body = Text() - body.append(r["task"]["cmdline"] + "\n\n", style="bold white") - body.append("Expected\n", style="dim") - body.append("─" * 48 + "\n", style="dim") - for line in exp.strip().splitlines(): - body.append(line + "\n", style="yellow") - body.append(f"\nActual ({len(actual):,} chars)\n", style="dim") - body.append("─" * 48 + "\n", style="dim") - for line in actual.splitlines(): - body.append(line + "\n") - console.print(Panel(body, title="[bold red] FAILED [/bold red]", border_style="red")) - - # ── Dispatch failures ───────────────────────────────────────────────────── - dispatched = [r for r in results if r["status"] == "dispatch-failed"] - if dispatched: - body = Text() - for r in dispatched: - body.append(r["task"]["cmdline"], style="white") - if r.get("err_msg"): - body.append(f" {r['err_msg']}", style="dim") - body.append("\n") - console.print(Panel(body, title="[bold red] DISPATCH FAILED [/bold red]", border_style="red")) - - # ── Timeouts ────────────────────────────────────────────────────────────── - timeouts = [r for r in results if r["status"] == "timed-out"] - if timeouts: - body = Text() - for r in timeouts: - body.append(r["task"]["cmdline"] + "\n", style="white") - console.print(Panel( - body, - title=f"[bold yellow] TIMED OUT [/bold yellow][dim] (>{POLL_TIMEOUT}s)[/dim]", - border_style="yellow", - )) - - return 1 + if output_path: + with open(output_path, "w") as f: + file_console = Console(file=f, highlight=False, no_color=True) + return _render_summary(file_console, results) + else: + return _render_summary(console, results) if __name__ == "__main__": diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_features.py b/tests/test_features.py new file mode 100644 index 0000000..a35dbf7 --- /dev/null +++ b/tests/test_features.py @@ -0,0 +1,295 @@ +"""Unit tests for the preamble and output-file features.""" +import io +import sys +import os +import pytest +from unittest.mock import MagicMock, patch, call + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from rich.console import Console +import run + + +# ── _ps_run ─────────────────────────────────────────────────────────────────── + +def _make_channel(exit_code, stdout_bytes=b"", stderr_bytes=b""): + out_ch = MagicMock() + out_ch.channel.recv_exit_status.return_value = exit_code + out_ch.read.return_value = stdout_bytes + err_ch = MagicMock() + err_ch.read.return_value = stderr_bytes + client = MagicMock() + client.exec_command.return_value = (None, out_ch, err_ch) + return client + + +def test_ps_run_success(): + client = _make_channel(0, b"hello world") + code, out, err = run._ps_run(client, "Write-Output 'hello world'") + assert code == 0 + assert out == "hello world" + assert err == "" + # Verify -EncodedCommand was used + call_arg = client.exec_command.call_args[0][0] + assert "-EncodedCommand" in call_arg + assert "powershell" in call_arg.lower() + + +def test_ps_run_failure(): + client = _make_channel(1, stderr_bytes=b"Access denied") + code, out, err = run._ps_run(client, "Do-Something") + assert code == 1 + assert err == "Access denied" + + +def test_ps_run_encodes_utf16le(): + import base64 + client = _make_channel(0) + run._ps_run(client, "Get-Process") + call_arg = client.exec_command.call_args[0][0] + # Extract the base64 payload and decode it + encoded = call_arg.split("-EncodedCommand ")[1].strip() + decoded = base64.b64decode(encoded).decode("utf-16-le") + assert decoded == "Get-Process" + + +# ── preamble in ssh_deliver ─────────────────────────────────────────────────── + +def _mock_ssh_deliver_deps(monkeypatch, preamble_results=None): + """Patch everything in ssh_deliver except the preamble path under test.""" + preamble_results = preamble_results or [] + + fake_client = MagicMock() + + # ssh_connect returns our fake client + monkeypatch.setattr(run, "ssh_connect", lambda cfg: fake_client) + + # _ps_run returns successive (exit_code, out, err) tuples + call_iter = iter(preamble_results) + monkeypatch.setattr(run, "_ps_run", lambda client, cmd: next(call_iter)) + + # Stub out everything after preamble + monkeypatch.setattr(run, "ssh_terminate_agent", lambda *a: None) + monkeypatch.setattr(run, "remove_agents_by_name", lambda *a: None) + monkeypatch.setattr(run, "ssh_start_agent", lambda *a: None) + monkeypatch.setattr(run, "get_agent_list", lambda *a: []) + monkeypatch.setattr(run, "wait_for_active_agent", + lambda *a, **kw: {"a_id": "abc123", "a_last_tick": 1}) + + # Stub exec_command for the alive-check + chk = MagicMock() + chk.read.return_value = b"alive" + fake_client.exec_command.return_value = (None, chk, MagicMock()) + + return fake_client + + +def test_preamble_runs_all_commands(monkeypatch): + calls = [] + fake_client = _mock_ssh_deliver_deps( + monkeypatch, + preamble_results=[(0, "ok1", ""), (0, "ok2", "")], + ) + monkeypatch.setattr(run, "_ps_run", lambda c, cmd: calls.append(cmd) or (0, "", "")) + + ssh_cfg = { + "host": "127.0.0.1", + "username": "user", + "agent_path": r"C:\ci\agent.exe", + "preamble": ["New-Item -Force -Path C:\\test", "Set-Location C:\\test"], + } + run.ssh_deliver("https://x", {}, ssh_cfg) + + assert calls == ["New-Item -Force -Path C:\\test", "Set-Location C:\\test"] + + +def test_preamble_string_is_accepted(monkeypatch): + """A bare string preamble (not a list) should run as a single command.""" + calls = [] + _mock_ssh_deliver_deps(monkeypatch) + monkeypatch.setattr(run, "_ps_run", lambda c, cmd: calls.append(cmd) or (0, "", "")) + + ssh_cfg = { + "host": "127.0.0.1", + "username": "user", + "agent_path": r"C:\ci\agent.exe", + "preamble": "New-Item -Force -Path C:\\test", + } + run.ssh_deliver("https://x", {}, ssh_cfg) + assert calls == ["New-Item -Force -Path C:\\test"] + + +def test_preamble_failure_calls_die(monkeypatch): + _mock_ssh_deliver_deps(monkeypatch) + monkeypatch.setattr(run, "_ps_run", lambda c, cmd: (1, "", "Access denied")) + + ssh_cfg = { + "host": "127.0.0.1", + "username": "user", + "agent_path": r"C:\ci\agent.exe", + "preamble": ["Bad-Command"], + } + with pytest.raises(SystemExit): + run.ssh_deliver("https://x", {}, ssh_cfg) + + +def test_no_preamble_runs_fine(monkeypatch): + _mock_ssh_deliver_deps(monkeypatch) + ps_run_called = [] + monkeypatch.setattr(run, "_ps_run", lambda c, cmd: ps_run_called.append(cmd) or (0, "", "")) + + ssh_cfg = { + "host": "127.0.0.1", + "username": "user", + "agent_path": r"C:\ci\agent.exe", + } + run.ssh_deliver("https://x", {}, ssh_cfg) + assert ps_run_called == [] + + +# ── _render_summary ─────────────────────────────────────────────────────────── + +def _console_to_str(): + buf = io.StringIO() + c = Console(file=buf, highlight=False, no_color=True, width=80) + return c, buf + + +def test_render_summary_all_passed(): + results = [ + {"task": {"cmdline": "whoami"}, "status": "passed", "result": {"a_text": "user", "a_message": ""}}, + {"task": {"cmdline": "dir"}, "status": "passed", "result": {"a_text": "ok", "a_message": ""}}, + ] + c, buf = _console_to_str() + exit_code = run._render_summary(c, results) + + assert exit_code == 0 + out = buf.getvalue() + assert "2" in out # total run count + assert "passed" in out + assert "All tasks passed" in out + assert "failed" not in out.lower().replace("passed", "") + + +def test_render_summary_with_failure(): + results = [ + {"task": {"cmdline": "whoami", "expected": "admin"}, "status": "failed", + "result": {"a_text": "user", "a_message": ""}}, + ] + c, buf = _console_to_str() + exit_code = run._render_summary(c, results) + + assert exit_code == 1 + out = buf.getvalue() + assert "failed" in out.lower() + assert "whoami" in out + assert "admin" in out # expected value shown + assert "user" in out # actual value shown + + +def test_render_summary_with_xfail(): + results = [ + {"task": {"cmdline": "xyzzy"}, "status": "xfail", "result": None, "err_msg": ""}, + {"task": {"cmdline": "whoami"}, "status": "passed", "result": {"a_text": "user", "a_message": ""}}, + ] + c, buf = _console_to_str() + exit_code = run._render_summary(c, results) + + assert exit_code == 0 # xfail does not count as a failure + out = buf.getvalue() + assert "xfail" in out + + +def test_render_summary_dispatch_failed(): + results = [ + {"task": {"cmdline": "bad cmd"}, "status": "dispatch-failed", "result": None, "err_msg": "unknown command"}, + ] + c, buf = _console_to_str() + exit_code = run._render_summary(c, results) + + assert exit_code == 1 + out = buf.getvalue() + assert "dispatch-failed" in out + assert "bad cmd" in out + + +def test_render_summary_timed_out(): + results = [ + {"task": {"cmdline": "slow cmd"}, "status": "timed-out", "result": None}, + ] + c, buf = _console_to_str() + exit_code = run._render_summary(c, results) + + assert exit_code == 1 + out = buf.getvalue() + assert "timed out" in out.lower() + assert "slow cmd" in out + + +# ── --output flag: stdout is suppressed ────────────────────────────────────── + +def test_output_flag_silences_stdout(monkeypatch, tmp_path): + """With -o, nothing should reach the real stdout console during the run.""" + output_file = tmp_path / "results.txt" + + # Restore the real console before and after (main() mutates the global) + real_console = run.console + + captured_prints = [] + + def fake_main_body(): + # Simulate what main() does: silence console then write summary + run.console = Console(file=io.StringIO(), highlight=False) + # Any console.print calls go to the sink + run.console.print("this should not reach stdout") + results = [ + {"task": {"cmdline": "whoami"}, "status": "passed", + "result": {"a_text": "user", "a_message": ""}}, + ] + with open(output_file, "w") as f: + fc = Console(file=f, highlight=False, no_color=True) + return run._render_summary(fc, results) + + try: + exit_code = fake_main_body() + finally: + run.console = real_console + + assert exit_code == 0 + content = output_file.read_text() + assert "passed" in content + assert "All tasks passed" in content + # Progress message did NOT reach the file + assert "this should not reach stdout" not in content + + +def test_output_file_success_contains_only_summary(tmp_path): + output_file = tmp_path / "out.txt" + results = [ + {"task": {"cmdline": "whoami"}, "status": "passed", + "result": {"a_text": "ci_runner", "a_message": ""}}, + ] + with open(output_file, "w") as f: + fc = Console(file=f, highlight=False, no_color=True) + run._render_summary(fc, results) + + content = output_file.read_text() + assert "All tasks passed" in content + assert "ci_runner" not in content # agent output not in summary + + +def test_output_file_failure_contains_detail(tmp_path): + output_file = tmp_path / "out.txt" + results = [ + {"task": {"cmdline": "whoami", "expected": "SYSTEM"}, "status": "failed", + "result": {"a_text": "ci_runner", "a_message": ""}}, + ] + with open(output_file, "w") as f: + fc = Console(file=f, highlight=False, no_color=True) + run._render_summary(fc, results) + + content = output_file.read_text() + assert "SYSTEM" in content # expected shown + assert "ci_runner" in content # actual shown + assert "FAILED" in content From 16212339bd2826f62802cc512eedbc2d52b2b654 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Wed, 13 May 2026 10:48:27 +0200 Subject: [PATCH 2/9] feat: move preamble cmds to CI config, replace pytest with CI tests - Remove pytest dev dependency and tests/ directory - Move C:\ci creation, Defender disable, and firewall rule from workflow PowerShell steps into ssh.preamble of the CI config (run post-SSH-connect as the admin ci_runner user) - Trim "Create agent drop directory" step to only C:\tmp (needed before SSH key copy); remove "Disable Defender and open callback port" step entirely - Add feature validation inside the docker container: --help grep for -o flag (no server needed), integration test runs with -o /tmp/ci-results.txt and verifies the output file contains the expected summary on success Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yaml | 67 +++++--- pyproject.toml | 6 - tests/__init__.py | 0 tests/test_features.py | 295 ------------------------------------ 4 files changed, 42 insertions(+), 326 deletions(-) delete mode 100644 tests/__init__.py delete mode 100644 tests/test_features.py diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b305a05..b63fdad 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -16,7 +16,7 @@ env: CI_PASS: Ci_Test_Pass1! CI_AGENT_DIR: 'C:\ci' CI_AGENT_PATH: 'C:\ci\agent.exe' - CI_CONTAINER: 'ghcr.io/thegr3atjosh/adaptix-prebuilt:latest' + CI_CONTAINER: 'ghcr.io/thegr3atjosh/adaptix-prebuilt:latest' jobs: integration-test: @@ -29,7 +29,10 @@ jobs: shell: powershell run: echo "WSLENV=CI_USER/u:CI_PASS/u:CI_AGENT_PATH/u:CI_CONTAINER/u" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - # ── Windows: CI user, OpenSSH, agent directory ────────────────────────── + # ── Windows: CI user, OpenSSH, tmp directory ──────────────────────────── + # C:\ci (agent drop dir) and Defender/firewall config are handled by the + # SSH preamble after connecting — these steps only do what must exist + # before SSH is available. - name: Create CI user shell: powershell @@ -57,18 +60,9 @@ jobs: Set-Content $cfg Restart-Service sshd - - name: Create agent drop directory + - name: Create tmp directory for SSH key transfer shell: powershell - run: | - New-Item -ItemType Directory -Force -Path $env:CI_AGENT_DIR | Out-Null - New-Item -ItemType Directory -Force -Path C:\tmp | Out-Null - - - name: Disable Defender and open callback port - shell: powershell - run: | - Set-MpPreference -DisableRealtimeMonitoring $true - Add-MpPreference -ExclusionPath $env:CI_AGENT_DIR - New-NetFirewallRule -DisplayName "CI_C2_8080" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any | Out-Null + run: New-Item -ItemType Directory -Force -Path C:\tmp | Out-Null # ── WSL: Ubuntu + Docker ──────────────────────────────────────────────── @@ -84,10 +78,10 @@ jobs: # WSL compatibility for Docker sudo update-alternatives --set iptables /usr/sbin/iptables-legacy || true sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy || true - + # Start dockerd in the background sudo dockerd > /dev/null 2>&1 & - + echo "Waiting for Docker to start..." for i in $(seq 1 30); do if sudo docker info >/dev/null 2>&1; then @@ -122,7 +116,7 @@ jobs: WSL_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' | head -1) WSL_GW=$(ip route show default 2>/dev/null | awk 'NR==1{print $3}') WINDOWS_IP=$(cmd.exe /c ipconfig 2>/dev/null | tr -d '\r' | awk '/vEthernet.*WSL/{f=1} f && /IPv4 Address/{print $NF; exit}') - + if [ -z "$WINDOWS_IP" ] || ip addr show 2>/dev/null | grep -qF "$WINDOWS_IP"; then CALLBACK_HOST="127.0.0.1" SSH_HOST="127.0.0.1" @@ -132,7 +126,7 @@ jobs: SSH_HOST="${WSL_GW:-$WINDOWS_IP}" echo "=== WSL2 NAT mode: WSL_IP=$WSL_IP WSL_GW=$WSL_GW WINDOWS_IP=$WINDOWS_IP, SSH→$SSH_HOST ===" fi - + cat > /tmp/ci_config.yaml << EOF server: url: https://127.0.0.1:4321 @@ -176,6 +170,11 @@ jobs: source_path: /tmp/ci_agent.exe agent_path: '$CI_AGENT_PATH' terminate: true + preamble: + - 'New-Item -ItemType Directory -Force -Path C:\ci | Out-Null' + - 'Set-MpPreference -DisableRealtimeMonitoring \$true' + - 'Add-MpPreference -ExclusionPath C:\ci' + - 'New-NetFirewallRule -DisplayName CI_C2_8080 -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any | Out-Null' EOF - name: Pull Adaptix Container @@ -194,36 +193,54 @@ jobs: -v /tmp/ci_config.yaml:/tmp/ci_config.yaml:ro \ "$CI_CONTAINER" \ bash -c ' - # Ensure the globally installed testing-kit via `uv tool` is in PATH export PATH="/root/.local/bin:$PATH" + # ── Feature validation (no server required) ────────────────── + echo "=== Feature validation ===" + adaptix-testing --help 2>&1 | grep -q -- "-o" && \ + echo "✓ --output flag available" || \ + { echo "✗ --output flag missing from CLI"; exit 1; } + echo "=== Feature validation passed ===" + + # ── Server startup ─────────────────────────────────────────── echo "Generating required TLS certificate..." openssl req -x509 -nodes -newkey rsa:2048 \ -keyout /tmp/adaptixc2/dist/server.rsa.key \ -out /tmp/adaptixc2/dist/server.rsa.crt \ -days 1 -subj "/CN=ci" 2>/dev/null - + echo "Starting AdaptixC2 Server..." cd /tmp/adaptixc2/dist ./adaptixserver -profile profile.yaml > /tmp/adaptixserver.log 2>&1 & SERVER_PID=$! - + # Wait up to 60s for the C2 to boot up fully for i in $(seq 1 60); do (exec 3<>/dev/tcp/127.0.0.1/4321) 2>/dev/null && break sleep 1 done (exec 3<>/dev/tcp/127.0.0.1/4321) 2>/dev/null || { - echo "=== AdaptixC2 server failed to start within 30s ===" + echo "=== AdaptixC2 server failed to start within 60s ===" cat /tmp/adaptixserver.log exit 1 } - + + # ── Integration tests (exercises --output and preamble) ────── echo "AdaptixC2 Ready! Running integration tests..." - adaptix-testing -c /tmp/ci_config.yaml -t /workspace/.github/ci/tasks.yaml + adaptix-testing -c /tmp/ci_config.yaml -t /workspace/.github/ci/tasks.yaml \ + -o /tmp/ci-results.txt TEST_EXIT_CODE=$? - - echo "Tests Finished. Tearing down container." + + echo "=== Test results ===" + cat /tmp/ci-results.txt + + # Verify output file has a summary on success + if [ $TEST_EXIT_CODE -eq 0 ]; then + grep -q "All tasks passed" /tmp/ci-results.txt && \ + echo "✓ Output file contains expected summary" || \ + { echo "✗ Output file missing success summary"; exit 1; } + fi + kill $SERVER_PID 2>/dev/null exit $TEST_EXIT_CODE ' diff --git a/pyproject.toml b/pyproject.toml index a6cb7ab..37bc703 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,9 +18,3 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] include = ["run.py"] - -[dependency-groups] -dev = [ - "pytest>=8.0", - "pytest-mock>=3.14", -] diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_features.py b/tests/test_features.py deleted file mode 100644 index a35dbf7..0000000 --- a/tests/test_features.py +++ /dev/null @@ -1,295 +0,0 @@ -"""Unit tests for the preamble and output-file features.""" -import io -import sys -import os -import pytest -from unittest.mock import MagicMock, patch, call - -sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) - -from rich.console import Console -import run - - -# ── _ps_run ─────────────────────────────────────────────────────────────────── - -def _make_channel(exit_code, stdout_bytes=b"", stderr_bytes=b""): - out_ch = MagicMock() - out_ch.channel.recv_exit_status.return_value = exit_code - out_ch.read.return_value = stdout_bytes - err_ch = MagicMock() - err_ch.read.return_value = stderr_bytes - client = MagicMock() - client.exec_command.return_value = (None, out_ch, err_ch) - return client - - -def test_ps_run_success(): - client = _make_channel(0, b"hello world") - code, out, err = run._ps_run(client, "Write-Output 'hello world'") - assert code == 0 - assert out == "hello world" - assert err == "" - # Verify -EncodedCommand was used - call_arg = client.exec_command.call_args[0][0] - assert "-EncodedCommand" in call_arg - assert "powershell" in call_arg.lower() - - -def test_ps_run_failure(): - client = _make_channel(1, stderr_bytes=b"Access denied") - code, out, err = run._ps_run(client, "Do-Something") - assert code == 1 - assert err == "Access denied" - - -def test_ps_run_encodes_utf16le(): - import base64 - client = _make_channel(0) - run._ps_run(client, "Get-Process") - call_arg = client.exec_command.call_args[0][0] - # Extract the base64 payload and decode it - encoded = call_arg.split("-EncodedCommand ")[1].strip() - decoded = base64.b64decode(encoded).decode("utf-16-le") - assert decoded == "Get-Process" - - -# ── preamble in ssh_deliver ─────────────────────────────────────────────────── - -def _mock_ssh_deliver_deps(monkeypatch, preamble_results=None): - """Patch everything in ssh_deliver except the preamble path under test.""" - preamble_results = preamble_results or [] - - fake_client = MagicMock() - - # ssh_connect returns our fake client - monkeypatch.setattr(run, "ssh_connect", lambda cfg: fake_client) - - # _ps_run returns successive (exit_code, out, err) tuples - call_iter = iter(preamble_results) - monkeypatch.setattr(run, "_ps_run", lambda client, cmd: next(call_iter)) - - # Stub out everything after preamble - monkeypatch.setattr(run, "ssh_terminate_agent", lambda *a: None) - monkeypatch.setattr(run, "remove_agents_by_name", lambda *a: None) - monkeypatch.setattr(run, "ssh_start_agent", lambda *a: None) - monkeypatch.setattr(run, "get_agent_list", lambda *a: []) - monkeypatch.setattr(run, "wait_for_active_agent", - lambda *a, **kw: {"a_id": "abc123", "a_last_tick": 1}) - - # Stub exec_command for the alive-check - chk = MagicMock() - chk.read.return_value = b"alive" - fake_client.exec_command.return_value = (None, chk, MagicMock()) - - return fake_client - - -def test_preamble_runs_all_commands(monkeypatch): - calls = [] - fake_client = _mock_ssh_deliver_deps( - monkeypatch, - preamble_results=[(0, "ok1", ""), (0, "ok2", "")], - ) - monkeypatch.setattr(run, "_ps_run", lambda c, cmd: calls.append(cmd) or (0, "", "")) - - ssh_cfg = { - "host": "127.0.0.1", - "username": "user", - "agent_path": r"C:\ci\agent.exe", - "preamble": ["New-Item -Force -Path C:\\test", "Set-Location C:\\test"], - } - run.ssh_deliver("https://x", {}, ssh_cfg) - - assert calls == ["New-Item -Force -Path C:\\test", "Set-Location C:\\test"] - - -def test_preamble_string_is_accepted(monkeypatch): - """A bare string preamble (not a list) should run as a single command.""" - calls = [] - _mock_ssh_deliver_deps(monkeypatch) - monkeypatch.setattr(run, "_ps_run", lambda c, cmd: calls.append(cmd) or (0, "", "")) - - ssh_cfg = { - "host": "127.0.0.1", - "username": "user", - "agent_path": r"C:\ci\agent.exe", - "preamble": "New-Item -Force -Path C:\\test", - } - run.ssh_deliver("https://x", {}, ssh_cfg) - assert calls == ["New-Item -Force -Path C:\\test"] - - -def test_preamble_failure_calls_die(monkeypatch): - _mock_ssh_deliver_deps(monkeypatch) - monkeypatch.setattr(run, "_ps_run", lambda c, cmd: (1, "", "Access denied")) - - ssh_cfg = { - "host": "127.0.0.1", - "username": "user", - "agent_path": r"C:\ci\agent.exe", - "preamble": ["Bad-Command"], - } - with pytest.raises(SystemExit): - run.ssh_deliver("https://x", {}, ssh_cfg) - - -def test_no_preamble_runs_fine(monkeypatch): - _mock_ssh_deliver_deps(monkeypatch) - ps_run_called = [] - monkeypatch.setattr(run, "_ps_run", lambda c, cmd: ps_run_called.append(cmd) or (0, "", "")) - - ssh_cfg = { - "host": "127.0.0.1", - "username": "user", - "agent_path": r"C:\ci\agent.exe", - } - run.ssh_deliver("https://x", {}, ssh_cfg) - assert ps_run_called == [] - - -# ── _render_summary ─────────────────────────────────────────────────────────── - -def _console_to_str(): - buf = io.StringIO() - c = Console(file=buf, highlight=False, no_color=True, width=80) - return c, buf - - -def test_render_summary_all_passed(): - results = [ - {"task": {"cmdline": "whoami"}, "status": "passed", "result": {"a_text": "user", "a_message": ""}}, - {"task": {"cmdline": "dir"}, "status": "passed", "result": {"a_text": "ok", "a_message": ""}}, - ] - c, buf = _console_to_str() - exit_code = run._render_summary(c, results) - - assert exit_code == 0 - out = buf.getvalue() - assert "2" in out # total run count - assert "passed" in out - assert "All tasks passed" in out - assert "failed" not in out.lower().replace("passed", "") - - -def test_render_summary_with_failure(): - results = [ - {"task": {"cmdline": "whoami", "expected": "admin"}, "status": "failed", - "result": {"a_text": "user", "a_message": ""}}, - ] - c, buf = _console_to_str() - exit_code = run._render_summary(c, results) - - assert exit_code == 1 - out = buf.getvalue() - assert "failed" in out.lower() - assert "whoami" in out - assert "admin" in out # expected value shown - assert "user" in out # actual value shown - - -def test_render_summary_with_xfail(): - results = [ - {"task": {"cmdline": "xyzzy"}, "status": "xfail", "result": None, "err_msg": ""}, - {"task": {"cmdline": "whoami"}, "status": "passed", "result": {"a_text": "user", "a_message": ""}}, - ] - c, buf = _console_to_str() - exit_code = run._render_summary(c, results) - - assert exit_code == 0 # xfail does not count as a failure - out = buf.getvalue() - assert "xfail" in out - - -def test_render_summary_dispatch_failed(): - results = [ - {"task": {"cmdline": "bad cmd"}, "status": "dispatch-failed", "result": None, "err_msg": "unknown command"}, - ] - c, buf = _console_to_str() - exit_code = run._render_summary(c, results) - - assert exit_code == 1 - out = buf.getvalue() - assert "dispatch-failed" in out - assert "bad cmd" in out - - -def test_render_summary_timed_out(): - results = [ - {"task": {"cmdline": "slow cmd"}, "status": "timed-out", "result": None}, - ] - c, buf = _console_to_str() - exit_code = run._render_summary(c, results) - - assert exit_code == 1 - out = buf.getvalue() - assert "timed out" in out.lower() - assert "slow cmd" in out - - -# ── --output flag: stdout is suppressed ────────────────────────────────────── - -def test_output_flag_silences_stdout(monkeypatch, tmp_path): - """With -o, nothing should reach the real stdout console during the run.""" - output_file = tmp_path / "results.txt" - - # Restore the real console before and after (main() mutates the global) - real_console = run.console - - captured_prints = [] - - def fake_main_body(): - # Simulate what main() does: silence console then write summary - run.console = Console(file=io.StringIO(), highlight=False) - # Any console.print calls go to the sink - run.console.print("this should not reach stdout") - results = [ - {"task": {"cmdline": "whoami"}, "status": "passed", - "result": {"a_text": "user", "a_message": ""}}, - ] - with open(output_file, "w") as f: - fc = Console(file=f, highlight=False, no_color=True) - return run._render_summary(fc, results) - - try: - exit_code = fake_main_body() - finally: - run.console = real_console - - assert exit_code == 0 - content = output_file.read_text() - assert "passed" in content - assert "All tasks passed" in content - # Progress message did NOT reach the file - assert "this should not reach stdout" not in content - - -def test_output_file_success_contains_only_summary(tmp_path): - output_file = tmp_path / "out.txt" - results = [ - {"task": {"cmdline": "whoami"}, "status": "passed", - "result": {"a_text": "ci_runner", "a_message": ""}}, - ] - with open(output_file, "w") as f: - fc = Console(file=f, highlight=False, no_color=True) - run._render_summary(fc, results) - - content = output_file.read_text() - assert "All tasks passed" in content - assert "ci_runner" not in content # agent output not in summary - - -def test_output_file_failure_contains_detail(tmp_path): - output_file = tmp_path / "out.txt" - results = [ - {"task": {"cmdline": "whoami", "expected": "SYSTEM"}, "status": "failed", - "result": {"a_text": "ci_runner", "a_message": ""}}, - ] - with open(output_file, "w") as f: - fc = Console(file=f, highlight=False, no_color=True) - run._render_summary(fc, results) - - content = output_file.read_text() - assert "SYSTEM" in content # expected shown - assert "ci_runner" in content # actual shown - assert "FAILED" in content From 1f9217a2c466ed185ef281a3907ce3bdb7a1792a Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Wed, 13 May 2026 11:00:27 +0200 Subject: [PATCH 3/9] fix: install current branch testing-kit inside container before tests The prebuilt container image has a pinned version of adaptix-testing baked in. Add uv tool install --force /workspace at the top of the container script to overwrite it with the current branch's code before any tests run. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b63fdad..d2f113c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -195,6 +195,9 @@ jobs: bash -c ' export PATH="/root/.local/bin:$PATH" + # Install current branch version over the container's baked-in copy + uv tool install --force /workspace + # ── Feature validation (no server required) ────────────────── echo "=== Feature validation ===" adaptix-testing --help 2>&1 | grep -q -- "-o" && \ From 49ff0bbec0fb74d9644fe4a596db4218c857c3ff Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Wed, 13 May 2026 11:09:35 +0200 Subject: [PATCH 4/9] fix: use pip3 instead of uv to install current branch inside container uv is not on PATH inside the Docker container's bash -c context. pip3 install --break-system-packages is reliable and drops adaptix-testing into /usr/local/bin which is prepended to PATH immediately after. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yaml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d2f113c..9f90034 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -193,10 +193,9 @@ jobs: -v /tmp/ci_config.yaml:/tmp/ci_config.yaml:ro \ "$CI_CONTAINER" \ bash -c ' - export PATH="/root/.local/bin:$PATH" - - # Install current branch version over the container's baked-in copy - uv tool install --force /workspace + # Install current branch version over the container's pre-baked copy + pip3 install --break-system-packages --quiet /workspace + export PATH="/usr/local/bin:/root/.local/bin:$PATH" # ── Feature validation (no server required) ────────────────── echo "=== Feature validation ===" From 7a7dcb635d3f1f086d2174caeb3b4fcc0d203925 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Wed, 13 May 2026 16:56:46 +0200 Subject: [PATCH 5/9] replace uv with pip3 --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 9f90034..975789e 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -194,7 +194,7 @@ jobs: "$CI_CONTAINER" \ bash -c ' # Install current branch version over the container's pre-baked copy - pip3 install --break-system-packages --quiet /workspace + uv tool install /workspace --editable export PATH="/usr/local/bin:/root/.local/bin:$PATH" # ── Feature validation (no server required) ────────────────── From a168aafc5d8f4fa55ecd225bf519a7f5a0eb43c4 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 15 May 2026 07:46:03 +0200 Subject: [PATCH 6/9] fix: replace uv with pip3 to install workspace in CI container uv is not on PATH when the container runs at runtime; pip3 installs the editable package directly to /usr/local/bin which is always available. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 975789e..3c08174 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -194,7 +194,7 @@ jobs: "$CI_CONTAINER" \ bash -c ' # Install current branch version over the container's pre-baked copy - uv tool install /workspace --editable + pip3 install --break-system-packages -e /workspace export PATH="/usr/local/bin:/root/.local/bin:$PATH" # ── Feature validation (no server required) ────────────────── From 5b84e9ab7db1512d9cfe8594f93737cd2a9d134e Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 15 May 2026 08:00:13 +0200 Subject: [PATCH 7/9] fix: run workspace run.py via pre-baked venv instead of installing pip3 and uv are not reliably on PATH in the pre-built container because apt-get autoremove stripped python3-pip during image build. The uv venv created at image-build time already has all dependencies; pointing its Python directly at /workspace/run.py tests the current branch without needing any install step. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3c08174..8d2300f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -193,13 +193,14 @@ jobs: -v /tmp/ci_config.yaml:/tmp/ci_config.yaml:ro \ "$CI_CONTAINER" \ bash -c ' - # Install current branch version over the container's pre-baked copy - pip3 install --break-system-packages -e /workspace + # Use pre-baked venv Python to run workspace code directly. + # Avoids pip3/uv PATH issues in the pre-built image. + VENV_PY=/root/.local/share/uv/tools/adaptix-testing/.venv/bin/python export PATH="/usr/local/bin:/root/.local/bin:$PATH" # ── Feature validation (no server required) ────────────────── echo "=== Feature validation ===" - adaptix-testing --help 2>&1 | grep -q -- "-o" && \ + "$VENV_PY" /workspace/run.py --help 2>&1 | grep -q -- "-o" && \ echo "✓ --output flag available" || \ { echo "✗ --output flag missing from CLI"; exit 1; } echo "=== Feature validation passed ===" @@ -229,7 +230,7 @@ jobs: # ── Integration tests (exercises --output and preamble) ────── echo "AdaptixC2 Ready! Running integration tests..." - adaptix-testing -c /tmp/ci_config.yaml -t /workspace/.github/ci/tasks.yaml \ + "$VENV_PY" /workspace/run.py -c /tmp/ci_config.yaml -t /workspace/.github/ci/tasks.yaml \ -o /tmp/ci-results.txt TEST_EXIT_CODE=$? From dc7bd5d1448fe45a19d3212e8901eef7c1ec2cd6 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 15 May 2026 08:05:43 +0200 Subject: [PATCH 8/9] fix: derive VENV_PY from installed entry point shebang Hardcoding the venv path assumed 'python' binary name and exact tool directory name. Reading the shebang of the pre-baked adaptix-testing entry point gives the correct interpreter path directly. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8d2300f..e5b5e92 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -193,10 +193,10 @@ jobs: -v /tmp/ci_config.yaml:/tmp/ci_config.yaml:ro \ "$CI_CONTAINER" \ bash -c ' - # Use pre-baked venv Python to run workspace code directly. - # Avoids pip3/uv PATH issues in the pre-built image. - VENV_PY=/root/.local/share/uv/tools/adaptix-testing/.venv/bin/python export PATH="/usr/local/bin:/root/.local/bin:$PATH" + # Extract venv Python from the installed entry point's shebang — + # guaranteed correct regardless of Python version or tool name. + VENV_PY=$(sed -n '1s/^#!//p' /root/.local/bin/adaptix-testing) # ── Feature validation (no server required) ────────────────── echo "=== Feature validation ===" From 19a3552b0ec124fddf77c87ed66dcff5a784e5a6 Mon Sep 17 00:00:00 2001 From: TheGr3atJosh <90441217+TheGr3atJosh@users.noreply.github.com> Date: Fri, 15 May 2026 08:48:19 +0200 Subject: [PATCH 9/9] fix: restore uv tool install now that container image is fixed The adaptix-container image now preserves pip3 (autoremove removed), so uv is available. PATH is set before the uv call so the reinstall and the adaptix-testing entry point are both found. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yaml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e5b5e92..a7b3807 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -194,13 +194,11 @@ jobs: "$CI_CONTAINER" \ bash -c ' export PATH="/usr/local/bin:/root/.local/bin:$PATH" - # Extract venv Python from the installed entry point's shebang — - # guaranteed correct regardless of Python version or tool name. - VENV_PY=$(sed -n '1s/^#!//p' /root/.local/bin/adaptix-testing) + uv tool install /workspace --reinstall # ── Feature validation (no server required) ────────────────── echo "=== Feature validation ===" - "$VENV_PY" /workspace/run.py --help 2>&1 | grep -q -- "-o" && \ + adaptix-testing --help 2>&1 | grep -q -- "-o" && \ echo "✓ --output flag available" || \ { echo "✗ --output flag missing from CLI"; exit 1; } echo "=== Feature validation passed ===" @@ -230,7 +228,7 @@ jobs: # ── Integration tests (exercises --output and preamble) ────── echo "AdaptixC2 Ready! Running integration tests..." - "$VENV_PY" /workspace/run.py -c /tmp/ci_config.yaml -t /workspace/.github/ci/tasks.yaml \ + adaptix-testing -c /tmp/ci_config.yaml -t /workspace/.github/ci/tasks.yaml \ -o /tmp/ci-results.txt TEST_EXIT_CODE=$?