From bd182dc221cd2d764ed6567f5d03f987e60bf44f Mon Sep 17 00:00:00 2001 From: DonsWayo Date: Wed, 17 Jun 2026 13:21:39 +0200 Subject: [PATCH 1/2] Container detail view + exec into shell - ContainerDetail model (image/command/env/ports/mounts/started) + containerExecCommand helper - ContainerEngine.detail(id:) via ContainerClient.get; SDKContainerEngine maps the config - AppState.detail + execShell (opens an interactive shell in Terminal via osascript) - DetailWindow: inspect fields + toolbar Shell/Logs; opened from a row 'Details' action - Tests: exec-command building + formatBytes (30 tests pass) Closes #8 --- App/AppState.swift | 13 +++ App/ConsaiApp.swift | 6 ++ App/Shots/MockEngines.swift | 7 ++ App/Views/ContainerRow.swift | 1 + App/Views/DetailWindow.swift | 85 +++++++++++++++++++ .../Sources/ConsaiCore/Engines/Engines.swift | 2 + .../Engines/SDKContainerEngine.swift | 22 +++++ .../ConsaiCore/Models/ContainerDetail.swift | 42 +++++++++ .../ConsaiCoreTests/CLIEngineTests.swift | 17 ++++ 9 files changed, 195 insertions(+) create mode 100644 App/Views/DetailWindow.swift create mode 100644 ConsaiCore/Sources/ConsaiCore/Models/ContainerDetail.swift diff --git a/App/AppState.swift b/App/AppState.swift index 4380123..83d2b83 100644 --- a/App/AppState.swift +++ b/App/AppState.swift @@ -291,6 +291,19 @@ final class AppState { } } + // MARK: - Container detail / exec + + func detail(_ id: String) async -> ContainerDetail? { + do { return try await containerEngine.detail(id: id) } + catch { lastError = describe(error); return nil } + } + + /// Open an interactive shell into the container in Terminal. + func execShell(_ id: String) { + let binary = AppState.storedPath("containerBinaryPath") ?? "/usr/local/bin/container" + ContainerShell.openShell(binaryPath: binary, id: id) + } + // MARK: - Networks & volumes func loadInfra() async { diff --git a/App/ConsaiApp.swift b/App/ConsaiApp.swift index c5bf897..f976dee 100644 --- a/App/ConsaiApp.swift +++ b/App/ConsaiApp.swift @@ -80,5 +80,11 @@ struct ConsaiApp: App { .preferredColorScheme(.dark).tint(Theme.jade) } .defaultSize(width: 720, height: 460) + + WindowGroup(id: "detail", for: String.self) { $id in + DetailWindow(containerID: id ?? "") + .environment(appState) + } + .defaultSize(width: 480, height: 420) } } diff --git a/App/Shots/MockEngines.swift b/App/Shots/MockEngines.swift index 4fcac2a..216dbe7 100644 --- a/App/Shots/MockEngines.swift +++ b/App/Shots/MockEngines.swift @@ -13,6 +13,13 @@ struct MockContainerEngine: ContainerEngine { func delete(id: String) async throws {} func memoryUsage(id: String) async -> UInt64? { containers.first { $0.id == id }?.memoryBytes } func cpuUsage(id: String) async -> UInt64? { nil } // cpu% needs sampling; mocks preset cpuPercent + func detail(id: String) async throws -> ContainerDetail { + ContainerDetail(id: id, image: containers.first { $0.id == id }?.image ?? "img", + command: "sleep 3600", env: ["PATH=/usr/bin", "TZ=UTC"], + ports: [PortBinding(host: 8080, container: 80, proto: "tcp")], + mounts: [MountBinding(source: "/data", destination: "/var/data")], + startedAt: nil) + } } struct MockComposeEngine: ComposeEngine { diff --git a/App/Views/ContainerRow.swift b/App/Views/ContainerRow.swift index 186e66e..006f625 100644 --- a/App/Views/ContainerRow.swift +++ b/App/Views/ContainerRow.swift @@ -90,6 +90,7 @@ struct ContainerRow: View { iconButton("play.fill", "Start") { Task { await appState.start(container.id) } } } iconButton("doc.text", "Logs") { openWindow(id: "logs", value: container.id) } + iconButton("info.circle", "Details") { openWindow(id: "detail", value: container.id) } iconButton("trash", "Delete") { confirmingDelete = true } } diff --git a/App/Views/DetailWindow.swift b/App/Views/DetailWindow.swift new file mode 100644 index 0000000..a818c89 --- /dev/null +++ b/App/Views/DetailWindow.swift @@ -0,0 +1,85 @@ +import SwiftUI +import ConsaiCore +import AppKit + +/// Opens an interactive shell into a container via Terminal. +enum ContainerShell { + static func openShell(binaryPath: String, id: String) { + let command = containerExecCommand(binary: binaryPath, id: id) + // Escape for AppleScript string literal. + let escaped = command.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let script = "tell application \"Terminal\"\nactivate\ndo script \"\(escaped)\"\nend tell" + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + proc.arguments = ["-e", script] + try? proc.run() + } +} + +/// Per-container detail: image, command, started, env, ports, mounts; open a shell or logs. +struct DetailWindow: View { + @Environment(AppState.self) private var appState + @Environment(\.openWindow) private var openWindow + let containerID: String + + @State private var detail: ContainerDetail? + @State private var loading = true + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if loading { + ProgressView().padding(24) + } else if let detail { + field("Image", detail.image) + field("Command", detail.command.isEmpty ? "—" : detail.command) + if let started = detail.startedAt { + field("Started", started.formatted(date: .abbreviated, time: .shortened)) + } + listSection("ENVIRONMENT", detail.env.isEmpty ? ["—"] : detail.env) + listSection("PORTS", detail.ports.isEmpty ? ["—"] : detail.ports.map { "\($0.host) → \($0.container)/\($0.proto)" }) + listSection("MOUNTS", detail.mounts.isEmpty ? ["—"] : detail.mounts.map { "\($0.source) → \($0.destination)" }) + } else { + EmptyState(symbol: "questionmark.circle", title: "No detail", subtitle: "Couldn't load this container.") + } + } + .padding(.bottom, 10) + } + .frame(minWidth: 460, minHeight: 360) + .background(Theme.bg) + .preferredColorScheme(.dark).tint(Theme.jade) + .navigationTitle(containerID) + .toolbar { + ToolbarItemGroup { + Button { appState.execShell(containerID) } label: { Label("Shell", systemImage: "terminal") } + Button { openWindow(id: "logs", value: containerID) } label: { Label("Logs", systemImage: "doc.text") } + } + } + .onAppear { + NSApp.activate(ignoringOtherApps: true) + Task { detail = await appState.detail(containerID); loading = false } + } + } + + private func field(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label.uppercased()).font(Theme.sectionLabel).tracking(1.5).foregroundStyle(Theme.dim2) + Text(value).font(Theme.mono(11)).foregroundStyle(Theme.text).textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16).padding(.vertical, 7) + } + + private func listSection(_ title: String, _ items: [String]) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title).font(Theme.sectionLabel).tracking(1.5).foregroundStyle(Theme.dim2) + ForEach(items, id: \.self) { item in + Text(item).font(Theme.mono(11)).foregroundStyle(Theme.dim).textSelection(.enabled) + .lineLimit(1).truncationMode(.middle) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16).padding(.vertical, 7) + } +} diff --git a/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift b/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift index 05c1b7c..08e9c08 100644 --- a/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift +++ b/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift @@ -12,6 +12,8 @@ public protocol ContainerEngine: Sendable { func memoryUsage(id: String) async -> UInt64? /// Best-effort cumulative CPU time in microseconds (for delta-based CPU%); nil if unavailable. func cpuUsage(id: String) async -> UInt64? + /// Full inspect data (env, ports, mounts, started) for one container. + func detail(id: String) async throws -> ContainerDetail } /// Orchestrates compose stacks by shelling out to the `container-compose` CLI. diff --git a/ConsaiCore/Sources/ConsaiCore/Engines/SDKContainerEngine.swift b/ConsaiCore/Sources/ConsaiCore/Engines/SDKContainerEngine.swift index 18ac564..f2fe87e 100644 --- a/ConsaiCore/Sources/ConsaiCore/Engines/SDKContainerEngine.swift +++ b/ConsaiCore/Sources/ConsaiCore/Engines/SDKContainerEngine.swift @@ -65,6 +65,28 @@ public struct SDKContainerEngine: ContainerEngine { try? await ContainerClient().stats(id: id).cpuUsageUsec } + public func detail(id: String) async throws -> ContainerDetail { + do { + let snapshot = try await ContainerClient().get(id: id) + let config = snapshot.configuration + let command = ([config.initProcess.executable] + config.initProcess.arguments) + .filter { !$0.isEmpty }.joined(separator: " ") + return ContainerDetail( + id: snapshot.id, + image: config.image.reference, + command: command, + env: config.initProcess.environment.sorted(), + ports: config.publishedPorts.map { + PortBinding(host: Int($0.hostPort), container: Int($0.containerPort), proto: $0.proto.rawValue) + }, + mounts: config.mounts.map { MountBinding(source: $0.source, destination: $0.destination) }, + startedAt: snapshot.startedDate + ) + } catch { + throw ConsaiError.sdk(String(describing: error)) + } + } + // MARK: - Mapping (SDK → Consai) /// In this SDK the container id *is* its display name (`configuration.id`). diff --git a/ConsaiCore/Sources/ConsaiCore/Models/ContainerDetail.swift b/ConsaiCore/Sources/ConsaiCore/Models/ContainerDetail.swift new file mode 100644 index 0000000..e538387 --- /dev/null +++ b/ConsaiCore/Sources/ConsaiCore/Models/ContainerDetail.swift @@ -0,0 +1,42 @@ +import Foundation + +public struct PortBinding: Sendable, Hashable, Identifiable { + public var id: String { "\(host):\(container)/\(proto)" } + public let host: Int + public let container: Int + public let proto: String + public init(host: Int, container: Int, proto: String) { + self.host = host; self.container = container; self.proto = proto + } +} + +public struct MountBinding: Sendable, Hashable, Identifiable { + public var id: String { "\(source)->\(destination)" } + public let source: String + public let destination: String + public init(source: String, destination: String) { + self.source = source; self.destination = destination + } +} + +/// Full inspect data for one container (from `ContainerClient.get`). +public struct ContainerDetail: Sendable { + public let id: String + public let image: String + public let command: String + public let env: [String] // "KEY=VALUE" + public let ports: [PortBinding] + public let mounts: [MountBinding] + public let startedAt: Date? + + public init(id: String, image: String, command: String, env: [String], + ports: [PortBinding], mounts: [MountBinding], startedAt: Date?) { + self.id = id; self.image = image; self.command = command + self.env = env; self.ports = ports; self.mounts = mounts; self.startedAt = startedAt + } +} + +/// The command to open an interactive shell in a container. Pure for testing. +public func containerExecCommand(binary: String, id: String, shell: String = "sh") -> String { + "\(binary) exec -it \(id) \(shell)" +} diff --git a/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift b/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift index 0a417d9..a8879b1 100644 --- a/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift +++ b/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift @@ -120,6 +120,23 @@ final class SpyProcessRunner: ProcessRunning, @unchecked Sendable { } } +@Suite struct ContainerDetailTests { + @Test func execCommandBuildsInteractiveShell() { + #expect(containerExecCommand(binary: "/usr/local/bin/container", id: "web") + == "/usr/local/bin/container exec -it web sh") + #expect(containerExecCommand(binary: "container", id: "db", shell: "bash") + == "container exec -it db bash") + } +} + +@Suite struct FormatBytesTests { + @Test func formatsMBAndGB() { + #expect(formatBytes(38 * 1_048_576) == "38 MB") + #expect(formatBytes(1024 * 1_048_576) == "1.0 GB") + #expect(formatBytes(0) == "0 MB") + } +} + @Suite struct CLIServiceHealthTests { @Test func parsesNegativeSignalsAsStopped() { #expect(CLIServiceHealth.parseStatus(exitCode: 0, output: "apiserver is not running") == .stopped) From bc15c3307dc1abe14b2c01b55e09f694112eda82 Mon Sep 17 00:00:00 2001 From: DonsWayo Date: Wed, 17 Jun 2026 13:24:15 +0200 Subject: [PATCH 2/2] Harden exec: reject non-LDH container names before shell/AppleScript interpolation Security review flagged command injection: the container id is interpolated into an AppleScript 'do script'. Add isValidContainerName allowlist (^[A-Za-z0-9][A-Za-z0-9_.-]*$) and refuse anything else in ContainerShell.openShell, with a test covering injection attempts. --- App/Views/DetailWindow.swift | 5 ++++- .../Sources/ConsaiCore/Models/ContainerDetail.swift | 9 +++++++++ .../Tests/ConsaiCoreTests/CLIEngineTests.swift | 13 +++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/App/Views/DetailWindow.swift b/App/Views/DetailWindow.swift index a818c89..6241357 100644 --- a/App/Views/DetailWindow.swift +++ b/App/Views/DetailWindow.swift @@ -5,8 +5,11 @@ import AppKit /// Opens an interactive shell into a container via Terminal. enum ContainerShell { static func openShell(binaryPath: String, id: String) { + // Refuse ids that aren't plain container names — they're interpolated into an + // AppleScript/shell `do script`, so anything exotic is a command-injection risk. + guard isValidContainerName(id) else { return } let command = containerExecCommand(binary: binaryPath, id: id) - // Escape for AppleScript string literal. + // Escape for AppleScript string literal (defense-in-depth). let escaped = command.replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") let script = "tell application \"Terminal\"\nactivate\ndo script \"\(escaped)\"\nend tell" diff --git a/ConsaiCore/Sources/ConsaiCore/Models/ContainerDetail.swift b/ConsaiCore/Sources/ConsaiCore/Models/ContainerDetail.swift index e538387..4f7032a 100644 --- a/ConsaiCore/Sources/ConsaiCore/Models/ContainerDetail.swift +++ b/ConsaiCore/Sources/ConsaiCore/Models/ContainerDetail.swift @@ -40,3 +40,12 @@ public struct ContainerDetail: Sendable { public func containerExecCommand(binary: String, id: String, shell: String = "sh") -> String { "\(binary) exec -it \(id) \(shell)" } + +/// Whether a container name/id is safe to interpolate into a shell/AppleScript command. +/// Apple container names are LDH-style (alphanumeric + `-`/`.`/`_`); anything else (spaces, +/// quotes, `;`, `$`, backticks, newlines, …) is rejected to prevent command injection. +public func isValidContainerName(_ name: String) -> Bool { + !name.isEmpty + && name.count <= 128 + && name.range(of: "^[A-Za-z0-9][A-Za-z0-9_.-]*$", options: .regularExpression) != nil +} diff --git a/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift b/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift index a8879b1..fc677c9 100644 --- a/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift +++ b/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift @@ -127,6 +127,19 @@ final class SpyProcessRunner: ProcessRunning, @unchecked Sendable { #expect(containerExecCommand(binary: "container", id: "db", shell: "bash") == "container exec -it db bash") } + + @Test func validatesContainerNamesAndRejectsInjection() { + #expect(isValidContainerName("shop-api")) + #expect(isValidContainerName("my_db.1")) + #expect(!isValidContainerName("")) + #expect(!isValidContainerName("-leadingdash")) // must start alphanumeric + // Injection attempts must be rejected before reaching the shell/AppleScript. + #expect(!isValidContainerName("web; rm -rf /")) + #expect(!isValidContainerName("a$(whoami)")) + #expect(!isValidContainerName("a\"; do shell script \"x")) + #expect(!isValidContainerName("a b")) + #expect(!isValidContainerName("a\nb")) + } } @Suite struct FormatBytesTests {