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
25 changes: 25 additions & 0 deletions .boite/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
# >>> boite managed >>>
# Boite keeps this block up to date. Write your own rules BELOW it —
# git uses the last matching pattern, so yours win. To stop Boite
# touching this file at all, put `# boite: unmanaged` anywhere in it.

# Regenerated from Boite's types, one beside each file they describe —
# boite.json here, screen.json in every screen, and the panels' one in
# every panels/ directory. Ignored everywhere, INCLUDING inside shared/:
# a generated file in somebody's repository is a diff nobody wrote.
*.schema.json

# Rolling snapshot of the last config Boite itself wrote, for recovering
# from an outside edit that breaks boite.json. Local to this machine.
.last-good/**

# Which pane you were last in, and which screen you are showing.
state.json

# Your screens. Sharing one moves it into shared/, which IS tracked —
# that move is the whole of "share".
screens/**

.DS_Store
# <<< boite managed <<<

# Written by Boite the first time it stored a boite in this project.
# It won't be rewritten — edit freely.
#
Expand Down
13 changes: 13 additions & 0 deletions .boite/boite.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema" : "./boite.schema.json",
"schemaVersion" : 23,
"createdAt" : "2026-08-28T02:05:44Z",
"id" : "5D79B032-A706-4E5E-AE70-0F4A2B581E5B",
"name" : "agentc",
"children" : [

],
"color" : "#89b4fa",
"icon" : "boite://prompt.underscore",
"workingDirectory" : "."
}
85 changes: 85 additions & 0 deletions .github/workflows/toolkit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: Publish agentc Toolkit

# The toolkit is versioned independently of agentc: it is rebuilt and published
# only when its manifest changes, so an agentc release never reissues a toolkit
# that nobody touched.
on:
push:
branches: [main]
paths:
- "scripts/toolkit/**"
- ".github/workflows/toolkit.yml"
workflow_dispatch:

jobs:
publish:
runs-on: ubuntu-24.04
permissions:
contents: write

steps:
- name: Checkout
uses: actions/checkout@v6

- name: Read toolkit version
id: manifest
run: |
source scripts/toolkit/manifest.sh
echo "version=$TOOLKIT_VERSION" >> "$GITHUB_OUTPUT"
echo "tag=toolkit-v$TOOLKIT_VERSION" >> "$GITHUB_OUTPUT"

- name: Check agentc agrees on the version
run: |
source scripts/toolkit/manifest.sh
declared=$(grep -oE 'static let version = "[^"]+"' Sources/agentc/ToolkitManager.swift \
| head -1 | sed 's/.*"\(.*\)"/\1/')
if [ "$declared" != "$TOOLKIT_VERSION" ]; then
echo "::error::manifest.sh has TOOLKIT_VERSION=$TOOLKIT_VERSION but" \
"ToolkitManager.version is \"$declared\" — they must match."
exit 1
fi

- name: Refuse to republish an existing version
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "${{ steps.manifest.outputs.tag }}" >/dev/null 2>&1; then
echo "::error::${{ steps.manifest.outputs.tag }} already exists." \
"Bump TOOLKIT_VERSION in scripts/toolkit/manifest.sh (and ToolkitManager.version)."
exit 1
fi

# Every tool ships as a prebuilt static binary, so one runner produces both
# bundles; nothing here is compiled for the target architecture.
- name: Build bundles
run: |
./scripts/toolkit/build-toolkit.sh --arch x64 --output dist
./scripts/toolkit/build-toolkit.sh --arch arm64 --output dist

- name: Verify bundle contents
run: |
for arch in x64 arm64; do
echo "== $arch =="
tar tzf "dist/agentc-toolkit-$arch-linux-static.tar.gz"
tar xzOf "dist/agentc-toolkit-$arch-linux-static.tar.gz" ./TOOLKIT
done
# The x64 bundle runs here, so it can prove itself before shipping.
mkdir -p check && tar xzf dist/agentc-toolkit-x64-linux-static.tar.gz -C check
./check/bin/curl --version
./check/bin/jq --version
./check/bin/rg --version
CURL_CA_BUNDLE=./check/share/ca-certificates.crt \
./check/bin/curl -fsS -o /dev/null https://github.com/

- name: Publish release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.manifest.outputs.tag }}
name: agentc Toolkit v${{ steps.manifest.outputs.version }}
body: |
Tools mounted read-only into every agentc container at
`/agent-isolation/toolkit`, built from `scripts/toolkit/manifest.sh`.

Published independently of agentc releases: this bundle changes only
when its manifest does.
files: dist/*.tar.gz
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
/claudec
/agentc
/prompt.md
/dist
42 changes: 24 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Run AI coding agents in isolated containers with persistent profiles and per-project memory isolation.

Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [GitHub Copilot CLI](https://gh.io/copilot-install), and more — with pluggable agent configurations via the [agent-isolation-configurations](https://github.com/laosb/agent-isolation-configurations) repo. Contributions for additional agents are welcome!
Supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [ChatGPT Codex CLI](https://learn.chatgpt.com/docs/codex/cli), and more — with pluggable agent configurations via the [agent-isolation-configurations](https://github.com/laosb/agent-isolation-configurations) repo. Contributions for additional agents are welcome!

## Install

Expand All @@ -27,12 +27,11 @@ curl -fsSL https://raw.githubusercontent.com/laosb/agentc/main/install.sh | sh

```sh
agentc run # start default agent (claude) in $PWD
agentc run -c claude,copilot # activate multiple configurations
agentc run -c codex # use a different agent configuration
agentc run "explain this code" # forward args to the agent entrypoint
agentc run -e TZ=Europe/Berlin # set a container environment variable
agentc sh # open a shell in the container
agentc sh -- ls -la /home/agent # run a command inside the container
agentc version # print version info
```

Use `agentc --help` and `agentc <subcommand> --help` for full CLI reference.
Expand Down Expand Up @@ -60,6 +59,24 @@ agentc run -c claude,copilot
agentc run -c copilot
```

### Toolkit

Every session mounts the **agentc Toolkit** read-only at `/agent-isolation/toolkit`: a
small set of static binaries — `curl`, `jq`, `ripgrep` — and a CA bundle. It exists so
agents always have some basic tools to work with even in `alpine`-like slim images.

The toolkit is designed in such a way that they would not override what the image itself
provides.

```sh
agentc run --no-toolkit # only what the image itself provides
agentc run --toolkit ~/my-toolkit # a locally built bundle
```

The toolkit is versioned independently of agentc and downloaded once, to
`~/.agentc/toolkit/v<N>`. Its contents are defined by
[`scripts/toolkit/manifest.sh`](./scripts/toolkit/manifest.sh).

### Project Settings

Use `agentc init` to place a `.agentc/settings.json` file in your project root to set default agent options for the project. CLI flags override project settings; some fields (like `excludes` and `additionalMounts`) are merged.
Expand All @@ -68,7 +85,7 @@ See [docs/project-settings.md](./docs/project-settings.md) for the full schema a

### 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. The default image is pre-configured for faster startup, but you can use any base image:
`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:

```sh
agentc run -i debian:latest # stock Debian
Expand Down Expand Up @@ -114,6 +131,9 @@ agentc-bootstrap (In-container bootstrap program)

The `agentc-bootstrap` binary is a standalone statically-linked Linux executable that runs as the container entrypoint. It creates `agent` user and does the rest of agent initialization as needed.

The agentc Toolkit is built separately from `scripts/toolkit/manifest.sh` and released on its own
schedule; see [Toolkit](#toolkit).

## Development

Swift 6.1+. Tested on Swift 6.3.
Expand Down Expand Up @@ -144,20 +164,6 @@ cp .build/<sdk>/release/agentc-bootstrap ~/.agentc/bin/bootstrap

For released versions, `agentc` automatically downloads the matching bootstrap binary on first run. During development, you can also use `--bootstrap <path>` to specify a custom bootstrap binary or shell script, or `--respect-image-entrypoint` to skip the bootstrap entirely.

## Migrating from claudec

The `claudec` CLI was removed in v1.0.0-alpha.8. To migrate your profiles and configurations:

```sh
agentc migrate-from-claudec
```

If you have scripts or muscle memory that use the `claudec` command, you can set up a shell alias:

```sh
alias claudec='agentc run --'
```

## License

[MIT License](./LICENSE).
21 changes: 17 additions & 4 deletions Sources/AgentIsolation/AgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ public final class AgentSession<Runtime: ContainerRuntime>: Sendable {

if !config.customPTY {
// In non-custom mode, nothing will ever be fed through the rawOut/stdin
// streams — close them up front so consumers see an immediate EOF.
// streams — close them up front so consumers see an immediate EOF.
rawOutContinuation.finish()
stdinContinuation.finish()
}
Expand Down Expand Up @@ -127,7 +127,7 @@ public final class AgentSession<Runtime: ContainerRuntime>: Sendable {
var mounts: [ContainerConfiguration.Mount] = []
var tempDirs: [URL] = []

// Profile home → /home/agent
// Profile home → /home/agent
mounts.append(
.init(
hostPath: config.profileHomeDir.path,
Expand Down Expand Up @@ -155,7 +155,7 @@ public final class AgentSession<Runtime: ContainerRuntime>: Sendable {
))
}

// Configurations directory → /agent-isolation/agents (read-only)
// Configurations directory → /agent-isolation/agents (read-only)
mounts.append(
.init(
hostPath: config.configurationsDir.path,
Expand Down Expand Up @@ -196,6 +196,19 @@ public final class AgentSession<Runtime: ContainerRuntime>: Sendable {
))
}

// agentc Toolkit → /agent-isolation/toolkit (read-only). The bootstrap puts
// its bin directory at the very end of PATH, so these tools fill gaps in the
// image without ever shadowing what the image itself provides.
if let toolkitDir = config.toolkitDir {
mounts.append(
.init(
hostPath: AgentIsolationPathUtils.resolveSymlinksWithPlatformConsiderations(toolkitDir)
.path,
containerPath: "/agent-isolation/toolkit",
isReadOnly: true
))
}

// Bootstrap file: copy to a temp dir and mount so it can be shared as a virtiofs volume.
var overridesEntrypoint = false
switch config.bootstrapMode {
Expand Down Expand Up @@ -276,7 +289,7 @@ public final class AgentSession<Runtime: ContainerRuntime>: Sendable {
state.tempDirs = tempDirs
}
} catch {
// Container never came up — purge temp dirs eagerly and finish streams.
// Container never came up — purge temp dirs eagerly and finish streams.
for dir in tempDirs {
try? FileManager.default.removeItem(at: dir)
}
Expand Down
10 changes: 10 additions & 0 deletions Sources/AgentIsolation/IsolationConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ public struct IsolationConfig: Sendable {
/// Controls how the container entrypoint is set up.
public var bootstrapMode: BootstrapMode

/// Host directory holding the agentc Toolkit, mounted read-only at
/// `/agent-isolation/toolkit`.
///
/// The bootstrap appends its `bin` to the end of `PATH`, so these tools are
/// only reached for names the image does not provide itself. `nil` mounts
/// nothing and leaves the container with exactly what its image ships.
public var toolkitDir: URL?

/// Arguments forwarded to the container entrypoint.
public var arguments: [String]

Expand Down Expand Up @@ -87,6 +95,7 @@ public struct IsolationConfig: Sendable {
configurationsDir: URL,
configurations: [String] = ["claude"],
bootstrapMode: BootstrapMode = .imageDefault,
toolkitDir: URL? = nil,
arguments: [String] = [],
environment: [String: String] = [:],
allocateTTY: Bool = false,
Expand All @@ -103,6 +112,7 @@ public struct IsolationConfig: Sendable {
self.configurationsDir = configurationsDir
self.configurations = configurations
self.bootstrapMode = bootstrapMode
self.toolkitDir = toolkitDir
self.arguments = arguments
self.environment = environment
self.allocateTTY = allocateTTY
Expand Down
5 changes: 5 additions & 0 deletions Sources/agentc-bootstrap/ConfigurationRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
var path = Helpers.envVar("PATH") ?? "/usr/bin:/bin"
path = "\(home)/.local/bin:\(path)"

// The toolkit goes last, and everything after this point prepends, so it
// stays last: its tools are only reached for names nothing else provides.
Toolkit.appendToPath(&path)
Toolkit.exportCABundleIfImageHasNone()

var lastEntrypoint: [String]?

for configName in configurations {
Expand Down
54 changes: 54 additions & 0 deletions Sources/agentc-bootstrap/Toolkit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// The agentc Toolkit, mounted read-only by the host at /agent-isolation/toolkit.
//
// It exists so a container is never completely without tools: an image with
// nothing but a shell still gets a curl for `prepare.sh` to install with, and a
// set of CA roots to verify the connection against. The image always wins where
// it has an opinion — the toolkit's binaries sit at the end of PATH, and its CA
// bundle is only pointed at when the image ships no trust store of its own.

#if canImport(FoundationEssentials) && canImport(Musl)
import Musl

enum Toolkit {
static let root = "/agent-isolation/toolkit"
static let binDirectory = "\(root)/bin"
static let caBundle = "\(root)/share/ca-certificates.crt"

/// Trust store locations, in the order they are probed.
///
/// A hashed directory counts as much as a bundle file: either way the image
/// has roots of its own and the toolkit should keep out of the way.
static let systemTrustStores = [
"/etc/ssl/certs/ca-certificates.crt", // Debian, Ubuntu, Alpine
"/etc/pki/tls/certs/ca-bundle.crt", // Fedora, RHEL
"/etc/ssl/ca-bundle.pem", // openSUSE
"/etc/ssl/cert.pem", // Alpine, BSD
"/etc/ssl/certs", // hashed directory
]

/// Put the toolkit's binaries at the end of `PATH`.
///
/// Last place is the point: `curl` resolves to the image's own build wherever
/// there is one, and to the toolkit's only where there is not.
static func appendToPath(_ path: inout String) {
guard access(binDirectory, X_OK) == 0 else { return }
path = "\(path):\(binDirectory)"
}

/// Point TLS clients at the bundled roots, but only on an image that has none.
///
/// Without this, HTTPS fails outright on a minimal image — including the
/// `curl … | bash` line that nearly every agent installer is built around.
/// An image that ships a trust store keeps it, so a privately-trusted CA
/// added to the image goes on working.
static func exportCABundleIfImageHasNone() {
guard access(caBundle, R_OK) == 0 else { return }
guard !systemTrustStores.contains(where: { access($0, F_OK) == 0 }) else { return }

// curl, OpenSSL and git each read their own variable.
setenv("CURL_CA_BUNDLE", caBundle, 0)
setenv("SSL_CERT_FILE", caBundle, 0)
setenv("GIT_SSL_CAINFO", caBundle, 0)
}
}
#endif
Loading
Loading