diff --git a/app/Infrastructure/AirlockRunner.cs b/app/Infrastructure/AirlockRunner.cs index 0a63a2a..6b04713 100644 --- a/app/Infrastructure/AirlockRunner.cs +++ b/app/Infrastructure/AirlockRunner.cs @@ -69,7 +69,7 @@ public static int Run( DebugLogger.Log($"Using external network: {externalNetwork}"); } - var appImage = $"{ImagePrefix}:{imageTag}"; + var appImage = ContainerRunner.IsAbsoluteImageReference(imageTag) ? imageTag : $"{ImagePrefix}:{imageTag}"; var proxyImage = $"{ImagePrefix}:proxy"; Console.WriteLine("🛡️ Starting in Airlock mode..."); diff --git a/app/Infrastructure/ContainerRunner.cs b/app/Infrastructure/ContainerRunner.cs index 746349d..78a3a21 100644 --- a/app/Infrastructure/ContainerRunner.cs +++ b/app/Infrastructure/ContainerRunner.cs @@ -11,8 +11,16 @@ public static class ContainerRunner /// /// Gets the full image name for a given tag. + /// If the tag contains a '/', it is treated as an absolute image reference and used as-is. /// - public static string GetImageName(string tag) => $"{ImagePrefix}:{tag}"; + public static string GetImageName(string tag) => + IsAbsoluteImageReference(tag) ? tag : $"{ImagePrefix}:{tag}"; + + /// + /// Determines whether a value is an absolute image reference (e.g., "myregistry.io/myimage:v1") + /// rather than a simple tag (e.g., "dotnet-rust"). + /// + public static bool IsAbsoluteImageReference(string value) => value.Contains('/'); /// /// Runs a container command with the given arguments. diff --git a/app/Tools/EchoTool.cs b/app/Tools/EchoTool.cs index 26f8b37..ab84503 100644 --- a/app/Tools/EchoTool.cs +++ b/app/Tools/EchoTool.cs @@ -13,6 +13,10 @@ public class EchoTool : Infrastructure.ICliTool public string GetImageName(string tag) { + // If the tag is an absolute image reference (contains '/'), use it as-is + if (Infrastructure.ContainerRunner.IsAbsoluteImageReference(tag)) + return tag; + // Echo uses the same images as GitHub Copilot (copilot-* tags) // It doesn't need separate images - it just echoes config instead of running the tool const string imagePrefix = "ghcr.io/gordonbeeming/copilot_here"; diff --git a/app/Tools/GitHubCopilotTool.cs b/app/Tools/GitHubCopilotTool.cs index bc708a0..2d4f094 100644 --- a/app/Tools/GitHubCopilotTool.cs +++ b/app/Tools/GitHubCopilotTool.cs @@ -16,6 +16,10 @@ public sealed class GitHubCopilotTool : ICliTool public string GetImageName(string tag) { + // If the tag is an absolute image reference (contains '/'), use it as-is + if (ContainerRunner.IsAbsoluteImageReference(tag)) + return tag; + // Image name format: ghcr.io/gordonbeeming/copilot_here:copilot-{variant} // Tag matches what users invoke: "copilot" (not "github-copilot") const string imagePrefix = "ghcr.io/gordonbeeming/copilot_here"; diff --git a/tests/CopilotHere.UnitTests/ContainerRunnerImageTests.cs b/tests/CopilotHere.UnitTests/ContainerRunnerImageTests.cs new file mode 100644 index 0000000..2532085 --- /dev/null +++ b/tests/CopilotHere.UnitTests/ContainerRunnerImageTests.cs @@ -0,0 +1,93 @@ +using CopilotHere.Infrastructure; +using TUnit.Core; + +namespace CopilotHere.Tests; + +public class ContainerRunnerImageTests +{ + // === IsAbsoluteImageReference === + + [Test] + public async Task IsAbsoluteImageReference_WithSimpleTag_ReturnsFalse() + { + await Assert.That(ContainerRunner.IsAbsoluteImageReference("latest")).IsFalse(); + } + + [Test] + public async Task IsAbsoluteImageReference_WithDotnetTag_ReturnsFalse() + { + await Assert.That(ContainerRunner.IsAbsoluteImageReference("dotnet-rust")).IsFalse(); + } + + [Test] + public async Task IsAbsoluteImageReference_WithRegistryImage_ReturnsTrue() + { + await Assert.That(ContainerRunner.IsAbsoluteImageReference("myregistry.io/myimage:v1")).IsTrue(); + } + + [Test] + public async Task IsAbsoluteImageReference_WithDockerHubImage_ReturnsTrue() + { + await Assert.That(ContainerRunner.IsAbsoluteImageReference("myuser/myimage:latest")).IsTrue(); + } + + [Test] + public async Task IsAbsoluteImageReference_WithGhcrImage_ReturnsTrue() + { + await Assert.That(ContainerRunner.IsAbsoluteImageReference("ghcr.io/myorg/myimage:v2")).IsTrue(); + } + + [Test] + public async Task IsAbsoluteImageReference_WithLocalRegistryImage_ReturnsTrue() + { + await Assert.That(ContainerRunner.IsAbsoluteImageReference("localhost:5000/myimage:dev")).IsTrue(); + } + + [Test] + public async Task IsAbsoluteImageReference_WithNestedPath_ReturnsTrue() + { + await Assert.That(ContainerRunner.IsAbsoluteImageReference("registry.example.com/org/team/image:tag")).IsTrue(); + } + + // === GetImageName === + + [Test] + public async Task GetImageName_WithSimpleTag_ReturnsFullImageWithPrefix() + { + // Act + var imageName = ContainerRunner.GetImageName("latest"); + + // Assert + await Assert.That(imageName).IsEqualTo("ghcr.io/gordonbeeming/copilot_here:latest"); + } + + [Test] + public async Task GetImageName_WithDotnetTag_ReturnsFullImageWithPrefix() + { + // Act + var imageName = ContainerRunner.GetImageName("dotnet-rust"); + + // Assert + await Assert.That(imageName).IsEqualTo("ghcr.io/gordonbeeming/copilot_here:dotnet-rust"); + } + + [Test] + public async Task GetImageName_WithAbsoluteImage_ReturnsAsIs() + { + // Act + var imageName = ContainerRunner.GetImageName("myregistry.io/myimage:v1"); + + // Assert + await Assert.That(imageName).IsEqualTo("myregistry.io/myimage:v1"); + } + + [Test] + public async Task GetImageName_WithDockerHubImage_ReturnsAsIs() + { + // Act + var imageName = ContainerRunner.GetImageName("myuser/custom-copilot:latest"); + + // Assert + await Assert.That(imageName).IsEqualTo("myuser/custom-copilot:latest"); + } +} diff --git a/tests/CopilotHere.UnitTests/EchoToolTests.cs b/tests/CopilotHere.UnitTests/EchoToolTests.cs index 8c4f302..830fecc 100644 --- a/tests/CopilotHere.UnitTests/EchoToolTests.cs +++ b/tests/CopilotHere.UnitTests/EchoToolTests.cs @@ -42,6 +42,26 @@ public async Task GetImageName_WithDotnet_ReturnsCorrectFormat() await Assert.That(imageName).IsEqualTo("ghcr.io/gordonbeeming/copilot_here:copilot-dotnet"); } + [Test] + public async Task GetImageName_WithAbsoluteImage_ReturnsAsIs() + { + // Act + var imageName = _tool.GetImageName("myregistry.io/custom-image:v1"); + + // Assert + await Assert.That(imageName).IsEqualTo("myregistry.io/custom-image:v1"); + } + + [Test] + public async Task GetImageName_WithDockerHubAbsoluteImage_ReturnsAsIs() + { + // Act + var imageName = _tool.GetImageName("myuser/custom-image:latest"); + + // Assert + await Assert.That(imageName).IsEqualTo("myuser/custom-image:latest"); + } + [Test] public async Task GetDockerfile_ReturnsCorrectPath() { diff --git a/tests/CopilotHere.UnitTests/GitHubCopilotToolTests.cs b/tests/CopilotHere.UnitTests/GitHubCopilotToolTests.cs index d431caf..9694840 100644 --- a/tests/CopilotHere.UnitTests/GitHubCopilotToolTests.cs +++ b/tests/CopilotHere.UnitTests/GitHubCopilotToolTests.cs @@ -52,6 +52,26 @@ public async Task GetImageName_WithEmptyTag_ReturnsDefault() await Assert.That(imageName).IsEqualTo("ghcr.io/gordonbeeming/copilot_here:copilot-latest"); } + [Test] + public async Task GetImageName_WithAbsoluteImage_ReturnsAsIs() + { + // Act + var imageName = _tool.GetImageName("myregistry.io/custom-copilot:v1"); + + // Assert + await Assert.That(imageName).IsEqualTo("myregistry.io/custom-copilot:v1"); + } + + [Test] + public async Task GetImageName_WithDockerHubAbsoluteImage_ReturnsAsIs() + { + // Act + var imageName = _tool.GetImageName("myuser/copilot-custom:latest"); + + // Assert + await Assert.That(imageName).IsEqualTo("myuser/copilot-custom:latest"); + } + [Test] public async Task GetDockerfile_ReturnsCorrectPath() {