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()
{