Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hash>
```

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:
Expand Down
30 changes: 24 additions & 6 deletions Sources/AgentIsolation/AgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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``.
Expand Down Expand Up @@ -112,11 +114,26 @@ public final class AgentSession<Runtime: ContainerRuntime>: 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,
Expand Down Expand Up @@ -187,8 +204,9 @@ public final class AgentSession<Runtime: ContainerRuntime>: 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,
Expand Down
18 changes: 16 additions & 2 deletions Sources/AgentIsolation/IsolationConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand All @@ -24,9 +33,12 @@ public struct IsolationConfig: Sendable {
public var profileHomeDir: URL

/// Host workspace directory to mount inside the container.
/// Mounted at /workspace/<folderName>-<last10 of sha256(canonicalPath)>.
/// Its destination is controlled by ``mountPathScheme``.
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]
Expand Down Expand Up @@ -70,7 +82,7 @@ public struct IsolationConfig: Sendable {
public var memoryLimitMiB: Int

/// Additional host directories to mount inside the container.
/// Each is mounted at /workspace/<pathIdentifier(canonicalPath)>.
/// Destinations follow ``mountPathScheme``.
public var additionalHostMounts: [URL]

/// When true, passes `AGENTC_VERBOSE=1` to the container so that the bootstrap
Expand All @@ -91,6 +103,7 @@ public struct IsolationConfig: Sendable {
image: String,
profileHomeDir: URL,
workspace: URL,
mountPathScheme: MountPathScheme = .workspace,
excludeFolders: [String] = [],
configurationsDir: URL,
configurations: [String] = ["claude"],
Expand All @@ -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
Expand Down
53 changes: 51 additions & 2 deletions Sources/AgentIsolation/PathUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = [
"/home/agent",
"/agent-isolation/agents",
"/agent-isolation/toolkit",
"/entrypoint-bootstrap",
]

/// Resolve symlinks with platform consideration.
///
/// On macOS, `/tmp`, `/var`, `/etc` → `/private/...` mapping is applied.
Expand Down Expand Up @@ -45,14 +53,55 @@ 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 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)
}

/// Compute the container workspace mount path for a given host workspace URL.
///
/// The path format is `/workspace/<folderName>-<last10sha>` 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.
Expand Down
3 changes: 3 additions & 0 deletions Sources/AgentIsolation/ProjectSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]?
Expand All @@ -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,
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Sources/agentc-bootstrap/ConfigurationRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
21 changes: 18 additions & 3 deletions Sources/agentc-bootstrap/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down Expand Up @@ -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("/") {
Expand All @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions Sources/agentc-bootstrap/RootSetup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions Sources/agentc/Commands/InitCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion Sources/agentc/SessionRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ enum SessionRunner {
image: resolvedImage,
profileHomeDir: profileHomeDir,
workspace: workspace,
mountPathScheme: options.resolveMountPathScheme(projectSettings: projectSettings),
excludeFolders: excludeFolders,
configurationsDir: configurationsDir,
configurations: configNames,
Expand Down Expand Up @@ -142,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)
Expand Down
13 changes: 13 additions & 0 deletions Sources/agentc/SharedOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ struct EnvironmentVariableOption: ExpressibleByArgument, Sendable, Equatable {
}
}

extension MountPathScheme: ExpressibleByArgument {}

struct SharedOptions: ParsableArguments {
@Option(name: .shortAndLong, help: "Container runtime.")
var runtime: RuntimeChoice?
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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] {
Expand Down
Loading
Loading