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 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/README.md b/README.md index 39401a1..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. @@ -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`. 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. @@ -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 @@ -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. @@ -144,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). 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-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 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'") + } + } +} 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")) + } + } +} 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 +)