From b7d3a838a83e2572c847481c56b38177a87e34de Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Fri, 28 Aug 2026 07:04:09 +0000 Subject: [PATCH 1/5] feat: add mount path scheme configuration --- Sources/AgentIsolation/IsolationConfig.swift | 14 +++++++++++ Sources/AgentIsolation/ProjectSettings.swift | 3 +++ Sources/agentc/Commands/InitCommand.swift | 1 + Sources/agentc/SessionRunner.swift | 1 + Sources/agentc/SharedOptions.swift | 13 +++++++++++ .../ProjectSettingsTests.swift | 14 +++++++++++ .../InitCommandIntegrationTests.swift | 23 +++++++++++++++++++ 7 files changed, 69 insertions(+) diff --git a/Sources/AgentIsolation/IsolationConfig.swift b/Sources/AgentIsolation/IsolationConfig.swift index 9a43c73..465ce4a 100644 --- a/Sources/AgentIsolation/IsolationConfig.swift +++ b/Sources/AgentIsolation/IsolationConfig.swift @@ -15,6 +15,15 @@ public enum BootstrapMode: Sendable { case imageDefault } +/// Determines how host-backed mount destinations are represented in the container. +public enum MountPathScheme: String, Codable, Sendable { + /// Mount host paths beneath `/workspace` using a stable, canonical-path identifier. + case workspace + + /// Preserve the caller-visible absolute host path as the container destination. + case host +} + /// Configuration for running an isolated agent container session. public struct IsolationConfig: Sendable { /// Container image reference (e.g. "ghcr.io/laosb/claudec:latest"). @@ -27,6 +36,9 @@ public struct IsolationConfig: Sendable { /// Mounted at /workspace/-. public var workspace: URL + /// Controls how destinations are chosen for the workspace and additional host mounts. + public var mountPathScheme: MountPathScheme + /// Subfolder names within the workspace to mask with empty read-only mounts. /// Strips leading/trailing slashes. Multiple values allowed. public var excludeFolders: [String] @@ -91,6 +103,7 @@ public struct IsolationConfig: Sendable { image: String, profileHomeDir: URL, workspace: URL, + mountPathScheme: MountPathScheme = .workspace, excludeFolders: [String] = [], configurationsDir: URL, configurations: [String] = ["claude"], @@ -108,6 +121,7 @@ public struct IsolationConfig: Sendable { self.image = image self.profileHomeDir = profileHomeDir self.workspace = workspace + self.mountPathScheme = mountPathScheme self.excludeFolders = excludeFolders self.configurationsDir = configurationsDir self.configurations = configurations diff --git a/Sources/AgentIsolation/ProjectSettings.swift b/Sources/AgentIsolation/ProjectSettings.swift index f14c787..89fe103 100644 --- a/Sources/AgentIsolation/ProjectSettings.swift +++ b/Sources/AgentIsolation/ProjectSettings.swift @@ -36,6 +36,7 @@ public struct ProjectSettings: Codable, Sendable, Equatable { public struct AgentSettings: Codable, Sendable, Equatable { public var image: String? public var profile: String? + public var mountPathScheme: MountPathScheme? public var excludes: [String]? public var configurations: [String]? public var additionalMounts: [String]? @@ -50,6 +51,7 @@ public struct ProjectSettings: Codable, Sendable, Equatable { public init( image: String? = nil, profile: String? = nil, + mountPathScheme: MountPathScheme? = nil, excludes: [String]? = nil, configurations: [String]? = nil, additionalMounts: [String]? = nil, @@ -63,6 +65,7 @@ public struct ProjectSettings: Codable, Sendable, Equatable { ) { self.image = image self.profile = profile + self.mountPathScheme = mountPathScheme self.excludes = excludes self.configurations = configurations self.additionalMounts = additionalMounts diff --git a/Sources/agentc/Commands/InitCommand.swift b/Sources/agentc/Commands/InitCommand.swift index 0c78343..5dc22c4 100644 --- a/Sources/agentc/Commands/InitCommand.swift +++ b/Sources/agentc/Commands/InitCommand.swift @@ -121,6 +121,7 @@ struct InitCommand: AsyncParsableCommand { agent: .init( image: options.image ?? "ghcr.io/laosb/claudec:latest", profile: options.profile, + mountPathScheme: options.resolveMountPathScheme(), excludes: excludes, configurations: configurations, additionalMounts: options.additionalMount.isEmpty ? nil : options.additionalMount, diff --git a/Sources/agentc/SessionRunner.swift b/Sources/agentc/SessionRunner.swift index 407e1f1..7f075ee 100644 --- a/Sources/agentc/SessionRunner.swift +++ b/Sources/agentc/SessionRunner.swift @@ -62,6 +62,7 @@ enum SessionRunner { image: resolvedImage, profileHomeDir: profileHomeDir, workspace: workspace, + mountPathScheme: options.resolveMountPathScheme(projectSettings: projectSettings), excludeFolders: excludeFolders, configurationsDir: configurationsDir, configurations: configNames, diff --git a/Sources/agentc/SharedOptions.swift b/Sources/agentc/SharedOptions.swift index 0701213..40e380b 100644 --- a/Sources/agentc/SharedOptions.swift +++ b/Sources/agentc/SharedOptions.swift @@ -27,6 +27,8 @@ struct EnvironmentVariableOption: ExpressibleByArgument, Sendable, Equatable { } } +extension MountPathScheme: ExpressibleByArgument {} + struct SharedOptions: ParsableArguments { @Option(name: .shortAndLong, help: "Container runtime.") var runtime: RuntimeChoice? @@ -55,6 +57,12 @@ struct SharedOptions: ParsableArguments { @Option(name: .shortAndLong, help: "Host directory to mount as the workspace.") var workspace: String? + @Option( + name: .customLong("mount-path-scheme"), + help: "Container destination scheme for host mounts (workspace or host)." + ) + var mountPathScheme: MountPathScheme? + @Option( name: .customLong("exclude"), help: "Comma-separated workspace sub-folders to mask with empty overlays.") @@ -190,6 +198,11 @@ extension SharedOptions { return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) } + /// Resolve host-backed mount destinations. CLI flag → project settings → workspace. + func resolveMountPathScheme(projectSettings: ProjectSettings? = nil) -> MountPathScheme { + mountPathScheme ?? projectSettings?.agent?.mountPathScheme ?? .workspace + } + /// Resolve excluded folders list. /// When both CLI and project settings specify excludes, both sets are merged. func resolveExcludeFolders(projectSettings: ProjectSettings? = nil) -> [String] { diff --git a/Tests/AgentIsolationTests/ProjectSettingsTests.swift b/Tests/AgentIsolationTests/ProjectSettingsTests.swift index e620f97..83e7002 100644 --- a/Tests/AgentIsolationTests/ProjectSettingsTests.swift +++ b/Tests/AgentIsolationTests/ProjectSettingsTests.swift @@ -14,6 +14,7 @@ struct ProjectSettingsDecodingTests { "agent": { "image": "my-image:latest", "profile": "work", + "mountPathScheme": "host", "excludes": [".git", "node_modules"], "configurations": ["claude", "copilot"], "additionalMounts": ["/data/models"], @@ -36,6 +37,7 @@ struct ProjectSettingsDecodingTests { let agent = try #require(settings.agent) #expect(agent.image == "my-image:latest") #expect(agent.profile == "work") + #expect(agent.mountPathScheme == .host) #expect(agent.excludes == [".git", "node_modules"]) #expect(agent.configurations == ["claude", "copilot"]) #expect(agent.additionalMounts == ["/data/models"]) @@ -94,6 +96,7 @@ struct ProjectSettingsDecodingTests { #expect(agent.image == "custom:v1") #expect(agent.cpus == 2) #expect(agent.profile == nil) + #expect(agent.mountPathScheme == nil) #expect(agent.excludes == nil) #expect(agent.configurations == nil) #expect(agent.additionalMounts == nil) @@ -117,6 +120,17 @@ struct ProjectSettingsDecodingTests { #expect(agent.image == nil) #expect(agent.cpus == nil) } + + @Test("Rejects an invalid mount path scheme") + func rejectsInvalidMountPathScheme() throws { + let json = """ + { "agent": { "mountPathScheme": "elsewhere" } } + """ + + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(ProjectSettings.self, from: Data(json.utf8)) + } + } } // MARK: - File Search Tests diff --git a/Tests/AgentcIntegrationTests/InitCommandIntegrationTests.swift b/Tests/AgentcIntegrationTests/InitCommandIntegrationTests.swift index 6b6069b..ee72cf0 100644 --- a/Tests/AgentcIntegrationTests/InitCommandIntegrationTests.swift +++ b/Tests/AgentcIntegrationTests/InitCommandIntegrationTests.swift @@ -32,6 +32,7 @@ struct InitCommandIntegrationTests { let agent = json["agent"] as! [String: Any] #expect(agent["image"] as? String == "ghcr.io/laosb/claudec:latest") #expect(agent["configurations"] as? [String] == ["claude"]) + #expect(agent["mountPathScheme"] as? String == "workspace") #expect(agent["cpus"] as? Int == 1) #expect(agent["memoryMiB"] as? Int == 1536) } @@ -51,6 +52,7 @@ struct InitCommandIntegrationTests { "--cpus", "4", "--memory-mib", "4096", "--image", "custom:latest", + "--mount-path-scheme", "host", "--configurations", "claude,copilot", "--exclude", "node_modules,.git", "--env", "TZ=America/Los_Angeles", @@ -67,6 +69,7 @@ struct InitCommandIntegrationTests { #expect(agent["image"] as? String == "custom:latest") #expect(agent["cpus"] as? Int == 4) #expect(agent["memoryMiB"] as? Int == 4096) + #expect(agent["mountPathScheme"] as? String == "host") #expect(agent["configurations"] as? [String] == ["claude", "copilot"]) #expect(agent["excludes"] as? [String] == ["node_modules", ".git"]) let environment = agent["environment"] as? [String: String] @@ -95,6 +98,26 @@ struct InitCommandIntegrationTests { #expect(!FileManager.default.fileExists(atPath: base.appendingPathComponent(".agentc").path)) } + @Test("agentc init rejects an invalid mount path scheme") + func initRejectsInvalidMountPathScheme() async throws { + let base = URL( + fileURLWithPath: "/tmp/__TEST_agentc_init_scheme.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let result = await runAgentc( + args: [ + "init", + base.path, + "--skip-container-init", + "--mount-path-scheme", "elsewhere", + ] + ) + + #expect(result.exitCode != 0) + #expect(!FileManager.default.fileExists(atPath: base.appendingPathComponent(".agentc").path)) + } + @Test("agentc init with --profile writes profile to settings") func initWithProfile() async throws { let base = URL( From d1fcb32b9be4f8f2cf99ed3364640f6d5c08a1b4 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Fri, 28 Aug 2026 07:09:14 +0000 Subject: [PATCH 2/5] feat: apply mount schemes to host-backed paths --- Sources/AgentIsolation/AgentSession.swift | 30 ++- Sources/AgentIsolation/IsolationConfig.swift | 2 +- Sources/AgentIsolation/PathUtils.swift | 34 +++- .../AgentSessionTests.swift | 186 ++++++++++++++++++ Tests/AgentIsolationTests/PathUtilTests.swift | 37 ++++ .../AgentcIntegrationTests.swift | 46 +++++ .../ProjectSettingsIntegrationTests.swift | 70 +++++++ 7 files changed, 396 insertions(+), 9 deletions(-) diff --git a/Sources/AgentIsolation/AgentSession.swift b/Sources/AgentIsolation/AgentSession.swift index 64df947..995e7cc 100644 --- a/Sources/AgentIsolation/AgentSession.swift +++ b/Sources/AgentIsolation/AgentSession.swift @@ -7,7 +7,7 @@ import Synchronization #endif /// Errors surfaced by ``AgentSession``. -public enum AgentSessionError: Error, Sendable { +public enum AgentSessionError: Error, Sendable, Equatable { /// ``AgentSession/write(_:)`` or ``AgentSession/resize(cols:rows:)`` was called /// on a session whose ``IsolationConfig/customPTY`` is `false`. case customPTYNotEnabled @@ -16,6 +16,8 @@ public enum AgentSessionError: Error, Sendable { case notStarted /// ``AgentSession/start(entrypoint:timeout:)`` was called more than once. case alreadyStarted + /// A host-preserving mount would replace a destination owned by agentc. + case unsafeMountDestination(String) } /// Orchestrates running an isolated agent container session using a ``ContainerRuntime``. @@ -112,11 +114,26 @@ public final class AgentSession: Sendable { in: config.configurationsDir ) - try await runtime.prepare() - let canonicalWorkspace = AgentIsolationPathUtils.resolveSymlinksWithPlatformConsiderations( config.workspace) - let wsContainerPath = AgentIsolationPathUtils.workspaceContainerPath(for: config.workspace) + let wsContainerPath = AgentIsolationPathUtils.containerMountPath( + for: config.workspace, + scheme: config.mountPathScheme) + + if config.mountPathScheme == .host { + let hostDestinations = + [wsContainerPath] + + config.additionalHostMounts.map { + AgentIsolationPathUtils.containerMountPath(for: $0, scheme: .host) + } + if let reserved = hostDestinations.first(where: { + AgentIsolationPathUtils.isReservedHostMountDestination($0) + }) { + throw AgentSessionError.unsafeMountDestination(reserved) + } + } + + try await runtime.prepare() try FileManager.default.createDirectory( at: config.profileHomeDir, @@ -187,8 +204,9 @@ public final class AgentSession: Sendable { // Additional host mounts (from CLI --additional-mount flags) for hostMount in config.additionalHostMounts { let canonical = AgentIsolationPathUtils.resolveSymlinksWithPlatformConsiderations(hostMount) - let containerPath = - "/workspace/\(AgentIsolationPathUtils.pathIdentifier(for: canonical.path))" + let containerPath = AgentIsolationPathUtils.containerMountPath( + for: hostMount, + scheme: config.mountPathScheme) mounts.append( .init( hostPath: canonical.path, diff --git a/Sources/AgentIsolation/IsolationConfig.swift b/Sources/AgentIsolation/IsolationConfig.swift index 465ce4a..fdd71d7 100644 --- a/Sources/AgentIsolation/IsolationConfig.swift +++ b/Sources/AgentIsolation/IsolationConfig.swift @@ -82,7 +82,7 @@ public struct IsolationConfig: Sendable { public var memoryLimitMiB: Int /// Additional host directories to mount inside the container. - /// Each is mounted at /workspace/. + /// Destinations follow ``mountPathScheme``. public var additionalHostMounts: [URL] /// When true, passes `AGENTC_VERBOSE=1` to the container so that the bootstrap diff --git a/Sources/AgentIsolation/PathUtils.swift b/Sources/AgentIsolation/PathUtils.swift index d4e370d..339f888 100644 --- a/Sources/AgentIsolation/PathUtils.swift +++ b/Sources/AgentIsolation/PathUtils.swift @@ -7,6 +7,14 @@ import Crypto #endif public enum AgentIsolationPathUtils { + /// Agent-owned mount destinations that host-path-preserving mounts must not replace. + public static let reservedContainerMountPaths: Set = [ + "/home/agent", + "/agent-isolation/agents", + "/agent-isolation/toolkit", + "/entrypoint-bootstrap", + ] + /// Resolve symlinks with platform consideration. /// /// On macOS, `/tmp`, `/var`, `/etc` → `/private/...` mapping is applied. @@ -45,14 +53,36 @@ public enum AgentIsolationPathUtils { return "\(name)-\(String(hash.suffix(10)))" } + /// Compute the container destination for a host-backed mount. + /// + /// The workspace scheme uses the canonical host path for its stable identifier. + /// The host scheme standardizes the caller-visible absolute path without resolving + /// symlinks, allowing the bind source and destination to intentionally differ. + public static func containerMountPath( + for hostPath: URL, + scheme: MountPathScheme + ) -> String { + switch scheme { + case .workspace: + let canonical = resolveSymlinksWithPlatformConsiderations(hostPath) + return "/workspace/\(pathIdentifier(for: canonical.path))" + case .host: + return hostPath.standardizedFileURL.path + } + } + + /// Whether a host-preserving destination conflicts with agentc-owned container paths. + public static func isReservedHostMountDestination(_ path: String) -> Bool { + path == "/" || reservedContainerMountPaths.contains(path) + } + /// Compute the container workspace mount path for a given host workspace URL. /// /// The path format is `/workspace/-` where `folderName` is the /// last path component of the canonical workspace path and `last10sha` is the last 10 /// characters of the SHA-256 hex digest of the full canonical path. public static func workspaceContainerPath(for workspace: URL) -> String { - let canonical = resolveSymlinksWithPlatformConsiderations(workspace) - return "/workspace/\(pathIdentifier(for: canonical.path))" + containerMountPath(for: workspace, scheme: .workspace) } /// Compute the legacy container workspace mount path for a given host workspace URL. diff --git a/Tests/AgentIsolationTests/AgentSessionTests.swift b/Tests/AgentIsolationTests/AgentSessionTests.swift index c2ada9c..33bbee2 100644 --- a/Tests/AgentIsolationTests/AgentSessionTests.swift +++ b/Tests/AgentIsolationTests/AgentSessionTests.swift @@ -210,6 +210,159 @@ struct AgentSessionTests { #expect(workDir!.hasPrefix("/workspace/")) } + @Test("Host scheme preserves workspace destination and working directory") + func hostSchemeWorkspace() async throws { + let runtime = MockRuntime(config: .init(storagePath: "/tmp")) + let base = URL(fileURLWithPath: "/tmp/agentc-host-ws-\(UUID().uuidString)") + let workspace = base.appendingPathComponent("workspace") + let profileDir = base.appendingPathComponent("profile/home") + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let config = IsolationConfig( + image: "test:latest", + profileHomeDir: profileDir, + workspace: workspace, + mountPathScheme: .host, + configurationsDir: base, + configurations: [], + arguments: ["pwd"] + ) + let session = AgentSession(config: config, runtime: runtime) + try await session.start() + _ = try await session.wait() + + let containerConfig = try #require(runtime.lastContainerConfiguration) + let workspaceMount = containerConfig.mounts.first { $0.containerPath == workspace.path } + #expect(workspaceMount != nil) + #expect(containerConfig.workingDirectory == workspace.path) + } + + @Test("Host scheme uses a canonical source and caller-visible symlink destination") + func hostSchemeCanonicalSource() async throws { + let runtime = MockRuntime(config: .init(storagePath: "/tmp")) + let base = URL(fileURLWithPath: "/tmp/agentc-host-link-\(UUID().uuidString)") + let target = base.appendingPathComponent("target") + let link = base.appendingPathComponent("workspace-link") + let profileDir = base.appendingPathComponent("profile/home") + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + defer { try? FileManager.default.removeItem(at: base) } + + let config = IsolationConfig( + image: "test:latest", + profileHomeDir: profileDir, + workspace: link, + mountPathScheme: .host, + configurationsDir: base, + configurations: [], + arguments: ["pwd"] + ) + let session = AgentSession(config: config, runtime: runtime) + try await session.start() + _ = try await session.wait() + + let mounts = try #require(runtime.lastContainerConfiguration).mounts + let workspaceMount = try #require(mounts.first { $0.containerPath == link.path }) + let resolvedTarget = target.resolvingSymlinksInPath().path + #if os(macOS) + let expectedSource = + resolvedTarget.hasPrefix("/tmp") ? "/private\(resolvedTarget)" : resolvedTarget + #else + let expectedSource = resolvedTarget + #endif + #expect(workspaceMount.hostPath == expectedSource) + } + + @Test("Host scheme places exclude overlays beneath the preserved workspace path") + func hostSchemeExcludeFolders() async throws { + let runtime = MockRuntime(config: .init(storagePath: "/tmp")) + let base = URL(fileURLWithPath: "/tmp/agentc-host-exclude-\(UUID().uuidString)") + let workspace = base.appendingPathComponent("workspace") + let profileDir = base.appendingPathComponent("profile/home") + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let config = IsolationConfig( + image: "test:latest", + profileHomeDir: profileDir, + workspace: workspace, + mountPathScheme: .host, + excludeFolders: ["secret"], + configurationsDir: base, + configurations: [], + arguments: ["ls"] + ) + let session = AgentSession(config: config, runtime: runtime) + try await session.start() + _ = try await session.wait() + + let mounts = try #require(runtime.lastContainerConfiguration).mounts + let overlay = mounts.first { $0.containerPath == "\(workspace.path)/secret" } + #expect(overlay?.isReadOnly == true) + } + + @Test("Additional host mounts follow the configured scheme") + func additionalHostMountSchemes() async throws { + let base = URL(fileURLWithPath: "/tmp/agentc-host-additional-\(UUID().uuidString)") + let workspace = base.appendingPathComponent("workspace") + let additional = base.appendingPathComponent("shared") + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: additional, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + for scheme in [MountPathScheme.workspace, .host] { + let runtime = MockRuntime(config: .init(storagePath: "/tmp")) + let config = IsolationConfig( + image: "test:latest", + profileHomeDir: base.appendingPathComponent("profile-\(scheme.rawValue)/home"), + workspace: workspace, + mountPathScheme: scheme, + configurationsDir: base, + configurations: [], + arguments: ["ls"], + additionalHostMounts: [additional] + ) + let session = AgentSession(config: config, runtime: runtime) + try await session.start() + _ = try await session.wait() + + let mounts = try #require(runtime.lastContainerConfiguration).mounts + let expected = AgentIsolationPathUtils.containerMountPath(for: additional, scheme: scheme) + #expect(mounts.contains { $0.containerPath == expected }) + } + } + + @Test("Host scheme rejects agentc-owned destinations") + func hostSchemeRejectsReservedDestinations() async throws { + for destination in [ + "/", "/home/agent", "/agent-isolation/agents", "/agent-isolation/toolkit", + "/entrypoint-bootstrap", + ] { + let runtime = MockRuntime(config: .init(storagePath: "/tmp")) + let config = IsolationConfig( + image: "test:latest", + profileHomeDir: URL(fileURLWithPath: "/tmp/profile"), + workspace: URL(fileURLWithPath: destination), + mountPathScheme: .host, + configurationsDir: URL(fileURLWithPath: "/tmp"), + configurations: [], + arguments: ["true"] + ) + let session = AgentSession(config: config, runtime: runtime) + + do { + try await session.start() + Issue.record("Expected \(destination) to be rejected") + } catch AgentSessionError.unsafeMountDestination(let rejected) { + #expect(rejected == destination) + } catch { + Issue.record("Unexpected error: \(error)") + } + #expect(runtime.prepareCallCount == 0) + } + } + @Test("Creates exclude folder overlay mounts") func excludeFolders() async throws { let runtime = MockRuntime(config: .init(storagePath: "/tmp")) @@ -660,6 +813,39 @@ struct ConfigurationTests { #expect(additionalMount?.hostPath.contains("additionalMounts/\(expectedSegment)") == true) } + @Test("Configuration additional mounts do not follow the host path scheme") + func configurationMountsIgnoreHostScheme() async throws { + let runtime = MockRuntime(config: .init(storagePath: "/tmp")) + let base = URL(fileURLWithPath: "/tmp/agentc-config-mount-scheme-\(UUID().uuidString)") + let profileDir = base.appendingPathComponent("home") + let configsDir = try makeConfigsDir(configs: [ + "myconfig": [ + "additionalMounts": ["/data/models"], + "entrypoint": ["echo"], + ] + ]) + defer { + try? FileManager.default.removeItem(at: base) + try? FileManager.default.removeItem(at: configsDir) + } + + let config = IsolationConfig( + image: "test:latest", + profileHomeDir: profileDir, + workspace: base, + mountPathScheme: .host, + configurationsDir: configsDir, + configurations: ["myconfig"], + arguments: ["echo"] + ) + let session = AgentSession(config: config, runtime: runtime) + try await session.start() + _ = try await session.wait() + + let mounts = try #require(runtime.lastContainerConfiguration).mounts + #expect(mounts.contains { $0.containerPath == "/data/models" }) + } + @Test("Creates additional mounts from multiple configurations") func additionalMountsMultiple() async throws { let runtime = MockRuntime(config: .init(storagePath: "/tmp")) diff --git a/Tests/AgentIsolationTests/PathUtilTests.swift b/Tests/AgentIsolationTests/PathUtilTests.swift index 5e9b14c..fa67a9d 100644 --- a/Tests/AgentIsolationTests/PathUtilTests.swift +++ b/Tests/AgentIsolationTests/PathUtilTests.swift @@ -21,6 +21,43 @@ struct PathUtilTests { #expect(hashPart.allSatisfy { $0.isHexDigit }) } + @Test("workspace mount scheme preserves the existing path format") + func workspaceMountScheme() throws { + let base = URL(fileURLWithPath: "/tmp/agentc-path-scheme-\(UUID().uuidString)") + let wsDir = base.appendingPathComponent("myproject") + try FileManager.default.createDirectory(at: wsDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + #expect( + AgentIsolationPathUtils.containerMountPath(for: wsDir, scheme: .workspace) + == AgentIsolationPathUtils.workspaceContainerPath(for: wsDir)) + } + + @Test("host mount scheme standardizes without resolving symlinks") + func hostMountSchemePreservesSymlink() throws { + let base = URL(fileURLWithPath: "/tmp/agentc-path-scheme-\(UUID().uuidString)") + let target = base.appendingPathComponent("target") + let link = base.appendingPathComponent("link") + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + defer { try? FileManager.default.removeItem(at: base) } + + let requested = link.appendingPathComponent("..").appendingPathComponent("link") + #expect( + AgentIsolationPathUtils.containerMountPath(for: requested, scheme: .host) + == link.path) + } + + @Test("host mount scheme identifies reserved destinations") + func reservedHostDestinations() { + #expect(AgentIsolationPathUtils.isReservedHostMountDestination("/")) + #expect(AgentIsolationPathUtils.isReservedHostMountDestination("/home/agent")) + #expect(AgentIsolationPathUtils.isReservedHostMountDestination("/agent-isolation/agents")) + #expect(AgentIsolationPathUtils.isReservedHostMountDestination("/agent-isolation/toolkit")) + #expect(AgentIsolationPathUtils.isReservedHostMountDestination("/entrypoint-bootstrap")) + #expect(!AgentIsolationPathUtils.isReservedHostMountDestination("/home/agent/project")) + } + @Test("legacyWorkspaceContainerPath format is full sha256") func legacyPathFormat() throws { let base = URL(fileURLWithPath: "/tmp/claudec-wp-\(UUID().uuidString)") diff --git a/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift b/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift index cff8013..0014e8c 100644 --- a/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift +++ b/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift @@ -202,6 +202,52 @@ struct AgentcIntegrationTests { #expect(result.output.contains(containerPath)) } + @Test("--mount-path-scheme host preserves the workspace path and working directory") + func hostMountPathScheme() async throws { + let ws = URL(fileURLWithPath: "/tmp/__TEST_agentc_hostws.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: ws, withIntermediateDirectories: true) + try "host_scheme_content".write( + to: ws.appendingPathComponent("probe.txt"), atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: ws) } + + let result = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--workspace", ws.path, + "--mount-path-scheme", "host", + "--no-update-image", + "--", "printf '%s|' \"$PWD\"; cat probe.txt", + ] + ) + #expect(result.exitCode == 0) + #expect(result.stdout == "\(ws.path)|host_scheme_content") + } + + @Test("--mount-path-scheme host applies to --additional-mount") + func hostSchemeAdditionalMount() async throws { + let shared = URL(fileURLWithPath: "/tmp/__TEST_agentc_hostmnt.\(UUID().uuidString.prefix(6))") + try FileManager.default.createDirectory(at: shared, withIntermediateDirectories: true) + try "additional_host_content".write( + to: shared.appendingPathComponent("probe.txt"), atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: shared) } + + let result = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--mount-path-scheme", "host", + "--additional-mount", shared.path, + "--no-update-image", + "--", "cat", "\(shared.path)/probe.txt", + ] + ) + #expect(result.exitCode == 0) + #expect(result.stdout == "additional_host_content") + } + @Test("--exclude hides sub-folder contents") func excludeFolders() async throws { let ws = URL(fileURLWithPath: "/tmp/__TEST_agentc_excl.\(UUID().uuidString.prefix(6))") diff --git a/Tests/AgentcIntegrationTests/ProjectSettingsIntegrationTests.swift b/Tests/AgentcIntegrationTests/ProjectSettingsIntegrationTests.swift index 90f5e8b..632b099 100644 --- a/Tests/AgentcIntegrationTests/ProjectSettingsIntegrationTests.swift +++ b/Tests/AgentcIntegrationTests/ProjectSettingsIntegrationTests.swift @@ -148,6 +148,44 @@ struct ProjectSettingsIntegrationTests { #expect(result.stdout == "America/Los_Angeles|en_US.UTF-8") } + @Test("--agentc-folder applies agent.mountPathScheme to workspace and additional mounts") + func agentcFolderAppliesMountPathScheme() async throws { + let base = URL(fileURLWithPath: "/tmp/__TEST_agentc_ps_scheme.\(UUID().uuidString.prefix(6))") + let workspace = base.appendingPathComponent("workspace") + let shared = base.appendingPathComponent("shared") + let settingsDir = base.appendingPathComponent("settings") + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: shared, withIntermediateDirectories: true) + try "settings_mount_content".write( + to: shared.appendingPathComponent("probe.txt"), atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: base) } + + try writeProjectSettings( + """ + { + "agent": { + "mountPathScheme": "host", + "additionalMounts": ["\(shared.path)"] + } + } + """, + at: settingsDir) + + let result = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--workspace", workspace.path, + "--agentc-folder", settingsDir.appendingPathComponent(".agentc").path, + "--no-update-image", + "--", "printf '%s|' \"$PWD\"; cat '\(shared.path)/probe.txt'", + ] + ) + #expect(result.exitCode == 0) + #expect(result.stdout == "\(workspace.path)|settings_mount_content") + } + // MARK: - CLI Override @Test("CLI --cpus overrides project settings agent.cpus") @@ -179,6 +217,38 @@ struct ProjectSettingsIntegrationTests { #expect(reported == "3") } + @Test("CLI --mount-path-scheme overrides project settings") + func cliOverridesProjectMountPathScheme() async throws { + let base = URL(fileURLWithPath: "/tmp/__TEST_agentc_ps_ovscheme.\(UUID().uuidString.prefix(6))") + let workspace = base.appendingPathComponent("workspace") + let settingsDir = base.appendingPathComponent("settings") + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + try writeProjectSettings( + """ + { "agent": { "mountPathScheme": "host" } } + """, + at: settingsDir) + + let result = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--workspace", workspace.path, + "--agentc-folder", settingsDir.appendingPathComponent(".agentc").path, + "--mount-path-scheme", "workspace", + "--no-update-image", + "--", "pwd", + ] + ) + #expect(result.exitCode == 0) + #expect( + result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + == workspaceContainerPath(for: workspace)) + } + @Test("CLI --env overrides matching project environment variables") func cliOverridesProjectEnvironment() async throws { let base = URL(fileURLWithPath: "/tmp/__TEST_agentc_ps_envov.\(UUID().uuidString.prefix(6))") From f6a8d0e49be679ae3f4677285984ea763ff5be86 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Fri, 28 Aug 2026 07:11:33 +0000 Subject: [PATCH 3/5] feat(bootstrap): reserve execution stdout for workloads --- .../ConfigurationRunner.swift | 4 +- Sources/agentc-bootstrap/Helpers.swift | 21 ++++++-- Sources/agentc-bootstrap/RootSetup.swift | 6 ++- Sources/agentc/SessionRunner.swift | 2 +- .../AgentcIntegrationTests.swift | 54 +++++++++++++++++++ 5 files changed, 79 insertions(+), 8 deletions(-) diff --git a/Sources/agentc-bootstrap/ConfigurationRunner.swift b/Sources/agentc-bootstrap/ConfigurationRunner.swift index fdaf9c1..73ed77e 100644 --- a/Sources/agentc-bootstrap/ConfigurationRunner.swift +++ b/Sources/agentc-bootstrap/ConfigurationRunner.swift @@ -61,10 +61,10 @@ stderr) } if access(prepareScript, X_OK) == 0 { - try Helpers.run(command: prepareScript, arguments: []) + try Helpers.run(command: prepareScript, arguments: [], output: .stderr) } else { let shell = access("/bin/bash", X_OK) == 0 ? "/bin/bash" : "/bin/sh" - try Helpers.run(command: shell, arguments: [prepareScript]) + try Helpers.run(command: shell, arguments: [prepareScript], output: .stderr) } } diff --git a/Sources/agentc-bootstrap/Helpers.swift b/Sources/agentc-bootstrap/Helpers.swift index 2c25df6..20968b2 100644 --- a/Sources/agentc-bootstrap/Helpers.swift +++ b/Sources/agentc-bootstrap/Helpers.swift @@ -57,6 +57,14 @@ // MARK: - Helpers + enum CommandOutput { + /// Route command stdout to the bootstrap's stderr while preserving command stderr. + case stderr + + /// Discard both command stdout and stderr. + case discarded + } + enum Helpers { /// Read an environment variable. static func envVar(_ name: String) -> String? { @@ -84,10 +92,14 @@ return nil } - /// Run a command synchronously via posix_spawnp. Throws on non-zero exit. + /// Run a setup command synchronously via posix_spawnp. Throws on non-zero exit. + /// + /// Setup stdout is routed away from the final workload's stdout by default. @discardableResult static func run( - command: String, arguments: [String], silent: Bool = false + command: String, + arguments: [String], + output: CommandOutput ) throws -> Int32 { let execPath: String if command.hasPrefix("/") { @@ -112,7 +124,10 @@ defer { posix_spawn_file_actions_destroy(&fileActions) } var devNullFd: Int32 = -1 - if silent { + switch output { + case .stderr: + posix_spawn_file_actions_adddup2(&fileActions, STDERR_FILENO, STDOUT_FILENO) + case .discarded: devNullFd = open("/dev/null", O_WRONLY) if devNullFd >= 0 { posix_spawn_file_actions_adddup2(&fileActions, devNullFd, STDOUT_FILENO) diff --git a/Sources/agentc-bootstrap/RootSetup.swift b/Sources/agentc-bootstrap/RootSetup.swift index 3d64287..c3df223 100644 --- a/Sources/agentc-bootstrap/RootSetup.swift +++ b/Sources/agentc-bootstrap/RootSetup.swift @@ -19,14 +19,16 @@ // Debian/Ubuntu: -d sets home without creating it (no -m). try Helpers.run( command: "useradd", - arguments: ["-d", "/home/agent", "-s", shell, "agent"]) + arguments: ["-d", "/home/agent", "-s", shell, "agent"], + output: .stderr) } else if Helpers.commandExists("adduser") { // Alpine/BusyBox: -H prevents creating the home directory. try Helpers.run( command: "adduser", arguments: [ "-D", "-h", "/home/agent", "-s", shell, "-H", "agent", - ]) + ], + output: .stderr) } else { throw BootstrapError.setupFailed( "No useradd or adduser command found") diff --git a/Sources/agentc/SessionRunner.swift b/Sources/agentc/SessionRunner.swift index 7f075ee..c9e2cbe 100644 --- a/Sources/agentc/SessionRunner.swift +++ b/Sources/agentc/SessionRunner.swift @@ -143,7 +143,7 @@ enum SessionRunner { let newImage = try? await runtime.pullImage(ref: config.image) if let oldImage, let newImage, oldImage.digest != newImage.digest { if options.verbose { - print("agentc: loaded newer image for \(config.image)") + writeToStderr("agentc: loaded newer image for \(config.image)\n") } if !options.keepOldImage { try? await runtime.removeImage(digest: oldImage.digest) diff --git a/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift b/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift index 0014e8c..6dde6c0 100644 --- a/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift +++ b/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift @@ -530,6 +530,60 @@ struct AgentcIntegrationTests { // MARK: - --verbose flag + @Test("Non-TTY run reserves stdout for workload output") + func nonTTYRunReservesStdout() async throws { + let base = URL(fileURLWithPath: "/tmp/__TEST_agentc_stdout.\(UUID().uuidString.prefix(6))") + let configDir = base.appendingPathComponent("configurations/stdout-test") + try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + try """ + { + "v": 0, + "entrypoint": ["/bin/sh", "-c", "printf workload-output"], + "additionalMounts": [], + "additionalBinPaths": [] + } + """.write( + to: configDir.appendingPathComponent("settings.json"), + atomically: true, + encoding: .utf8) + + let prepareScript = configDir.appendingPathComponent("prepare.sh") + try """ + #!/bin/sh + echo prepare-stdout + echo prepare-stderr >&2 + """.write(to: prepareScript, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: prepareScript.path) + + let configurationsDir = base.appendingPathComponent("configurations") + try FileManager.default.createDirectory( + at: configurationsDir.appendingPathComponent(".git"), withIntermediateDirectories: true) + _ = FileManager.default.createFile( + atPath: configurationsDir.appendingPathComponent(".agentc-last-pull").path, + contents: nil) + + let result = await runAgentc( + args: [ + "run", + "--verbose", + "--profile", sharedProfile, + "--configurations-dir", configurationsDir.path, + "--configurations", "stdout-test", + "--no-update-image", + "--no-toolkit", + ] + ) + + #expect(result.exitCode == 0) + #expect(result.stdout == "workload-output") + #expect(result.stderr.contains("==> Running prepare.sh")) + #expect(result.stderr.contains("prepare-stdout")) + #expect(result.stderr.contains("prepare-stderr")) + } + @Test("Without --verbose, bootstrap prepare.sh message is suppressed") func verboseSuppressed() async throws { let result = await runAgentc( From ff67abfd738986bb8e7e7a1b5c992366973b4410 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Fri, 28 Aug 2026 07:12:17 +0000 Subject: [PATCH 4/5] doc: explain mount paths and execution streams --- README.md | 41 ++++++++++++++ Sources/AgentIsolation/IsolationConfig.swift | 2 +- docs/project-settings.md | 58 +++++++++++++++++++- 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8cffbea..d033f95 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,47 @@ Use `agentc init` to place a `.agentc/settings.json` file in your project root t See [docs/project-settings.md](./docs/project-settings.md) for the full schema and override rules. +### Mount Paths + +By default, host-backed paths use the `workspace` scheme. The workspace and every +`--additional-mount` receive stable destinations beneath `/workspace`: + +```text +/Users/me/project → /workspace/project- +``` + +Use `--mount-path-scheme host` when a script or external protocol requires the same +absolute path inside and outside the container: + +```sh +agentc run \ + --mount-path-scheme host \ + --additional-mount /Users/me/shared +``` + +In this mode, the workspace working directory remains `/Users/me/project`, and the +additional mount remains `/Users/me/shared`. The bind source is still canonicalized, +but the destination preserves the standardized caller-visible path without resolving +symlinks. For example, a requested macOS path under `/tmp/project` remains +`/tmp/project` in the container even when its bind source is `/private/tmp/project`. + +Path preservation applies only to resources agentc mounts: the workspace, +`--additional-mount`, and `agent.additionalMounts`. It does not expose arbitrary host +executables, sockets, or files. Configuration-repository `additionalMounts` are +profile-backed container paths and retain their existing semantics. Host mode rejects +`/` and exact collisions with agentc-owned destinations such as `/home/agent`, +`/agent-isolation/agents`, `/agent-isolation/toolkit`, and `/entrypoint-bootstrap`. + +Set the project default with `agent.mountPathScheme`; the CLI flag overrides it. The +default remains `workspace`. + +### Scripted Execution I/O + +For `agentc run` and `agentc sh`, stdout belongs to the launched workload. Agentc +progress, setup output, warnings, and verbose diagnostics go to stderr, including +output produced by configuration `prepare.sh` scripts. This makes non-interactive +output safe to pipe or parse while preserving the existing automatic TTY behavior. + ### Container Images `agentc` works with any standard container image — it automatically sets up the agent user, sudo, and required tools at container start via an embedded bootstrap script. Images that ship no tooling of their own are covered by the [toolkit](#toolkit). The default image is pre-configured for faster startup, but you can use any base image: diff --git a/Sources/AgentIsolation/IsolationConfig.swift b/Sources/AgentIsolation/IsolationConfig.swift index fdd71d7..b7a3346 100644 --- a/Sources/AgentIsolation/IsolationConfig.swift +++ b/Sources/AgentIsolation/IsolationConfig.swift @@ -33,7 +33,7 @@ public struct IsolationConfig: Sendable { public var profileHomeDir: URL /// Host workspace directory to mount inside the container. - /// Mounted at /workspace/-. + /// Its destination is controlled by ``mountPathScheme``. public var workspace: URL /// Controls how destinations are chosen for the workspace and additional host mounts. diff --git a/docs/project-settings.md b/docs/project-settings.md index 1482060..19987b6 100644 --- a/docs/project-settings.md +++ b/docs/project-settings.md @@ -11,6 +11,7 @@ Create `.agentc/settings.json` in your project root: "agent": { "image": "my-org/dev-image:latest", "configurations": ["claude"], + "mountPathScheme": "workspace", "cpus": 4, "memoryMiB": 4096, "excludes": ["node_modules", ".git"] @@ -41,6 +42,7 @@ All fields are optional. Only the values you specify take effect. "agent": { "image": "", "profile": "", + "mountPathScheme": "workspace | host", "excludes": ["", ...], "configurations": ["", ...], "additionalMounts": ["", ...], @@ -66,6 +68,7 @@ All fields are optional. Only the values you specify take effect. |---|---|---| | `agent.image` | `--image`, `-i` | Default container image reference. | | `agent.profile` | `--profile`, `-p` | Default profile name. | +| `agent.mountPathScheme` | `--mount-path-scheme` | Destination scheme for the workspace and host-backed additional mounts. Defaults to `workspace`. | | `agent.excludes` | `--exclude` | Workspace sub-folders to mask with empty overlays. | | `agent.configurations` | `--configurations`, `-c` | Agent configuration names to activate. | | `agent.additionalMounts` | `--additional-mount` | Additional host directories to mount. | @@ -84,7 +87,7 @@ When both CLI flags and project settings specify a value, the behavior depends o **Override** (CLI wins, project settings used as fallback): -- `image`, `profile`, `configurations`, `cpus`, `memoryMiB`, `bootstrap`, `respectImageEntrypoint`, `docker.runtime` +- `image`, `profile`, `mountPathScheme`, `configurations`, `cpus`, `memoryMiB`, `bootstrap`, `respectImageEntrypoint`, `docker.runtime` **Merge** (both sets are combined): @@ -106,6 +109,59 @@ For fields with override behavior, the full priority chain is: 3. Profile settings (`~/.agentc/profiles//settings.json`) — only for `configurations` 4. Built-in default (lowest priority) +## Mount Path Schemes + +`workspace` is the default and preserves the existing isolated layout. Agentc +canonicalizes the host path and mounts it at a stable hashed destination: + +```text +host: /Users/me/project +container: /workspace/project- +``` + +`host` preserves the standardized absolute path as the container destination: + +```text +host: /Users/me/project +container: /Users/me/project +``` + +The bind source may still resolve symlinks while the destination does not. On macOS, +for example, requesting `/tmp/project` can bind from `/private/tmp/project` while the +container continues to see `/tmp/project`. + +The selected scheme applies consistently to: + +- the workspace mount and container working directory; +- workspace exclude overlays; +- CLI `--additional-mount` values; +- project `agent.additionalMounts` values. + +It does not alter configuration-repository `additionalMounts`; those are container +paths backed by profile storage. Nor does host mode expose arbitrary host paths that +were not mounted. Host executables, sockets, and files remain outside the container +namespace unless they are part of one of the mounted resources. + +For safety, host mode rejects `/` and exact collisions with agentc-owned mount points: +`/home/agent`, `/agent-isolation/agents`, `/agent-isolation/toolkit`, and +`/entrypoint-bootstrap`. + +CLI selection overrides the project setting: + +```sh +agentc run --mount-path-scheme host +``` + +Or set the project default: + +```json +{ + "agent": { + "mountPathScheme": "host" + } +} +``` + ## Examples ### Minimal: Set a Default Image From 70aa0e05ecd81ecbdc0d007cd78c001418b2c6fd Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Fri, 28 Aug 2026 07:15:27 +0000 Subject: [PATCH 5/5] fix: preserve symlinks in host mount destinations --- Sources/AgentIsolation/PathUtils.swift | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/Sources/AgentIsolation/PathUtils.swift b/Sources/AgentIsolation/PathUtils.swift index 339f888..6964dd7 100644 --- a/Sources/AgentIsolation/PathUtils.swift +++ b/Sources/AgentIsolation/PathUtils.swift @@ -67,10 +67,29 @@ public enum AgentIsolationPathUtils { let canonical = resolveSymlinksWithPlatformConsiderations(hostPath) return "/workspace/\(pathIdentifier(for: canonical.path))" case .host: - return hostPath.standardizedFileURL.path + return lexicallyStandardizedAbsolutePath(hostPath.path) } } + /// Standardize `.` and `..` path components without consulting the filesystem. + /// + /// `URL.standardizedFileURL` may resolve symlinks as part of standardization, + /// which would change the caller-visible destination required by the host scheme. + private static func lexicallyStandardizedAbsolutePath(_ path: String) -> String { + var components: [Substring] = [] + for component in path.split(separator: "/", omittingEmptySubsequences: true) { + switch component { + case ".": + continue + case "..": + if !components.isEmpty { components.removeLast() } + default: + components.append(component) + } + } + return components.isEmpty ? "/" : "/" + components.joined(separator: "/") + } + /// Whether a host-preserving destination conflicts with agentc-owned container paths. public static func isReservedHostMountDestination(_ path: String) -> Bool { path == "/" || reservedContainerMountPaths.contains(path)