From 82f77353ee2bb71b9d499604038681e237d578aa Mon Sep 17 00:00:00 2001 From: laosb Date: Fri, 28 Aug 2026 03:31:57 +0000 Subject: [PATCH 1/7] feat(toolkit): agentc Toolkit bundle manifest, builder and release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolkit is a small set of static tools — curl, jq, ripgrep and a CA bundle — that later gets mounted read-only into every container so an image that ships nothing still has something to bootstrap with. It is versioned on its own: scripts/toolkit/manifest.sh is the sole input, CI republishes only when that file changes, and it refuses to overwrite an existing toolkit-v tag so the version has to be bumped alongside. Every download is pinned to a SHA-256 that is checked before anything is unpacked, and the archive is written deterministically, so an unchanged manifest reproduces the same bytes. --- .github/workflows/toolkit.yml | 85 +++++++++++++++++++ .gitignore | 1 + scripts/toolkit/build-toolkit.sh | 135 +++++++++++++++++++++++++++++++ scripts/toolkit/manifest.sh | 40 +++++++++ 4 files changed, 261 insertions(+) create mode 100644 .github/workflows/toolkit.yml create mode 100755 scripts/toolkit/build-toolkit.sh create mode 100644 scripts/toolkit/manifest.sh diff --git a/.github/workflows/toolkit.yml b/.github/workflows/toolkit.yml new file mode 100644 index 0000000..9c937d4 --- /dev/null +++ b/.github/workflows/toolkit.yml @@ -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 diff --git a/.gitignore b/.gitignore index d961232..0b3e802 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /claudec /agentc /prompt.md +/dist diff --git a/scripts/toolkit/build-toolkit.sh b/scripts/toolkit/build-toolkit.sh new file mode 100755 index 0000000..7f26550 --- /dev/null +++ b/scripts/toolkit/build-toolkit.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# +# Build an agentc Toolkit bundle from scripts/toolkit/manifest.sh. +# +# ./scripts/toolkit/build-toolkit.sh # host architecture +# ./scripts/toolkit/build-toolkit.sh --arch arm64 +# ./scripts/toolkit/build-toolkit.sh --output /tmp/out +# +# Produces /agentc-toolkit--linux-static.tar.gz. Every download is +# verified against the digest pinned in the manifest before anything is unpacked, +# and the archive is written deterministically, so the same manifest always +# yields byte-identical output. + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=manifest.sh +source "$script_dir/manifest.sh" + +arch="" +output="dist" + +while [ $# -gt 0 ]; do + case "$1" in + --arch) arch="${2:-}"; shift 2 ;; + --output) output="${2:-}"; shift 2 ;; + -h|--help) sed -n '2,15p' "$0" | sed 's/^#\{1,2\} \{0,1\}//'; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +if [ -z "$arch" ]; then + case "$(uname -m)" in + x86_64|amd64) arch=x64 ;; + aarch64|arm64) arch=arm64 ;; + *) echo "unsupported host architecture: $(uname -m); pass --arch" >&2; exit 2 ;; + esac +fi + +case "$arch" in + x64|arm64) ;; + *) echo "unsupported architecture: $arch (expected x64 or arm64)" >&2; exit 2 ;; +esac + +# GNU tar for --sort/--mtime; bsdtar cannot write a reproducible archive. +tar_bin=tar +if ! tar --version 2>/dev/null | grep -q GNU; then + if command -v gtar >/dev/null 2>&1; then + tar_bin=gtar + else + echo "GNU tar is required (macOS: brew install gnu-tar)" >&2 + exit 1 + fi +fi + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +root="$work/toolkit" +mkdir -p "$root" + +contents_manifest="$work/contents" +: > "$contents_manifest" + +while IFS='|' read -r dest entry_arch url digest member; do + [ -n "$dest" ] || continue + case "$dest" in \#*) continue ;; esac + [ "$entry_arch" = "$arch" ] || [ "$entry_arch" = any ] || continue + + case "$url" in + https://*) ;; + *) echo "refusing non-https source for $dest: $url" >&2; exit 1 ;; + esac + + echo "==> $dest <- $url" + download="$work/download" + rm -rf "$download"; mkdir -p "$download" + file="$download/$(basename "$url")" + curl -fsSL --proto '=https' --tlsv1.2 -o "$file" "$url" + + actual="$(sha256 "$file")" + if [ "$actual" != "$digest" ]; then + echo "checksum mismatch for $url" >&2 + echo " expected $digest" >&2 + echo " actual $actual" >&2 + exit 1 + fi + + mkdir -p "$root/$(dirname "$dest")" + if [ "$member" = "-" ]; then + cp "$file" "$root/$dest" + else + # Unpack only the named member, and only after the digest checked out. + case "$file" in + *.tar.gz|*.tgz) "$tar_bin" xzf "$file" -C "$download" "$member" ;; + *.tar.xz) "$tar_bin" xJf "$file" -C "$download" "$member" ;; + *) echo "don't know how to extract from $file" >&2; exit 1 ;; + esac + cp "$download/$member" "$root/$dest" + fi + + case "$dest" in + bin/*) chmod 0755 "$root/$dest" ;; + *) chmod 0644 "$root/$dest" ;; + esac + printf '%s %s\n' "$(sha256 "$root/$dest")" "$dest" >> "$contents_manifest" +done <<< "$TOOLKIT_CONTENTS" + +# Shipped inside the bundle so an installed toolkit says what it is. +{ + echo "agentc Toolkit v$TOOLKIT_VERSION ($arch)" + echo + cat "$contents_manifest" +} > "$root/TOOLKIT" +chmod 0644 "$root/TOOLKIT" + +mkdir -p "$output" +output="$(cd "$output" && pwd)" +archive="$output/agentc-toolkit-$arch-linux-static.tar.gz" + +# Fixed ordering, timestamps and ownership, and gzip without its own mtime, so +# an unchanged manifest reproduces the same bytes. +(cd "$root" && "$tar_bin" --sort=name --mtime='@0' --owner=0 --group=0 \ + --numeric-owner --format=gnu -cf - .) | gzip -9n > "$archive" + +echo +echo "built $archive" +echo " $(sha256 "$archive")" diff --git a/scripts/toolkit/manifest.sh b/scripts/toolkit/manifest.sh new file mode 100644 index 0000000..877937c --- /dev/null +++ b/scripts/toolkit/manifest.sh @@ -0,0 +1,40 @@ +# agentc Toolkit — what goes into the bundle. +# +# The toolkit is a small set of tools mounted read-only into every agentc +# container at /agent-isolation/toolkit, with bin/ appended to the *end* of +# PATH. It guarantees that curl and friends exist even on an image that ships +# nothing, while leaving the image's own copies in charge when it has them. +# +# This file is the only input to the bundle: CI rebuilds and republishes the +# toolkit when it changes, and at no other time — an agentc release does not +# reissue an unchanged toolkit. +# +# Bump TOOLKIT_VERSION in the same commit as any change below. The release tag +# is `toolkit-v$TOOLKIT_VERSION`, and CI refuses to overwrite an existing tag. +# `ToolkitManager.version` must match; ToolkitManifestTests enforces that. + +TOOLKIT_VERSION=1 + +# dest | arch | url | sha256 | member +# +# dest where the file lands inside the bundle. Anything under bin/ is +# made executable; everything else is mounted read-only as data. +# arch x64, arm64, or any (included in both bundles). +# sha256 digest of the file at `url`, verified before it is unpacked. +# member path to extract from the archive, or `-` when the URL already +# points at the file itself. Archives may be .tar.gz or .tar.xz; +# nothing is unpacked beyond the single named member. +# +# Every URL must be https, and every digest must be one a human checked against +# what upstream published — not one taken from the download it is meant to +# verify. +TOOLKIT_CONTENTS=$(cat <<'CONTENTS' +bin/curl|x64|https://github.com/stunnel/static-curl/releases/download/8.21.0/curl-linux-x86_64-musl-8.21.0.tar.xz|e955f211202ded2536164588331acfc987dc4b7857efa3577717b1ffeab22029|curl +bin/curl|arm64|https://github.com/stunnel/static-curl/releases/download/8.21.0/curl-linux-aarch64-musl-8.21.0.tar.xz|d3f10502a9c6ead9bc3763fde3d12467db03661a263e11fec2ef2edc70e98e9f|curl +bin/jq|x64|https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64|b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f|- +bin/jq|arm64|https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-arm64|8b85c817833814ddca00a144c33705546355afccf0cf39b188f3cdb48b852309|- +bin/rg|x64|https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-x86_64-unknown-linux-musl.tar.gz|33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c|ripgrep-15.2.0-x86_64-unknown-linux-musl/rg +bin/rg|arm64|https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-aarch64-unknown-linux-musl.tar.gz|800b1e7206afe799dfb5a6901f23147cfaabe0e52210538100f61e86e1740915|ripgrep-15.2.0-aarch64-unknown-linux-musl/rg +share/ca-certificates.crt|any|https://curl.se/ca/cacert-2026-08-13.pem|f66dff1bdf8f96060b8177976f8b7d9254bc89bc4db933d769f7384d28480bc9|- +CONTENTS +) From a31c760fa7cd0246836d9c343d3130e0d61639dd Mon Sep 17 00:00:00 2001 From: laosb Date: Fri, 28 Aug 2026 03:38:06 +0000 Subject: [PATCH 2/7] feat(toolkit): download the toolkit and mount it into every session ToolkitManager installs the bundle for the running architecture into ~/.agentc/toolkit/v on first use and AgentSession mounts it read-only at /agent-isolation/toolkit. Failing to install one is a warning, not an error: a session without a toolkit behaves exactly as it did before, using whatever the image ships. --toolkit points at a local bundle, which is how you exercise a manifest change before it is published, and --no-toolkit opts out. Nothing is mounted when --respect-image-entrypoint is in play, since without the bootstrap nothing would put it on PATH. ToolkitManifestTests reads the shell manifest and ToolkitManager side by side so the version agentc downloads cannot drift from the one CI publishes. --- Sources/AgentIsolation/AgentSession.swift | 21 ++- Sources/AgentIsolation/IsolationConfig.swift | 10 ++ Sources/agentc/BootstrapManager.swift | 12 +- Sources/agentc/HostArchitecture.swift | 15 ++ Sources/agentc/SessionRunner.swift | 2 + Sources/agentc/SharedOptions.swift | 23 +++ Sources/agentc/ToolkitManager.swift | 141 ++++++++++++++++++ .../AgentSessionTests.swift | 63 ++++++++ .../ToolkitManifestTests.swift | 83 +++++++++++ 9 files changed, 355 insertions(+), 15 deletions(-) create mode 100644 Sources/agentc/HostArchitecture.swift create mode 100644 Sources/agentc/ToolkitManager.swift create mode 100644 Tests/AgentIsolationTests/ToolkitManifestTests.swift diff --git a/Sources/AgentIsolation/AgentSession.swift b/Sources/AgentIsolation/AgentSession.swift index 1bd511c..64df947 100644 --- a/Sources/AgentIsolation/AgentSession.swift +++ b/Sources/AgentIsolation/AgentSession.swift @@ -99,7 +99,7 @@ public final class AgentSession: 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() } @@ -127,7 +127,7 @@ public final class AgentSession: Sendable { var mounts: [ContainerConfiguration.Mount] = [] var tempDirs: [URL] = [] - // Profile home → /home/agent + // Profile home → /home/agent mounts.append( .init( hostPath: config.profileHomeDir.path, @@ -155,7 +155,7 @@ public final class AgentSession: Sendable { )) } - // Configurations directory → /agent-isolation/agents (read-only) + // Configurations directory → /agent-isolation/agents (read-only) mounts.append( .init( hostPath: config.configurationsDir.path, @@ -196,6 +196,19 @@ public final class AgentSession: 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 { @@ -276,7 +289,7 @@ public final class AgentSession: 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) } diff --git a/Sources/AgentIsolation/IsolationConfig.swift b/Sources/AgentIsolation/IsolationConfig.swift index 77b6b0d..9a43c73 100644 --- a/Sources/AgentIsolation/IsolationConfig.swift +++ b/Sources/AgentIsolation/IsolationConfig.swift @@ -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] @@ -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, @@ -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 diff --git a/Sources/agentc/BootstrapManager.swift b/Sources/agentc/BootstrapManager.swift index bbe332f..f4d150f 100644 --- a/Sources/agentc/BootstrapManager.swift +++ b/Sources/agentc/BootstrapManager.swift @@ -53,7 +53,7 @@ enum BootstrapManager { private static func downloadBootstrap( version: String, to destination: URL, verbose: Bool ) async throws { - let arch = hostArchLabel() + let arch = HostArchitecture.label let assetName = "agentc-bootstrap-\(arch)-linux-static.tar.gz" let url = "https://github.com/laosb/agentc/releases/download/v\(version)/\(assetName)" @@ -106,14 +106,4 @@ enum BootstrapManager { writeToStderr("agentc: bootstrap binary installed to \(destination.path)\n") } } - - private static func hostArchLabel() -> String { - #if arch(arm64) - return "arm64" - #elseif arch(x86_64) - return "x64" - #else - return "unknown" - #endif - } } diff --git a/Sources/agentc/HostArchitecture.swift b/Sources/agentc/HostArchitecture.swift new file mode 100644 index 0000000..a65233a --- /dev/null +++ b/Sources/agentc/HostArchitecture.swift @@ -0,0 +1,15 @@ +/// The architecture label agentc uses in release asset names. +/// +/// Containers run at the host's architecture, so the same label picks both the +/// bootstrap binary and the toolkit bundle. +enum HostArchitecture { + static var label: String { + #if arch(arm64) + return "arm64" + #elseif arch(x86_64) + return "x64" + #else + return "unknown" + #endif + } +} diff --git a/Sources/agentc/SessionRunner.swift b/Sources/agentc/SessionRunner.swift index 81280ad..407e1f1 100644 --- a/Sources/agentc/SessionRunner.swift +++ b/Sources/agentc/SessionRunner.swift @@ -56,6 +56,7 @@ enum SessionRunner { let resolvedImage = options.resolveImage(projectSettings: projectSettings) let bootstrapMode = try await options.resolveBootstrapMode(projectSettings: projectSettings) + let toolkitDir = await options.resolveToolkitDir(bootstrapMode: bootstrapMode) let isolationConfig = IsolationConfig( image: resolvedImage, @@ -65,6 +66,7 @@ enum SessionRunner { configurationsDir: configurationsDir, configurations: configNames, bootstrapMode: bootstrapMode, + toolkitDir: toolkitDir, arguments: arguments, environment: options.resolveEnvironment(projectSettings: projectSettings), allocateTTY: allocateTTY, diff --git a/Sources/agentc/SharedOptions.swift b/Sources/agentc/SharedOptions.swift index fa11013..0701213 100644 --- a/Sources/agentc/SharedOptions.swift +++ b/Sources/agentc/SharedOptions.swift @@ -69,6 +69,19 @@ struct SharedOptions: ParsableArguments { ) var respectImageEntrypoint: Bool = false + @Option( + name: .customLong("toolkit"), + help: ArgumentHelp( + "Directory holding an agentc Toolkit to mount, instead of the published one.", + valueName: "path")) + var toolkitPath: String? + + @Flag( + name: .customLong("no-toolkit"), + help: "Do not mount the agentc Toolkit; use only what the image provides." + ) + var noToolkit: Bool = false + @Option( name: .long, help: ArgumentHelp("Additional host directory to mount in the container.", valueName: "path") @@ -238,6 +251,16 @@ extension SharedOptions { return .file(binary) } + /// Resolve the agentc Toolkit directory to mount, or `nil` to mount none. + /// + /// Nothing is mounted when the image keeps its own entrypoint: without the + /// bootstrap there is no one to put the toolkit on `PATH`. + func resolveToolkitDir(bootstrapMode: BootstrapMode) async -> URL? { + guard !noToolkit else { return nil } + guard case .file = bootstrapMode else { return nil } + return await ToolkitManager.resolveToolkit(override: toolkitPath, verbose: verbose) + } + /// Resolve image reference. CLI flag → project settings → default. func resolveImage(projectSettings: ProjectSettings? = nil) -> String { image ?? projectSettings?.agent?.image ?? "ghcr.io/laosb/claudec:latest" diff --git a/Sources/agentc/ToolkitManager.swift b/Sources/agentc/ToolkitManager.swift new file mode 100644 index 0000000..133f728 --- /dev/null +++ b/Sources/agentc/ToolkitManager.swift @@ -0,0 +1,141 @@ +#if canImport(FoundationEssentials) + import FoundationEssentials +#else + import Foundation +#endif + +import Subprocess + +/// Locates or downloads the agentc Toolkit — the tools mounted into every session. +/// +/// The toolkit is a handful of static binaries (curl, jq, ripgrep) plus a CA +/// bundle, mounted read-only at `/agent-isolation/toolkit` so that even an image +/// carrying nothing but a shell has something for `prepare.sh` to bootstrap +/// with. The bootstrap appends its `bin` to the *end* of `PATH`, so an image +/// that ships its own copies keeps using them. +/// +/// It is versioned independently of agentc: a release is cut only when +/// `scripts/toolkit/manifest.sh` changes, so upgrading agentc does not re-download +/// a toolkit that did not move. +enum ToolkitManager { + /// Toolkit release this agentc expects. + /// + /// Must equal `TOOLKIT_VERSION` in `scripts/toolkit/manifest.sh`; + /// `ToolkitManifestTests` fails the build when the two drift apart. + static let version = "1" + + /// Where an installed toolkit lives. Each version gets its own directory, so + /// a downgrade finds its toolkit still in place. + static var toolkitDir: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".agentc/toolkit/v\(version)") + } + + /// Marks a directory as a complete toolkit, and records what is in it. + private static let markerFile = "TOOLKIT" + + /// Resolve the toolkit directory, downloading it on first use of a version. + /// + /// Returns `nil` when no toolkit could be made available. That is deliberately + /// not fatal: a session without one behaves exactly as it did before the + /// toolkit existed, falling back to whatever the image ships. + static func resolveToolkit(override: String? = nil, verbose: Bool = false) async -> URL? { + if let override, !override.isEmpty { + let directory = URL(fileURLWithPath: override) + guard FileManager.default.fileExists(atPath: directory.path) else { + writeToStderr("agentc: --toolkit \(override) does not exist; continuing without it\n") + return nil + } + return directory + } + + let directory = toolkitDir + if FileManager.default.fileExists( + atPath: directory.appendingPathComponent(markerFile).path) + { + return directory + } + + do { + try await downloadToolkit(to: directory, verbose: verbose) + return directory + } catch { + // Worth saying out loud even without --verbose: tools the configurations + // expect to find may not be there. + writeToStderr( + "agentc: could not install the toolkit (\(error)); " + + "continuing with the tools the image provides\n") + return nil + } + } + + /// Why no toolkit could be installed. Never reaches the user as a thrown + /// error — it is reported as a warning and the session carries on. + private struct ToolkitUnavailable: Error, CustomStringConvertible { + let description: String + + init(_ description: String) { self.description = description } + } + + private static func downloadToolkit(to destination: URL, verbose: Bool) async throws { + let assetName = "agentc-toolkit-\(HostArchitecture.label)-linux-static.tar.gz" + let url = + "https://github.com/laosb/agentc/releases/download/toolkit-v\(version)/\(assetName)" + + if verbose { + writeToStderr("agentc: downloading toolkit v\(version)...\n") + } + + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("agentc-toolkit-dl-\(UUID().uuidString)") + let staging = tmpDir.appendingPathComponent("toolkit") + try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let tarPath = tmpDir.appendingPathComponent(assetName) + + let curlResult = try await run( + .name("curl"), + arguments: ["-fsSL", url, "-o", tarPath.path], + output: .discarded + ) + guard curlResult.terminationStatus.isSuccess else { + throw ToolkitUnavailable("failed to download \(url)") + } + + let tarResult = try await run( + .name("tar"), + arguments: ["xzf", tarPath.path, "-C", staging.path], + output: .discarded + ) + guard tarResult.terminationStatus.isSuccess else { + throw ToolkitUnavailable("failed to extract \(assetName)") + } + + guard + FileManager.default.fileExists( + atPath: staging.appendingPathComponent(markerFile).path) + else { + throw ToolkitUnavailable("\(assetName) is not a toolkit bundle") + } + + try FileManager.default.createDirectory( + at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + + do { + try FileManager.default.moveItem(at: staging, to: destination) + } catch { + // Another agentc process very likely installed the same version while this + // one was downloading; theirs is as good as ours. + guard + FileManager.default.fileExists( + atPath: destination.appendingPathComponent(markerFile).path) + else { throw error } + return + } + + if verbose { + writeToStderr("agentc: toolkit installed to \(destination.path)\n") + } + } +} diff --git a/Tests/AgentIsolationTests/AgentSessionTests.swift b/Tests/AgentIsolationTests/AgentSessionTests.swift index db68482..c2ada9c 100644 --- a/Tests/AgentIsolationTests/AgentSessionTests.swift +++ b/Tests/AgentIsolationTests/AgentSessionTests.swift @@ -75,6 +75,69 @@ struct AgentSessionTests { #expect(homeMount?.hostPath == profileDir.path) } + @Test("Mounts the toolkit read-only at /agent-isolation/toolkit") + func mountsToolkit() async throws { + let runtime = MockRuntime(config: .init(storagePath: "/tmp")) + let profileDir = URL(fileURLWithPath: "/tmp/claudec-test-\(UUID().uuidString)/home") + let configsDir = URL(fileURLWithPath: "/tmp/claudec-test-configs-\(UUID().uuidString)") + let toolkitDir = URL(fileURLWithPath: "/tmp/claudec-test-toolkit-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: configsDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: toolkitDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: profileDir.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: configsDir) + try? FileManager.default.removeItem(at: toolkitDir) + } + + let config = IsolationConfig( + image: "test:latest", + profileHomeDir: profileDir, + workspace: URL(fileURLWithPath: "/tmp"), + configurationsDir: configsDir, + configurations: [], + toolkitDir: toolkitDir, + arguments: ["echo"] + ) + let session = AgentSession(config: config, runtime: runtime) + try await session.start() + _ = try await session.wait() + + let mounts = runtime.lastContainerConfiguration!.mounts + let toolkit = mounts.first { $0.containerPath == "/agent-isolation/toolkit" } + #expect(toolkit != nil) + // The host path is canonicalized, so match on the directory name rather than + // the full path — /tmp is a symlink on macOS. + #expect(toolkit?.hostPath.hasSuffix(toolkitDir.lastPathComponent) == true) + #expect(toolkit?.isReadOnly == true) + } + + @Test("Mounts no toolkit when none was resolved") + func mountsNoToolkitByDefault() async throws { + let runtime = MockRuntime(config: .init(storagePath: "/tmp")) + let profileDir = URL(fileURLWithPath: "/tmp/claudec-test-\(UUID().uuidString)/home") + let configsDir = URL(fileURLWithPath: "/tmp/claudec-test-configs-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: configsDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: profileDir.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: configsDir) + } + + let config = IsolationConfig( + image: "test:latest", + profileHomeDir: profileDir, + workspace: URL(fileURLWithPath: "/tmp"), + configurationsDir: configsDir, + configurations: [], + arguments: ["echo"] + ) + let session = AgentSession(config: config, runtime: runtime) + try await session.start() + _ = try await session.wait() + + let mounts = runtime.lastContainerConfiguration!.mounts + #expect(!mounts.contains { $0.containerPath == "/agent-isolation/toolkit" }) + } + @Test("Mounts workspace at /workspace/-") func mountsWorkspace() async throws { let runtime = MockRuntime(config: .init(storagePath: "/tmp")) diff --git a/Tests/AgentIsolationTests/ToolkitManifestTests.swift b/Tests/AgentIsolationTests/ToolkitManifestTests.swift new file mode 100644 index 0000000..3324fa2 --- /dev/null +++ b/Tests/AgentIsolationTests/ToolkitManifestTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing + +/// Guards on `scripts/toolkit/manifest.sh`, the file CI publishes the toolkit from. +/// +/// The manifest is shell, not Swift, so nothing else in the build would notice a +/// typo in it — or notice it drifting away from the version `agentc` asks the +/// release server for. These read both files as text, which is also the only way +/// to reach `ToolkitManager` from here: it lives in an executable target. +@Suite("Toolkit manifest") +struct ToolkitManifestTests { + private static let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // AgentIsolationTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // repository root + + private static func read(_ relativePath: String) throws -> String { + try String(contentsOf: repositoryRoot.appending(path: relativePath), encoding: .utf8) + } + + /// The `dest|arch|url|sha256|member` rows, without the surrounding shell. + private static func contentRows(of manifest: String) -> [[String]] { + manifest + .split(separator: "\n") + .map(String.init) + .filter { $0.contains("|") && !$0.hasPrefix("#") } + .map { $0.split(separator: "|", omittingEmptySubsequences: false).map(String.init) } + } + + @Test("agentc asks for the version the manifest publishes") + func versionsAgree() throws { + let manifest = try Self.read("scripts/toolkit/manifest.sh") + let source = try Self.read("Sources/agentc/ToolkitManager.swift") + + let declared = try #require( + manifest.firstMatch(of: /TOOLKIT_VERSION=([0-9]+)/)?.1, + "manifest.sh should declare TOOLKIT_VERSION") + let expected = try #require( + source.firstMatch(of: /static let version = "([^"]+)"/)?.1, + "ToolkitManager should declare a version") + + #expect( + String(declared) == String(expected), + """ + scripts/toolkit/manifest.sh publishes toolkit v\(declared) but agentc \ + downloads v\(expected). Bump both together. + """) + } + + @Test("Every entry is pinned to an https URL and a SHA-256") + func entriesArePinned() throws { + let rows = Self.contentRows(of: try Self.read("scripts/toolkit/manifest.sh")) + #expect(!rows.isEmpty) + + for row in rows { + #expect(row.count == 5, "expected dest|arch|url|sha256|member, got \(row)") + let (dest, arch, url, digest) = (row[0], row[1], row[2], row[3]) + + #expect(url.hasPrefix("https://"), "\(dest): \(url) is not https") + #expect(["x64", "arm64", "any"].contains(arch), "\(dest): unknown architecture '\(arch)'") + #expect(digest.count == 64, "\(dest): \(digest) is not a SHA-256") + #expect( + digest.allSatisfy { "0123456789abcdef".contains($0) }, + "\(dest): \(digest) is not lowercase hex") + #expect(!dest.contains(".."), "\(dest): destinations stay inside the bundle") + } + } + + @Test("Every tool is available on both architectures") + func everyToolCoversBothArchitectures() throws { + let rows = Self.contentRows(of: try Self.read("scripts/toolkit/manifest.sh")) + let architectures = Dictionary(grouping: rows, by: { $0[0] }) + .mapValues { Set($0.map { $0[1] }) } + + for (dest, archs) in architectures { + // A container gets exactly one bundle, so anything missing for an + // architecture is simply absent for every session running on it. + #expect( + archs == ["any"] || archs == ["x64", "arm64"], + "\(dest) is built for \(archs.sorted()) — it needs both, or 'any'") + } + } +} From 98edd2bff4ccbf2d1adcdfafa599eb36cd5cdcd5 Mon Sep 17 00:00:00 2001 From: laosb Date: Fri, 28 Aug 2026 03:38:14 +0000 Subject: [PATCH 3/7] feat(bootstrap): put toolkit tools last on PATH, and use its CA roots when the image has none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toolkit's bin directory is appended before any configuration gets to touch PATH, and every configuration prepends, so it stays in last place: curl resolves to the image's own build wherever there is one and to the toolkit's only where there is not. This is the opposite of what the curl configuration did by installing into $HOME/.local/bin, where it shadowed the image. The CA bundle matters just as much. On an image with no trust store, HTTPS fails outright — including the 'curl … | bash' line nearly every agent installer is built around — so CURL_CA_BUNDLE, SSL_CERT_FILE and GIT_SSL_CAINFO are pointed at the bundled roots, but only when the image ships none of its own, leaving a privately-trusted CA baked into an image working as before. --- .../ConfigurationRunner.swift | 5 ++ Sources/agentc-bootstrap/Toolkit.swift | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 Sources/agentc-bootstrap/Toolkit.swift diff --git a/Sources/agentc-bootstrap/ConfigurationRunner.swift b/Sources/agentc-bootstrap/ConfigurationRunner.swift index 53a4888..fdaf9c1 100644 --- a/Sources/agentc-bootstrap/ConfigurationRunner.swift +++ b/Sources/agentc-bootstrap/ConfigurationRunner.swift @@ -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 { diff --git a/Sources/agentc-bootstrap/Toolkit.swift b/Sources/agentc-bootstrap/Toolkit.swift new file mode 100644 index 0000000..7d3e81d --- /dev/null +++ b/Sources/agentc-bootstrap/Toolkit.swift @@ -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 From 1c7cefdd18b1878e9c6a4a871ff4c73b3bfb347a Mon Sep 17 00:00:00 2001 From: laosb Date: Fri, 28 Aug 2026 03:40:55 +0000 Subject: [PATCH 4/7] test(toolkit): end-to-end coverage for what the toolkit does and does not shadow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite builds a bundle from the manifest in this checkout rather than downloading a published one, so a manifest change is exercised on the branch that makes it, and skips itself when the bundle cannot be built. Beyond 'curl exists', the tests pin the placement: debian:latest resolves curl, jq and rg to /agent-isolation/toolkit/bin, buildpack-deps:scm keeps resolving curl to /usr/bin, --no-toolkit leaves the container with neither, and an HTTPS fetch succeeds either way — with CURL_CA_BUNDLE set only on the images that have no trust store to override. --- .../ToolkitIntegrationTests.swift | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 Tests/AgentcIntegrationTests/ToolkitIntegrationTests.swift diff --git a/Tests/AgentcIntegrationTests/ToolkitIntegrationTests.swift b/Tests/AgentcIntegrationTests/ToolkitIntegrationTests.swift new file mode 100644 index 0000000..731f0d8 --- /dev/null +++ b/Tests/AgentcIntegrationTests/ToolkitIntegrationTests.swift @@ -0,0 +1,153 @@ +import Foundation +import Subprocess +import Testing + +#if canImport(System) + import System +#else + import SystemPackage +#endif + +/// A toolkit bundle built from the manifest in this checkout. +/// +/// Built rather than downloaded so the tests cover the manifest as it stands in +/// the branch, not the last one that happened to be released. `nil` when the +/// build fails (no network, no `xz`), which skips the suite instead of failing it. +let sharedToolkitDir: String? = { + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let output = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("agentc-toolkit-tests") + let staged = output.appendingPathComponent("toolkit") + + if FileManager.default.fileExists(atPath: staged.appendingPathComponent("TOOLKIT").path) { + return staged.path + } + + let script = repoRoot.appendingPathComponent("scripts/toolkit/build-toolkit.sh") + let archive = output.appendingPathComponent( + "agentc-toolkit-\(hostArchLabel())-linux-static.tar.gz") + + do { + try? FileManager.default.removeItem(at: output) + try FileManager.default.createDirectory(at: staged, withIntermediateDirectories: true) + guard try runSync(script.path, ["--output", output.path]) else { return nil } + guard try runSync("/usr/bin/env", ["tar", "xzf", archive.path, "-C", staged.path]) else { + return nil + } + return staged.path + } catch { + return nil + } +}() + +private func hostArchLabel() -> String { + #if arch(arm64) + return "arm64" + #else + return "x64" + #endif +} + +private func runSync(_ executable: String, _ arguments: [String]) throws -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 +} + +/// End-to-end coverage for the agentc Toolkit. +/// +/// The guarantee under test is not just "curl exists" but where it sits: the +/// toolkit fills in what an image lacks and never displaces what it has. +@Suite("Toolkit Integration Tests", .enabled(if: sharedToolkitDir != nil)) +struct ToolkitIntegrationTests { + init() { + _ = sharedProfile + } + + private func run(image: String, command: String, toolkit: Bool = true) async -> ProcessOutput { + var args = [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--image", image, + "--no-update-image", + ] + if toolkit { + args += ["--toolkit", sharedToolkitDir!] + } else { + args += ["--no-toolkit"] + } + // `agentc sh` joins what follows into a single `bash -c` string. + return await runAgentc(args: args + ["--", command]) + } + + @Test("Fills in tools an image does not ship") + func providesMissingTools() async throws { + // debian:latest carries none of these. + let result = await run( + image: "docker.io/library/debian:latest", + command: "command -v curl jq rg && curl --version | head -1 && rg --version | head -1") + + #expect(result.exitCode == 0) + #expect(result.output.contains("/agent-isolation/toolkit/bin/curl")) + #expect(result.output.contains("/agent-isolation/toolkit/bin/jq")) + #expect(result.output.contains("/agent-isolation/toolkit/bin/rg")) + #expect(result.output.contains("curl 8.")) + #expect(result.output.contains("ripgrep")) + } + + @Test("Never displaces a tool the image already has") + func imageToolsWin() async throws { + // buildpack-deps:scm ships its own curl, and it must stay the one that runs. + let result = await run( + image: "docker.io/library/buildpack-deps:scm", + command: "command -v curl") + + #expect(result.exitCode == 0) + #expect(result.output.contains("/usr/bin/curl")) + #expect(!result.output.contains("/agent-isolation/toolkit/bin/curl")) + } + + @Test("Leaves the container untouched when asked not to mount") + func noToolkitFlag() async throws { + let result = await run( + image: "docker.io/library/debian:latest", + command: "command -v rg || echo no-ripgrep", + toolkit: false) + + #expect(result.exitCode == 0) + #expect(result.output.contains("no-ripgrep")) + #expect(!result.output.contains("/agent-isolation/toolkit")) + } + + @Test("HTTPS works even on an image with no trust store") + func httpsWorksWithoutSystemTrustStore() async throws { + let result = await run( + image: "docker.io/library/debian:latest", + command: """ + if [ -e /etc/ssl/certs/ca-certificates.crt ] || [ -e /etc/ssl/cert.pem ]; then \ + echo image-has-store; else echo image-has-no-store; fi; \ + echo "cainfo=${CURL_CA_BUNDLE:-unset}"; \ + curl -fsS -o /dev/null -w 'status=%{http_code}\\n' https://github.com/ + """) + + #expect(result.exitCode == 0) + #expect(result.output.contains("status=200")) + + // The bundled roots are a fallback, not an override: whichever branch the + // image falls into, the variable must agree with it. + if result.output.contains("image-has-no-store") { + #expect(result.output.contains("cainfo=/agent-isolation/toolkit/share/ca-certificates.crt")) + } else { + #expect(result.output.contains("cainfo=unset")) + } + } +} From d3d4431b5571ae889ab6567047ae0a6eaa3d8913 Mon Sep 17 00:00:00 2001 From: laosb Date: Fri, 28 Aug 2026 03:41:27 +0000 Subject: [PATCH 5/7] doc: document the agentc Toolkit --- README.md | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 39401a1..1cd451d 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,35 @@ 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 a +container is never entirely without tools; an image carrying nothing but a shell still has +a `curl` for a configuration's `prepare.sh` to install with. + +The toolkit's `bin` sits at the **end** of `PATH`, so it only ever fills gaps: an image +that ships its own `curl` keeps using it, and so does anything a configuration puts in +`additionalBinPaths`. Its CA bundle is used the same way — `CURL_CA_BUNDLE`, +`SSL_CERT_FILE` and `GIT_SSL_CAINFO` are pointed at it only on an image that ships no +trust store of its own, which is otherwise a container where every HTTPS request fails. + +```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`. Its contents are defined by +[`scripts/toolkit/manifest.sh`](./scripts/toolkit/manifest.sh) — every entry pinned to an +HTTPS URL and a SHA-256 — and CI publishes a new bundle only when that file changes, so +upgrading agentc does not re-download a toolkit that did not move. To add a tool, add a row, +bump `TOOLKIT_VERSION` and `ToolkitManager.version` together, and build it locally first: + +```sh +./scripts/toolkit/build-toolkit.sh --output dist +``` + ### 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. @@ -68,7 +97,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 @@ -114,6 +143,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. From ad84ea8d034290ba4439836f07bb5230944f2826 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Fri, 28 Aug 2026 13:24:16 +0800 Subject: [PATCH 6/7] chore: boite updates --- .boite/.gitignore | 25 +++++++++++++++++++++++++ .boite/boite.json | 13 +++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 .boite/boite.json diff --git a/.boite/.gitignore b/.boite/.gitignore index 816bc5f..35fa7f8 100644 --- a/.boite/.gitignore +++ b/.boite/.gitignore @@ -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. # diff --git a/.boite/boite.json b/.boite/boite.json new file mode 100644 index 0000000..ce65b85 --- /dev/null +++ b/.boite/boite.json @@ -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" : "." +} \ No newline at end of file From 9ee8c0471543305bc80014eff2ca7c0584e3452d Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Fri, 28 Aug 2026 13:36:31 +0800 Subject: [PATCH 7/7] doc: revise README. --- README.md | 40 +++++++--------------------------------- 1 file changed, 7 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 1cd451d..8cffbea 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 --help` for full CLI reference. @@ -63,15 +62,11 @@ 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 a -container is never entirely without tools; an image carrying nothing but a shell still has -a `curl` for a configuration's `prepare.sh` to install with. +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's `bin` sits at the **end** of `PATH`, so it only ever fills gaps: an image -that ships its own `curl` keeps using it, and so does anything a configuration puts in -`additionalBinPaths`. Its CA bundle is used the same way — `CURL_CA_BUNDLE`, -`SSL_CERT_FILE` and `GIT_SSL_CAINFO` are pointed at it only on an image that ships no -trust store of its own, which is otherwise a container where every HTTPS request fails. +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 @@ -80,14 +75,7 @@ agentc run --toolkit ~/my-toolkit # a locally built bundle The toolkit is versioned independently of agentc and downloaded once, to `~/.agentc/toolkit/v`. Its contents are defined by -[`scripts/toolkit/manifest.sh`](./scripts/toolkit/manifest.sh) — every entry pinned to an -HTTPS URL and a SHA-256 — and CI publishes a new bundle only when that file changes, so -upgrading agentc does not re-download a toolkit that did not move. To add a tool, add a row, -bump `TOOLKIT_VERSION` and `ToolkitManager.version` together, and build it locally first: - -```sh -./scripts/toolkit/build-toolkit.sh --output dist -``` +[`scripts/toolkit/manifest.sh`](./scripts/toolkit/manifest.sh). ### Project Settings @@ -176,20 +164,6 @@ cp .build//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 ` 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).