diff --git a/.github/actions/mcode-companion/action.yml b/.github/actions/mcode-companion/action.yml deleted file mode 100644 index d8dfe7faf..000000000 --- a/.github/actions/mcode-companion/action.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: MiniMax companion -description: Restore the pinned MiniMax companion into $RUNNER_TEMP/minimax-runtime, building it only when its inputs change. -runs: - using: composite - steps: - - id: cache - uses: actions/cache@v6 - with: - path: ${{ runner.temp }}/minimax-runtime - key: mcode-harness-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/mcode-harness/**', 'scripts/build-mcode-harness.sh') }} - - if: steps.cache.outputs.cache-hit != 'true' - shell: bash - run: | - source_repository="$(node -p 'require("./packages/mcode-harness/source.json").repository')" - source_revision="$(node -p 'require("./packages/mcode-harness/source.json").revision')" - source_version="$(node -p 'require("./packages/mcode-harness/source.json").version')" - git init --quiet "$RUNNER_TEMP/mcode-source" - git -C "$RUNNER_TEMP/mcode-source" remote add origin "$source_repository" - git -C "$RUNNER_TEMP/mcode-source" fetch --depth 1 origin "$source_revision" - git -C "$RUNNER_TEMP/mcode-source" checkout --detach FETCH_HEAD - npm install --prefix "$RUNNER_TEMP/mcode-native" --no-audit --no-fund --include=optional --install-strategy=nested "@minimax-ai/code@$source_version" - MCODE_NATIVE_SOURCE="$RUNNER_TEMP/mcode-source" \ - MCODE_CLI_DIR="$RUNNER_TEMP/mcode-native/node_modules/@minimax-ai/code" \ - MCODE_HARNESS_BUILD_DIR="$RUNNER_TEMP/minimax-runtime" \ - bash scripts/build-mcode-harness.sh diff --git a/.github/workflows/cache-warm.yml b/.github/workflows/cache-warm.yml index 946466909..6959554b3 100644 --- a/.github/workflows/cache-warm.yml +++ b/.github/workflows/cache-warm.yml @@ -15,28 +15,6 @@ concurrency: cancel-in-progress: false jobs: - companion: - strategy: - fail-fast: false - matrix: - include: - - runner: blacksmith-2vcpu-ubuntu-2204 - github-runner: ubuntu-22.04 - - runner: macos-15 - github-runner: macos-15 - runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && matrix.github-runner || matrix.runner }} - timeout-minutes: 15 - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/node - with: - node-version: '22.22.0' - lockfiles: | - packages/claude-sdk-adapter/pnpm-lock.yaml - packages/mcode-harness/package-lock.json - - run: pnpm --dir packages/claude-sdk-adapter fetch - - uses: ./.github/actions/mcode-companion - linux: runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} timeout-minutes: 15 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0a307c7ad..b44cd4c1c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -190,8 +190,6 @@ jobs: include: - runner: ubuntu-24.04 architecture: amd64 - - runner: ubuntu-24.04-arm - architecture: arm64 runs-on: ${{ matrix.runner }} env: GOARCH: ${{ matrix.architecture }} diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 1a803da6f..2c61ece3c 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: upload-artifacts: - description: Retain the three native installers for packaging + description: Retain the Linux amd64 installer for packaging type: boolean default: true workflow_call: @@ -52,97 +52,56 @@ jobs: - uses: actions/setup-go@v7 with: go-version-file: go.mod - - uses: ./.github/actions/node - with: - node-version: '22.22.0' - lockfiles: | - packages/claude-sdk-adapter/pnpm-lock.yaml - packages/mcode-harness/package-lock.json - - name: Build the native daemon and test bundle boundaries - id: build - run: | - go build -ldflags "-X github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/cli.Version=$(git rev-parse HEAD)" -o "$RUNNER_TEMP/oac-daemon${{ runner.os == 'Windows' && '.exe' || '' }}" ./apps/daemon/cmd/oac-daemon - if [[ "$RUNNER_OS" == Linux ]]; then - CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$RUNNER_TEMP/oac-sandbox-io" ./apps/sandboxio/cmd/oac-sandbox-io - fi - node --test scripts/build-native-installer.test.mjs - name: Build and test the shared Core installer run: | go build -o "$RUNNER_TEMP/oac${{ runner.os == 'Windows' && '.exe' || '' }}" ./services/core/cmd/oac go test ./services/core/cmd/oac -count=1 -timeout=3m + - name: Reject unsupported self-hosted installations + if: runner.os != 'Linux' + run: go test ./apps/daemon/internal/cli -run '^TestNativeUnsupportedPlatformPrecedesInputAndState$' -count=1 -timeout=3m - name: Exercise the Windows Core launcher if: runner.os == 'Windows' shell: pwsh run: ./deploy/test_install.ps1 -Binary "$env:RUNNER_TEMP/oac.exe" - - name: Verify native download bootstrap and recovery - run: go test ./services/core/internal/nativeinstaller -count=1 -timeout=3m - - name: Native filesystem and process lifecycle - id: filesystem - run: >- - go test -race -count=1 - ./internal/runtimefs - ./apps/daemon/internal/paths - ./apps/daemon/internal/daemonize - ./apps/daemon/internal/agent/clirunner - - name: Native Harness process ownership - id: harness - run: >- - go test -race -count=1 - ./apps/daemon/internal/agent/codex - -run 'TestJSONRPCClientOwnsToolDescendants' - - name: Native installation - id: files - run: >- - go test -race -count=1 - ./apps/daemon/internal/cli - -run 'TestNative' - - name: Build pinned native components + - uses: actions/setup-node@v6 + if: runner.os == 'Linux' + with: + node-version: '22' + - name: Build the Linux amd64 launcher and Sandbox I/O + if: runner.os == 'Linux' + id: build run: | - pnpm --dir packages/claude-sdk-adapter install --frozen-lockfile - pnpm --dir packages/claude-sdk-adapter build --outDir "$RUNNER_TEMP/claude-compiled" - pnpm --dir packages/claude-sdk-adapter --config.extend-node-path=false --filter @oac/claude-sdk-adapter deploy --prod "$RUNNER_TEMP/claude-runtime" - rm -rf "$RUNNER_TEMP/claude-runtime/dist" - mv "$RUNNER_TEMP/claude-compiled" "$RUNNER_TEMP/claude-runtime/dist" - # Reify the dedicated frozen export lock without pnpm's symlink layout. - # Flattening isolated package links changes Node dependency resolution. - rm -rf "$RUNNER_TEMP/claude-runtime/node_modules" - pnpm --dir "$RUNNER_TEMP/claude-runtime" install --prod --frozen-lockfile --config.node-linker=hoisted --ignore-scripts - npm install --prefix "$RUNNER_TEMP/native-tools" --no-save @openai/codex@0.153.4 - - name: Restore or build the pinned MiniMax companion on Unix - if: runner.os != 'Windows' - uses: ./.github/actions/mcode-companion - - name: Package and exercise CLI-only installation, additions and reuse + CGO_ENABLED=0 go build -ldflags "-X github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/cli.Version=$(git rev-parse HEAD)" -o "$RUNNER_TEMP/oac-daemon" ./apps/daemon/cmd/oac-daemon + CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$RUNNER_TEMP/oac-sandbox-io" ./apps/sandboxio/cmd/oac-sandbox-io + node --test scripts/build-native-installer.test.mjs scripts/build-native-catalog.test.mjs + - name: Verify download recovery and launcher installation + if: runner.os == 'Linux' + id: files + run: go test -race -count=1 ./services/core/internal/nativeinstaller ./apps/daemon/internal/cli ./apps/daemon/internal/daemonize ./apps/daemon/internal/agent/clirunner ./internal/runtimefs + - name: Prepare Sandbox I/O directories + if: runner.os == 'Linux' + run: sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /environment/workspace /environment/initialization /environment/packages /home/runtime + - name: Package and exercise launcher installation and reuse + if: runner.os == 'Linux' id: package - run: | - if [[ "$RUNNER_OS" == Windows ]]; then - export CLAUDE_CODE_GIT_BASH_PATH='C:\Program Files\Git\bin\bash.exe' - fi - node scripts/build-native-installer-ci.mjs - - name: Bootstrap, authenticate, install and connect natively - id: onboarding + run: node scripts/build-native-installer-ci.mjs + - name: Bootstrap, authenticate, install and connect if: runner.os == 'Linux' + id: onboarding run: node scripts/native-onboarding-smoke.mjs - - name: Verify native Harness protocols without model requests - id: protocol - run: | - if [[ "$RUNNER_OS" == Windows ]]; then - export CLAUDE_CODE_GIT_BASH_PATH='C:\Program Files\Git\bin\bash.exe' - fi - node scripts/native-harness-smoke.mjs --codex-binary "$RUNNER_TEMP/native-installer/components/codex/bin/codex${{ runner.os == 'Windows' && '.exe' || '' }}" --claude-runtime "$RUNNER_TEMP/native-installer/components/claude" - - name: Archive the native distribution - run: | - cd "$RUNNER_TEMP" - tar -C native-installer -czf "oac-native-installer-${{ runner.os }}-${{ runner.arch }}.tar.gz" . + - name: Archive the Linux amd64 installer + if: runner.os == 'Linux' + run: tar -C "$RUNNER_TEMP/native-installer" -czf "$RUNNER_TEMP/oac-native-installer-Linux-X64.tar.gz" . - uses: actions/upload-artifact@v6 - if: inputs.upload-artifacts + if: inputs.upload-artifacts && runner.os == 'Linux' with: - name: oac-native-installer-${{ runner.os }}-${{ runner.arch }} - path: ${{ runner.temp }}/oac-native-installer-*.tar.gz + name: oac-native-installer-Linux-X64 + path: ${{ runner.temp }}/oac-native-installer-Linux-X64.tar.gz if-no-files-found: error retention-days: 7 compression-level: 0 - name: Record failed native check phases - if: failure() + if: failure() && runner.os == 'Linux' env: PHASES: ${{ toJSON(steps) }} PLATFORM: ${{ matrix.platform }} @@ -157,7 +116,7 @@ jobs: writeFileSync(join(dir, 'phases.json'), JSON.stringify({ platform: process.env.PLATFORM, phases }, null, 2)); JS - name: Retain native failure diagnostics - if: failure() + if: failure() && runner.os == 'Linux' uses: actions/upload-artifact@v6 with: name: native-diagnostics-${{ matrix.platform }}-${{ github.run_attempt }} @@ -178,3 +137,5 @@ jobs: go-version-file: go.mod - run: go test ./services/core/cmd/oac -count=1 -timeout=3m - run: go build -o "$RUNNER_TEMP/oac" ./services/core/cmd/oac + - name: Reject unsupported self-hosted installations + run: go test ./apps/daemon/internal/cli -run '^TestNativeUnsupportedPlatformPrecedesInputAndState$' -count=1 -timeout=3m diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8ff4ab28..e7931da11 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,14 +109,10 @@ jobs: bash scripts/prepare-release-runtimes.sh - uses: actions/download-artifact@v6 with: - pattern: oac-native-installer-* - merge-multiple: true + name: oac-native-installer-Linux-X64 path: ${{ runner.temp }}/native-artifacts - name: Assemble the native installation catalog run: node scripts/build-native-catalog.mjs "$RUNNER_TEMP/native-artifacts" "$RUNNER_TEMP/native-installers" - - uses: docker/setup-qemu-action@v3 - with: - platforms: arm64 - uses: ./.github/actions/e2b-provider - name: Build matched artifacts env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8897a73c..0b90b9b1b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,6 @@ This guide owns how to work in the repository: documentation ownership, the repo | Provider registration and lifecycle | [Sandbox Provider guide](docs/sandbox-provider.md) | | Sandbox deployment, selection and administrative transitions | [Sandbox deployment](contracts/agents-api/sandbox-deployment.md) | | Operator node tasks | [Nodes guide](docs/getting-started/nodes.md) | -| Codex, Claude and MiniMax Runtime adapters and images | [Codex](services/core/deploy/codex/README.md), [Claude](services/core/deploy/claude/README.md), [MiniMax](services/core/deploy/mcode/README.md) | | Claude private SDK bridge | [Claude SDK adapter](packages/claude-sdk-adapter/README.md) | | E2B template construction | [E2B template builder](services/core/deploy/e2b/README.md) | | E2B and microsandbox Provider helper implementation | [E2B helper](services/core/tools/e2b-provider/README.md), [microsandbox helper](services/core/tools/microsandbox-provider/README.md) | diff --git a/Makefile b/Makefile index c824b9783..51c8022a8 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ SQLC_VERSION ?= v1.29.0 SQLC ?= go run github.com/sqlc-dev/sqlc/cmd/sqlc@$(SQLC_VERSION) SWAG_VERSION ?= v1.16.4 -.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-sandbox-io build-core check-core check-core-packages check-core-integration docker-build-core check-core-container build-codex-runtime build-claude-runtime build-claude-sdk-runtime build-mcode-harness build-mcode-runtime +.PHONY: help check check-database check-go check-sqlc sqlc-generate node-deps check-claude-sdk check-web check-mcode-harness build-daemon build-sandbox-io build-core check-core check-core-packages check-core-integration docker-build-core check-core-container build-claude-sdk-runtime build-mcode-harness help: @printf '%s\n' 'make build-core Build standalone Core commands' 'make build-daemon Build the execution daemon' 'make build-sandbox-io Build the Sandbox I/O service for Linux' 'make check Run Core, persistence and runtime checks' 'See README.md for runtime prerequisites and deployment.' @@ -158,18 +158,9 @@ check-mcode-harness: @for script in packages/mcode-harness/*.mjs; do node --check "$$script"; done bash -n scripts/build-mcode-harness.sh scripts/build-mcode-runtime.sh -build-codex-runtime: - ./scripts/build-codex-runtime.sh - -build-claude-runtime: - ./scripts/build-claude-runtime.sh - build-mcode-harness: ./scripts/build-mcode-harness.sh -build-mcode-runtime: - ./scripts/build-mcode-runtime.sh - .PHONY: build-microsandbox-provider check-microsandbox-provider build-microsandbox-provider: @test "$$(go env GOOS)" = linux || { echo 'The microsandbox provider helper requires Linux' >&2; exit 1; } diff --git a/apps/daemon/internal/agent/claudesdk/installation.go b/apps/daemon/internal/agent/claudesdk/installation.go index 2bc814f6a..0bb7af3bb 100644 --- a/apps/daemon/internal/agent/claudesdk/installation.go +++ b/apps/daemon/internal/agent/claudesdk/installation.go @@ -1,23 +1,14 @@ package claudesdk import ( - "context" - "fmt" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "path/filepath" - "runtime" ) func Installation() agent.Installation { - return agent.Installation{AgentKind: "claude_sdk", Version: "0.3.269", Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows" }, + return agent.Installation{AgentKind: "claude_sdk", Environment: func(dir, node string) map[string]string { return map[string]string{"OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT": filepath.Join(dir, "dist", "main.js"), "OAC_RUNTIME_CLAUDE_SDK_NODE": node} }, - Check: func(ctx context.Context, dir, node string, env []string) error { - got, err := CheckRuntime(ctx, Config{Node: node, Entrypoint: filepath.Join(dir, "dist", "main.js"), Env: env}) - if err != nil || got.SDK != "0.3.269" || !got.SupportsLocalRuntime() { - return fmt.Errorf("Claude installation is incompatible; Windows requires Git Bash") - } - return nil - }} + } } diff --git a/apps/daemon/internal/agent/codex/installation.go b/apps/daemon/internal/agent/codex/installation.go index fe8e645b5..2bbfec8b6 100644 --- a/apps/daemon/internal/agent/codex/installation.go +++ b/apps/daemon/internal/agent/codex/installation.go @@ -1,10 +1,7 @@ package codex import ( - "context" - "fmt" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" "path/filepath" "runtime" ) @@ -17,15 +14,9 @@ func Installation() agent.Installation { } return filepath.Join(dir, "bin", name) } - return agent.Installation{AgentKind: "codex", Version: "0.153.4", Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows" }, + return agent.Installation{AgentKind: "codex", Environment: func(dir, node string) map[string]string { return map[string]string{"OAC_RUNTIME_CODEX_BIN": binary(dir)} }, - Check: func(ctx context.Context, dir, node string, env []string) error { - got, err := installroot.Probe(ctx, binary(dir), []string{"--version"}, env, dir) - if err != nil || got != "codex-cli 0.153.4" { - return fmt.Errorf("Codex installation is incompatible") - } - return nil - }} + } } diff --git a/apps/daemon/internal/agent/installation.go b/apps/daemon/internal/agent/installation.go index 6c13bf9d7..837b74d85 100644 --- a/apps/daemon/internal/agent/installation.go +++ b/apps/daemon/internal/agent/installation.go @@ -2,7 +2,6 @@ package agent import ( "bytes" - "context" "encoding/json" "fmt" "maps" @@ -11,15 +10,10 @@ import ( "slices" ) -// Installation is an optional adapter-owned native distribution contract. -// It supplies activation paths and readiness checks, never execution or model -// configuration. Runtime owns copying, checksums, publication and installation locks. +// Installation supplies adapter-owned activation paths for agent-host images. type Installation struct { AgentKind string - Version string - Supported func() bool Environment func(directory, node string) map[string]string - Check func(context.Context, string, string, []string) error } // ManifestEnvironment reads the installation manifest at path, which an diff --git a/apps/daemon/internal/agent/installroot/probe.go b/apps/daemon/internal/agent/installroot/probe.go deleted file mode 100644 index d2932e3f8..000000000 --- a/apps/daemon/internal/agent/installroot/probe.go +++ /dev/null @@ -1,36 +0,0 @@ -// Package installroot probes native adapter installations. -package installroot - -import ( - "context" - "errors" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" - "io" - "strings" - "time" -) - -// Native diagnostics are deliberately discarded: dependencies may echo their -// environment. Readiness is separate from model credentials and a live Turn. -func Probe(parent context.Context, binary string, args, env []string, dir string) (string, error) { - ctx, cancel := context.WithTimeout(parent, 25*time.Second) - defer cancel() - p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Env: env, Dir: dir, KillTimeout: 250 * time.Millisecond}) - if err != nil { - return "", errors.New("native component failed to start") - } - defer p.Cancel() - done := make(chan struct{}) - go func() { _, _ = io.Copy(io.Discard, p.Stderr); close(done) }() - raw, err := io.ReadAll(io.LimitReader(p.Stdout, 64*1024+1)) - if err != nil || len(raw) > 64*1024 { - p.Cancel() - } - _, _ = io.Copy(io.Discard, p.Stdout) - <-done - waitErr := p.Wait() - if err != nil || waitErr != nil || ctx.Err() != nil || len(raw) > 64*1024 { - return "", errors.New("native component compatibility check failed") - } - return strings.TrimSpace(string(raw)), nil -} diff --git a/apps/daemon/internal/agent/mcode/installation.go b/apps/daemon/internal/agent/mcode/installation.go index 5f6937fc1..ffed50501 100644 --- a/apps/daemon/internal/agent/mcode/installation.go +++ b/apps/daemon/internal/agent/mcode/installation.go @@ -1,28 +1,14 @@ package mcode import ( - "context" - "fmt" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" "path/filepath" - "runtime" ) func Installation() agent.Installation { - return agent.Installation{AgentKind: "mcode", Version: SupportedVersion, Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" }, + return agent.Installation{AgentKind: "mcode", Environment: func(dir, node string) map[string]string { return map[string]string{"OAC_RUNTIME_MCODE_BIN": filepath.Join(dir, "native", "cli.js"), "OAC_RUNTIME_MCODE_NODE": node, "OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE": filepath.Join(dir, "bridge.mjs")} }, - Check: func(ctx context.Context, dir, node string, env []string) error { - got, err := installroot.Probe(ctx, node, []string{filepath.Join(dir, "native", "cli.js"), "--version"}, env, dir) - if err != nil || got != SupportedVersion { - return fmt.Errorf("MiniMax installation is incompatible") - } - raw, err := installroot.Probe(ctx, node, []string{filepath.Join(dir, "check.mjs")}, env, dir) - if err != nil || ValidateWorkspaceReadiness([]byte(raw)) != nil { - return fmt.Errorf("MiniMax companion is unavailable or incompatible; use the current native distribution and verify Bash") - } - return nil - }} + } } diff --git a/apps/daemon/internal/agent/mcode/workspace_readiness.go b/apps/daemon/internal/agent/mcode/workspace_readiness.go deleted file mode 100644 index 4c496943b..000000000 --- a/apps/daemon/internal/agent/mcode/workspace_readiness.go +++ /dev/null @@ -1,19 +0,0 @@ -package mcode - -import ( - "encoding/json" - "fmt" -) - -// ValidateWorkspaceReadiness checks the installed companion's private contract. -// Neither upstream version alone nor a successful CLI --version proves cleanup. -func ValidateWorkspaceReadiness(raw []byte) error { - var info struct { - Protocol int `json:"protocol"` - Native, Source string - } - if len(raw) > 4096 || json.Unmarshal(raw, &info) != nil || info.Protocol != 2 || info.Native != SupportedVersion || info.Source != "33b259bbbeb1c16433390869938191d09bdb0680" { - return fmt.Errorf("mcode: workspace companion check failed") - } - return nil -} diff --git a/apps/daemon/internal/agent/mcode/workspace_readiness_test.go b/apps/daemon/internal/agent/mcode/workspace_readiness_test.go deleted file mode 100644 index 319a4aefc..000000000 --- a/apps/daemon/internal/agent/mcode/workspace_readiness_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package mcode - -import ( - "strings" - "testing" -) - -func TestValidateWorkspaceReadinessRejectsInvalidDescriptors(t *testing.T) { - if err := ValidateWorkspaceReadiness([]byte(`{"protocol":2,"native":"0.4.12","source":"33b259bbbeb1c16433390869938191d09bdb0680"}`)); err != nil { - t.Fatal(err) - } - for _, raw := range []string{"", "null", "{", strings.Repeat(" ", 4097), - `{"protocol":1,"native":"0.4.12","source":"33b259bbbeb1c16433390869938191d09bdb0680"}`, - `{"protocol":2,"native":"0.4.11","source":"33b259bbbeb1c16433390869938191d09bdb0680"}`, - `{"protocol":2,"native":"0.4.12","source":"other"}`} { - if err := ValidateWorkspaceReadiness([]byte(raw)); err == nil || err.Error() != "mcode: workspace companion check failed" { - t.Fatal("invalid descriptor accepted or exposed", err) - } - } -} diff --git a/apps/daemon/internal/cli/native_bundle.go b/apps/daemon/internal/cli/native_bundle.go index 3f2126ce4..55975a9b2 100644 --- a/apps/daemon/internal/cli/native_bundle.go +++ b/apps/daemon/internal/cli/native_bundle.go @@ -2,45 +2,20 @@ package cli import ( "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" "errors" - "fmt" "io" "os" - "path" "path/filepath" - "reflect" "runtime" - "slices" - "strings" ) // This is release content, never an input file for installation options. type nativeBundle struct { - Schema int `json:"schema"` - DaemonVersion string `json:"daemon_version"` - OS string `json:"os"` - Arch string `json:"arch"` - Components map[string]nativeComponent `json:"components"` + Schema int `json:"schema"` + DaemonVersion string `json:"daemon_version"` + OS string `json:"os"` + Arch string `json:"arch"` } -type nativeComponent struct { - Version string `json:"version"` - Files map[string]nativeFile `json:"files"` -} -type nativeFile struct { - SHA256 string `json:"sha256"` - Executable bool `json:"executable"` -} - -var nativePins = func() map[string]string { - out := map[string]string{"node": "22.22.0"} - for name, spec := range nativeHarnesses { - out[name] = spec.Version - } - return out -}() func readNativeJSON(file string, value any) error { f, err := os.Open(file) @@ -54,8 +29,7 @@ func readNativeJSON(file string, value any) error { } return decodeEnvironmentJSON(raw, value) } - -func readNativeBundle(directory string, selected []string) (nativeBundle, error) { +func readNativeBundle(directory string) (nativeBundle, error) { var b nativeBundle if readNativeJSON(filepath.Join(directory, "bundle.json"), &b) != nil { return b, errors.New("install: use the native installer distribution containing bundle.json") @@ -63,199 +37,9 @@ func readNativeBundle(directory string, selected []string) (nativeBundle, error) if b.Schema != 1 || b.DaemonVersion != Version || b.OS != runtime.GOOS || b.Arch != runtime.GOARCH { return b, errors.New("install: distribution version or platform mismatch; use a matching current release") } - for _, name := range append([]string{"node"}, selected...) { - c, ok := b.Components[name] - if !ok || c.Version != nativePins[name] || len(c.Files) == 0 { - return b, fmt.Errorf("install: pinned %s component missing from this distribution", name) - } - for file, info := range c.Files { - if !validBundlePath(file) || len(info.SHA256) != 64 { - return b, errors.New("install: invalid component file manifest") - } - if _, err := hex.DecodeString(info.SHA256); err != nil { - return b, errors.New("install: invalid component digest") - } - } - } return b, nil } -func validBundlePath(name string) bool { - return name != "." && path.Clean(name) == name && !strings.ContainsAny(name, "\\\x00\r\n:") && filepath.IsLocal(filepath.FromSlash(name)) && name != ".oac-install.json" -} - -func nativeComponentRoot(root, name string) string { return filepath.Join(root, "components", name) } - -func componentReceipt(root string) (nativeComponent, error) { - var c nativeComponent - err := readNativeJSON(filepath.Join(root, ".oac-install.json"), &c) - return c, err -} - -var errNativeComponentMismatch = errors.New("installed files do not match the verified release") - -func checkComponentFiles(ctx context.Context, directory string, c nativeComponent) error { - root, err := os.OpenRoot(directory) - if err != nil { - return err - } - defer root.Close() - for name, expected := range c.Files { - if !validBundlePath(name) { - return errors.New("invalid installed path") - } - f, err := root.Open(filepath.FromSlash(name)) - if err != nil { - return err - } - info, statErr := f.Stat() - if statErr != nil { - f.Close() - return statErr - } - if !info.Mode().IsRegular() { - f.Close() - return errNativeComponentMismatch - } - h := sha256.New() - _, copyErr := nativeCopy(ctx, h, f) - f.Close() - if copyErr != nil { - return copyErr - } - if hex.EncodeToString(h.Sum(nil)) != expected.SHA256 || (runtime.GOOS != "windows" && expected.Executable && info.Mode().Perm()&0100 == 0) { - return errNativeComponentMismatch - } - } - return nil -} - -// A complete component is published once. An interruption cannot turn a partial -// copy into an installation; a subsequent run can reuse an already-published one. -func installNativeComponent(ctx context.Context, source, root, name string, expected nativeComponent) error { - dest := nativeComponentRoot(root, name) - if _, err := os.Lstat(dest); err == nil { - got, e := componentReceipt(dest) - if e != nil || !reflect.DeepEqual(got, expected) { - return fmt.Errorf("install: existing %s is incompatible; preserve it and use a separate installation directory", name) - } - if err := checkComponentFiles(ctx, dest, got); err != nil { - if errors.Is(err, errNativeComponentMismatch) || errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("install: existing %s is incomplete or modified; reinstall separately", name) - } - return fmt.Errorf("install: cannot verify existing %s: %w", name, err) - } - return nil - } else if !errors.Is(err, os.ErrNotExist) { - return err - } - parent := filepath.Dir(dest) - if err := os.MkdirAll(parent, 0700); err != nil { - return err - } - tmp, err := os.MkdirTemp(parent, ".install-"+name+"-") - if err != nil { - return err - } - defer os.RemoveAll(tmp) - src, err := os.OpenRoot(filepath.Join(source, "components", name)) - if err != nil { - return err - } - defer src.Close() - var required uint64 - for name := range expected.Files { - if !validBundlePath(name) { - return errors.New("install: invalid component path") - } - info, err := src.Stat(filepath.FromSlash(name)) - if err != nil { - return err - } - if !info.Mode().IsRegular() || info.Size() < 0 || uint64(info.Size()) > ^uint64(0)-required { - return errors.New("install: invalid component size") - } - required += uint64(info.Size()) - } - if err = requireNativeSpace(parent, required); err != nil { - return err - } - for name, expectedFile := range expected.Files { - if err := ctx.Err(); err != nil { - return err - } - if !validBundlePath(name) { - return errors.New("install: invalid component path") - } - in, err := src.Open(filepath.FromSlash(name)) - if err != nil { - return errors.New("install: component file unavailable") - } - info, err := in.Stat() - if err != nil || !info.Mode().IsRegular() { - in.Close() - return errors.New("install: component must contain regular files") - } - destination := filepath.Join(tmp, filepath.FromSlash(name)) - if err = os.MkdirAll(filepath.Dir(destination), 0700); err != nil { - in.Close() - return err - } - mode := os.FileMode(0600) - if expectedFile.Executable { - mode = 0700 - } - out, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) - if err != nil { - in.Close() - return err - } - h := sha256.New() - _, err = io.Copy(io.MultiWriter(out, h), nativeCopyReader{ctx: ctx, Reader: in}) - in.Close() - if err == nil { - err = out.Sync() - } - closeErr := out.Close() - if err != nil { - return fmt.Errorf("install: component copy failed: %w", err) - } - if closeErr != nil { - return fmt.Errorf("install: component write failed: %w", closeErr) - } - if hex.EncodeToString(h.Sum(nil)) != expectedFile.SHA256 { - return errors.New("install: component checksum failed") - } - } - raw, _ := json.Marshal(expected) - if err = os.WriteFile(filepath.Join(tmp, ".oac-install.json"), raw, 0600); err != nil { - return err - } - if err := ctx.Err(); err != nil { - return err - } - return os.Rename(tmp, dest) -} - -func selectedNativeHarnesses(value string) ([]string, error) { - var out []string - for _, name := range strings.Split(value, ",") { - name = strings.TrimSpace(name) - spec, ok := nativeHarnesses[name] - if !ok { - return nil, errors.New("install: --harness requires codex,claude,minimax (comma-separated)") - } - if !spec.Supported() { - return nil, fmt.Errorf("install: %s adapter is unsupported on %s; select a supported Harness", name, runtime.GOOS) - } - if !slices.Contains(out, name) { - out = append(out, name) - } - } - slices.Sort(out) - return out, nil -} - type nativeCopyReader struct { io.Reader ctx context.Context diff --git a/apps/daemon/internal/cli/native_harness.go b/apps/daemon/internal/cli/native_harness.go deleted file mode 100644 index cf831a29a..000000000 --- a/apps/daemon/internal/cli/native_harness.go +++ /dev/null @@ -1,93 +0,0 @@ -package cli - -import ( - "context" - "errors" - "fmt" - - "os" - "path/filepath" - "runtime" - "strings" - - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudesdk" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/installroot" - "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" -) - -func nativeExe(name string) string { - if runtime.GOOS == "windows" { - return name + ".exe" - } - return name -} -func nativeNode(root string) string { - base := nativeComponentRoot(root, "node") - if runtime.GOOS != "windows" { - base = filepath.Join(base, "bin") - } - return filepath.Join(base, nativeExe("node")) -} - -func nativeHarnessEnvironment(root string, selected []string) map[string]string { - node := nativeNode(root) - values := map[string]string{"PATH": filepath.Dir(node) + string(os.PathListSeparator) + os.Getenv("PATH")} - for _, name := range selected { - dir := nativeComponentRoot(root, name) - values["PATH"] = filepath.Join(dir, "bin") + string(os.PathListSeparator) + values["PATH"] - for key, value := range nativeHarnesses[name].Environment(dir, node) { - values[key] = value - } - - } - return values -} - -func withNativeEnv(values map[string]string) []string { - env := make([]string, 0, len(os.Environ())+len(values)) - for _, entry := range os.Environ() { - key, _, _ := strings.Cut(entry, "=") - // Windows environment names are case-insensitive. - replaced := false - for k := range values { - if strings.EqualFold(k, key) { - replaced = true - break - } - } - if !replaced { - env = append(env, entry) - } - } - for key, value := range values { - env = append(env, key+"="+value) - } - return env -} - -// Installation registration is local to Runtime; Core does not see native paths. -var nativeHarnesses = map[string]agent.Installation{ - "codex": codex.Installation(), "claude": claudesdk.Installation(), "minimax": mcode.Installation(), -} -var probeNativeInstallation = checkNativeInstallation - -func checkNativeInstallation(ctx context.Context, root string, selected []string) error { - env := withNativeEnv(nativeHarnessEnvironment(root, selected)) - node := nativeNode(root) - version, err := installroot.Probe(ctx, node, []string{"--version"}, env, root) - if err != nil || version != "v"+nativePins["node"] { - return errors.New("install: bundled Node is unavailable or incompatible; use the matching native distribution") - } - for _, name := range selected { - spec, ok := nativeHarnesses[name] - if !ok || !spec.Supported() { - return fmt.Errorf("install: %s is unsupported on this platform", name) - } - if err = spec.Check(ctx, nativeComponentRoot(root, name), node, env); err != nil { - return fmt.Errorf("install: %s; no installed files were replaced", err) - } - } - return nil -} diff --git a/apps/daemon/internal/cli/native_install.go b/apps/daemon/internal/cli/native_install.go index b26ec277d..aa96d111c 100644 --- a/apps/daemon/internal/cli/native_install.go +++ b/apps/daemon/internal/cli/native_install.go @@ -11,8 +11,7 @@ import ( "fmt" "os" "path/filepath" - "slices" - "strings" + "runtime" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/daemonize" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/paths" @@ -21,14 +20,51 @@ import ( // Installation is local state, not an options-file input or an OS service. type nativeInstallation struct { - Version string `json:"version"` - Remote string `json:"remote_url"` - Environment string `json:"environment_id"` - Workspace string `json:"workspace_directory"` - Credential string `json:"credential_file"` - CapabilityDirectory string `json:"capability_directory"` - ToolEnvironmentFile string `json:"tool_environment_file,omitempty"` - Harnesses []string `json:"harnesses"` + Version string `json:"version"` + Remote string `json:"remote_url"` + Environment string `json:"environment_id"` + Credential string `json:"credential_file"` +} + +// UnsupportedPlatformError identifies unsupported local installation and startup. +type UnsupportedPlatformError struct{ OS, Arch string } + +func (e *UnsupportedPlatformError) Error() string { + return fmt.Sprintf("self-hosted installation requires Linux amd64; this platform is %s/%s", e.OS, e.Arch) +} +func requireNativePlatform() error { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + return &UnsupportedPlatformError{runtime.GOOS, runtime.GOARCH} + } + return nil +} + +// Directory preparation belongs to installation, before credential issuance. +var prepareNativeEnvironment = prepareNativeEnvironmentDirectories + +func prepareNativeEnvironmentDirectories(workspace string) error { + for _, directory := range []string{workspace, "/environment/workspace", "/environment/initialization", "/environment/packages", "/home/runtime"} { + if runtimefs.ValidateLocalPath(directory) != nil { + return errors.New("install: workspace must be a clean absolute directory") + } + if err := os.MkdirAll(directory, 0700); err != nil { + return fmt.Errorf("install: prepare %s for the current account with write and search permissions, then retry: %v", directory, err) + } + file, err := os.CreateTemp(directory, ".oac-install-check-") + if err != nil { + return fmt.Errorf("install: prepare %s for the current account with write and search permissions, then retry: %v", directory, err) + } + name := file.Name() + closeErr := file.Close() + removeErr := os.Remove(name) + if closeErr != nil { + return closeErr + } + if removeErr != nil { + return removeErr + } + } + return nil } func lockNativeInstallation(root string) (*os.Root, func(), error) { @@ -58,28 +94,20 @@ func validateNativeInstallation(c nativeInstallation) error { if !environmentUUID(c.Environment) { return errors.New("install: --environment-id requires a canonical Environment ID") } - for _, p := range []string{c.Workspace, c.Credential, c.CapabilityDirectory} { - if runtimefs.ValidateLocalPath(p) != nil { - return errors.New("install: --workspace, --credential-file and capability destination must be clean absolute paths") - } + if runtimefs.ValidateLocalPath(c.Credential) != nil { + return errors.New("install: --credential-file must be a clean absolute path") } - info, err := os.Stat(c.Workspace) - if err != nil || !info.IsDir() { - return errors.New("install: --workspace requires an existing directory") - } - if _, _, err = executorCredential(c.Credential, c.Environment); err != nil { + if _, _, err := executorCredential(c.Credential, c.Environment); err != nil { return err } - if c.ToolEnvironmentFile != "" { - var values map[string]string - if runtimefs.ValidateLocalPath(c.ToolEnvironmentFile) != nil || readNativeJSON(c.ToolEnvironmentFile, &values) != nil { - return errors.New("install: --tool-env-file requires an absolute JSON file containing string values") - } - } + return nil } func runInstall(rc *runContext, args []string) error { + if err := requireNativePlatform(); err != nil { + return err + } o, err := parseNativeInstall(rc, args) if errors.Is(err, flag.ErrHelp) { return nil @@ -102,19 +130,8 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO if runtimefs.ValidateLocalPath(o.Directory) != nil || runtimefs.ValidateLocalPath(o.Bundle) != nil { return errors.New("install: --install-dir and --bundle-dir must be clean absolute directories") } - selected, err := selectedNativeHarnesses(o.Harness) - if err != nil { - return err - } - if o.RequiredHarness != "" && !slices.Contains(selected, o.RequiredHarness) { - return errors.New("install: --harness must include the Session Harness") - } o.Version = Version - if o.CapabilityDirectory == "" { - o.CapabilityDirectory = filepath.Join(o.Directory, "capabilities") - } - bundle, err := readNativeBundle(o.Bundle, selected) - if err != nil { + if _, err := readNativeBundle(o.Bundle); err != nil { return err } held, unlock, err := lockNativeInstallation(o.Directory) @@ -125,16 +142,13 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO if err = cleanNativeTemporaryFiles(o.Directory); err != nil { return err } + if err = prepareNativeEnvironment(o.Workspace); err != nil { + return err + } if o.OnboardURL != "" { - if runtimefs.ValidateLocalPath(o.Workspace) != nil { - return errors.New("install: Session workspace is invalid on this platform") - } if err = prepareOnboardingCredential(ctx, o, held); err != nil { return err } - if err = os.MkdirAll(o.Workspace, 0700); err != nil { - return errors.New("install: cannot create workspace with the current user's permissions; prepare it manually and retry") - } } if err = validateNativeInstallation(o.nativeInstallation); err != nil { return err @@ -142,55 +156,28 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO var previous nativeInstallation raw, err := runtimefs.ReadPrivate(held, "installation.json", 1<<20) if err == nil { - if decodeEnvironmentJSON(raw, &previous) != nil || previous.Version != Version || len(previous.Harnesses) == 0 { + if decodeEnvironmentJSON(raw, &previous) != nil || previous.Version != Version { return errors.New("install: existing installation is unsupported; preserve it and reinstall separately") } - wanted := o.nativeInstallation - wanted.Harnesses = nil - comparison := previous - comparison.Harnesses = nil - before, _ := json.Marshal(comparison) - after, _ := json.Marshal(wanted) + before, _ := json.Marshal(previous) + after, _ := json.Marshal(o.nativeInstallation) if !bytes.Equal(before, after) { - return errors.New("install: existing connection settings differ; additive installation cannot replace them") + return errors.New("install: existing connection settings differ; installation cannot replace them") } } else if !errors.Is(err, os.ErrNotExist) { return errors.New("install: cannot read existing installation") } - all := append([]string{}, previous.Harnesses...) - for _, name := range selected { - if !slices.Contains(all, name) { - all = append(all, name) - } - } - slices.Sort(all) - // Verify existing components before adding any new one; never repair or - // upgrade an installed dependency as a side effect of adding a Harness. - if len(previous.Harnesses) > 0 { - if err = nativeInstallPhase(rc.stdout, "Verifying installed components", func() error { return verifyNativeComponents(ctx, o.Directory, previous.Harnesses) }); err != nil { - return err - } - } - if err = nativeInstallPhase(rc.stdout, "Installing Runtime", func() error { return installNativeBinary(ctx, o.Bundle, o.Directory, len(previous.Harnesses) > 0) }); err != nil { - return err - } - for _, name := range append([]string{"node"}, selected...) { - if err = nativeInstallPhase(rc.stdout, "Installing "+name, func() error { return installNativeComponent(ctx, o.Bundle, o.Directory, name, bundle.Components[name]) }); err != nil { - return err - } - } - if err = nativeInstallPhase(rc.stdout, "Checking installed programs", func() error { return probeNativeInstallation(ctx, o.Directory, all) }); err != nil { + if err = nativeInstallPhase(rc.stdout, "Installing sandbox launcher", func() error { return installNativeBinary(ctx, o.Bundle, o.Directory, previous.Version != "") }); err != nil { return err } if err = ctx.Err(); err != nil { return err } - o.Harnesses = all raw, _ = json.MarshalIndent(o.nativeInstallation, "", " ") if err = runtimefs.WritePrivateAtomic(held, "installation.json", raw); err != nil { return err } - fmt.Fprintln(rc.stdout, "Installation: ready; verified Harnesses:", all) + fmt.Fprintln(rc.stdout, "Installation: ready.") if o.OnboardURL == "" { fmt.Fprintln(rc.stdout, "Host connection: not checked by install; run the installed oac-daemon start, then check Host connection in Core.") } @@ -198,25 +185,6 @@ func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallO return nil } -func verifyNativeComponents(ctx context.Context, root string, selected []string) error { - for _, name := range append([]string{"node"}, selected...) { - if _, ok := nativePins[name]; !ok { - return errors.New("installation contains an unsupported Harness") - } - c, err := componentReceipt(nativeComponentRoot(root, name)) - if err != nil || c.Version != nativePins[name] || len(c.Files) == 0 { - return fmt.Errorf("installed %s is missing, modified or incompatible; reinstall separately (no automatic repair or upgrade)", name) - } - if err = checkComponentFiles(ctx, nativeComponentRoot(root, name), c); err != nil { - if errors.Is(err, errNativeComponentMismatch) || errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("installed %s is missing or modified; reinstall separately", name) - } - return fmt.Errorf("install: cannot verify installed %s: %w", name, err) - } - } - return nil -} - // installNativeBinary installs the running oac-daemon and the distribution's // other programs (nativeBundlePrograms) into bin. func installNativeBinary(ctx context.Context, bundle, root string, existing bool) error { @@ -228,7 +196,7 @@ func installNativeBinary(ctx context.Context, bundle, root string, existing bool if err = os.MkdirAll(dir, 0700); err != nil { return err } - if err = installNativeProgram(ctx, exe, dir, nativeExe("oac-daemon"), existing); err != nil { + if err = installNativeProgram(ctx, exe, dir, "oac-daemon", existing); err != nil { return err } for _, name := range nativeBundlePrograms { @@ -281,7 +249,7 @@ func installNativeProgram(ctx context.Context, source, dir, name string, existin if err = requireNativeSpace(dir, uint64(info.Size())); err != nil { return err } - out, err := os.CreateTemp(dir, "."+strings.TrimSuffix(name, ".exe")+"-") + out, err := os.CreateTemp(dir, "."+name+"-") if err != nil { return err } @@ -304,6 +272,9 @@ func installNativeProgram(ctx context.Context, source, dir, name string, existin } func runStart(rc *runContext, args []string) error { + if err := requireNativePlatform(); err != nil { + return err + } ctx, stop := daemonize.NotifyContext(context.Background()) defer stop() flags := newFlagSet("start") diff --git a/apps/daemon/internal/cli/native_install_input.go b/apps/daemon/internal/cli/native_install_input.go index f8a18c8fb..0b6824545 100644 --- a/apps/daemon/internal/cli/native_install_input.go +++ b/apps/daemon/internal/cli/native_install_input.go @@ -15,9 +15,9 @@ import ( type nativeInstallOptions struct { nativeInstallation - Directory, Bundle, Harness string - OnboardURL, Authorization, RequiredHarness string - Interactive, NonInteractive bool + Directory, Bundle, Workspace string + OnboardURL, Authorization string + Interactive, NonInteractive bool } func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, error) { @@ -27,13 +27,10 @@ func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, er flags.StringVar(&o.Authorization, "authorization", "", "short-lived installation authorization (never an executor credential)") flags.StringVar(&o.Remote, "remote", "", "Environment remote_url from Core") flags.StringVar(&o.Environment, "environment-id", "", "Environment ID from Core") - flags.StringVar(&o.Workspace, "workspace", "", "existing absolute workspace directory") + flags.StringVar(&o.Workspace, "workspace", "", "absolute workspace directory to prepare") flags.StringVar(&o.Credential, "credential-file", "", "absolute executor credential JSON file (never the token)") - flags.StringVar(&o.CapabilityDirectory, "capability-directory", "", "capability snapshot destination") - flags.StringVar(&o.ToolEnvironmentFile, "tool-env-file", "", "optional tool environment JSON file") flags.StringVar(&o.Directory, "install-dir", "", "installation directory (default OAC_RUNTIME_HOME or ~/.oac)") flags.StringVar(&o.Bundle, "bundle-dir", "", "native distribution directory (defaults beside the executable)") - flags.StringVar(&o.Harness, "harness", "", "comma-separated codex,claude,minimax") flags.BoolVar(&o.Interactive, "interactive", false, "ask for missing installation options") flags.BoolVar(&o.NonInteractive, "non-interactive", false, "require command-line options; never prompt") if err := flags.Parse(args); err != nil { @@ -94,15 +91,12 @@ func promptNativeInstall(input io.Reader, output io.Writer, o *nativeInstallOpti value *string optional bool }{ - {"Harnesses (comma-separated codex,claude,minimax)", &o.Harness, false}, {"Installation directory (Enter uses current default)", &o.Directory, true}, {"Distribution directory (Enter uses current default)", &o.Bundle, true}, {"Environment remote URL", &o.Remote, false}, {"Environment ID", &o.Environment, false}, - {"Existing workspace directory", &o.Workspace, false}, + {"Workspace directory", &o.Workspace, false}, {"Credential JSON file path (do not enter a token)", &o.Credential, false}, - {"Capability snapshot directory (optional)", &o.CapabilityDirectory, true}, - {"Tool environment JSON file path (optional)", &o.ToolEnvironmentFile, true}, } { if *p.value != "" && !p.optional { continue @@ -124,19 +118,16 @@ func promptNativeInstall(input io.Reader, output io.Writer, o *nativeInstallOpti func promptOnboarding(r *bufio.Reader, output io.Writer, o *nativeInstallOptions) error { fmt.Fprintf(output, "Environment: %s\nWorkspace: %s (set by this Session)\n", o.Environment, o.Workspace) - if o.Harness == "" { - o.Harness = o.RequiredHarness - } for _, item := range []struct { label string value *string }{ - {"Harnesses, comma-separated", &o.Harness}, {"Installation directory", &o.Directory}, + {"Installation directory", &o.Directory}, } { fmt.Fprintf(output, "%s [%s]: ", item.label, *item.value) line, err := r.ReadString('\n') if err != nil { - return errors.New("install: interactive input ended; use --non-interactive with --harness and optional --install-dir") + return errors.New("install: interactive input ended; use --non-interactive with optional --install-dir") } if value := strings.TrimSpace(line); value != "" { *item.value = value diff --git a/apps/daemon/internal/cli/native_install_interrupt_unix_test.go b/apps/daemon/internal/cli/native_install_interrupt_unix_test.go deleted file mode 100644 index d8a7e84da..000000000 --- a/apps/daemon/internal/cli/native_install_interrupt_unix_test.go +++ /dev/null @@ -1,108 +0,0 @@ -//go:build unix - -package cli - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "io" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "testing" - "time" -) - -func TestNativeInstallerInterruptHelper(t *testing.T) { - if os.Getenv("OAC_TEST_INSTALL_INTERRUPT") != "1" { - t.Skip("subprocess helper") - } - var args []string - if json.Unmarshal([]byte(os.Getenv("OAC_TEST_INSTALL_ARGS")), &args) != nil { - os.Exit(3) - } - err := runInstall(&runContext{stdout: io.Discard, stderr: io.Discard}, args) - if err != nil { - os.Exit(2) - } - os.Exit(0) -} - -func TestNativeInstallationInterruptReapsProbe(t *testing.T) { - _, args, root, bundle := nativeInstallFixture(t) - marker := filepath.Join(t.TempDir(), "probe-pids") - var b nativeBundle - if err := readNativeJSON(filepath.Join(bundle, "bundle.json"), &b); err != nil { - t.Fatal(err) - } - // A real, separately grouped subprocess must be reaped by installation's - // signal handler, not just by a caller returning a synthetic probe error. - script := []byte("#!/bin/sh\nsleep 300 &\nprintf '%s %s' \"$$\" \"$!\" > \"$OAC_TEST_PROBE_PIDS\"\nwait\n") - sum := sha256.Sum256(script) - b.Components["node"] = nativeComponent{Version: nativePins["node"], Files: map[string]nativeFile{"bin/node": {SHA256: hex.EncodeToString(sum[:]), Executable: true}}} - dir := filepath.Join(nativeComponentRoot(bundle, "node"), "bin") - if err := os.MkdirAll(dir, 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "node"), script, 0700); err != nil { - t.Fatal(err) - } - raw, _ := json.Marshal(b) - if err := os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600); err != nil { - t.Fatal(err) - } - arguments, _ := json.Marshal(args) - cmd := exec.Command(os.Args[0], "-test.run=^TestNativeInstallerInterruptHelper$") - cmd.Env = append(os.Environ(), "OAC_TEST_INSTALL_INTERRUPT=1", "OAC_TEST_INSTALL_ARGS="+string(arguments), "OAC_TEST_PROBE_PIDS="+marker) - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - defer func() { _ = cmd.Process.Kill() }() - var pids []string - deadline := time.Now().Add(10 * time.Second) - for time.Now().Before(deadline) { - if data, err := os.ReadFile(marker); err == nil { - pids = strings.Fields(string(data)) - if len(pids) == 2 { - break - } - } - time.Sleep(20 * time.Millisecond) - } - if len(pids) != 2 { - t.Fatal("probe did not start") - } - defer func() { - for _, id := range pids { - pid, _ := strconv.Atoi(id) - p, _ := os.FindProcess(pid) - _ = p.Kill() - } - }() - if err := cmd.Process.Signal(os.Interrupt); err != nil { - t.Fatal(err) - } - select { - case err := <-done: - if err == nil { - t.Fatal("interrupted installation succeeded") - } - case <-time.After(5 * time.Second): - t.Fatal("installer did not settle after interrupt") - } - for _, pid := range pids { - state, _ := exec.Command("ps", "-p", pid, "-o", "stat=").Output() - if s := strings.TrimSpace(string(state)); s != "" && !strings.HasPrefix(s, "Z") { - t.Fatal("interrupted installer left a running native process") - } - } - if _, err := os.Stat(filepath.Join(root, "daemon", "installation.json")); !errors.Is(err, os.ErrNotExist) { - t.Fatal("interrupted installation committed settings") - } -} diff --git a/apps/daemon/internal/cli/native_install_io.go b/apps/daemon/internal/cli/native_install_io.go index a3441b468..42d15bbcf 100644 --- a/apps/daemon/internal/cli/native_install_io.go +++ b/apps/daemon/internal/cli/native_install_io.go @@ -13,9 +13,8 @@ import ( // These exact temporary names are reserved by the installer, never workspace data. var nativeTemporaryNames = map[string]*regexp.Regexp{ - "components": regexp.MustCompile(`^\.install-(node|codex|claude|minimax)-[0-9]+$`), - "bin": regexp.MustCompile(`^\.(oac-daemon|oac-sandbox-io)-[0-9]+$`), - "daemon": regexp.MustCompile(`^\.(installation\.json|executor-credential\.json|sandbox-io-bootstrap\.json)-[0-9a-f]{24}\.tmp$`), + "bin": regexp.MustCompile(`^\.(oac-daemon|oac-sandbox-io)-[0-9]+$`), + "daemon": regexp.MustCompile(`^\.(installation\.json|executor-credential\.json|sandbox-io-bootstrap\.json)-[0-9a-f]{24}\.tmp$`), } // The caller holds the installation lock, including while recovering a failed copy. @@ -66,7 +65,7 @@ func nativeInstallError(err error) error { return nil } if nativeDiskFull(err) { - return errors.New("install: disk space or quota exhausted; free space and retry (completed components and credentials were preserved)") + return errors.New("install: disk space or quota exhausted; free space and retry (installed programs and credentials were preserved)") } if errors.Is(err, os.ErrPermission) { return errors.New("install: filesystem access denied; check directory permissions and files in use, then retry with the same account") diff --git a/apps/daemon/internal/cli/native_install_io_unix.go b/apps/daemon/internal/cli/native_install_io_linux.go similarity index 95% rename from apps/daemon/internal/cli/native_install_io_unix.go rename to apps/daemon/internal/cli/native_install_io_linux.go index 7cb72e0f6..cffbfc2b9 100644 --- a/apps/daemon/internal/cli/native_install_io_unix.go +++ b/apps/daemon/internal/cli/native_install_io_linux.go @@ -1,4 +1,4 @@ -//go:build linux || darwin +//go:build linux package cli diff --git a/apps/daemon/internal/cli/native_install_io_other.go b/apps/daemon/internal/cli/native_install_io_other.go new file mode 100644 index 000000000..0bc90e8f3 --- /dev/null +++ b/apps/daemon/internal/cli/native_install_io_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package cli + +import "os" + +func nativeAvailableSpace(string) (uint64, error) { return 0, requireNativePlatform() } +func nativeDiskFull(error) bool { return false } +func nativeTerminal(*os.File) bool { return false } diff --git a/apps/daemon/internal/cli/native_install_io_windows.go b/apps/daemon/internal/cli/native_install_io_windows.go deleted file mode 100644 index 3c43c0152..000000000 --- a/apps/daemon/internal/cli/native_install_io_windows.go +++ /dev/null @@ -1,24 +0,0 @@ -package cli - -import ( - "errors" - "golang.org/x/sys/windows" - "os" -) - -func nativeAvailableSpace(directory string) (uint64, error) { - name, err := windows.UTF16PtrFromString(directory) - if err != nil { - return 0, err - } - var free, total, available uint64 - err = windows.GetDiskFreeSpaceEx(name, &available, &total, &free) - return available, err -} -func nativeDiskFull(err error) bool { - return errors.Is(err, windows.ERROR_DISK_FULL) || errors.Is(err, windows.ERROR_HANDLE_DISK_FULL) || errors.Is(err, windows.ERROR_DISK_QUOTA_EXCEEDED) -} -func nativeTerminal(f *os.File) bool { - var mode uint32 - return windows.GetConsoleMode(windows.Handle(f.Fd()), &mode) == nil -} diff --git a/apps/daemon/internal/cli/native_install_recovery_test.go b/apps/daemon/internal/cli/native_install_recovery_test.go index 12dabf52f..a52dfd3f9 100644 --- a/apps/daemon/internal/cli/native_install_recovery_test.go +++ b/apps/daemon/internal/cli/native_install_recovery_test.go @@ -12,7 +12,7 @@ import ( func TestNativeInstallationCleansOnlyReservedPartialFiles(t *testing.T) { rc, args, root, _ := nativeInstallFixture(t) - names := []string{"components/.install-codex-12345/partial", "bin/.oac-daemon-9876", "daemon/.installation.json-0123456789abcdef01234567.tmp"} + names := []string{"bin/.oac-daemon-9876", "daemon/.installation.json-0123456789abcdef01234567.tmp"} for _, name := range names { path := filepath.Join(root, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { @@ -22,7 +22,7 @@ func TestNativeInstallationCleansOnlyReservedPartialFiles(t *testing.T) { t.Fatal(err) } } - keep := filepath.Join(root, "components", ".install-codex-user-notes") + keep := filepath.Join(root, "bin", ".oac-daemon-user-notes") if err := os.WriteFile(keep, []byte("keep"), 0600); err != nil { t.Fatal(err) } @@ -52,7 +52,7 @@ func TestNativeInstallationCleansOnlyReservedPartialFiles(t *testing.T) { t.Fatal("unrelated file changed") } output := rc.stdout.(*bytes.Buffer).String() - if !strings.Contains(output, "Installing codex") || strings.Contains(output, "\r") || strings.Contains(output, "\x1b") { + if !strings.Contains(output, "Installing sandbox launcher") || strings.Contains(output, "\r") || strings.Contains(output, "\x1b") { t.Fatalf("invalid redirected progress: %q", output) } } @@ -85,11 +85,11 @@ func TestNativeStagingCleanupDoesNotFollowLinks(t *testing.T) { if err := os.WriteFile(keep, []byte("keep"), 0600); err != nil { t.Fatal(err) } - parent := filepath.Join(root, "components") + parent := filepath.Join(root, "bin") if err := os.Mkdir(parent, 0700); err != nil { t.Fatal(err) } - if err := os.Symlink(outside, filepath.Join(parent, ".install-codex-123")); err != nil { + if err := os.Symlink(outside, filepath.Join(parent, ".oac-daemon-123")); err != nil { t.Skip("symlink permission unavailable") } if err := cleanNativeTemporaryFiles(root); err != nil { @@ -112,21 +112,14 @@ func TestNativeVerificationCancellationPreservesInstallation(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) cancel() - if err = verifyNativeComponents(ctx, root, []string{"codex"}); !errors.Is(err, context.Canceled) { - t.Fatalf("cancelled verification reported damage: %v", err) - } - b, err := readNativeBundle(bundle, []string{"codex"}) - if err != nil { - t.Fatal(err) - } - if err = installNativeComponent(ctx, bundle, root, "codex", b.Components["codex"]); !errors.Is(err, context.Canceled) { + if err = installNativeBinary(ctx, bundle, root, true); !errors.Is(err, context.Canceled) { t.Fatalf("cancelled reuse reported damage: %v", err) } after, err := os.ReadFile(config) if err != nil || !bytes.Equal(before, after) { t.Fatal("cancellation changed installation") } - if err = verifyNativeComponents(context.Background(), root, []string{"codex"}); err != nil { + if err = installNativeBinary(context.Background(), bundle, root, true); err != nil { t.Fatal(err) } } diff --git a/apps/daemon/internal/cli/native_install_test.go b/apps/daemon/internal/cli/native_install_test.go index 240344036..20063b7d0 100644 --- a/apps/daemon/internal/cli/native_install_test.go +++ b/apps/daemon/internal/cli/native_install_test.go @@ -2,11 +2,11 @@ package cli import ( "bytes" - "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" + "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "runtime" @@ -18,48 +18,38 @@ import ( func nativeInstallFixture(t *testing.T) (*runContext, []string, string, string) { t.Helper() - root := t.TempDir() - bundle := t.TempDir() - workspace := t.TempDir() + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + t.Skip("Linux amd64 installer") + } + root, bundle, workspace := t.TempDir(), t.TempDir(), t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) + // Fixed machine directories are exercised separately; each installer fixture + // prepares its declared workspace without changing this development machine. + previous := prepareNativeEnvironment + prepareNativeEnvironment = func(workspace string) error { return os.MkdirAll(workspace, 0700) } + t.Cleanup(func() { prepareNativeEnvironment = previous }) key := filepath.Join(root, "credential.json") environment := uuid.NewString() body, _ := json.Marshal(map[string]string{"key_id": uuid.NewString(), "executor_token": "private-test-credential", "environment_id": environment}) if err := os.WriteFile(key, body, 0600); err != nil { t.Fatal(err) } - b := nativeBundle{Schema: 1, DaemonVersion: Version, OS: runtime.GOOS, Arch: runtime.GOARCH, Components: map[string]nativeComponent{}} - for _, name := range []string{"node", "codex", "claude"} { - data := []byte("fixture " + name) - sum := sha256.Sum256(data) - b.Components[name] = nativeComponent{Version: nativePins[name], Files: map[string]nativeFile{"program": {SHA256: hex.EncodeToString(sum[:]), Executable: true}}} - dir := nativeComponentRoot(bundle, name) - if err := os.MkdirAll(dir, 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "program"), data, 0700); err != nil { - t.Fatal(err) - } + raw, _ := json.Marshal(nativeBundle{Schema: 1, DaemonVersion: Version, OS: runtime.GOOS, Arch: runtime.GOARCH}) + if err := os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600); err != nil { + t.Fatal(err) } for _, name := range nativeBundlePrograms { if err := os.WriteFile(filepath.Join(bundle, name), []byte("#!/bin/sh\nexit 0\n"), 0700); err != nil { t.Fatal(err) } } - raw, _ := json.Marshal(b) - if err := os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600); err != nil { - t.Fatal(err) - } - old := probeNativeInstallation - probeNativeInstallation = func(context.Context, string, []string) error { return nil } - t.Cleanup(func() { probeNativeInstallation = old }) output := new(bytes.Buffer) rc := &runContext{stdin: strings.NewReader("must not read"), stdout: output, stderr: output} - args := []string{"--non-interactive", "--bundle-dir", bundle, "--harness", "codex", "--remote", "ws://localhost:12345/api/v1/agent-daemon/ws", "--environment-id", environment, "--workspace", workspace, "--credential-file", key} + args := []string{"--non-interactive", "--bundle-dir", bundle, "--remote", "ws://localhost:12345/api/v1/agent-daemon/ws", "--environment-id", environment, "--workspace", workspace, "--credential-file", key} return rc, args, root, bundle } -func TestNativeInstallationAdditiveReuseAndVersionRejection(t *testing.T) { +func TestNativeInstallationReuseAndVersionRejection(t *testing.T) { rc, args, root, _ := nativeInstallFixture(t) if err := runInstall(rc, args); err != nil { t.Fatal(err) @@ -71,27 +61,30 @@ func TestNativeInstallationAdditiveReuseAndVersionRejection(t *testing.T) { } again, _ := os.ReadFile(file) if !bytes.Equal(first, again) { - t.Fatal("reuse rewrote settings") - } - if err := runInstall(rc, append(args, "--harness", "claude")); err != nil { - t.Fatal(err) + t.Fatal("reuse changed settings") } - var installed nativeInstallation - if err := readNativeJSON(file, &installed); err != nil { + var persisted map[string]any + if err := json.Unmarshal(first, &persisted); err != nil { t.Fatal(err) } - if strings.Join(installed.Harnesses, ",") != "claude,codex" { - t.Fatal("lost or failed to add Harness") + if len(persisted) != 4 { + t.Fatalf("unexpected persisted settings: %v", persisted) } if strings.Contains(rc.stdout.(*bytes.Buffer).String(), "private-test-credential") { t.Fatal("credential leaked") } - if err := runInstall(rc, append(args, "--workspace", t.TempDir())); err == nil { + if err := runInstall(rc, append(args, "--remote", "ws://localhost:4321/api/v1/agent-daemon/ws")); err == nil { t.Fatal("replaced connection settings") } + var installed nativeInstallation + if err := readNativeJSON(file, &installed); err != nil { + t.Fatal(err) + } installed.Version = "old-version" raw, _ := json.Marshal(installed) - _ = os.WriteFile(file, raw, 0600) + if err := os.WriteFile(file, raw, 0600); err != nil { + t.Fatal(err) + } if err := runInstall(rc, args); err == nil { t.Fatal("accepted historical installation") } @@ -103,42 +96,52 @@ func TestNativeInstallationAdditiveReuseAndVersionRejection(t *testing.T) { } } -func TestNativeInstallationFailureAndRecoveryPreserveExisting(t *testing.T) { - rc, args, root, bundle := nativeInstallFixture(t) - if err := runInstall(rc, args); err != nil { - t.Fatal(err) - } - file := filepath.Join(root, "daemon", "installation.json") - before, _ := os.ReadFile(file) - probeNativeInstallation = func(context.Context, string, []string) error { return errors.New("synthetic unavailable") } - if err := runInstall(rc, append(args, "--harness", "claude")); err == nil { - t.Fatal("published failed installation") - } - if after, _ := os.ReadFile(file); !bytes.Equal(before, after) { - t.Fatal("failed addition changed settings") - } - probeNativeInstallation = func(context.Context, string, []string) error { return nil } - if err := runInstall(rc, append(args, "--harness", "claude")); err != nil { - t.Fatalf("could not reuse complete unpublished component: %v", err) - } - // Source corruption cannot affect an existing component, and cannot be used - // to repair a modified installed one silently. - _ = os.WriteFile(filepath.Join(nativeComponentRoot(root, "codex"), "program"), []byte("changed"), 0700) - if err := runInstall(rc, args); err == nil { - t.Fatal("silently repaired modified executable") - } - if got, _ := os.ReadFile(filepath.Join(nativeComponentRoot(root, "codex"), "program")); string(got) != "changed" { - t.Fatal("overwrote installed data") +func TestNativeInstallationDoesNotRepairPrograms(t *testing.T) { + for _, program := range []string{"oac-daemon", "oac-sandbox-io"} { + for _, missing := range []bool{true, false} { + t.Run(program+"/"+map[bool]string{true: "missing", false: "modified"}[missing], func(t *testing.T) { + rc, args, root, _ := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + config := filepath.Join(root, "daemon", "installation.json") + before, _ := os.ReadFile(config) + binary := filepath.Join(root, "bin", program) + var err error + if missing { + err = os.Remove(binary) + } else { + err = os.WriteFile(binary, []byte("modified"), 0700) + } + if err != nil { + t.Fatal(err) + } + if err := runInstall(rc, args); err == nil { + t.Fatal("silently repaired installed executable") + } + if missing { + if _, err := os.Stat(binary); !errors.Is(err, os.ErrNotExist) { + t.Fatal("recreated missing program") + } + } else { + if raw, _ := os.ReadFile(binary); string(raw) != "modified" { + t.Fatal("replaced modified program") + } + } + if after, _ := os.ReadFile(config); !bytes.Equal(before, after) { + t.Fatal("failure changed settings") + } + }) + } } - _ = bundle } func TestNativeInstallationMissingInputAndSecrets(t *testing.T) { rc, _, root, _ := nativeInstallFixture(t) - for _, args := range [][]string{{"--non-interactive"}, {"--non-interactive", "--remote", "ws://user:secret@host/x"}, {"--interactive=secret"}, {"--token", "private-test-credential"}} { + for _, args := range [][]string{{"--non-interactive"}, {"--non-interactive", "--remote", "ws://user:secret@host/x"}, {"--interactive=secret"}, {"--token", "private-test-credential"}, {"--harness", "codex"}, {"--capability-directory", "/tmp"}, {"--tool-env-file", "/tmp"}} { err := runInstall(rc, args) if err == nil { - t.Fatal("accepted incomplete input") + t.Fatal("accepted incomplete or obsolete input") } if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "private-test-credential") { t.Fatal("echoed sensitive argument") @@ -149,43 +152,7 @@ func TestNativeInstallationMissingInputAndSecrets(t *testing.T) { } } -func TestNativeInstallationInteractiveMultipleHarnesses(t *testing.T) { - rc, args, root, bundle := nativeInstallFixture(t) - var o nativeInstallOptions - // Prompt and parameter paths feed the same installer options. - o.Directory = root - o.Bundle = bundle - // Existing connection fields need no second interactive input. - for i := 0; i < len(args)-1; i++ { - switch args[i] { - case "--remote": - o.Remote = args[i+1] - case "--environment-id": - o.Environment = args[i+1] - case "--workspace": - o.Workspace = args[i+1] - case "--credential-file": - o.Credential = args[i+1] - } - } - input := "codex,claude\n\n\n\n\n" - if err := promptNativeInstall(strings.NewReader(input), rc.stdout, &o); err != nil { - t.Fatal(err) - } - if err := runInstall(rc, append(args, "--harness", o.Harness)); err != nil { - t.Fatal(err) - } - var c nativeInstallation - _ = readNativeJSON(filepath.Join(root, "daemon", "installation.json"), &c) - if len(c.Harnesses) != 2 { - t.Fatal("multi-selection not installed") - } - if err := promptNativeInstall(strings.NewReader(""), rc.stdout, &nativeInstallOptions{}); err == nil { - t.Fatal("EOF accepted") - } -} - -func TestNativeInstallationLockAndManifestContainment(t *testing.T) { +func TestNativeInstallationLockAndPlatformMismatch(t *testing.T) { rc, args, root, bundle := nativeInstallFixture(t) _, unlock, err := lockNativeInstallation(root) if err != nil { @@ -196,53 +163,68 @@ func TestNativeInstallationLockAndManifestContainment(t *testing.T) { } unlock() var b nativeBundle - _ = readNativeJSON(filepath.Join(bundle, "bundle.json"), &b) - c := b.Components["codex"] - c.Files["../escaped"] = c.Files["program"] - b.Components["codex"] = c + if err := readNativeJSON(filepath.Join(bundle, "bundle.json"), &b); err != nil { + t.Fatal(err) + } + b.Arch = "arm64" raw, _ := json.Marshal(b) - _ = os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600) - if err = runInstall(rc, args); err == nil { - t.Fatal("accepted manifest traversal") + if err := os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600); err != nil { + t.Fatal(err) } - if _, err = os.Stat(filepath.Join(root, "components")); !errors.Is(err, os.ErrNotExist) { - t.Fatal("manifest validation happened after mutation") + if err := runInstall(rc, args); err == nil { + t.Fatal("foreign bundle accepted") } } -func TestNativeInstallationPlatformMismatchAndUnsupportedSelection(t *testing.T) { - rc, args, _, bundle := nativeInstallFixture(t) - var b nativeBundle - _ = readNativeJSON(filepath.Join(bundle, "bundle.json"), &b) - b.OS = "other" - raw, _ := json.Marshal(b) - _ = os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600) - if err := runInstall(rc, args); err == nil { - t.Fatal("foreign bundle accepted") +func TestNativeInstallationDirectoryFailurePrecedesClaim(t *testing.T) { + rc, _, root, bundle := nativeInstallFixture(t) + environment := uuid.NewString() + claims := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/claim") { + claims++ + w.WriteHeader(204) + return + } + t.Error("unexpected request") + })) + defer server.Close() + prepareNativeEnvironment = func(string) error { return errors.New("directory not writable") } + options := nativeInstallOptions{nativeInstallation: nativeInstallation{Environment: environment, Remote: "ws://localhost:12345/api/v1/agent-daemon/ws"}, Directory: root, Bundle: bundle, Workspace: "/workspace", OnboardURL: server.URL} + if err := installNativeOptions(t.Context(), rc, &options); err == nil || !strings.Contains(err.Error(), "not writable") { + t.Fatalf("wrong failure: %v", err) } - if _, err := selectedNativeHarnesses("anything"); err == nil { - t.Fatal("unknown Harness accepted") + if claims != 0 { + t.Fatal("claimed credential before directory preparation") } - if runtime.GOOS == "windows" { - if _, err := selectedNativeHarnesses("minimax"); err == nil { - t.Fatal("unsupported Windows MiniMax accepted") - } + if _, err := os.Stat(filepath.Join(root, "daemon", "executor-credential.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("credential created before directory preparation") } } -func TestNativeInstallationDoesNotRepairMissingDaemon(t *testing.T) { - rc, args, root, _ := nativeInstallFixture(t) - if err := runInstall(rc, args); err != nil { - t.Fatal(err) +func TestNativeUnsupportedPlatformPrecedesInputAndState(t *testing.T) { + if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" { + t.Skip("unsupported-platform build") + } + rc := &runContext{stdout: io.Discard, stderr: io.Discard} + t.Setenv("OAC_RUNTIME_HOME", filepath.Join(t.TempDir(), "absent")) + for _, run := range []func(*runContext, []string) error{runInstall, runStart} { + err := run(rc, []string{"--invalid"}) + var platform *UnsupportedPlatformError + if !errors.As(err, &platform) { + t.Fatalf("expected typed platform error before parsing: %v", err) + } } - binary := filepath.Join(root, "bin", nativeExe("oac-daemon")) - if err := os.Remove(binary); err != nil { +} + +func TestNativeDirectoryFailureIdentifiesPreparation(t *testing.T) { + file := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(file, []byte("data"), 0600); err != nil { t.Fatal(err) } - if err := runInstall(rc, args); err == nil { - t.Fatal("silently repaired a modified installation") - } - if _, err := os.Stat(binary); !errors.Is(err, os.ErrNotExist) { - t.Fatal("recreated a removed daemon") + directory := filepath.Join(file, "workspace") + err := nativeInstallError(prepareNativeEnvironmentDirectories(directory)) + if err == nil || !strings.Contains(err.Error(), directory) || !strings.Contains(err.Error(), "current account") { + t.Fatalf("not actionable: %v", err) } } diff --git a/apps/daemon/internal/cli/native_onboarding.go b/apps/daemon/internal/cli/native_onboarding.go index 66aec60c7..3417342c6 100644 --- a/apps/daemon/internal/cli/native_onboarding.go +++ b/apps/daemon/internal/cli/native_onboarding.go @@ -88,14 +88,6 @@ func prepareOnboarding(o *nativeInstallOptions) error { return errors.New("install: supplied options conflict with the Session's frozen environment") } o.Remote, o.Environment, o.Workspace = info.RemoteURL, info.EnvironmentID, info.Workspace - for name, spec := range nativeHarnesses { - if spec.AgentKind == info.Harness { - o.RequiredHarness = name - } - } - if o.RequiredHarness == "" { - return errors.New("install: the Session requires an unsupported Harness") - } if o.Directory == "" { root, err := paths.Root() if err != nil { @@ -115,7 +107,7 @@ func prepareOnboarding(o *nativeInstallOptions) error { func prepareOnboardingCredential(ctx context.Context, o *nativeInstallOptions, held *os.Root) error { var previous nativeInstallation if raw, err := runtimefs.ReadPrivate(held, "installation.json", 1<<20); err == nil { - if decodeEnvironmentJSON(raw, &previous) != nil || previous.Version != Version || previous.Remote != o.Remote || previous.Environment != o.Environment || previous.Workspace != o.Workspace { + if decodeEnvironmentJSON(raw, &previous) != nil || previous.Version != Version || previous.Remote != o.Remote || previous.Environment != o.Environment { return errors.New("install: this directory belongs to an incompatible installation; choose a separate directory") } o.Credential = previous.Credential @@ -151,7 +143,7 @@ func prepareOnboardingCredential(ctx context.Context, o *nativeInstallOptions, h } func finishOnboarding(ctx context.Context, rc *runContext, o nativeInstallOptions) error { - executable := filepath.Join(o.Directory, "bin", nativeExe("oac-daemon")) + executable := filepath.Join(o.Directory, "bin", "oac-daemon") pidPath := filepath.Join(o.Directory, "daemon", paths.DefaultProfile, "connect.pid") if _, err := daemonize.ReadPIDFile(pidPath); err != nil { if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, daemonize.ErrStaleOrCorrupt) { @@ -160,7 +152,7 @@ func finishOnboarding(ctx context.Context, rc *runContext, o nativeInstallOption // Run the installed executable. A detached child must never reference the // temporary bundle or inherit the short-lived authorization in argv. command := exec.CommandContext(ctx, executable, "start") - command.Env = withNativeEnv(map[string]string{"OAC_RUNTIME_HOME": o.Directory, daemonize.BackgroundSentinelEnv: ""}) + command.Env = append(os.Environ(), "OAC_RUNTIME_HOME="+o.Directory, daemonize.BackgroundSentinelEnv+"=") command.Stdout, command.Stderr = rc.stdout, rc.stderr if err := command.Run(); err != nil { fmt.Fprintln(rc.stderr, "Host connection: start failed. Rerun this command to resume, or run the installed oac-daemon start.") diff --git a/apps/daemon/internal/cli/native_start_other.go b/apps/daemon/internal/cli/native_start_other.go index 5732cc434..e69a84528 100644 --- a/apps/daemon/internal/cli/native_start_other.go +++ b/apps/daemon/internal/cli/native_start_other.go @@ -4,16 +4,11 @@ package cli import ( "context" - "errors" ) // nativeBundlePrograms are the distribution's programs besides oac-daemon. var nativeBundlePrograms []string -// errSandboxPlatform rejects start off Linux: oac-sandbox-io, which serves a -// self-hosted machine, runs only on Linux. -var errSandboxPlatform = errors.New("start: self-hosted Environments run only on Linux") - func runSandboxLauncher(context.Context, *runContext, bool, string, nativeInstallation) error { - return errSandboxPlatform + return requireNativePlatform() } diff --git a/apps/daemon/internal/cli/root.go b/apps/daemon/internal/cli/root.go index 75d7ae0fb..4a7ba253d 100644 --- a/apps/daemon/internal/cli/root.go +++ b/apps/daemon/internal/cli/root.go @@ -34,7 +34,7 @@ func defaultRunContext() *runContext { // commands lists subcommands in --help render order: the user's // likely flow install → start → stop / logs. var commands = []command{ - {name: "install", summary: "Install a native daemon and selected Harnesses", run: runInstall}, + {name: "install", summary: "Install the sandbox launcher and Sandbox I/O", run: runInstall}, {name: "start", summary: "Start the installed native daemon", run: runStart}, {name: "agent-host", summary: "Serve Sessions from the agent-host container", run: runAgentHost}, {name: "stop", summary: "Stop the background daemon", run: runStop}, diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 3addd7584..86940ae04 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -360,7 +360,7 @@ Every resource list, the Session log and the project list share one grammar: - Sections follow: usage figures in a KPI strip, then tables in cards. - A Session's **History** header holds an outline "Jump to the failed Turn" (with the count when several failed) before the view switch while any Turn failed; it shows the conversation (the Turn table when there are no Items), scrolls the page body to the next failed Turn and focuses it. - An active project's page ends its keys with a **How to call** section (see Dialogs) before its write operations. -- A self-hosted Session's **Executor credentials** section ends with **Connect a host**: a Linux/macOS or PowerShell selector, the one copyable command Core generated for that platform, and a link to the native installation guide. The console shows Core's command as it is and never builds one. The command installs the daemon and its Harnesses, starts it and checks its connection; its authorization expires after 30 minutes. Requirements and reconnection details belong in the title help. Reconnection after credential rotation requires `stop`, replacement of the configured credential file, then `start`; disconnection does not imply process exit. Connection status comes only from Core. When Core has no command, a note replaces it; an archived project shows a note instead. +- A self-hosted Session's **Executor credentials** section ends with **Connect a host**: the one copyable command Core generated for Linux amd64 and a link to the native installation guide. The console shows Core's command as it is and never builds one. The command installs the launcher and Sandbox I/O service, starts the launcher and checks its connection; its authorization expires after 30 minutes. Requirements and reconnection details belong in the title help. Reconnection after credential rotation requires `stop`, replacement of the configured credential file, then `start`; disconnection does not imply process exit. Connection status comes only from Core. When Core has no command, a note replaces it; an archived project shows a note instead. ### Dialogs Dialogs are 448px Paper cards (960px when wide) with 8px corners, a 52px header and a 56px Margin Gray footer separated by Hairlines, and the overlay shadow. They cannot be closed while a request runs. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index f2022d3a8..b4ee933a1 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -56,7 +56,7 @@ The console runs beside the administrator's own Core, with execution, files and - **Executor credentials.** Core issues executor credentials; the console does so with the deployment's Core key. A Session page whose environment is self-hosted has an Executor credentials section: issue a credential (shown once as one line of JSON, to copy or download, never stored), rotate it (the old one stops working immediately) or revoke it (the executor disconnects; installed Runtime state and workspace contents are not deleted). The file lets one executor connect for that environment only; it cannot call the Agents API. - **Default provider observations.** Each configured harness offers Usage details for Core's last successful use and any newer classified provider error. Missing records remain unknown; an error at or before the last success is no longer actionable. These are best-effort observations, not readiness checks. Failed refreshes qualify retained records, and replacing the provider starts a new observation history. - **Host connection.** Core's connection observation and credential metadata share one five-second read while visible. Never connected, connected, disconnected, bound credential revoked, and unknown are distinct; a recent heartbeat alone never proves connectivity. Only a fresh connected read marks Host connected. Stale or failed reads withhold completion. Recovery rotates the bound key, stops the installed daemon, replaces the host credential file and starts the daemon again. -- **Connect a host.** The Linux/macOS and PowerShell commands come from Core's installation read for the Session's environment; the console shows them as they are, with a link to the native installation guide, and never builds one itself. A command downloads the matching installer, installs the chosen Harnesses, starts the daemon and checks its connection. Its authorization expires after 30 minutes; the console reads a fresh one every 20 minutes, and says the command is unavailable when Core has none. No model readiness is implied. Rotating a credential requires stopping the installed daemon, replacing the configured file and starting that same daemon again. A disconnected daemon may still be running; `start` alone does not replace it. +- **Connect a host.** The Linux amd64 command comes from Core's installation read for the Session's environment; the console shows it as it is, with a link to the native installation guide, and never builds one itself. The command installs the matching launcher and Sandbox I/O service, starts the launcher and checks its connection. Harnesses run on the agent host. Its authorization expires after 30 minutes; the console reads a fresh one every 20 minutes, and says the command is unavailable when Core has none. No model readiness is implied. Rotating a credential requires stopping the installed daemon, replacing the configured file and starting that same daemon again. A disconnected daemon may still be running; `start` alone does not replace it. - **Typed write errors.** Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Unknown codes retain Core's fallback message; uncertain write outcomes stay form-level and are never retried automatically. - **Read failures.** Overview and Session log distinguish unavailable reads from successful empty results. Failed reads have a visible retry; retained or partial data says it may be incomplete or out of date, and Session filter totals stay missing while any required read has failed. Only successful empty reads show zero. - **Local-only address.** Overview, Nodes and System warn when Core reports `local_only` and lead to System to review the public address. Add node is unavailable with a reason; Getting started keeps the first step to do until the public address is fixed. An unread installation address cannot complete that step, and a failed read offers Retry. diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 108f1da9e..769bda9b4 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -654,7 +654,7 @@ http.createServer(async (request, response) => { const path = url.pathname.slice("/core/v1".length); if (path === "/harnesses" || path.startsWith("/harnesses/")) return await harnessRoute(request, response, path); const installation = path.match(/^\/projects\/([^/]+)\/environments\/([^/]+)\/installation$/); - if (installation && request.method === "GET") return send(response, 200, { status: "available", version: "fixture", expires_at: Math.floor(Date.now()/1000)+1800, commands: { posix: "bash fixture-bootstrap --authorization fixture-short-lived", powershell: "& fixture-bootstrap.ps1 -Authorization fixture-short-lived" } }); + if (installation && request.method === "GET") return send(response, 200, { status: "available", version: "fixture", expires_at: Math.floor(Date.now()/1000)+1800, commands: { posix: "bash fixture-bootstrap --authorization fixture-short-lived" } }); const credentials = path.match(EXECUTOR_CREDENTIALS); if (credentials) return await executorCredentialRoute(request, response, credentials[1], credentials[2], credentials[3]); return write ? await adminWrite(request, response, path) : adminRead(response, path, url); diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index be9449e51..dfe5981a5 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -82,9 +82,8 @@ test("shows a self-hosted Session's install command, issues its credential once, const install = section.getByRole("region", { name: "Connect a host" }); await expect(install.getByLabel("Executor install command").locator("pre")).toHaveText("bash fixture-bootstrap --authorization fixture-short-lived"); await expect(install.getByRole("link")).toHaveAttribute("href", /docs\/getting-started\/self-hosted.md$/); - await install.getByRole("combobox", { name: "Host platform" }).click(); - await page.getByRole("option", { name: "Windows · PowerShell" }).click(); - await expect(install.locator("pre")).toContainText("fixture-bootstrap.ps1 -Authorization fixture-short-lived"); + await expect(install).toContainText("Linux amd64"); + await expect(install.getByRole("combobox")).toHaveCount(0); await install.screenshot({ path: test.info().outputPath("native-host.png") }); await section.getByRole("button", { name: "Issue credential" }).click(); diff --git a/apps/web/src/features/sessions/ExecutorInstallPanel.tsx b/apps/web/src/features/sessions/ExecutorInstallPanel.tsx index 6d12b4143..acad0401e 100644 --- a/apps/web/src/features/sessions/ExecutorInstallPanel.tsx +++ b/apps/web/src/features/sessions/ExecutorInstallPanel.tsx @@ -1,12 +1,12 @@ import { Check, Copy } from "lucide-react"; -import { useEffect, useId, useRef, useState, type RefObject } from "react"; +import { useEffect, useId, useRef, type RefObject } from "react"; import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; -import { ConsoleSelect } from "../../components/console-select"; import { useCopy } from "../api-keys/IssuedKey"; import { useQuery } from "@tanstack/react-query"; import { admin } from "../../lib/projects"; -import type { ExecutorInstall, HostShell } from "./executor-install"; + +type ExecutorInstall = { kind: "unavailable" } | { kind: "ready"; commands: { posix: string } }; export function useExecutorInstall(projectId: string, environmentId: string, archived: boolean): ExecutorInstall { const query = useQuery({ @@ -27,7 +27,6 @@ export function useExecutorInstall(projectId: string, environmentId: string, arc export function ExecutorInstallPanel({ install, archived, connected = false }: { install: ExecutorInstall; archived: boolean; connected?: boolean }) { const { t } = useTranslation("sessions"); const headingId = useId(); - const [shell, setShell] = useState("posix"); return

{t("executor.install.title")}

@@ -40,8 +39,7 @@ export function ExecutorInstallPanel({ install, archived, connected = false }: {

{t(archived ? "executor.install.archived" : "executor.install.steps")}

{t("executor.install.guide")} {install.kind === "ready" ? <> - { if (value === "posix" || value === "powershell") setShell(value); }} /> - +

{t("executor.install.start")}

:

{t("executor.install.unavailable")}

}
; diff --git a/apps/web/src/features/sessions/executor-install.ts b/apps/web/src/features/sessions/executor-install.ts deleted file mode 100644 index 941ce1565..000000000 --- a/apps/web/src/features/sessions/executor-install.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type HostShell = "posix" | "powershell"; -export type ExecutorInstall = { kind: "unavailable" } | { kind: "ready"; commands: Record }; diff --git a/apps/web/src/i18n/locales/en/sessions.ts b/apps/web/src/i18n/locales/en/sessions.ts index be06deeb7..3d7899f7e 100644 --- a/apps/web/src/i18n/locales/en/sessions.ts +++ b/apps/web/src/i18n/locales/en/sessions.ts @@ -198,11 +198,10 @@ export const sessions = { hostDone: "Host connected", hostPending: "Awaiting connection", title: "Connect a host", - lifecycle: "Install and run the daemon with your current account. Choose one or more Harnesses; the Session Harness is required. Windows does not support MiniMax. The workspace remains the one selected for this Session.", - steps: "Copy this command and run it on your machine. It downloads the matching installer, installs your Harnesses, starts the daemon and checks its connection.", + lifecycle: "Connect a Linux amd64 machine using your current account. The machine runs the Sandbox I/O service; Harnesses run on the agent host. The workspace remains the one selected for this Session.", + steps: "Copy this command and run it on your Linux amd64 machine. It downloads the matching launcher and Sandbox I/O service, starts the launcher and checks its connection.", archived: "This project is archived. New installation authorizations are unavailable.", guide: "Installation guide", - platform: "Host platform", start: "The command authorization expires after 30 minutes. Connection does not verify model access.", unavailable: "An installation command is unavailable. Refresh the Session or ask the Core operator to check its native installation artifacts.", terminal: "Terminal", diff --git a/apps/web/src/i18n/locales/zh-CN/sessions.ts b/apps/web/src/i18n/locales/zh-CN/sessions.ts index a5bb065d3..b491bba15 100644 --- a/apps/web/src/i18n/locales/zh-CN/sessions.ts +++ b/apps/web/src/i18n/locales/zh-CN/sessions.ts @@ -195,11 +195,10 @@ export const sessions = { hostDone: "主机已连接", hostPending: "等待连接", title: "连接主机", - lifecycle: "使用当前账户安装和运行 daemon。可选择多个 Harness,其中必须包含此 Session 使用的 Harness;Windows 不支持 MiniMax。工作目录与此 Session 的配置保持一致。", - steps: "复制命令并在自己的机器上运行,即可下载匹配的安装包、安装 Harness、启动 daemon 并验证连接。", + lifecycle: "使用当前账户连接 Linux amd64 机器。机器运行 Sandbox I/O 服务,Harness 在 agent host 上运行。工作目录与此 Session 的配置保持一致。", + steps: "复制命令并在 Linux amd64 机器上运行,即可下载匹配的启动器和 Sandbox I/O 服务、启动并验证连接。", archived: "此项目已归档,无法获取新的安装授权。", guide: "安装指南", - platform: "主机系统", start: "命令中的安装授权在 30 分钟后过期。连接成功不代表模型调用可用。", unavailable: "安装命令暂不可用。请刷新 Session,或请 Core 管理员检查原生安装包。", terminal: "终端", diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index 2bdf90a9b..34884a61d 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -20,7 +20,7 @@ Session create, retrieve and update responses of a `self_hosted` Session carry ` | `status` | `available`, or `unavailable` when this Core has no matching native installers; `message` then says so | | `version` | The Core build the commands install | | `expires_at` | Unix time when the grant expires, 30 minutes after the response | -| `commands.posix`, `commands.powershell` | The install command for Linux/macOS and for Windows PowerShell | +| `commands.posix` | The install command for Linux amd64 | The grant is bound to the Environment, the Session creator's principal and the Core build. It stops working when it expires, when the Session is deleted, when the Project is archived or when Core runs a different build. Reading the Session again returns a fresh grant. Core stores no grant: each response signs a new one, and stored events never carry it. Treat the command as a temporary secret: it can claim the credential, but it cannot run work or read files. @@ -30,9 +30,9 @@ The installer calls these machine routes on Core: | Route | Authorization | Purpose | | --- | --- | --- | -| `GET /api/v1/agent-daemon/install/{version}/bootstrap.sh`, `bootstrap.ps1` | None | Platform bootstrap scripts | +| `GET /api/v1/agent-daemon/install/{version}/bootstrap.sh` | None | Linux bootstrap script | | `GET /api/v1/agent-daemon/install/{version}/{os}-{arch}.sha256`, `{os}-{arch}.tar.gz` | None | Installer checksum and archive. Core serves a local copy, or redirects (307) to the versioned release URL in its catalog | -| `POST /api/v1/agent-daemon/installation` | Grant | The frozen binding: `version`, `protocol_version`, `environment_id`, `remote_url`, `workspace_directory`, `harness` | +| `POST /api/v1/agent-daemon/installation` | Grant | The frozen binding: `version`, `protocol_version`, `environment_id`, `remote_url`, `workspace_directory` | | `POST /api/v1/agent-daemon/installation/claim` | Grant | `{"executor_token":"SECRET"}`; 204 | An invalid or expired grant returns 401 `installation_authorization_invalid`. Without matching installers the grant routes return 503 `installation_unavailable`. Core signs each grant with the installation's [`secrets/core/credential.key`](../../docs/configuration.md#compose-installations). A malformed secret returns 400. Artifact routes carry no credential, and the grant is sent only to Core, never to an artifact host. diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index c2e982225..d84f74c16 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -210,13 +210,13 @@ Keep provider keys in private operator files, never in commits or logs. Existing ## Native installer participation -An adapter may supply `agent.Installation` from `installation.go` in its own package: registered kind, pinned version, supported platforms, activation environment and a bounded readiness probe. Register it in `cli/native_harness.go` and add its pinned component to the native distribution builder. This optional contract does not change Executor and Turn semantics. The Runtime owns checksums, copying, locks and additive installation; adapters own native layout and probes. Validate installation and execution on each advertised platform. Missing or incompatible native content fails; it never installs itself during a Turn. +An adapter supplies `agent.Installation` from `installation.go` in its own package: its registered kind and activation environment. The agent host uses this declaration to activate the packaged Harness. Adapters own native layout; validate the packaged content and execution on the Linux agent host. Missing or incompatible native content fails; it never installs itself during a Turn. Self-hosted installers carry no Harness or Node.js. -The agent-host image uses the same contract. `deploy/distribution/AgentHost.Dockerfile` installs each Harness in its own directory and lists it in the image's manifest, `/opt/oac/harnesses.json`, and `agent.ManifestEnvironment` activates it from there through `Installation.Environment`. A Harness the agent host runs is added there too. +`deploy/distribution/AgentHost.Dockerfile` installs each Harness in its own directory and lists it in the image's manifest, `/opt/oac/harnesses.json`. `agent.ManifestEnvironment` activates it through `Installation.Environment`. Add each new Harness to that image and manifest; use the shared [image build](../../docs/maintainers.md#runtime-images-and-helpers) and [view qualification](#qualify-the-view) workflow. ## Native process ownership -The daemon's `clirunner` starts every native child in its own Unix process group (a Job object on Windows); other hosts reject the launch. Explicit and parent-context cancellation share a TERM grace period (three seconds by default) and a bounded KILL escalation. An internal reaper also cleans remaining group members when the direct process exits, even if a descendant still holds stdout open; during cancellation, surviving descendants keep the remaining grace after the leader exits. The daemon's `stop` command waits up to ten seconds for confirmed shutdown, which covers that grace period and the pipe and owner cleanup after it. +The daemon's `clirunner` starts every native child in its own process group on Linux; other platforms return its typed unsupported error. Explicit and parent-context cancellation share a TERM grace period (three seconds by default) and a bounded KILL escalation. An internal reaper also cleans remaining group members when the direct process exits, even if a descendant still holds stdout open; during cancellation, surviving descendants keep the remaining grace after the leader exits. The daemon's `stop` command waits up to ten seconds for confirmed shutdown, which covers that grace period and the pipe and owner cleanup after it. Owned output pipes stay readable after the leader exits. Consumers drain stdout and stderr before calling `Wait`, which joins the cached process result and closes the readers. `Done` reports leader reaping and group cleanup signals; it is not a native execution receipt or proof of persisted history. SDK adapters settle each Turn and drain its observations before publishing completion, and Executor close also closes the query and awaits the native child. Process groups are lifecycle supervision, not isolation or containment of descendants that leave the group. @@ -328,8 +328,8 @@ Run the adapter's Turns, cancellation and continuation in a view, then qualify e ## Native references -| Harness | Adapter | Native transport | Runtime guide | -| --- | --- | --- | --- | -| Codex | [`agent/codex`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/codex/executor.go) | app-server | [Codex Runtime](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md) | -| Claude Code | [`agent/claudesdk`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/claudesdk/executor.go) | [TypeScript SDK bridge](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/claude-sdk-adapter/README.md) | [Claude Runtime](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/claude/README.md) | -| MiniMax Code | [`agent/mcode`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/apps/daemon/internal/agent/mcode) | ACP and native workspace companion | [MiniMax Code Runtime](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/mcode/README.md) | +| Harness | Adapter | Native transport | +| --- | --- | --- | +| Codex | [`agent/codex`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/codex/executor.go) | app-server | +| Claude Code | [`agent/claudesdk`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/claudesdk/executor.go) | [TypeScript SDK bridge](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/claude-sdk-adapter/README.md) | +| MiniMax Code | [`agent/mcode`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/apps/daemon/internal/agent/mcode) | ACP and native workspace companion | diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 9996a9068..05cffe0a4 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -115,6 +115,8 @@ Each item is Core's deliberate or native behavior where the official service beh **Execution and history** +- `self_hosted` installation supports Linux amd64 only. `oac-daemon install` and `start` return `UnsupportedPlatformError` on other platforms, before any installation credential claim. macOS, Windows and Linux arm64 Sandbox I/O execution are not qualified; see [self-hosted platforms](../../docs/getting-started/self-hosted.md#platforms). + - The stream does not emit reasoning-summary events, Environment `pending` or `ready` events, or every pinned interim tool-output variant. - Native Item variants beyond those listed under [Turns and Items](./sessions-events.md#turns-and-items) are not projected, and Items cannot be modified. - A function result that cancellation prevents from being applied never appears as an Item. diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml index c326ac074..2c27bacc4 100644 --- a/contracts/agents-api/runtime.openapi.yaml +++ b/contracts/agents-api/runtime.openapi.yaml @@ -189,8 +189,6 @@ definitions: properties: environment_id: type: string - harness: - type: string protocol_version: type: string remote_url: diff --git a/contracts/agents-api/v1/installation.go b/contracts/agents-api/v1/installation.go index 9ae3a993e..49b7f86d0 100644 --- a/contracts/agents-api/v1/installation.go +++ b/contracts/agents-api/v1/installation.go @@ -22,5 +22,4 @@ type NativeInstallationContext struct { EnvironmentID string `json:"environment_id"` RemoteURL string `json:"remote_url"` Workspace string `json:"workspace_directory"` - Harness string `json:"harness"` } diff --git a/contracts/agents-api/zh/environment-executor-credentials.md b/contracts/agents-api/zh/environment-executor-credentials.md index fe5666e12..ab6c0592d 100644 --- a/contracts/agents-api/zh/environment-executor-credentials.md +++ b/contracts/agents-api/zh/environment-executor-credentials.md @@ -1,7 +1,7 @@ --- title: "Environment 执行器凭证" source: contracts/agents-api/environment-executor-credentials.md -source_hash: b1e07476ee5307bb57f58a94547a9ebde1cf377aeb456117c150632108435f7e +source_hash: 54ab0ff969c6a8c69799358cd0c7627fa8b508405b77833654f0ab47bfeb3341 --- 执行器凭证允许 `oac-daemon` 为一个 `self_hosted` Environment 注册,并通过 [sandbox Link](../../../docs/zh/sandbox-link-protocol.md) 为它提供服务。它只授权该 Environment 的私有 daemon 路由(`/api/v1/agent-daemon/*`),以及注册后在 Link 上 Serve 该 Environment 的 enrollment resource,不授权 `/v1`、`/core/v1`、sandbox node 注册或 Project 资源。Project 的 principal 是其执行 principal。Core 只保存密钥摘要。 @@ -22,7 +22,7 @@ Core 不创建、停止或回收机器。断开连接、撤销凭证或删除 Se | `status` | `available`;当 Core 没有匹配的原生安装器时为 `unavailable`,此时 `message` 说明原因 | | `version` | 命令安装的 Core 构建版本 | | `expires_at` | grant 到期的 Unix 时间,为响应生成后 30 分钟 | -| `commands.posix`, `commands.powershell` | Linux/macOS 和 Windows PowerShell 的安装命令 | +| `commands.posix` | Linux amd64 的安装命令 | grant 绑定 Environment、Session 创建者的 principal 和 Core 构建版本。在到期、Session 被删除、Project 被归档或 Core 运行另一构建版本时失效。重新读取 Session 会获得新 grant。Core 不存储 grant:每个响应重新签名,存储的事件从不包含它。将命令视为临时秘密:它能领取凭证,但不能执行工作或读取文件。 @@ -32,9 +32,9 @@ grant 绑定 Environment、Session 创建者的 principal 和 Core 构建版本 | 路由 | 授权 | 用途 | | --- | --- | --- | -| `GET /api/v1/agent-daemon/install/{version}/bootstrap.sh`, `bootstrap.ps1` | 无 | 平台 bootstrap 脚本 | +| `GET /api/v1/agent-daemon/install/{version}/bootstrap.sh` | 无 | Linux bootstrap 脚本 | | `GET /api/v1/agent-daemon/install/{version}/{os}-{arch}.sha256`, `{os}-{arch}.tar.gz` | 无 | 安装器校验和与归档;Core 提供本地副本,或以 307 重定向到目录中的版本化发布 URL | -| `POST /api/v1/agent-daemon/installation` | Grant | 固定绑定:`version`、`protocol_version`、`environment_id`、`remote_url`、`workspace_directory`、`harness` | +| `POST /api/v1/agent-daemon/installation` | Grant | 固定绑定:`version`、`protocol_version`、`environment_id`、`remote_url`、`workspace_directory` | | `POST /api/v1/agent-daemon/installation/claim` | Grant | `{"executor_token":"SECRET"}`;204 | 无效或过期的 grant 返回 401 `installation_authorization_invalid`。没有匹配安装器时,grant 路由返回 503 `installation_unavailable`。Core 用安装的 [`secrets/core/credential.key`](../../../docs/zh/configuration.md#compose-installations) 签名每个 grant。格式错误的密钥返回 400。产物路由不携带凭证,grant 只发送给 Core,不发送给产物主机。 diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 3fdd1c863..0348ce0b3 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "添加 Harness" source: contracts/agents-api/harness-onboarding.md -source_hash: 45f460051d8c8f87045d0146f44f86fa9d3379974000870856eac7705f33347c +source_hash: 2436c12691cc2f2753f39df73a6f480f081c3ddef1936e40c7da12c09c36a35e --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、支持声明和验收。 @@ -212,13 +212,13 @@ Environment 验收使用 `services/core/tests/official_environment_{templates,se ## 原生安装器参与 {#native-installer-participation} -适配器可以从自身包中的 `installation.go` 提供 `agent.Installation`:已注册 kind、锁定版本、受支持平台、激活环境和有界就绪探测。在 `cli/native_harness.go` 中注册它,并将其锁定组件添加到原生分发构建器。此可选契约不会改变 Executor 和 Turn 语义。Runtime 负责校验和、复制、锁和增量安装;适配器负责原生布局和探测。必须在每个宣称的平台上验证安装和执行。原生内容缺失或不兼容时必须失败;绝不会在 Turn 期间自行安装。 +适配器从自身包中的 `installation.go` 提供 `agent.Installation`:已注册 kind 和激活环境。agent host 使用该声明激活打包的 Harness。适配器负责原生布局;必须在 Linux agent host 上验证打包内容和执行。原生内容缺失或不兼容时必须失败;绝不会在 Turn 期间自行安装。自托管安装器不携带 Harness 或 Node.js。 -agent-host 镜像使用同一契约。`deploy/distribution/AgentHost.Dockerfile` 把每个 Harness 安装在各自的目录中并列入镜像的清单 `/opt/oac/harnesses.json`,`agent.ManifestEnvironment` 再通过 `Installation.Environment` 从那里激活它。由 agent host 运行的 Harness 也要添加到这里。 +`deploy/distribution/AgentHost.Dockerfile` 把每个 Harness 安装在各自的目录中,并列入镜像清单 `/opt/oac/harnesses.json`。`agent.ManifestEnvironment` 通过 `Installation.Environment` 激活它。将每个新增 Harness 加入该镜像和清单,并使用共享的[镜像构建](../../../docs/zh/maintainers.md#runtime-images-and-helpers)与[视图验收](#qualify-the-view)流程。 ## 原生进程所有权 {#native-process-ownership} -daemon 的 `clirunner` 让每个原生子进程在自己的 Unix 进程组中启动(Windows 上为 Job 对象);其他主机会拒绝启动。显式取消和父上下文取消共享 TERM 宽限期(默认为三秒)以及有界的 KILL 升级过程。当直接进程退出时,内部回收器也会清理进程组的剩余成员,即使某个后代进程仍保持 stdout 打开;在取消过程中,主进程退出后,存活的后代进程仍会保留剩余宽限时间。daemon 的 `stop` 命令最多等待十秒以确认关闭,这涵盖该宽限期以及之后的管道和所有者清理。 +daemon 的 `clirunner` 在 Linux 上让每个原生子进程在自己的进程组中启动;其他平台返回其类型化的不支持错误。显式取消和父上下文取消共享 TERM 宽限期(默认为三秒)以及有界的 KILL 升级过程。当直接进程退出时,内部回收器也会清理进程组的剩余成员,即使某个后代进程仍保持 stdout 打开;在取消过程中,主进程退出后,存活的后代进程仍会保留剩余宽限时间。daemon 的 `stop` 命令最多等待十秒以确认关闭,这涵盖该宽限期以及之后的管道和所有者清理。 所属输出管道在主进程退出后仍可读取。消费者在调用 `Wait` 之前耗尽 stdout 和 stderr;`Wait` 会汇合缓存的进程结果并关闭读取器。`Done` 报告主进程回收和进程组清理信号;它不是原生执行回执,也不是历史已持久化的证据。SDK 适配器会结算每个 Turn,并在发布完成状态前耗尽其观察结果;Executor 关闭还会关闭 Query 并等待原生子进程。进程组用于生命周期监管,而不是隔离或遏制离开进程组的后代进程。 @@ -330,8 +330,8 @@ stdio 绑定在沙箱中以其别名运行。`ViewSession.MCP` 中索引为 `i` ## 原生参考 {#native-references} -| Harness | 适配器 | 原生传输方式 | Runtime 指南 | -| --- | --- | --- | --- | -| Codex | [`agent/codex`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/codex/executor.go) | app-server | [Codex Runtime](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md) | -| Claude Code | [`agent/claudesdk`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/claudesdk/executor.go) | [TypeScript SDK bridge](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/claude-sdk-adapter/README.md) | [Claude Runtime](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/claude/README.md) | -| MiniMax Code | [`agent/mcode`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/apps/daemon/internal/agent/mcode) | ACP 和原生工作区配套组件 | [MiniMax Code Runtime](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/mcode/README.md) | +| Harness | 适配器 | 原生传输方式 | +| --- | --- | --- | +| Codex | [`agent/codex`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/codex/executor.go) | app-server | +| Claude Code | [`agent/claudesdk`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/claudesdk/executor.go) | [TypeScript SDK bridge](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/claude-sdk-adapter/README.md) | +| MiniMax Code | [`agent/mcode`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/apps/daemon/internal/agent/mcode) | ACP 和原生工作区配套组件 | diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 98a3665fc..b00f58fd8 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "Agents API 覆盖台账" source: contracts/agents-api/index.md -source_hash: 65f39d4222c4f1bbab03e1c31d8eef367399a889a6bb50cbccc6dec42cc1cb88 +source_hash: 8feb92673f53693a526366264e0ff5094f69112859bfa0882e217a2b3ca07282 --- Core 旨在以下方固定版本为准支持完整的 OpenAI Agents API([public API rule](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/AGENTS.md#public-api))。本台账记录 Core 对各项资源实现了哪些内容、哪些契约保存其详细信息,并列出相对于 OpenAI 服务的所有已知差异和所有未解决缺口。[API namespaces and credentials](../../../docs/zh/api/index.md) 说明谁调用哪些 API;[Agents API guide](../../../docs/zh/api/public-agent-api.md) 介绍使用方法。 @@ -24,7 +24,6 @@ Core 旨在以下方固定版本为准支持完整的 OpenAI Agents API([publi Go 输入投影排除 `packages.system`,保留下方记录的明确拒绝行为。生成过程不会启用尚未支持的操作,也不会改变已存储安装配置的验证。 - 各项状态的证据必须来自固定版本的官方 SDK,以及针对运行中服务发出的原始 HTTP 请求,正如 [CONTRIBUTING](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#compatibility-evidence) 所要求。 ## 按资源划分的覆盖情况 {#coverage-by-resource} @@ -118,6 +117,8 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **执行和历史** +- `self_hosted` 安装仅支持 Linux amd64。`oac-daemon install` 和 `start` 在其他平台返回 `UnsupportedPlatformError`,且拒绝发生在领取安装凭据之前。macOS、Windows 和 Linux arm64 的 Sandbox I/O 执行尚未通过验收;参阅[自托管平台](../../../docs/zh/getting-started/self-hosted.md#platforms)。 + - 流不会发出 reasoning-summary 事件、Environment 的 `pending` 或 `ready` 事件,也不会覆盖固定版本中的所有临时 tool-output 变体。 - 除 [Turns and Items](sessions-events.md#turns-and-items) 中列出的变体外,其他原生 Item 变体不会被投影,而且 Items 无法修改。 - 如果取消导致函数结果无法应用,该结果将永远不会作为 Item 出现。 diff --git a/deploy/README.md b/deploy/README.md index 20a61d2e4..055bfa0d9 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -19,4 +19,4 @@ Web serves the console and forwards `/v1` and `/api/v1` to Core, so it is the on ## Native daemon installer -`oac-daemon install` installs the daemon and selected Harnesses on a self-hosted machine. The [credential contract](../contracts/agents-api/environment-executor-credentials.md#installation-grant) covers the grant it claims. The release catalog is in the Core image at `/opt/oac/native-installers`; Core serves it from there. Node installation is separate and stays in `node-install.pyz`. +`oac-daemon install` installs the launcher and Sandbox I/O on a Linux amd64 self-hosted machine. The [credential contract](../contracts/agents-api/environment-executor-credentials.md#installation-grant) covers the grant it claims. The release catalog is in the Core image at `/opt/oac/native-installers`; Core serves it from there. Node installation is separate and stays in `node-install.pyz`. diff --git a/deploy/distribution/AgentHost.Dockerfile b/deploy/distribution/AgentHost.Dockerfile index 338f73a44..314f8665a 100644 --- a/deploy/distribution/AgentHost.Dockerfile +++ b/deploy/distribution/AgentHost.Dockerfile @@ -1,13 +1,9 @@ -# The agent-host image and the sandbox image it works in. The context is what -# scripts/build-agent-host-images.sh prepares: the static oac-daemon, -# oac-process-shim and oac-sandbox-io, and the Runtime image builders' Harness -# payloads in codex/, claude/ and mcode/. Both images share the Runtime images' -# base and the sandbox shares their package layer. +# The context contains the static launcher, process shim and Sandbox I/O service, +# plus the validated Harness payloads in codex/, claude/ and mcode/. FROM node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27 AS base USER root -# The tools a Runtime image gives a sandbox, served by oac-sandbox-io. The -# caller appends the bootstrap file's path. +# Sandbox tools are served by oac-sandbox-io; the caller supplies its bootstrap. FROM base AS sandbox RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates bash git python3 python3-pip ripgrep \ @@ -21,9 +17,7 @@ ENTRYPOINT ["/usr/local/bin/oac-sandbox-io", "--bootstrap-file"] # Each Harness in its own directory, laid out as its agent.Installation # expects. harnesses.json is the only record of where they are; the agent host -# reads it with agent.ManifestEnvironment. The checks are the Runtime images' -# except MiniMax Code's companion check, which needs the tools that run in the -# sandbox here. +# reads it with agent.ManifestEnvironment. FROM base AS agent-host RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* diff --git a/deploy/distribution/Runtime.Dockerfile b/deploy/distribution/Runtime.Dockerfile deleted file mode 100644 index 886bc08d5..000000000 --- a/deploy/distribution/Runtime.Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# Each input is an immutable Linux amd64 image built from the same Core revision. -# Reuse the native packages from existing profiles. -ARG CODEX_IMAGE -ARG CLAUDE_IMAGE -ARG MCODE_IMAGE -FROM ${CODEX_IMAGE} AS codex -FROM ${CLAUDE_IMAGE} AS claude -FROM ${MCODE_IMAGE} - -# Keep the shared daemon and dependencies from the MiniMax base. -# Native harness packages remain outside the workspace. -COPY --from=codex /usr/local/bin/codex /usr/local/bin/codex -COPY --from=codex /usr/local/codex-resources /usr/local/codex-resources -COPY --from=claude /opt/claude-sdk /opt/claude-sdk - -ENV OAC_RUNTIME_CODEX_BIN=/usr/local/bin/codex \ - OAC_RUNTIME_CLAUDE_SDK_NODE=/usr/local/bin/node \ - OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT=/opt/claude-sdk/dist/main.js - -USER 1000:1000 -RUN test "$(codex --version)" = "codex-cli 0.153.4" \ - && node /opt/claude-sdk/dist/runtime_check.js /opt/claude-sdk/dist/main.js \ - && node /opt/mcode-harness/check.mjs \ - && /opt/mcode-harness/native/cli.js --version diff --git a/services/core/deploy/codex/seccomp.LICENSE b/deploy/distribution/seccomp.LICENSE similarity index 100% rename from services/core/deploy/codex/seccomp.LICENSE rename to deploy/distribution/seccomp.LICENSE diff --git a/services/core/deploy/codex/seccomp.json b/deploy/distribution/seccomp.json similarity index 98% rename from services/core/deploy/codex/seccomp.json rename to deploy/distribution/seccomp.json index a8d83b161..1cfbde68b 100644 --- a/services/core/deploy/codex/seccomp.json +++ b/deploy/distribution/seccomp.json @@ -922,17 +922,6 @@ "CAP_PERFMON" ] } - }, - { - "names": [ - "clone", - "unshare", - "setns", - "mount", - "umount2", - "pivot_root" - ], - "action": "SCMP_ACT_ALLOW" } ] } diff --git a/docs/concepts.md b/docs/concepts.md index 7a92003f6..749ea6225 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -24,9 +24,9 @@ Creating or editing application assets, starting Sessions and submitting input r ## Runtime and outer isolation -The Runtime daemon runs on Linux, macOS and Windows. Native platform behavior belongs to the Runtime and its Harness adapters; managed Sandbox Providers run Linux environments. +The Runtime runs Harnesses on the Linux agent host. Managed sandboxes and self-hosted machines run the Sandbox I/O service; the [self-hosted guide](./getting-started/self-hosted.md#platforms) owns supported installation platforms and host prerequisites. -Tools run with the permissions of the account that launches the daemon. The daemon adds no filesystem, permission or network isolation. Use the outer Environment for isolation: a managed Docker, E2B or microsandbox environment, or a container or VM around a self-hosted machine's Runtime. Authentication, private storage, locks and process cleanup protect the connection and lifecycle, but tools running as the same user can access Runtime data. +Tools run with the permissions of the account that launches the Sandbox I/O service. That service adds no filesystem, permission or network isolation. Use the outer Environment for isolation: a managed Docker, E2B or microsandbox environment, or a container or VM around a self-hosted machine's service. Authentication, private storage, locks and process cleanup protect the connection and lifecycle, but tools running as the same user can access the machine's service data. Harness history and model credentials remain on the agent host. ## Secrets and audit diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index aad1ccfcc..0640fe3d4 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -2,7 +2,7 @@ title: "Install Core and Web" --- -One command installs Core, the Web console and PostgreSQL on Linux, macOS or Windows. Sign in to Web with the Core key, set a default model and issue Project API keys. Applications call Core with those keys. Sessions run in sandboxes on nodes you add, or on E2B. +One command installs Core, the Web console, the agent host and PostgreSQL on a Linux amd64 Docker engine. The operator launcher also runs on macOS and Windows. Sign in to Web with the Core key, set a default model and issue Project API keys. Applications call Core with those keys. Sessions run in sandboxes on nodes you add, or on E2B. 1. [Check the prerequisites](#prerequisites). 2. [Run the installer](#install). @@ -16,8 +16,8 @@ This page follows the default path. Every flag, existing reverse proxies and off ## Prerequisites -- Linux amd64/arm64 or macOS Intel/Apple Silicon with curl; Windows x64 with PowerShell. -- Docker Engine 26 or newer and Docker Compose 2.26.0 or newer. On macOS and Windows, install and start Docker Desktop using Linux containers. +- An operator host with curl (Linux or macOS) or PowerShell (Windows x64). Its Docker engine must meet the execution platform requirement below. +- Docker Engine 26 or newer running Linux amd64 containers, and Docker Compose 2.26.0 or newer. ARM64 Docker engines are unsupported; the installer does not enable emulation. - An account that can run `docker` and write to its home directory. Ordinary users and root both work; the installer never calls sudo. - Free port 8080 for Web. See [ports](./install-options.md#ports). Docker must be able to publish it; the installer does not change host policy. - For anything off this machine, the origin in `OAC_PUBLIC_URL` must be the address browsers, nodes and executors use. You can sign in on this machine first. diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md index 8a4d53c59..3602e1309 100644 --- a/docs/getting-started/self-hosted.md +++ b/docs/getting-started/self-hosted.md @@ -10,9 +10,9 @@ The Session's Harness runs on the deployment's agent host and reads files and ru ## Platforms -Self-hosted machines run Linux amd64. On macOS and Windows, `oac-daemon start` refuses to start. +Self-hosted installations support Linux amd64 only. On other platforms, including macOS, Windows and Linux arm64, `oac-daemon install` and `oac-daemon start` return `UnsupportedPlatformError`; installation refuses before any credential claim. -The installer brings its own pinned Node.js and Harness versions (listed in [`scripts/build-native-installer.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/scripts/build-native-installer.mjs)) and leaves other installations of those tools untouched. On a platform without a matching installer, the command fails. +The installer contains the `oac-daemon` launcher, `oac-sandbox-io` and their version and platform metadata. Harnesses and their dependencies are packaged on the agent host; the self-hosted machine does not install them. The machine needs: @@ -21,7 +21,7 @@ The machine needs: - Python and pip when the Session's packages need them; - any system packages your setup needs. The daemon never runs apt, sudo or another elevation command, so install them through the host's normal administration. -No administrator privileges or Docker are needed. The download command also uses `curl`, `tar`, `gzip`, a SHA-256 tool and `flock`. The Runtime home must allow executable files to run. On a `noexec` mount, choose another absolute directory with `OAC_RUNTIME_HOME` before running the command. +Docker is not required. The installer creates the Session workspace, `/environment/{workspace,initialization,packages}` and `/home/runtime` and verifies that its account can write them. If that account cannot create the required directories under `/environment` and `/home`, an administrator must prepare those directories with suitable ownership before installation; the installer does not elevate privileges. The download command also uses `curl`, `tar`, `gzip`, a SHA-256 tool and `flock`. The Runtime home must allow executable files to run. On a `noexec` mount, choose another absolute directory with `OAC_RUNTIME_HOME` before running the command. ## Connect a machine @@ -50,7 +50,7 @@ No administrator privileges or Docker are needed. The download command also uses print(installation["commands"]["posix"]) ``` -2. Run the command on the target machine with the account that should run the tools. It downloads the installer matched to this Core, verifies its checksum, asks which Harnesses to install and where, installs them, creates the workspace if needed, starts the daemon and checks its connection. +2. Run the command on the target machine with the account that should run the tools. It downloads the installer matched to this Core, verifies its checksum, asks for the installation directory, installs the launcher and Sandbox I/O service, prepares the required directories, starts the launcher and checks its connection. 3. Send a Turn. A connected machine proves only authentication; the first Turn checks the Harness and the model. In Web, open the Session and copy the command under **Connect a host**. @@ -61,7 +61,7 @@ The installer reports three results: | Result | Meaning | | --- | --- | -| **Installation** | The selected Harnesses passed their readiness checks | +| **Installation** | The launcher and Sandbox I/O service are installed and their required directories are writable | | **Host connection** | Core confirmed that the machine serves this Environment over the [sandbox Link](../sandbox-link-protocol.md) | | **Model configuration** | Not checked; the first Turn uses the Session's model provider | @@ -76,10 +76,7 @@ Append these to the command: | Option | Effect | | --- | --- | | `--non-interactive` | Never prompt; missing input fails | -| `--harness codex,claude,minimax` | Harnesses to install, comma-separated. Must include the Session's Harness | | `--install-dir ABS` | Installation directory. Default: `environments/` under `~/.oac`, or under `OAC_RUNTIME_HOME` when set | -| `--capability-directory ABS` | Where [capability snapshots](#local-capability-directories) are stored. Default: `capabilities` in the installation directory | -| `--tool-env-file ABS` | A JSON file of string variables for tools and MCP servers; see [explicit local tool environment](../../contracts/agents-api/environments.md#explicit-local-tool-environment) | The workspace is fixed when the Session is created. For a different workspace, create another Session. @@ -95,7 +92,7 @@ environment = { } ``` -Paths are absolute; the daemon checks them, not Core. Fill these directories before the daemon connects. They are ordinary paths visible to the daemon; naming one does not mount it or create a sandbox. +Paths are absolute. Before the machine connects, fill these directories on the machine. The Environment owner on the agent host validates and reads them through the sandbox Link; naming a directory does not mount it or create a sandbox. Use `x_agents_core.environment` for the same Project-owned Skills, Plugin archives, files, packages, setup commands or Template used by a managed Session: @@ -112,7 +109,7 @@ session = client.beta.agents.sessions.create( The same extension works with `environment={"type": "openai_hosted"}`. Do not repeat a field in both `environment` and the extension. Setup runs with the daemon's account permissions. -Before the first Turn the daemon copies these sources into a snapshot. Reconnecting reuses the snapshot even after you edit the sources; a new Session takes a new snapshot. The [preparation contract](../../contracts/agents-api/environments.md#runtime-capability-preparation) lists fields, merge rules, snapshot behavior and failures. +Before the first Turn the Environment owner prepares a capability snapshot through the sandbox Link. Reconnecting reuses the snapshot even after you edit the sources; a new Session takes a new snapshot. The [preparation contract](../../contracts/agents-api/environments.md#runtime-capability-preparation) lists fields, merge rules, snapshot behavior and failures. ## Operate the installation @@ -121,15 +118,12 @@ The installation's `bin/oac-daemon` finds its own installation. Use it for: | Command | Effect | | --- | --- | | `oac-daemon start` | Enroll the machine and start the daemon in the background. The daemon runs the [Sandbox I/O service](../sandbox-bootstrap.md) and, when it exits, enrolls again and restarts it | -| `oac-daemon status` | Show the local profile and process; not the connection | | `oac-daemon logs -n 100`, `oac-daemon logs -f` | Print or follow the daemon log | | `oac-daemon stop` | Stop the daemon | If you set `OAC_RUNTIME_HOME`, use the same value for every command. Check the connection under **Host connection** on the Session's page in Web, or with the [connection status](../../contracts/agents-api/environment-executor-credentials.md#connection-status). -To add a Harness, run the install command again (a fresh copy from Web if it has expired) with the same installation directory and the Harness to add. The installer checks the existing contents, adds only missing components and keeps the Harnesses already installed. - -Stopping the daemon, cancelling a Turn or deleting the Session never removes the machine's workspace, native history or capability snapshot. An installation from another daemon version, or one whose files were changed, is refused. The installer never upgrades, repairs or migrates it; install into a separate directory. +Stopping the daemon, cancelling a Turn or deleting the Session does not remove the machine's workspace. Native Harness history belongs to the agent host. An installation from another daemon version, or one whose files were changed, is refused. The installer never upgrades, repairs or migrates it; install into a separate directory. ## Rotate or revoke @@ -149,7 +143,7 @@ In an archived Project, credentials cannot be issued or rotated; revocation rema The same installer accepts an already extracted distribution and a credential file issued by an operator, without the install command: ```sh -./oac-daemon install --non-interactive --harness codex \ +./oac-daemon install --non-interactive \ --install-dir "$HOME/.oac/my-runtime" \ --remote 'wss://core.example/api/v1/agent-daemon/ws' \ --environment-id '11111111-2222-4333-8444-555555555555' \ @@ -158,4 +152,4 @@ The same installer accepts an already extracted distribution and a credential fi "$HOME/.oac/my-runtime/bin/oac-daemon" start ``` -Use the Session's `remote_url` and Environment ID. This mode needs an existing workspace and does not start the daemon until you run `start`. +Use the Session's `remote_url`, Environment ID and workspace. `--workspace` prepares that directory during installation; the launcher does not persist a second workspace setting. This mode does not start the launcher until you run `start`. diff --git a/docs/maintainers.md b/docs/maintainers.md index 8ce15b217..0f0055950 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -8,7 +8,7 @@ This guide is for maintainers who build and publish OpenAgentCore. To install Co A distribution is a matched set of release assets built from one commit: the control archive (the installer, the `oac` command, and the Core, Web, ingress, agent-host and PostgreSQL images), the Runtime image and node artifacts as separate files, and the native installers. -Core, Web and ingress images are published as verified Linux amd64/arm64 indexes. The arm64 control archive contains those three images; Node, hosted Runtime, agent-host and offline payloads use Linux amd64. Release builders use QEMU for ARM image steps, including the E2B helper. Host `oac` binaries are built from the same command for Linux amd64/arm64, macOS amd64/arm64 and Windows amd64; launchers only select, verify and invoke them. Each version index is checked against its platform archives before floating tags move. +Installation images and archives support Linux amd64. Host `oac` binaries remain available for Linux amd64/arm64, macOS amd64/arm64 and Windows amd64 to operate a Linux amd64 Docker engine; they do not enable arm64 execution or emulation. Each version index is checked against its platform archive before floating tags move. Build on Linux x86_64 with a glibc compatible with Debian 12, Docker, the Go version in `go.mod`, a C compiler (the microsandbox helper is a CGO build), Node, pnpm, Python 3.9 or newer, curl, tar, pigz and sha256sum. The source must be clean and committed. First prepare the pinned Codex package and MiniMax Code companion, then build: @@ -45,7 +45,7 @@ A distribution carries the docs listed in `BUNDLED_DOCS` in `scripts/core-distri ### Native installers -Self-hosted machines install `oac-daemon` from per-platform native installers: Linux amd64, macOS arm64 and Windows amd64. Each is built on its own OS by the `native-check` workflow (`scripts/build-native-installer.mjs`, whose `pins` object fixes the Node.js and Harness versions) and verified on every selected native check. Manual packaging runs and release checks upload `oac-native-installer--.tar.gz` for seven days; ordinary PR and main checks do not upload successful packages. For a local distribution, download the three artifacts from a `native-check` run on that exact commit (a manual run or the release run; pull-request runs build the merge commit and do not match), then assemble the catalog from that checkout: +Self-hosted machines use the Linux amd64 installer containing `oac-daemon`, `oac-sandbox-io` and matched build/platform metadata. `native-check` builds and exercises it without Node.js or Harness payloads. Manual packaging and release checks retain `oac-native-installer-Linux-X64.tar.gz` for seven days; ordinary PR checks do not upload successful packages. Download that artifact from the exact source commit and assemble the catalog from the same checkout: ```sh node scripts/build-native-catalog.mjs INPUT_DIR OUTPUT_DIR @@ -56,50 +56,17 @@ The catalog records the commit, the Runtime protocol version, each archive's SHA ### Runtime images and helpers -`make build-core-distribution` builds all of these except the sandbox image. Build one on its own to test a Harness image or a helper. Run every command from the repository root; default outputs go under `${OAC_DEV_HOME:-$HOME/.oac}/build`. +`make build-core-distribution` builds the agent-host and sandbox targets of `deploy/distribution/AgentHost.Dockerfile`. Run commands from the repository root on Linux amd64; outputs default to `${OAC_DEV_HOME:-$HOME/.oac}/build`. -**Codex Runtime image.** Extract the official npm package `@openai/codex@0.153.4-linux-x64` under `~/.oac` (for example with `npm pack --ignore-scripts` and `tar -xzf`), then: - -```sh -export CODEX_CLI_DIR=/absolute/path/to/package -make build-codex-runtime -docker build --platform linux/amd64 -t oac-runtime:codex "${OAC_DEV_HOME:-$HOME/.oac}/build/codex-runtime" -``` - -The script checks the package version, builds `oac-daemon` for Linux amd64 and prepares a context with only the daemon, the unmodified `codex` and `codex-code-mode-host` executables, their resources and `services/core/deploy/codex/Dockerfile`. The Runtime image leaves out `codex-code-mode-host`, which only the agent-host image installs. - -**Claude Code Runtime image.** Node 20 or newer and pnpm are required. +Prepare the official pinned Codex Linux x64 package and the MiniMax companion with `scripts/prepare-release-runtimes.sh`, or supply `CODEX_CLI_DIR` and `MCODE_HARNESS_BUILD_DIR` for existing prepared inputs. `scripts/build-{codex,claude,mcode}-runtime.sh` validate and stage Harness payloads for both image builders; they do not build guest Runtime images. The Claude payload is a checksummed export of the pinned SDK and adapter: ```sh make build-claude-sdk-runtime -make build-claude-runtime -docker build --platform linux/amd64 -t oac-runtime:claude "${OAC_DEV_HOME:-$HOME/.oac}/build/claude-runtime" -``` - -The first step exports the adapter with the pinned Claude Agent SDK (`packages/claude-sdk-adapter/package.json`) as a checksummed archive for the host platform; the second verifies it and adds the daemon. The image step needs the `linux-x64-glibc` archive, so build both on Linux x86_64 with glibc. Keep the exported archive unchanged. - -**MiniMax Code Runtime image.** Build the companion from a checkout of the revision pinned in `packages/mcode-harness/source.json`, with the `@minimax-ai/code` npm package of the same version for native dependencies. The companion build runs on Linux x86_64 or macOS arm64 into a new directory; for the Linux Runtime image, build it on Linux x86_64 (macOS arm64 serves only the native installer): - -```sh -MCODE_NATIVE_SOURCE=/absolute/minimax-code \ -MCODE_CLI_DIR=/absolute/node_modules/@minimax-ai/code \ -MCODE_HARNESS_BUILD_DIR=/absolute/mcode-harness bash scripts/build-mcode-harness.sh -MCODE_HARNESS_BUILD_DIR=/absolute/mcode-harness bash scripts/build-mcode-runtime.sh -docker build --platform linux/amd64 -t oac-runtime:mcode "${OAC_DEV_HOME:-$HOME/.oac}/build/mcode-runtime" -``` - -`scripts/prepare-release-runtimes.sh` runs the companion build from the pins. - -The distribution combines the three Harness images into one Runtime image (`deploy/distribution/Runtime.Dockerfile`): the MiniMax Code image, which carries the daemon, with the Codex executable and resources and the Claude SDK bundle copied in. It verifies that each image carries the daemon built from the same commit. - -**Agent-host and sandbox images.** With the inputs of the three Harness images above: - -```sh export CODEX_CLI_DIR=/absolute/path/to/package MCODE_HARNESS_BUILD_DIR=/absolute/mcode-harness bash scripts/build-agent-host-images.sh ``` -The script runs the three Runtime image builders into one context, adds the static `oac-daemon`, `oac-process-shim` and `oac-sandbox-io`, and builds both targets of `deploy/distribution/AgentHost.Dockerfile` as `OAC_AGENT_HOST_IMAGE` (default `oac-agent-host:dev`) and `OAC_SANDBOX_IMAGE` (default `oac-sandbox:dev`). Further arguments, such as `--label`, go to both `docker build` calls. The agent-host image installs each Harness in its own directory under `/opt/oac/harnesses`, and `/opt/oac/harnesses.json` is the only record of where; the agent host reads it with `agent.ManifestEnvironment`. The sandbox image has the Runtime images' base and packages and no daemon or Harness, and runs `oac-sandbox-io --bootstrap-file ` as UID/GID 1000. [Qualify the view](../contracts/agents-api/harness-onboarding.md#qualify-the-view) runs both; [Agent-host container](./configuration.md#agent-host-container) lists what the agent host needs. The distribution builds the agent-host image from the payloads its Runtime image builders prepare, and publishes it; nothing in CI or the release builds the sandbox image. +The script builds `OAC_AGENT_HOST_IMAGE` (default `oac-agent-host:dev`) and `OAC_SANDBOX_IMAGE` (default `oac-sandbox:dev`). Additional arguments, such as `--label`, go to both Docker builds. The agent-host image contains `oac-daemon`, `oac-process-shim` and each Harness under `/opt/oac/harnesses`; `/opt/oac/harnesses.json` owns their activation paths. The sandbox image contains system tools and `oac-sandbox-io`, which runs as UID/GID 1000, with no Harness or daemon. The distribution verifies its Sandbox I/O executable and ships this image through the node artifact's existing `runtime` slot. E2B templates extract Sandbox I/O from this same image. [Qualify the view](../contracts/agents-api/harness-onboarding.md#qualify-the-view) runs both images; [Agent-host container](./configuration.md#agent-host-container) owns the host requirements. **E2B helper.** @@ -124,7 +91,7 @@ The helper is written to `~/.oac/build/microsandbox-provider/oac-microsandbox-pr `make build-core` builds `oac-core`, `oac-core-environment-key`, `oac-node` and `oac` into `${OAC_DEV_HOME:-$HOME/.oac}/build/oac-core` (`OAC_DEV_CORE_BUILD_DIR` selects another absolute directory). The build copies only the source set listed in `scripts/build-core.sh` (the Core service, its contracts, the shared packages it needs and the root Go module files) into a temporary context and builds with CGO disabled, read-only modules and trimmed paths. It needs no Node, Docker or other application. When Core gains a shared dependency, add that package to the list; never copy the whole repository to make it compile. -`make docker-build-core` builds the image `oac-core:dev` (`OAC_DEV_CORE_IMAGE` selects another name) from those five commands and the E2B helper. The base is the digest-pinned `debian:bookworm-slim` with CA certificates and the glibc runtime the helper needs; the default user is UID/GID 65532 and Core listens on `:8091`. This local target builds Linux amd64; the [distribution build](#build-a-distribution) builds both architectures. Changes to the image or its build need `make check-core-container` in addition to the relevant source checks: it runs the official-client suite against the image with a read-only root filesystem and needs Linux Docker, a non-root user, and the [test database and pinned SDK](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/README.md#official-client-verification) of the service checks (`OAC_TEST_DATABASE_URL` naming an `oac_*_tests` database with the migrations applied, and `OAC_TEST_OFFICIAL_SDK_PYTHON`). +`make docker-build-core` builds the image `oac-core:dev` (`OAC_DEV_CORE_IMAGE` selects another name) from those commands and the E2B helper. The base is the digest-pinned `debian:bookworm-slim` with CA certificates and the glibc runtime the helper needs; the default user is UID/GID 65532 and Core listens on `:8091`. This local target builds Linux amd64; the [distribution build](#build-a-distribution) uses the same architecture. Changes to the image or its build need `make check-core-container` in addition to the relevant source checks: it runs the official-client suite against the image with a read-only root filesystem and needs Linux Docker, a non-root user, and the [test database and pinned SDK](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/README.md#official-client-verification) of the service checks (`OAC_TEST_DATABASE_URL` naming an `oac_*_tests` database with the migrations applied, and `OAC_TEST_OFFICIAL_SDK_PYTHON`). ## Publish a version @@ -143,7 +110,7 @@ Distribution and Runtime archives use `pigz` level 6 with at most four compressi ### Container registry -Version releases and manual `build-` drafts publish `ghcr.io/minimax-ai/openagentcore/:`, where `` is `core`, `web`, `runtime`, `ingress` or `agent-host`. Core, Web and ingress indexes contain Linux amd64 and arm64 images; Runtime and agent-host contain Linux amd64. Platform images use `-` tags and are loaded from the release archives. Existing version tags must match the release images and platform set. The publisher verifies every version index before updating `latest` for a stable release; prereleases and drafts leave `latest` unchanged. PostgreSQL uses its upstream image. SemVer build metadata uses `_` in place of `+` in container tags; version strings are limited to 128 characters. After verifying the images, the publisher uploads the release's `compose.yaml` and checksum list. Compose pins ingress by its index digest, and initialization checks its build revision against the Compose revision. +Version releases and manual `build-` drafts publish `ghcr.io/minimax-ai/openagentcore/:`, where `` is `core`, `web`, `runtime`, `ingress` or `agent-host`. All component indexes contain Linux amd64 images; the `runtime` component contains the sandbox image. Platform images use `-` tags and are loaded from the release archives. Existing version tags must match the release images and platform set. The publisher verifies every version index before updating `latest` for a stable release; prereleases and drafts leave `latest` unchanged. PostgreSQL uses its upstream image. SemVer build metadata uses `_` in place of `+` in container tags; version strings are limited to 128 characters. After verifying the images, the publisher uploads the release's `compose.yaml` and checksum list. Compose pins ingress by its index digest, and initialization checks its build revision against the Compose revision. The combined build/publication job uses `GITHUB_TOKEN` with `packages: write`. On the first publication, GitHub creates each container package as private: a package administrator must change all five packages to **Public** in their package settings before users can pull anonymously. See [GitHub container visibility](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry). Verify an unauthenticated pull after changing visibility. Repository visibility alone does not make a new container package public. @@ -169,7 +136,7 @@ With `draft_release=true` the result is an unpublished `build-` draft ## Continuous integration -Every PR runs `core-check` and reports the required status `check`. Main uses GitHub branch protection requiring this check and an up-to-date branch before merging, so merging does not start another copy of the test suite. Changes must enter through checked PRs; an administrator bypass does not establish CI success. Main pushes publish the website when its inputs change and run `cache-warm`, which builds the MiniMax companion, the E2B helper and the pnpm store without running tests, because only caches saved on main can be restored by every PR and release tag. Version tags and manual release builds run the full release gate at their exact source commit. `scripts/ci_plan.py` owns the only input-to-check map. Component rules require both a matching directory or script prefix and a matching file suffix; exact dependency, workflow and shared build inputs have explicit rules. Rules accumulate across shared consumers and mixed changes. Paths with no matching build/test rule run hygiene only. Add the corresponding rule when introducing a new component, language, build input or resource location. +Every PR runs `core-check` and reports the required status `check`. Main uses GitHub branch protection requiring this check and an up-to-date branch before merging, so merging does not start another copy of the test suite. Changes must enter through checked PRs; an administrator bypass does not establish CI success. Main pushes publish the website when its inputs change and run `cache-warm`, which builds the E2B helper and the pnpm store without running tests, because only caches saved on main can be restored by every PR and release tag. Version tags and manual release builds run the full release gate at their exact source commit. `scripts/ci_plan.py` owns the only input-to-check map. Component rules require both a matching directory or script prefix and a matching file suffix; exact dependency, workflow and shared build inputs have explicit rules. Rules accumulate across shared consumers and mixed changes. Paths with no matching build/test rule run hygiene only. Add the corresponding rule when introducing a new component, language, build input or resource location. The planner compares the PR event's tested merge commit with its verified first parent. NUL-delimited Git output and disabled rename detection retain both old and new paths. The plan and reasons appear in the run summary. Missing or inconsistent history, mismatched checkouts, invalid paths, planner/orchestration changes and shared build inputs select the full gate. A verified empty diff selects hygiene only. Release, manual and explicit-ref calls always select every group. @@ -185,12 +152,12 @@ The planner compares the PR event's tested merge commit with its verified first | `website` | Website build and output checks for website, published documentation and dependency changes | | `web-acceptance` | Full Web browser suite in four isolated shards after Web unit/build success; each keeps one worker | | `api` | Reusable official-client acceptance against standalone commands and migrations; image acceptance when image/build/helper inputs change, and in every full gate | -| `native` | Reusable Linux, macOS and Windows builds, filesystem/process/Harness checks and native installation; all three platforms can run concurrently | +| `native` | Linux amd64 launcher, Sandbox I/O and installer checks; portable Core launcher build/tests on Linux, macOS and Windows | | `lint` | Reusable actionlint check, including local composite actions | `.github/actionlint.yaml` selects hygiene and lint. Known workflow changes select their consumers: the CI review and actionlint workflows run hygiene and lint; native workflow changes add native checks; API acceptance workflow changes add API checks with container acceptance enabled; website workflow changes add website checks. The shared Node action selects every job that uses it plus lint. A new or unclassified workflow/action selects the full gate until its consumers are declared in the planner. Planner tests and CI measurement scripts run hygiene; changing the planner itself runs the full gate. -Compose template and Compose test changes select both `distribution` fixtures and the `compose` smoke job; Core, Web, the daemon, shared Go packages and the image Dockerfiles also select the smoke job. Run `python3 scripts/compose-smoke.py` locally with Docker available to repeat it. The script uses a unique project, an automatically assigned loopback port and artifacts under `~/.oac/tests/`; it removes its containers and volumes on exit. CI also performs cleanup after a failed or interrupted smoke step. Diagnostics show container status without printing HTTP response bodies or sign-in keys. Core, Web, the agent host and the ingress image are built from the checkout; Web serves a placeholder page instead of the console build, and the agent-host image has no Harness; the smoke checks that the agent host connects to Core. Build-time node metadata comes from the release pinned in `deploy/compose/smoke-pins.json`; the initialization container runs with networking disabled. The smoke matrix runs on native Linux amd64 and arm64 runners; the native matrix builds and tests the shared Core installer on Linux, macOS and Windows. +Compose template and Compose test changes select both `distribution` fixtures and the `compose` smoke job; Core, Web, the daemon, shared Go packages and the image Dockerfiles also select the smoke job. Run `python3 scripts/compose-smoke.py` locally with Docker available to repeat it. The script uses a unique project, an automatically assigned loopback port and artifacts under `~/.oac/tests/`; it removes its containers and volumes on exit. CI also performs cleanup after a failed or interrupted smoke step. Diagnostics show container status without printing HTTP response bodies or sign-in keys. Core, Web, the agent host and the ingress image are built from the checkout; Web serves a placeholder page instead of the console build, and the agent-host image has no Harness; the smoke checks that the agent host connects to Core. Build-time node metadata comes from the release pinned in `deploy/compose/smoke-pins.json`; the initialization container runs with networking disabled. The smoke runs on Linux amd64; the native matrix builds and tests the portable Core launcher on Linux, macOS and Windows. Go module and workspace inputs select backend, API (including the container), native and distribution checks. Each Node module owns its manifest and lockfile. Website dependencies select website checks; Web dependencies select Web and browser checks; example dependencies select example checks; shared TypeScript client dependencies select Web, browser and example checks; Claude adapter dependencies select Harness, native and distribution checks. Shared package-manager configuration selects all Node consumers. The root TypeScript configuration selects Web and example checks; the adapter TypeScript configuration selects Harness and native checks. Each selected set includes hygiene. Mixed changes accumulate their consumers, and every job reads the same plan instead of maintaining its own path list. For example, a notification-only PR skips database, browser and native jobs, while a notification plus Core change adds backend and API checks. diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md index 2af64a037..28edddb4e 100644 --- a/docs/runtime-protocol.md +++ b/docs/runtime-protocol.md @@ -190,7 +190,7 @@ An adapter may add `code` and `http_status` to a Run's `error` frame. They are o The accepted codes are `authentication_error`, `rate_limit_exceeded`, `usage_limit_exceeded`, `server_overloaded`, `server_error`, `invalid_request`, `resource_not_found`, `request_timeout`, `context_length_exceeded`, `cyber_policy` and `connection_failed` ([`engine_failure.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/engine_failure.go)). Only `connection_failed` keeps `http_status`, and only an integer from 100 to 599; every other status is discarded. A missing, malformed or unknown value leaves the error unclassified without discarding Usage or `done`. -Core stores accepted values in the Turn outcome as `engine_error_code` and `engine_http_status`. The classification is subordinate to the terminal status and Core's `error_code`: it cannot turn a completed or cancelled Turn into a failure, hide an incomplete event stream, or override a persistence or cancellation-receipt failure. Normal delivery and terminal journal draining use the same extraction. [Session diagnostics](../contracts/agents-api/session-diagnostics.md) expose the category only for a failed Turn whose Core error is `engine_failed`. The [Codex](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md) and [Claude Code](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/claude/README.md) adapter guides give each Harness's mapping; an adapter never classifies error prose. +Core stores accepted values in the Turn outcome as `engine_error_code` and `engine_http_status`. The classification is subordinate to the terminal status and Core's `error_code`: it cannot turn a completed or cancelled Turn into a failure, hide an incomplete event stream, or override a persistence or cancellation-receipt failure. Normal delivery and terminal journal draining use the same extraction. [Session diagnostics](../contracts/agents-api/session-diagnostics.md) expose the category only for a failed Turn whose Core error is `engine_failed`. The [Codex](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/codex/error_classification.go) and [Claude Code](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/claude-sdk-adapter/src/adapter.ts) adapter implementations define each Harness's mapping; an adapter never classifies error prose. ## Workspace operations diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 3cbb75237..d6b209320 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -166,7 +166,7 @@ Terminal cleanup atomically withdraws the allocation's Serve authority, releases ### `oac-sandbox-io` -`oac-sandbox-io` is the only process a Provider starts in a hosted sandbox. It Serves the allocation over the [Sandbox link](./sandbox-link-protocol.md). `Bootstrap` is the allocation's `Reference` and `SandboxIO`, the service's [Sandbox bootstrap](./sandbox-bootstrap.md) input: the Link URL derived from the [public URL](./configuration.md#changing-the-public-url), the allocation's Serve credential, and the allocation with its Serve generation as resource. Core validates the whole `Bootstrap` once, with `Bootstrap.Validate`, which also checks that `SandboxIO` serves the `Reference`, before `Create`, and adapters deliver it as given. `Create` writes `SandboxIO` to a private file, `/home/runtime/sandbox-io-bootstrap.json` (mode 0600, UID 1000) in the reference adapters, and starts `oac-sandbox-io --bootstrap-file` with that path as UID 1000. `BootstrapComplete` implies that it was started. The input never travels in an argument or environment variable. Every Runtime image ships `/usr/local/bin/oac-sandbox-io`. Allocation cleanup revokes the resource at the relay before it calls `Kill`. +`oac-sandbox-io` is the only process a Provider starts in a hosted sandbox. It Serves the allocation over the [Sandbox link](./sandbox-link-protocol.md). `Bootstrap` is the allocation's `Reference` and `SandboxIO`, the service's [Sandbox bootstrap](./sandbox-bootstrap.md) input: the Link URL derived from the [public URL](./configuration.md#changing-the-public-url), the allocation's Serve credential, and the allocation with its Serve generation as resource. Core validates the whole `Bootstrap` once, with `Bootstrap.Validate`, which also checks that `SandboxIO` serves the `Reference`, before `Create`, and adapters deliver it as given. `Create` writes `SandboxIO` to a private file, `/home/runtime/sandbox-io-bootstrap.json` (mode 0600, UID 1000) in the reference adapters, and starts `oac-sandbox-io --bootstrap-file` with that path as UID 1000. `BootstrapComplete` implies that it was started. The input never travels in an argument or environment variable. The sandbox image ships `/usr/local/bin/oac-sandbox-io`; Harnesses are packaged in the agent-host image. Allocation cleanup revokes the resource at the relay before it calls `Kill`. ### Per-node lifecycle workers @@ -220,7 +220,7 @@ Native acceptance proves what fixtures cannot: creation, lease behavior, owned p The Docker Sandbox Provider ([`sandbox/docker`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/docker)) runs every allocation's sandbox as a container whose only process is [`oac-sandbox-io`](#oac-sandbox-io), with the same container settings ([`container_options.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/docker/container_options.go)): -- user 1000:1000, read-only root filesystem, all capabilities dropped, `no-new-privileges`, the [seccomp profile](#seccomp-profile) and AppArmor `unconfined`; +- user 1000:1000, read-only root filesystem, all capabilities dropped, `no-new-privileges`, the [seccomp profile](#seccomp-profile); - the node’s configured network and extra hosts ([node configuration](./configuration.md#docker-node-configuration)); - CPU and memory from the deployment specification, a 128-process limit and a 128 MiB `/tmp` tmpfs; - two named volumes labelled with the installation, tenant, Environment and allocation: `-home` at `/home` and `-environment` at `/environment`, whose `workspace` subdirectory is also mounted at `/workspace`. The Docker Engine must support volume subpath mounts. @@ -231,4 +231,4 @@ The node uses the explicit Unix socket in its [provider configuration](./configu ### Seccomp profile -[`seccomp.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/seccomp.json) is the Moby default profile at [revision 65adc7e](https://github.com/moby/profiles/blob/65adc7e022c97f55e45c054ff012988027733b87/seccomp/default.json) (Apache-2.0, see [seccomp.LICENSE](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/seccomp.LICENSE); upstream file SHA-256 `785b2429264afba4d594320337cb17f144f3c7d51585f9805eef72e28f4f9334`) with one appended rule that allows `clone`, `unshare`, `setns`, `mount`, `umount2` and `pivot_root`. The distribution ships this file to every Docker node as `runtime/seccomp.json`. +[`seccomp.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/distribution/seccomp.json) is the Moby default profile at [revision 65adc7e](https://github.com/moby/profiles/blob/65adc7e022c97f55e45c054ff012988027733b87/seccomp/default.json) (Apache-2.0, see [seccomp.LICENSE](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/distribution/seccomp.LICENSE); upstream file SHA-256 `785b2429264afba4d594320337cb17f144f3c7d51585f9805eef72e28f4f9334`). The distribution ships this file to every Docker node as `runtime/seccomp.json`. diff --git a/docs/zh/concepts.md b/docs/zh/concepts.md index ea03bf7f1..9e8209562 100644 --- a/docs/zh/concepts.md +++ b/docs/zh/concepts.md @@ -1,7 +1,7 @@ --- title: "概念" source: docs/concepts.md -source_hash: 2d65b520ffc8ccce8836d6196fb9daa217a438e4f79ae2aee70d4cb77da4c606 +source_hash: a1ec30de00bdb8aa75db3b2da705e6b5444ec590000c31a2b0e9d81875fc6822 --- Project 是 OpenAgentCore 的执行租户。应用使用其 API key;运维人员使用独立的 Core key 管理安装实例。[API 索引](api/index.md) 将每类调用方映射到对应命名空间和凭据。 @@ -26,9 +26,9 @@ Project 和 key 存储在 PostgreSQL 中。Core 仅在签发时返回一次 key ## Runtime 与外层隔离 {#runtime-and-outer-isolation} -Runtime daemon 在 Linux、macOS 和 Windows 上运行。原生平台行为由 Runtime 及其 Harness adapter 负责;托管 Sandbox Provider 运行 Linux 环境。 +Runtime 在 Linux agent host 上运行 Harness。托管沙箱和自托管机器运行 Sandbox I/O 服务;[自托管指南](./getting-started/self-hosted.md#platforms)定义支持的安装平台和主机前提条件。 -工具以启动 daemon 的账户权限运行。daemon 不增加文件系统、权限或网络隔离。应使用外层 Environment 提供隔离:托管 Docker、E2B 或 microsandbox 环境,或在自托管机器的 Runtime 外使用容器或虚拟机。认证、私有存储、锁和进程清理保护连接与生命周期,但以同一用户运行的工具可以访问 Runtime 数据。 +工具以启动 Sandbox I/O 服务的账户权限运行。该服务不增加文件系统、权限或网络隔离。应使用外层 Environment 提供隔离:托管 Docker、E2B 或 microsandbox 环境,或在自托管机器的服务外使用容器或虚拟机。认证、私有存储、锁和进程清理保护连接与生命周期,但以同一用户运行的工具可以访问机器上的服务数据。Harness 历史和模型凭据保留在 agent host 上。 ## 秘密与审计 {#secrets-and-audit} diff --git a/docs/zh/getting-started/install.md b/docs/zh/getting-started/install.md index d2cf1f2d1..df4812da7 100644 --- a/docs/zh/getting-started/install.md +++ b/docs/zh/getting-started/install.md @@ -1,10 +1,10 @@ --- title: "安装 Core 和 Web" source: docs/getting-started/install.md -source_hash: 4a340f0b9ebaf1c1477373c0a4279fcd3548340b2cdbef3b43d233e5872cef44 +source_hash: baa93565e3ec6c26752f692a3ad7771dc1b5259f98350620381b4d4315014a2e --- -一条命令即可在 Linux、macOS 或 Windows 上安装 Core、Web 控制台和 PostgreSQL。用 Core 密钥登录 Web,设置默认模型并签发 Project API 密钥。应用使用这些密钥调用 Core。Session 在你添加的节点上的沙箱中运行,也可以在 E2B 上运行。 +一条命令即可在 Linux amd64 Docker 引擎上安装 Core、Web 控制台、agent host 和 PostgreSQL。操作员启动器也可在 macOS 和 Windows 上运行。用 Core 密钥登录 Web,设置默认模型并签发 Project API 密钥。应用使用这些密钥调用 Core。Session 在你添加的节点上的沙箱中运行,也可以在 E2B 上运行。 1. [检查前置条件](#prerequisites)。 2. [运行安装程序](#install)。 @@ -18,8 +18,8 @@ source_hash: 4a340f0b9ebaf1c1477373c0a4279fcd3548340b2cdbef3b43d233e5872cef44 ## 前置条件 {#prerequisites} -- Linux amd64/arm64 或 macOS Intel/Apple Silicon,需安装 curl;Windows x64 需 PowerShell。 -- Docker Engine 26 或更高版本,以及 Docker Compose 2.26.0 或更高版本。macOS 和 Windows 使用已启动的 Docker Desktop,并选择 Linux 容器。 +- 操作端需要 curl(Linux 或 macOS)或 PowerShell(Windows x64);其 Docker 引擎必须满足下面的执行平台要求。 +- 运行 Linux amd64 容器的 Docker Engine 26 或更高版本,以及 Docker Compose 2.26.0 或更高版本。不支持 ARM64 Docker 引擎;安装器不会启用模拟。 - 能运行 `docker` 并向自己的主目录写入文件的账号。普通用户和 root 均可;安装程序不会调用 sudo。 - Web 的 8080 端口空闲。参阅[端口](install-options.md#ports)。Docker 必须能发布该端口;安装程序不会修改主机策略。 - 本机以外的访问要求 `OAC_PUBLIC_URL` 就是浏览器、节点和执行器使用的地址。可以先在本机登录。 diff --git a/docs/zh/getting-started/self-hosted.md b/docs/zh/getting-started/self-hosted.md index 5076e9836..2bb0e7565 100644 --- a/docs/zh/getting-started/self-hosted.md +++ b/docs/zh/getting-started/self-hosted.md @@ -1,7 +1,7 @@ --- title: "自托管执行器" source: docs/getting-started/self-hosted.md -source_hash: 90d238aec831101e7b9b8081920c8985b1f024a9d3f8f0235fc83b255f84125c +source_hash: fc22cdc9db23a43d4c90921a2bb06b16c41d6e7586c3533284aeca2f903a850a --- `self_hosted` Session 在应用拥有的机器上运行:工作站、虚拟机或你管理的沙箱。应用通过 `/v1` 创建 Session,并获得安装 `oac-daemon`、启动它并连接 Core 的命令。Web 在 Session 页面展示同一命令;Web 是可选的。Core 不创建、停止或回收这台机器。 @@ -12,9 +12,9 @@ Session 的 Harness 在部署的 agent host 上运行,通过这台机器的 [S ## 平台 {#platforms} -自托管机器运行 Linux amd64。在 macOS 和 Windows 上,`oac-daemon start` 拒绝启动。 +自托管安装仅支持 Linux amd64。在其他平台(包括 macOS、Windows 和 Linux arm64)上,`oac-daemon install` 和 `oac-daemon start` 返回 `UnsupportedPlatformError`;安装在领取凭据之前拒绝执行。 -安装程序自带固定版本的 Node.js 和 Harness(列于 [`scripts/build-native-installer.mjs`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/scripts/build-native-installer.mjs)),不修改这些工具的其他安装。在没有匹配安装程序的平台上,命令会失败。 +安装程序包含 `oac-daemon` 启动器、`oac-sandbox-io` 及其版本和平台元数据。Harness 及其依赖打包在 agent host 上,自托管机器不安装它们。 机器需要: @@ -23,7 +23,7 @@ Session 的 Harness 在部署的 agent host 上运行,通过这台机器的 [S - Session 的软件包需要时,安装 Python 和 pip; - 环境设置所需的系统软件包。守护进程不运行 apt、sudo 或其他提权命令,请通过主机的常规管理方式安装。 -不需要管理员权限或 Docker。下载命令还使用 `curl`、`tar`、`gzip`、SHA-256 工具和 `flock`。Runtime 主目录必须允许执行文件。如果挂载点设为 `noexec`,请先用 `OAC_RUNTIME_HOME` 指定另一个允许执行的绝对目录,再运行命令。 +不需要 Docker。安装程序创建 Session 工作区、`/environment/{workspace,initialization,packages}` 和 `/home/runtime`,并验证当前账号能写入这些目录。如果该账号不能在 `/environment` 和 `/home` 下创建所需目录,管理员必须在安装前准备好这些目录并设置合适的所有权;安装程序不会提权。下载命令还使用 `curl`、`tar`、`gzip`、SHA-256 工具和 `flock`。Runtime 主目录必须允许执行文件。如果挂载点设为 `noexec`,请先用 `OAC_RUNTIME_HOME` 指定另一个允许执行的绝对目录,再运行命令。 ## 连接机器 {#connect-a-machine} @@ -52,7 +52,7 @@ Session 的 Harness 在部署的 agent host 上运行,通过这台机器的 [S print(installation["commands"]["posix"]) ``` -2. 在目标机器上,以应运行工具的账号执行命令。命令下载与 Core 匹配的安装程序、验证校验和、询问要安装哪些 Harness 以及安装位置、完成安装、按需创建工作区、启动守护进程并检查连接。 +2. 在目标机器上,以应运行工具的账号执行命令。命令下载与 Core 匹配的安装程序、验证校验和、询问安装目录、安装启动器和 Sandbox I/O 服务、准备所需目录、启动并检查连接。 3. 发送一个 Turn。机器已连接只证明认证成功;第一个 Turn 才会检查 Harness 和模型。 在 Web 中打开 Session,复制 **Connect a host** 下的命令。 @@ -63,7 +63,7 @@ Session 的 Harness 在部署的 agent host 上运行,通过这台机器的 [S | 结果 | 含义 | | --- | --- | -| **Installation** | 所选 Harness 已通过就绪检查 | +| **Installation** | 启动器和 Sandbox I/O 服务已安装,所需目录可写 | | **Host connection** | Core 已确认这台机器通过[沙箱 Link](../sandbox-link-protocol.md) 为此 Environment 提供服务 | | **Model configuration** | 未检查;第一个 Turn 使用 Session 的模型提供商 | @@ -78,10 +78,7 @@ Session 的 Harness 在部署的 agent host 上运行,通过这台机器的 [S | 选项 | 效果 | | --- | --- | | `--non-interactive` | 不提示;缺少输入时失败 | -| `--harness codex,claude,minimax` | 要安装的 Harness,以逗号分隔。必须包含 Session 的 Harness | | `--install-dir ABS` | 安装目录。默认是 `~/.oac` 下的 `environments/`;设置了 `OAC_RUNTIME_HOME` 时则在该目录下 | -| `--capability-directory ABS` | [能力快照](#local-capability-directories)存储位置。默认是安装目录中的 `capabilities` | -| `--tool-env-file ABS` | 为工具和 MCP 服务器提供字符串变量的 JSON 文件;参阅[显式本地工具环境](../../../contracts/agents-api/zh/environments.md#explicit-local-tool-environment) | 工作区在创建 Session 时固定。使用不同工作区时,创建另一个 Session。 @@ -97,7 +94,7 @@ environment = { } ``` -路径为绝对路径;由守护进程而非 Core 检查。守护进程连接前,先准备这些目录。它们是守护进程可见的普通路径;指定路径不会挂载它或创建沙箱。 +路径必须是绝对路径。在机器连接前,先在机器上准备好这些目录。agent host 上的 Environment owner 通过沙箱 Link 验证并读取它们;指定目录不会挂载它或创建沙箱。 使用 `x_agents_core.environment` 提供与托管 Session 相同的 Project 所属 Skills、Plugin 归档、文件、软件包、设置命令或 Template: @@ -114,7 +111,7 @@ session = client.beta.agents.sessions.create( 同一扩展也支持 `environment={"type": "openai_hosted"}`。不要在 `environment` 和扩展中重复指定同一个字段。设置过程使用守护进程账号权限。 -第一个 Turn 之前,守护进程将这些来源复制成快照。即使来源之后被修改,重连仍复用快照;新的 Session 获取新快照。[准备协议](../../../contracts/agents-api/zh/environments.md#runtime-capability-preparation)列出字段、合并规则、快照行为和失败情况。 +第一个 Turn 之前,Environment owner 通过沙箱 Link 准备能力快照。即使源内容已修改,重连仍复用快照;新 Session 会创建新快照。[准备契约](../../../contracts/agents-api/zh/environments.md#runtime-capability-preparation)列出字段、合并规则、快照行为和失败结果。 ## 管理安装 {#operate-the-installation} @@ -123,15 +120,12 @@ session = client.beta.agents.sessions.create( | 命令 | 效果 | | --- | --- | | `oac-daemon start` | 注册机器并在后台启动守护进程。守护进程运行 [Sandbox I/O 服务](../sandbox-bootstrap.md),服务退出时重新注册并重启它 | -| `oac-daemon status` | 展示本地配置与进程,不表示连接状态 | | `oac-daemon logs -n 100`、`oac-daemon logs -f` | 输出或持续跟踪守护进程日志 | | `oac-daemon stop` | 停止守护进程 | 设置了 `OAC_RUNTIME_HOME` 时,每个命令都使用同一值。在 Web 的 Session 页面 **Host connection** 下检查连接,或使用[连接状态](../../../contracts/agents-api/zh/environment-executor-credentials.md#connection-status)。 -添加 Harness 时,以同一安装目录重新执行安装命令,并指定要添加的 Harness(命令过期时从 Web 复制新的)。安装程序检查已有内容、只添加缺失组件,并保留已安装的 Harness。 - -停止守护进程、取消 Turn 或删除 Session,都不会删除机器的工作区、原生历史或能力快照。安装程序拒绝其他守护进程版本的安装,以及文件已被修改的安装。程序不升级、修复或迁移它们;请安装到另一个目录。 +停止守护进程、取消 Turn 或删除 Session 不会删除机器的工作区。Harness 原生历史属于 agent host。安装程序拒绝其他守护进程版本的安装,以及文件已被修改的安装。程序不升级、修复或迁移它们;请安装到另一个目录。 ## 轮换或撤销 {#rotate-or-revoke} @@ -151,7 +145,7 @@ session = client.beta.agents.sessions.create( 同一安装程序也接受已解压的发行包和运维人员签发的凭据文件,无需安装命令: ```sh -./oac-daemon install --non-interactive --harness codex \ +./oac-daemon install --non-interactive \ --install-dir "$HOME/.oac/my-runtime" \ --remote 'wss://core.example/api/v1/agent-daemon/ws' \ --environment-id '11111111-2222-4333-8444-555555555555' \ @@ -160,4 +154,4 @@ session = client.beta.agents.sessions.create( "$HOME/.oac/my-runtime/bin/oac-daemon" start ``` -使用 Session 的 `remote_url` 和 Environment ID。此模式要求工作区已存在,且在运行 `start` 前不会启动守护进程。 +使用 Session 的 `remote_url`、Environment ID 和工作区。`--workspace` 在安装时准备该目录;启动器不持久化第二份工作区设置。此模式在运行 `start` 前不会启动。 diff --git a/docs/zh/maintainers.md b/docs/zh/maintainers.md index 0caf181d3..3a428a04e 100644 --- a/docs/zh/maintainers.md +++ b/docs/zh/maintainers.md @@ -1,7 +1,7 @@ --- title: "构建并发布 OpenAgentCore" source: docs/maintainers.md -source_hash: d9f683219343883082b468e7d931d6a07ced420af3f7a1cc3adba777a1afd275 +source_hash: 9d004eb3855c91d8e6059f81f1d7018be98c75daba44c347798b6a89c3c720c6 --- 本指南面向负责构建和发布 OpenAgentCore 的维护者。要安装 Core 和 Web,请使用 [安装指南](getting-started/install.md)。安装器代码遵循的规则见 [部署](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/README.md) 和 [节点安装器](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/node/README.md);必需检查见 [CONTRIBUTING](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#required-checks)。 @@ -10,7 +10,7 @@ source_hash: d9f683219343883082b468e7d931d6a07ced420af3f7a1cc3adba777a1afd275 分发包是从同一个提交构建的一组相互匹配的发布资源:控制归档(安装器、`oac` 命令,以及 Core、Web、ingress、agent-host 和 PostgreSQL 镜像)、作为独立文件的 Runtime 镜像和节点构件,以及原生安装器。 -Core、Web 和 ingress 镜像发布为经过校验的 Linux amd64/arm64 多架构索引。arm64 控制归档包含这三个镜像;Node、托管 Runtime、agent-host 和离线包使用 Linux amd64。发行构建使用 QEMU 执行 ARM 镜像步骤,包括 E2B helper。宿主机 `oac` 从同一份实现构建为 Linux amd64/arm64、macOS amd64/arm64 和 Windows amd64 二进制;启动脚本只选择、校验并运行它们。所有版本索引校验通过后才更新浮动标签。 +安装镜像和归档支持 Linux amd64。宿主机 `oac` 二进制仍提供 Linux amd64/arm64、macOS amd64/arm64 和 Windows amd64 版本,用于操作 Linux amd64 Docker 引擎;它们不启用 arm64 执行或模拟。每个版本索引校验其平台归档后才更新浮动标签。 请在 Linux x86_64 上构建,所需环境包括与 Debian 12 兼容的 glibc、Docker、`go.mod` 中指定的 Go 版本、C 编译器(microsandbox 辅助程序使用 CGO 构建)、Node、pnpm、Python 3.9 或更高版本、curl、tar、pigz 和 sha256sum。源代码必须保持干净并已提交。请先准备固定版本的 Codex 包和 MiniMax Code 配套程序,然后执行构建: @@ -47,7 +47,7 @@ ingress 镜像仅包含 `oac` 和四个节点元数据文件:`manifest.json` ### 原生安装器 {#native-installers} -自托管机器通过各平台的原生安装器安装 `oac-daemon`:Linux amd64、macOS arm64 和 Windows amd64。每个安装器均由 `native-check` 工作流在各自的操作系统上构建(`scripts/build-native-installer.mjs`,其中的 `pins` 对象会固定 Node.js 和 Harness 版本),并在每次选中的原生检查中得到验证。手动打包运行和发布检查会上传 `oac-native-installer--.tar.gz` 并保留七天;普通 PR 和 main 检查即使成功也不会上传软件包。要构建本地分发包,请从该确切提交的一次 `native-check` 运行中下载这三个构件(可以是手动运行或发布运行;PR 运行构建的是合并提交,因此不匹配),然后使用该检出内容组装目录: +自托管机器使用 Linux amd64 安装器,包含 `oac-daemon`、`oac-sandbox-io` 及匹配的构建和平台元数据。`native-check` 构建并验证它,不打包 Node.js 或 Harness。手动打包和发布检查保留 `oac-native-installer-Linux-X64.tar.gz` 七天;普通 PR 检查不上传成功的软件包。从精确的源提交下载该构件,并在同一检出中组装目录: ```sh node scripts/build-native-catalog.mjs INPUT_DIR OUTPUT_DIR @@ -58,50 +58,17 @@ export OAC_NATIVE_INSTALLER_BUILD_DIR=OUTPUT_DIR ### Runtime 镜像和辅助程序 {#runtime-images-and-helpers} -`make build-core-distribution` 会构建以下除沙箱镜像外的全部内容。也可以单独构建其中一项,以测试某个 Harness 镜像或辅助程序。所有命令都必须从仓库根目录运行;默认输出位于 `${OAC_DEV_HOME:-$HOME/.oac}/build` 下。 +`make build-core-distribution` 构建 `deploy/distribution/AgentHost.Dockerfile` 的 agent-host 和 sandbox 目标。在 Linux amd64 上从仓库根目录执行命令;输出默认位于 `${OAC_DEV_HOME:-$HOME/.oac}/build`。 -**Codex Runtime 镜像。** 在 `~/.oac` 下解压官方 npm 包 `@openai/codex@0.153.4-linux-x64`(例如使用 `npm pack --ignore-scripts` 和 `tar -xzf`),然后执行: - -```sh -export CODEX_CLI_DIR=/absolute/path/to/package -make build-codex-runtime -docker build --platform linux/amd64 -t oac-runtime:codex "${OAC_DEV_HOME:-$HOME/.oac}/build/codex-runtime" -``` - -该脚本会检查软件包版本,为 Linux amd64 构建 `oac-daemon`,并准备一个仅包含守护进程、未修改的 `codex` 和 `codex-code-mode-host` 可执行文件、相关资源和 `services/core/deploy/codex/Dockerfile` 的上下文。Runtime 镜像不包含 `codex-code-mode-host`,只有 agent-host 镜像安装它。 - -**Claude Code Runtime 镜像。** 必须使用 Node 20 或更高版本以及 pnpm。 +使用 `scripts/prepare-release-runtimes.sh` 准备固定版本的官方 Codex Linux x64 包和 MiniMax 配套程序,或通过 `CODEX_CLI_DIR` 和 `MCODE_HARNESS_BUILD_DIR` 指向已有输入。`scripts/build-{codex,claude,mcode}-runtime.sh` 为两个镜像构建器校验并准备 Harness 载荷,不构建客体 Runtime 镜像。Claude 载荷是固定 SDK 和适配器的带校验和导出: ```sh make build-claude-sdk-runtime -make build-claude-runtime -docker build --platform linux/amd64 -t oac-runtime:claude "${OAC_DEV_HOME:-$HOME/.oac}/build/claude-runtime" -``` - -第一步会将适配器和固定版本的 Claude Agent SDK(`packages/claude-sdk-adapter/package.json`)导出为适用于主机平台且带校验和的归档;第二步会验证该归档并添加守护进程。镜像步骤需要 `linux-x64-glibc` 归档,因此必须在带 glibc 的 Linux x86_64 上构建两者。请保持导出的归档不变。 - -**MiniMax Code Runtime 镜像。** 使用 `packages/mcode-harness/source.json` 中固定修订版本的检出,并使用相同版本的 `@minimax-ai/code` npm 包提供原生依赖,以构建配套程序。配套程序构建可在 Linux x86_64 或 macOS arm64 上运行,并输出到一个新目录;对于 Linux Runtime 镜像,请在 Linux x86_64 上构建它(macOS arm64 仅用于原生安装器): - -```sh -MCODE_NATIVE_SOURCE=/absolute/minimax-code \ -MCODE_CLI_DIR=/absolute/node_modules/@minimax-ai/code \ -MCODE_HARNESS_BUILD_DIR=/absolute/mcode-harness bash scripts/build-mcode-harness.sh -MCODE_HARNESS_BUILD_DIR=/absolute/mcode-harness bash scripts/build-mcode-runtime.sh -docker build --platform linux/amd64 -t oac-runtime:mcode "${OAC_DEV_HOME:-$HOME/.oac}/build/mcode-runtime" -``` - -`scripts/prepare-release-runtimes.sh` 会根据固定版本配置运行配套程序构建。 - -分发包将三个 Harness 镜像合并到一个 Runtime 镜像(`deploy/distribution/Runtime.Dockerfile`)中:以携带守护进程的 MiniMax Code 镜像为基础,并复制入 Codex 可执行文件及资源和 Claude SDK 包。构建过程会验证每个镜像都携带由同一提交构建的守护进程。 - -**Agent-host 和沙箱镜像。** 使用上述三个 Harness 镜像的输入: - -```sh export CODEX_CLI_DIR=/absolute/path/to/package MCODE_HARNESS_BUILD_DIR=/absolute/mcode-harness bash scripts/build-agent-host-images.sh ``` -该脚本将三个 Runtime 镜像构建器的产物准备到同一个上下文中,加入静态的 `oac-daemon`、`oac-process-shim` 和 `oac-sandbox-io`,并将 `deploy/distribution/AgentHost.Dockerfile` 的两个目标分别构建为 `OAC_AGENT_HOST_IMAGE`(默认 `oac-agent-host:dev`)和 `OAC_SANDBOX_IMAGE`(默认 `oac-sandbox:dev`)。其余参数(例如 `--label`)会传给两次 `docker build`。agent-host 镜像把每个 Harness 安装在 `/opt/oac/harnesses` 下各自的目录中,`/opt/oac/harnesses.json` 是这些位置的唯一记录;agent host 通过 `agent.ManifestEnvironment` 读取它。沙箱镜像具有 Runtime 镜像的基础层和软件包,不含守护进程和 Harness,并以 UID/GID 1000 运行 `oac-sandbox-io --bootstrap-file `。[认定视图资格](../../contracts/agents-api/zh/harness-onboarding.md#qualify-the-view)会运行这两个镜像;[Agent-host 容器](configuration.md#agent-host-container)列出 agent host 的需求。分发构建用其 Runtime 镜像构建器准备的载荷构建并发布 agent-host 镜像;CI 和发布流程都不构建沙箱镜像。 +脚本构建 `OAC_AGENT_HOST_IMAGE`(默认 `oac-agent-host:dev`)和 `OAC_SANDBOX_IMAGE`(默认 `oac-sandbox:dev`)。附加参数(例如 `--label`)传给两次 Docker 构建。agent-host 镜像包含 `oac-daemon`、`oac-process-shim`,以及 `/opt/oac/harnesses` 下的各 Harness;`/opt/oac/harnesses.json` 拥有其激活路径。沙箱镜像包含系统工具和以 UID/GID 1000 运行的 `oac-sandbox-io`,不含 Harness 或 daemon。分发包校验 Sandbox I/O 可执行文件,并通过节点构件已有的 `runtime` 槽位分发此镜像。E2B 模板从同一镜像提取 Sandbox I/O。[认定视图资格](../../contracts/agents-api/zh/harness-onboarding.md#qualify-the-view)运行两个镜像;[Agent-host 容器](configuration.md#agent-host-container)拥有宿主需求。 **E2B 辅助程序。** @@ -126,7 +93,7 @@ make check-microsandbox-provider `make build-core` 会将 `oac-core`、`oac-core-environment-key`、`oac-node` 和 `oac` 构建到 `${OAC_DEV_HOME:-$HOME/.oac}/build/oac-core`(`OAC_DEV_CORE_BUILD_DIR` 可选择其他绝对目录)。构建过程仅将 `scripts/build-core.sh` 中列出的源文件集(Core 服务、其契约、所需的共享软件包以及根 Go 模块文件)复制到临时上下文,并使用禁用 CGO、只读模块和裁剪路径的方式构建。它不需要 Node、Docker 或其他应用程序。Core 新增共享依赖时,请将该软件包加入列表;绝不能复制整个仓库来使其完成编译。 -`make docker-build-core` 会根据这五个命令和 E2B 辅助程序构建 `oac-core:dev` 镜像(`OAC_DEV_CORE_IMAGE` 可选择其他名称)。基础镜像是通过摘要固定的 `debian:bookworm-slim`,包含 CA 证书以及辅助程序所需的 glibc 运行时;默认用户的 UID/GID 为 65532,Core 监听 `:8091`。此本地构建目标生成 Linux amd64 镜像;[分发构建](#build-a-distribution)生成两种架构的镜像。对镜像或其构建进行更改时,除了相关的源代码检查外,还必须运行 `make check-core-container`:它会在只读根文件系统上针对该镜像运行官方客户端测试套件,并且需要 Linux Docker、非 root 用户,以及服务检查中的[测试数据库和固定版本 SDK](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/README.md#official-client-verification)(`OAC_TEST_DATABASE_URL` 指向一个已应用迁移的 `oac_*_tests` 数据库,并设置 `OAC_TEST_OFFICIAL_SDK_PYTHON`)。 +`make docker-build-core` 会根据这些命令和 E2B 辅助程序构建 `oac-core:dev` 镜像(`OAC_DEV_CORE_IMAGE` 可选择其他名称)。基础镜像是通过摘要固定的 `debian:bookworm-slim`,包含 CA 证书以及辅助程序所需的 glibc 运行时;默认用户的 UID/GID 为 65532,Core 监听 `:8091`。此本地构建目标生成 Linux amd64 镜像;[分发构建](#build-a-distribution)使用同一架构。对镜像或其构建进行更改时,除了相关的源代码检查外,还必须运行 `make check-core-container`:它会在只读根文件系统上针对该镜像运行官方客户端测试套件,并且需要 Linux Docker、非 root 用户,以及服务检查中的[测试数据库和固定版本 SDK](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/README.md#official-client-verification)(`OAC_TEST_DATABASE_URL` 指向一个已应用迁移的 `oac_*_tests` 数据库,并设置 `OAC_TEST_OFFICIAL_SDK_PYTHON`)。 ## 发布版本 {#publish-a-version} @@ -145,7 +112,7 @@ git push origin v1.2.3 ### 容器注册表 {#container-registry} -版本发布和手动 `build-` 草稿使用 `ghcr.io/minimax-ai/openagentcore/:`,其中 `` 为 `core`、`web`、`runtime`、`ingress` 或 `agent-host`。Core、Web 和 ingress 索引包含 Linux amd64 和 arm64 镜像,Runtime 和 agent-host 包含 Linux amd64。各平台镜像使用 `-` 标签,从发行归档加载。已有版本标签必须与发行镜像及平台集合一致。发布器校验全部版本索引后,才为稳定版更新 `latest`;预发布版和草稿保持 `latest` 不变。PostgreSQL 使用上游镜像。容器标签中的 SemVer 构建元数据用 `_` 替换 `+`,版本字符串上限为 128 个字符。镜像校验后,发布器上传该版本的 `compose.yaml` 和校验和清单。Compose 用索引摘要固定 ingress,初始化时检查其构建版本与 Compose 版本一致。 +版本发布和手动 `build-` 草稿使用 `ghcr.io/minimax-ai/openagentcore/:`,其中 `` 为 `core`、`web`、`runtime`、`ingress` 或 `agent-host`。所有组件索引均包含 Linux amd64 镜像;`runtime` 组件包含沙箱镜像。各平台镜像使用 `-` 标签,从发行归档加载。已有版本标签必须与发行镜像及平台集合一致。发布器校验全部版本索引后,才为稳定版更新 `latest`;预发布版和草稿保持 `latest` 不变。PostgreSQL 使用上游镜像。容器标签中的 SemVer 构建元数据用 `_` 替换 `+`,版本字符串上限为 128 个字符。镜像校验后,发布器上传该版本的 `compose.yaml` 和校验和清单。Compose 用索引摘要固定 ingress,初始化时检查其构建版本与 Compose 版本一致。 合并的构建/发布作业使用具有 `packages: write` 权限的 `GITHUB_TOKEN`。首次发布时,GitHub 会将每个容器软件包创建为私有:软件包管理员必须先在各自的软件包设置中将全部五个软件包改为 **Public**,用户才能匿名拉取。请参阅 [GitHub container visibility](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)。更改可见性后,请验证未认证拉取。仅更改仓库可见性并不会使新的容器软件包变为公开。 @@ -171,7 +138,7 @@ gh workflow run core-release --repo MiniMax-AI/OpenAgentCore --ref main \ ## 持续集成 {#continuous-integration} -每个 PR 都会运行 `core-check` 并报告必需状态 `check`。main 使用 GitHub 分支保护,合并前必须通过此检查且分支必须为最新状态,因此合并不会启动另一份测试套件。所有更改都必须通过经过检查的 PR 提交;管理员绕过检查并不代表 CI 成功。推送到 main 时,如果输入发生变化,就会发布网站,并运行 `cache-warm`:它构建 MiniMax companion、E2B 辅助程序和 pnpm 存储,不运行测试,因为只有 main 上保存的缓存能被每个 PR 和发布标签恢复。版本标签和手动发布构建会在其确切源代码提交上运行完整的发布门禁。`scripts/ci_plan.py` 管理唯一的输入到检查映射。组件规则要求同时匹配目录或脚本前缀以及文件后缀;确切的依赖项、工作流和共享构建输入都有明确规则。规则会在共享使用方和混合变更之间累加。没有匹配构建/测试规则的路径仅运行 hygiene。引入新组件、语言、构建输入或资源位置时,请添加相应规则。 +每个 PR 都会运行 `core-check` 并报告必需状态 `check`。main 使用 GitHub 分支保护,合并前必须通过此检查且分支必须为最新状态,因此合并不会启动另一份测试套件。所有更改都必须通过经过检查的 PR 提交;管理员绕过检查并不代表 CI 成功。推送到 main 时,如果输入发生变化,就会发布网站,并运行 `cache-warm`:它构建 E2B 辅助程序和 pnpm 存储,不运行测试,因为只有 main 上保存的缓存能被每个 PR 和发布标签恢复。版本标签和手动发布构建会在其确切源代码提交上运行完整的发布门禁。`scripts/ci_plan.py` 管理唯一的输入到检查映射。组件规则要求同时匹配目录或脚本前缀以及文件后缀;确切的依赖项、工作流和共享构建输入都有明确规则。规则会在共享使用方和混合变更之间累加。没有匹配构建/测试规则的路径仅运行 hygiene。引入新组件、语言、构建输入或资源位置时,请添加相应规则。 计划器会将 PR 事件所测试的合并提交与其已验证的第一个父提交进行比较。NUL 分隔的 Git 输出和禁用重命名检测会同时保留旧路径和新路径。计划及原因会显示在运行摘要中。历史记录缺失或不一致、检出不匹配、路径无效、计划器/编排发生变更以及共享构建输入发生变化时,都会选择完整门禁。经过验证的空差异仅选择 hygiene。发布、手动和显式 ref 调用始终选择所有组。 @@ -187,12 +154,12 @@ gh workflow run core-release --repo MiniMax-AI/OpenAgentCore --ref main \ | `website` | 针对网站、已发布文档和依赖项变更的网站构建与输出检查 | | `web-acceptance` | Web 单元测试/构建成功后,以四个隔离分片运行完整 Web 浏览器测试套件;每个分片保留一个工作线程 | | `api` | 针对独立命令和迁移的可复用官方客户端验收;当镜像/构建/辅助程序输入发生变化时进行镜像验收,并在每次完整门禁中执行 | -| `native` | 可复用的 Linux、macOS 和 Windows 构建、文件系统/进程/Harness 检查和原生安装;三个平台可并发运行 | +| `native` | Linux amd64 启动器、Sandbox I/O 和安装器检查;Linux、macOS 和 Windows 上的可移植 Core 启动器构建与测试 | | `lint` | 可复用的 actionlint 检查,包括本地复合操作 | `.github/actionlint.yaml` 会选择 hygiene 和 lint。已知工作流变更会选择其使用方:CI review 和 actionlint 工作流运行 hygiene 和 lint;原生工作流变更会添加原生检查;API 验收工作流变更会添加启用容器验收的 API 检查;网站工作流变更会添加网站检查。共享 Node 操作会选择使用它的每个作业以及 lint。新工作流或未分类的工作流/操作会选择完整门禁,直至在计划器中声明其使用方。计划器测试和 CI 测量脚本运行 hygiene;更改计划器本身会运行完整门禁。 -Compose 模板和 Compose 测试发生变更时,会同时选择 `distribution` 固定数据和 `compose` 冒烟作业;Core、Web、守护进程、共享 Go 软件包和镜像 Dockerfile 的变更也会选择冒烟作业。安装 Docker 后,可在本地运行 `python3 scripts/compose-smoke.py` 重复该测试。该脚本使用唯一的项目、自动分配的回环端口,并将在 `~/.oac/tests/` 下生成构件;退出时移除其容器和数据卷。CI 还会在冒烟步骤失败或中断后执行清理。诊断信息会显示容器状态,但不会打印 HTTP 响应正文或登录密钥。Core、Web、agent host 和 ingress 镜像都从当前检出构建;Web 提供占位页面而不是控制台构建,agent-host 镜像不含 Harness;冒烟测试会检查 agent host 能连接到 Core。构建时的节点元数据来自 `deploy/compose/smoke-pins.json` 固定的发布版本;初始化容器禁用网络运行。冒烟矩阵使用 Linux amd64 和 arm64 原生 runner;原生矩阵在 Linux、macOS 和 Windows 上构建并测试共享的 Core 安装器。 +Compose 模板和 Compose 测试发生变更时,会同时选择 `distribution` 固定数据和 `compose` 冒烟作业;Core、Web、守护进程、共享 Go 软件包和镜像 Dockerfile 的变更也会选择冒烟作业。安装 Docker 后,可在本地运行 `python3 scripts/compose-smoke.py` 重复该测试。该脚本使用唯一的项目、自动分配的回环端口,并将在 `~/.oac/tests/` 下生成构件;退出时移除其容器和数据卷。CI 还会在冒烟步骤失败或中断后执行清理。诊断信息会显示容器状态,但不会打印 HTTP 响应正文或登录密钥。Core、Web、agent host 和 ingress 镜像都从当前检出构建;Web 提供占位页面而不是控制台构建,agent-host 镜像不含 Harness;冒烟测试会检查 agent host 能连接到 Core。构建时的节点元数据来自 `deploy/compose/smoke-pins.json` 固定的发布版本;初始化容器禁用网络运行。冒烟测试在 Linux amd64 上运行;原生矩阵在 Linux、macOS 和 Windows 上构建并测试可移植 Core 启动器。 Go 模块和工作区输入会选择后端、API(包括容器)、原生和分发检查。每个 Node 模块都拥有自己的清单和锁文件。网站依赖项会选择网站检查;Web 依赖项会选择 Web 和浏览器检查;示例依赖项会选择示例检查;共享 TypeScript 客户端依赖项会选择 Web、浏览器和示例检查;Claude 适配器依赖项会选择 Harness、原生和分发检查。共享包管理器配置会选择所有 Node 使用方。根 TypeScript 配置会选择 Web 和示例检查;适配器 TypeScript 配置会选择 Harness 和原生检查。每个所选集合都包含 hygiene。混合变更会累加其使用方,并且每个作业都读取同一计划,而不是维护各自的路径列表。例如,仅修改通知的 PR 会跳过数据库、浏览器和原生作业,而同时修改通知和 Core 的 PR 会添加后端和 API 检查。 diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md index bafbdc176..258658bc0 100644 --- a/docs/zh/runtime-protocol.md +++ b/docs/zh/runtime-protocol.md @@ -1,7 +1,7 @@ --- title: "Core–Runtime 协议" source: docs/runtime-protocol.md -source_hash: 5c72353535e1a5adc69cedde20408a38dfcfb2a4256c6335f9f9e381fc71b0ed +source_hash: 472fdc062cf4304066d056bccae0dc0644283b5ac1d1e3ffca785a940a15a79d --- 此协议在 Runtime daemon 获取机器凭据后连接 Core 与 daemon,定义 daemon 连接上消息的含义和顺序。wire 类型、限制和验证器仅在 [`internal/agentdaemon/proto`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/internal/agentdaemon/proto) 中定义一次;Core 的 [gateway](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/runtimegateway) 与参考 Runtime 的 [dispatcher](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/apps/daemon/internal/dispatch) 都使用它们,因此无需同步第二套 payload schema。签发凭据和打开连接的 HTTP 路由见[机器连接 API](../../contracts/agents-api/zh/machine-api.md)。 @@ -192,7 +192,7 @@ adapter 可以给 Run 的 `error` frame 添加 `code` 和 `http_status`。它们 接受的 code 为 `authentication_error`、`rate_limit_exceeded`、`usage_limit_exceeded`、`server_overloaded`、`server_error`、`invalid_request`、`resource_not_found`、`request_timeout`、`context_length_exceeded`、`cyber_policy` 和 `connection_failed`([`engine_failure.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/internal/agentdaemon/proto/engine_failure.go))。仅 `connection_failed` 保留 `http_status`,且只能是 100 到 599 的整数;其他 status 都丢弃。值缺失、格式错误或未知时保持未分类错误,不丢弃 Usage 或 `done`。 -Core 在 Turn outcome 中将接受的值保存为 `engine_error_code` 和 `engine_http_status`。分类从属于终结状态和 Core 的 `error_code`:不能将已完成或已取消 Turn 变成失败、隐藏不完整事件流,或覆盖持久化或取消回执失败。正常 delivery 和 terminal journal draining 使用同一提取逻辑。[Session 诊断](../../contracts/agents-api/zh/session-diagnostics.md)仅为 Core error 为 `engine_failed` 的失败 Turn 暴露类别。[Codex](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/README.md) 和 [Claude Code](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/claude/README.md) adapter 指南给出各 Harness 的映射;adapter 不依据错误文字分类。 +Core 在 Turn outcome 中将接受的值保存为 `engine_error_code` 和 `engine_http_status`。分类从属于终结状态和 Core 的 `error_code`:不能将已完成或已取消 Turn 变成失败、隐藏不完整事件流,或覆盖持久化或取消回执失败。正常 delivery 和 terminal journal draining 使用同一提取逻辑。[Session 诊断](../../contracts/agents-api/zh/session-diagnostics.md)仅为 Core error 为 `engine_failed` 的失败 Turn 暴露类别。[Codex](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/apps/daemon/internal/agent/codex/error_classification.go) 和 [Claude Code](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/packages/claude-sdk-adapter/src/adapter.ts) adapter 实现定义各 Harness 的映射;adapter 不依据错误文字分类。 ## 工作区操作 {#workspace-operations} diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index efa88666b..843e73206 100644 --- a/docs/zh/sandbox-provider.md +++ b/docs/zh/sandbox-provider.md @@ -1,7 +1,7 @@ --- title: "添加 Sandbox Provider" source: docs/sandbox-provider.md -source_hash: 9caf2d1e9f5e5ba07d9727051765f1726682bfcc65a6459bde41b7eeb306fc0a +source_hash: 6378777af8dce6438d8796b51f5b737837e2ce56092fe2c48baf0ba91c0e4aea --- **Sandbox Provider** 为 Core 管理的 Environment 提供计算资源,以及在其中启动 [Sandbox I/O 服务](#oac-sandbox-io)的有界引导流程;该服务是 Provider 启动的唯一进程。本指南说明如何添加 Provider,并作为 Core 驱动 Provider 的参考。接口为 [`SandboxProvider`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/sandbox_provider.go)。 @@ -168,7 +168,7 @@ Core 在 Turn 之间检查已连接且已观察的计算资源仍是其 Session ### `oac-sandbox-io` {#oac-sandbox-io} -`oac-sandbox-io` 是 Provider 在托管 sandbox 中启动的唯一进程,它通过[沙箱 Link](./sandbox-link-protocol.md) Serve 该 allocation。`Bootstrap` 是 allocation 的 `Reference` 和 `SandboxIO`,后者是该服务的[沙箱引导](./sandbox-bootstrap.md)输入:从[公开 URL](./configuration.md#changing-the-public-url) 派生的 Link URL、allocation 的 Serve credential,以及作为 resource 的 allocation 及其 Serve generation。Core 在 `Create` 前用 `Bootstrap.Validate` 对整个 `Bootstrap` 校验一次,同时检查 `SandboxIO` Serve 的正是该 `Reference`;adapter 原样交付。`Create` 将 `SandboxIO` 写入私有文件(参考 adapter 中为 `/home/runtime/sandbox-io-bootstrap.json`,mode 0600、UID 1000),并以 UID 1000 启动 `oac-sandbox-io --bootstrap-file`,参数为该路径。`BootstrapComplete` 意味着它已启动。该输入从不通过命令参数或环境变量传递。每个 Runtime 镜像都包含 `/usr/local/bin/oac-sandbox-io`。allocation cleanup 在调用 `Kill` 前先在 relay 撤销该 resource。 +`oac-sandbox-io` 是 Provider 在托管 sandbox 中启动的唯一进程,它通过[沙箱 Link](./sandbox-link-protocol.md) Serve 该 allocation。`Bootstrap` 是 allocation 的 `Reference` 和 `SandboxIO`,后者是该服务的[沙箱引导](./sandbox-bootstrap.md)输入:从[公开 URL](./configuration.md#changing-the-public-url) 派生的 Link URL、allocation 的 Serve credential,以及作为 resource 的 allocation 及其 Serve generation。Core 在 `Create` 前用 `Bootstrap.Validate` 对整个 `Bootstrap` 校验一次,同时检查 `SandboxIO` Serve 的正是该 `Reference`;adapter 原样交付。`Create` 将 `SandboxIO` 写入私有文件(参考 adapter 中为 `/home/runtime/sandbox-io-bootstrap.json`,mode 0600、UID 1000),并以 UID 1000 启动 `oac-sandbox-io --bootstrap-file`,参数为该路径。`BootstrapComplete` 意味着它已启动。该输入从不通过命令参数或环境变量传递。沙箱镜像包含 `/usr/local/bin/oac-sandbox-io`;Harness 打包在 agent-host 镜像中。allocation cleanup 在调用 `Kill` 前先在 relay 撤销该 resource。 ### 每节点生命周期 worker {#per-node-lifecycle-workers} @@ -222,7 +222,7 @@ node 测试单独覆盖 disconnect、reconnect fencing,以及 Create response Docker Sandbox Provider([`sandbox/docker`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/services/core/internal/sandbox/docker))将每个 allocation 的 sandbox 作为唯一进程为 [`oac-sandbox-io`](#oac-sandbox-io) 的 container 运行,并使用相同 container setting([`container_options.go`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/internal/sandbox/docker/container_options.go)): -- user 1000:1000、只读 root filesystem、移除全部 capability、`no-new-privileges`、[seccomp profile](#seccomp-profile) 和 AppArmor `unconfined`; +- user 1000:1000、只读 root filesystem、移除全部 capability、`no-new-privileges`、[seccomp profile](#seccomp-profile); - node 配置的 network 和 extra host([node 配置](configuration.md#docker-node-configuration)); - deployment specification 中的 CPU 和 memory,128-process limit 和 128 MiB `/tmp` tmpfs; - 两个 named volume,label 包含 installation、tenant、Environment 和 allocation:`-home` 挂载到 `/home`,`-environment` 挂载到 `/environment`,后者的 `workspace` 子目录也挂载到 `/workspace`。Docker Engine 必须支持 volume subpath mount。 @@ -233,4 +233,4 @@ node 使用 [provider 配置](configuration.md#docker-node-configuration)中的 ### Seccomp profile {#seccomp-profile} -[`seccomp.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/seccomp.json) 是 [revision 65adc7e](https://github.com/moby/profiles/blob/65adc7e022c97f55e45c054ff012988027733b87/seccomp/default.json) 的 Moby default profile(Apache-2.0,参见 [seccomp.LICENSE](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/services/core/deploy/codex/seccomp.LICENSE);上游文件 SHA-256 为 `785b2429264afba4d594320337cb17f144f3c7d51585f9805eef72e28f4f9334`),追加一条允许 `clone`、`unshare`、`setns`、`mount`、`umount2` 和 `pivot_root` 的规则。发行包将此文件作为 `runtime/seccomp.json` 交付每个 Docker node。 +[`seccomp.json`](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/distribution/seccomp.json) 是 [revision 65adc7e](https://github.com/moby/profiles/blob/65adc7e022c97f55e45c054ff012988027733b87/seccomp/default.json) 的 Moby default profile(Apache-2.0,参见 [seccomp.LICENSE](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/distribution/seccomp.LICENSE);上游文件 SHA-256 为 `785b2429264afba4d594320337cb17f144f3c7d51585f9805eef72e28f4f9334`)。发行包将此文件作为 `runtime/seccomp.json` 交付每个 Docker node。 diff --git a/example/parsar/README.md b/example/parsar/README.md index 819d6e149..65eaf200a 100644 --- a/example/parsar/README.md +++ b/example/parsar/README.md @@ -20,7 +20,7 @@ Open http://127.0.0.1:18180. The Core URL is an origin without `/v1`; remote ori 1. **Models:** enter a Provider name, Base URL and API key, then fetch its `GET /v1/models` list. Select models with checkboxes or add custom model IDs, then save the Provider and selected models together. The dialog shows discovered and selected counts. For manual entry, skip discovery. No default group is created. Keys are stored in the local restricted SQLite file and never returned to the browser; leaving the key blank while editing preserves it. Discovery sends only that Provider's key and does not follow redirects. The list expects the OpenAI-shaped `data: [{id: "..."}]` response. Discovery failures leave manual entry available. Codex and Claude Code workspace Sessions pass the selected Provider URL/key explicitly to Core, using the harness protocol. These Sessions require an HTTPS Base URL and API key. Text-only Sessions and hosted MiniMax Code use Core's deployment connection; the creation dialog states that their Provider is only a catalog group. The example does not yet expose MiniMax Code's required token limits. 2. **Skills:** create a SKILL.md resource or upload a ZIP. Inspect versions, upload a new version, and choose the default. Core validates and stores bundles. 3. **MCP:** save anonymous HTTPS endpoints and bind them to Agents. Hosted Sessions install an inline environment Plugin; text-only Sessions use Core's service-origin MCP. Hosted HTTP MCP supports Claude Code and Codex, not MiniMax Code. Authentication, Vault management and OAuth setup are not included. -4. **Runtimes:** choose a Core-managed sandbox, text-only environment, or user machine. User machines select Linux/macOS/Windows and an existing absolute workspace path, plus an optional local capability directory. Each Session gets its own daemon installation and credential; choosing the same workspace path shares the host's files, so use distinct paths for isolated work. These are placement configurations, not online machine identities. Machine enrollment, fixed-node routing and self-hosted registration are omitted. +4. **Runtimes:** choose a Core-managed sandbox, text-only environment, or user machine. User machines require Linux amd64 and an existing absolute workspace path, plus an optional local capability directory. Each Session gets its own daemon installation and credential; choosing the same workspace path shares the host's files, so use distinct paths for isolated work. These are placement configurations, not online machine identities. Machine enrollment, fixed-node routing and self-hosted registration are omitted. 5. **Agents:** combine a model, harness, instructions, Skills and MCP services. Create, copy and edit configurations without allocating a runtime. 6. **Sessions:** open an Agent, choose a runtime and send the first message. Read streaming replies and tool activity, continue the conversation, cancel a running turn and reopen history. Unsupported Skill/MCP/runtime combinations are explained before creation. The Session's **Files** dialog uploads one file at a time (up to 5 MiB) through Core's public Environment Files API while idle. Each upload gets a unique path under `/workspace/inputs`; a path relative to the working directory is inserted into the message draft, not sent automatically. Unsent drafts, including uploaded file paths, survive reloads and navigation in the same browser tab and remain scoped to the Session. This keeps the API's `/workspace` alias distinct from the physical user-machine directory. Ask the Agent to save generated files under `./outputs`; published Artifacts can be paged and downloaded from the same dialog, even when the live workspace is unavailable. Downloads remain binary; errors stay in the dialog. Text-only Sessions have no file actions. Each hosted Session gets its own workspace; text-only has no workspace. @@ -62,7 +62,7 @@ It leaves sample resources for inspection and executes the configured model. It ## Connect a user machine -Create a user-machine runtime and start a Session with an Agent using Codex or Claude Code. No initial Turn is sent. Open **Connect user machine** and run the Core-provided command on the target host. The installer downloads the matching native distribution, installs the selected harness and starts the connection. Windows Claude Code also requires Git Bash. The page reads Core's public Environment status and enables sending after it reports connected. The example backend does not need or accept the administrator Core key. Commands carry temporary Environment-scoped authorization; do not share them. Session refresh renews them. +Create a user-machine runtime and start a Session with an Agent using Codex or Claude Code. No initial Turn is sent. Open **Connect user machine** and run the Core-provided command on the target host. The installer downloads the matching launcher and Sandbox I/O service for Linux amd64 and starts the connection. Harnesses run on the agent host. The page reads Core's public Environment status and enables sending after it reports connected. The example backend does not need or accept the administrator Core key. Commands carry temporary Environment-scoped authorization; do not share them. Session refresh renews them. The selected model Provider needs an HTTPS Base URL and API key. Its protocol is Responses for Codex and Anthropic for Claude Code. Core freezes and delivers the configuration to the enrolled executor. Local pending creation requests contain that configuration but browser Session responses omit the entire pending request. diff --git a/example/parsar/server/runtime-profile.mjs b/example/parsar/server/runtime-profile.mjs index adefda835..c0a0fe4b2 100644 --- a/example/parsar/server/runtime-profile.mjs +++ b/example/parsar/server/runtime-profile.mjs @@ -6,24 +6,13 @@ export function runtimeProfile(body) { throw new AppError(400, "运行环境无效。"); if (body.environment !== "self_hosted") return { environment: body.environment }; - if (!["linux", "macos", "windows"].includes(body.platform)) - throw new AppError(400, "请选择机器平台。"); + if (body.platform !== "linux") + throw new AppError(400, "用户机器仅支持 Linux amd64。"); const directory = (value) => { const result = text(value, "工作目录", 4096, true); - const portable = result.replaceAll("\\", "/"); - const clean = (path) => - posix.normalize(path) === path && (path === "/" || !path.endsWith("/")); - const windows = body.platform === "windows"; - const drive = /^[A-Za-z]:\//.test(portable); - const unc = portable.startsWith("//"); - const valid = windows - ? !/[:*?"<>|]/.test(drive ? portable.slice(2) : portable) && - (drive - ? clean(portable.slice(2)) - : unc && - portable.slice(2).split("/").length >= 2 && - portable.slice(2).split("/").every((part) => part && part !== "." && part !== "..")) - : result.startsWith("/") && !unc && !result.includes("\\") && clean(result); + const valid = result.startsWith("/") && !result.startsWith("//") && + !result.includes("\\") && posix.normalize(result) === result && + (result === "/" || !result.endsWith("/")); if (!valid || /[\x00-\x1f\x7f]/.test(result) || Buffer.byteLength(result) > 4096) throw new AppError(400, "请填写规范的绝对目录,不使用重复分隔符、末尾斜杠、. 或 ..。"); return result; diff --git a/example/parsar/src/ConnectMachine.tsx b/example/parsar/src/ConnectMachine.tsx index 98c4a0a01..22f51d136 100644 --- a/example/parsar/src/ConnectMachine.tsx +++ b/example/parsar/src/ConnectMachine.tsx @@ -46,12 +46,11 @@ export function ConnectMachine({ failed: "连接失败", }; const status = query.data?.status; - const windows = machine.platform === "windows"; const installation = session.x_agents_core?.installation; const command = installation?.status === "available" && (installation.expires_at ?? 0) > Date.now() / 1000 - ? installation.commands?.[windows ? "powershell" : "posix"] + ? installation.commands?.posix : undefined; return ( <> @@ -76,12 +75,12 @@ export function ConnectMachine({ 连接用户机器
-

在用户机器的{windows ? " PowerShell" : "终端"}中运行安装命令。

+

在 Linux amd64 用户机器的终端中运行安装命令。

工作目录:{machine.workspace_directory} - 安装器会下载与 Core 匹配的 Runtime 和所选执行引擎,创建工作目录并连接此会话。 - Windows 上 Claude Code 还需要 Git Bash。Runtime 使用启动用户的权限。 + 安装器会下载与 Core 匹配的启动器和 Sandbox I/O 服务并连接此会话。 + 工具使用启动用户的权限执行,执行引擎运行在 agent host 上。
{command ? ( diff --git a/example/parsar/src/Resources.tsx b/example/parsar/src/Resources.tsx index c371fa688..107f83f3d 100644 --- a/example/parsar/src/Resources.tsx +++ b/example/parsar/src/Resources.tsx @@ -130,7 +130,7 @@ function ResourceList({ : row.environment === "none" ? "纯文本环境" : row.environment === "self_hosted" - ? `用户机器 · ${row.platform === "macos" ? "macOS" : row.platform === "windows" ? "Windows" : "Linux"}` + ? "用户机器 · Linux amd64" : "Core 托管沙箱"}

@@ -257,9 +257,7 @@ function ResourceEditor({ } > 选择平台 - Linux - macOS - Windows + Linux amd64 @@ -273,11 +271,7 @@ function ResourceEditor({ workspace_directory: e.target.value, }) } - placeholder={ - form.platform === "windows" - ? "C:\\Users\\you\\project" - : "/home/you/project" - } + placeholder="/home/you/project" /> @@ -299,8 +293,7 @@ function ResourceEditor({ 机器访问权限 daemon - 以启动用户的权限执行,可访问该用户有权访问的文件和网络。工作目录必须已存在。Windows - 当前支持 Codex 和 Claude Code。 + 以启动用户的权限执行,可访问该用户有权访问的文件和网络。工作目录必须已存在。
diff --git a/example/parsar/src/lib/product.ts b/example/parsar/src/lib/product.ts index 2e8f251ba..1ccdc7a46 100644 --- a/example/parsar/src/lib/product.ts +++ b/example/parsar/src/lib/product.ts @@ -19,7 +19,7 @@ export interface MCPProfile extends NamedResource { } export interface RuntimeProfile extends NamedResource { environment: "none" | "openai_hosted" | "self_hosted"; - platform?: "linux" | "macos" | "windows"; + platform?: "linux"; workspace_directory?: string; capability_directories?: string[]; } @@ -38,7 +38,7 @@ export interface SessionRecord { created_at: number; core_session_id?: string; self_hosted?: { - platform: "linux" | "macos" | "windows"; + platform: "linux"; workspace_directory: string; }; } diff --git a/example/parsar/tests/fixture.mjs b/example/parsar/tests/fixture.mjs index 1a0db861a..ddd8949d7 100644 --- a/example/parsar/tests/fixture.mjs +++ b/example/parsar/tests/fixture.mjs @@ -265,7 +265,7 @@ createServer(async (req, res) => { ...(body.environment.type === "self_hosted" ? { x_agents_core: { installation: { status: "available", version: "fixture", expires_at: now() + 1800, - commands: { posix: "bash fixture-native-bootstrap.sh", powershell: "& fixture-native-bootstrap.ps1" }, + commands: { posix: "bash fixture-native-bootstrap.sh" }, } }, } : {}), status: "idle", diff --git a/example/parsar/tests/product.test.mjs b/example/parsar/tests/product.test.mjs index 4eb513bd7..43abffc41 100644 --- a/example/parsar/tests/product.test.mjs +++ b/example/parsar/tests/product.test.mjs @@ -38,8 +38,6 @@ test("native runtimes keep host paths, freeze provider secrets, and create witho ); for (const [platform, directory] of [ ["linux", "/home/user/project"], - ["macos", "/Users/user/project"], - ["windows", "C:\\Users\\user\\project"], ]) { const runtime = await put("runtimes", { name: platform, @@ -82,12 +80,16 @@ test("native runtimes keep host paths, freeze provider secrets, and create witho for (const [platform, directory] of [ ["linux", "relative"], ["linux", "/tmp/work/"], ["linux", "/tmp//work"], ["linux", "/tmp/../work"], ["linux", "/tmp/a\tb"], - ["windows", "\\work"], ["windows", "C:\\work\\"], ["windows", "C:\\a?b"], ]) { await assert.rejects(put("runtimes", { name: "bad", environment: "self_hosted", platform, workspace_directory: directory, }), /绝对目录/); } + for (const platform of ["macos", "windows", undefined]) { + await assert.rejects(put("runtimes", { + name: "Unsupported", environment: "self_hosted", platform, workspace_directory: "/workspace", + }), /Linux amd64/); + } const runtime = await put("runtimes", { name: "Local", environment: "self_hosted", platform: "linux", workspace_directory: "/workspace", }); diff --git a/example/parsar/tests/workflow.spec.ts b/example/parsar/tests/workflow.spec.ts index 01ceb1cda..fd95042ac 100644 --- a/example/parsar/tests/workflow.spec.ts +++ b/example/parsar/tests/workflow.spec.ts @@ -98,10 +98,7 @@ test.beforeEach(async ({ request }) => { await request.post("http://127.0.0.1:18181/reset"); }); -for (const platform of [ - { label: "macOS", path: "/Users/example/project", command: "bash fixture-native-bootstrap.sh" }, - { label: "Windows", path: "C:\\Users\\example\\project", command: "& fixture-native-bootstrap.ps1" }, -]) test(`self-hosted ${platform.label} uses Core's command and waits before sending`, async ({ +test("self-hosted Linux amd64 uses Core's command and waits before sending", async ({ page, request, }) => { @@ -114,14 +111,14 @@ for (const platform of [ await expect(page.getByRole("dialog")).toHaveCount(0); await navigate(page, "运行时"); await page.getByRole("button", { name: "添加运行时" }).click(); - await page.getByLabel("名称", { exact: true }).fill("我的 Mac"); + await page.getByLabel("名称", { exact: true }).fill("我的 Linux 机器"); await page.getByLabel("环境类型").click(); await page.getByRole("option", { name: "用户机器", exact: true }).click(); await page.getByLabel("机器平台").click(); - await page.getByRole("option", { name: platform.label, exact: true }).click(); + await page.getByRole("option", { name: "Linux amd64", exact: true }).click(); await page .getByLabel("工作目录", { exact: true }) - .fill(platform.path); + .fill("/home/example/project"); await page.getByRole("button", { name: "保存", exact: true }).click(); await expect(page.getByRole("dialog")).toHaveCount(0); await navigate(page, "Agents"); @@ -137,7 +134,7 @@ for (const platform of [ await page.getByRole("button", { name: "开始会话", exact: true }).click(); await page.getByLabel("会话名称").fill("本地会话"); await page.getByLabel("运行时", { exact: true }).click(); - await page.getByRole("option", { name: "我的 Mac", exact: true }).click(); + await page.getByRole("option", { name: "我的 Linux 机器", exact: true }).click(); await expect(page.getByLabel("第一条消息")).toHaveCount(0); await page.getByRole("button", { name: "开始", exact: true }).click(); await expect(page.getByText("等待连接", { exact: true })).toBeVisible(); @@ -146,9 +143,9 @@ for (const platform of [ page.getByRole("button", { name: "发送", exact: true }), ).toBeDisabled(); await page.getByRole("button", { name: "连接用户机器", exact: true }).click(); - await expect(page.getByRole("dialog")).toContainText(platform.command); + await expect(page.getByRole("dialog")).toContainText("bash fixture-native-bootstrap.sh"); await expect(page.getByRole("dialog")).toContainText( - platform.path, + "/home/example/project", ); const sessionRoute = "**/v1/agents/sessions/*"; await page.route(sessionRoute, async (route) => { @@ -160,7 +157,7 @@ for (const platform of [ await expect(page.getByRole("dialog")).toContainText("安装命令暂不可用"); await expect(page.getByRole("button", { name: "复制命令", exact: true })).toHaveCount(0); await page.unroute(sessionRoute); - await expect(page.getByRole("dialog")).toContainText(platform.command); + await expect(page.getByRole("dialog")).toContainText("bash fixture-native-bootstrap.sh"); await page.getByRole("button", { name: "Close", exact: true }).click(); await request.post("http://127.0.0.1:18181/connect-executor"); await expect(page.getByText("已连接", { exact: true })).toBeVisible(); diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts index c6bc99f97..e9ee99df3 100644 --- a/packages/agents-client/src/client.test.ts +++ b/packages/agents-client/src/client.test.ts @@ -2794,7 +2794,7 @@ describe("OpenAIAgentsClient", () => { }); it("preserves Session installation commands without treating them as execution configuration", () => { - const installation = { status: "available", version: "source", expires_at: 2000000000, commands: { posix: "bootstrap-posix", powershell: "bootstrap-windows" } }; + const installation = { status: "available", version: "source", expires_at: 2000000000, commands: { posix: "bootstrap-posix" } }; const resource = { ...sessionResource(), x_agents_core: { installation } }; expect(projectAgentSession(resource).x_agents_core).toEqual({ installation }); expect(() => projectAgentSession({ ...resource, x_agents_core: { installation: { ...installation, expires_at: "later" } } })).toThrow(); diff --git a/packages/agents-client/src/installation-projection.ts b/packages/agents-client/src/installation-projection.ts index a548c7b6f..4f58ef6c0 100644 --- a/packages/agents-client/src/installation-projection.ts +++ b/packages/agents-client/src/installation-projection.ts @@ -5,6 +5,6 @@ import type { EnvironmentInstallation } from "./types"; export function projectEnvironmentInstallation(value: unknown): EnvironmentInstallation | null { if (!isRecord(value) || typeof value.version !== "string") return null; if (value.status === "unavailable" && typeof value.message === "string") return { status: "unavailable", version: value.version, message: value.message }; - if (value.status !== "available" || !isNonnegativeInteger(value.expires_at) || !isRecord(value.commands) || typeof value.commands.posix !== "string" || typeof value.commands.powershell !== "string") return null; - return { status: "available", version: value.version, expires_at: value.expires_at, commands: { posix: value.commands.posix, powershell: value.commands.powershell } }; + if (value.status !== "available" || !isNonnegativeInteger(value.expires_at) || !isRecord(value.commands) || typeof value.commands.posix !== "string") return null; + return { status: "available", version: value.version, expires_at: value.expires_at, commands: { posix: value.commands.posix } }; } diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index 4981a8c3e..cd1f55336 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -517,7 +517,7 @@ export interface EnvironmentInstallation { status: "available" | "unavailable"; version: string; expires_at?: number; - commands?: { posix: string; powershell: string }; + commands?: { posix: string }; message?: string; } diff --git a/packages/claude-sdk-adapter/README.md b/packages/claude-sdk-adapter/README.md index 95b154d9c..14530da73 100644 --- a/packages/claude-sdk-adapter/README.md +++ b/packages/claude-sdk-adapter/README.md @@ -14,7 +14,7 @@ pnpm --dir packages/claude-sdk-adapter test make check-claude-sdk ``` -The package test compiles TypeScript before running its tests. The Make target also builds and checks the relocatable Runtime artifact. Changes to native execution require real-provider acceptance through Core and Runtime, including continuation and cancellation, followed by the repository's required `make check`. Use [the deployment guide](../../services/core/deploy/claude/README.md) for the qualified environment and Runtime build. +The package test compiles TypeScript before running its tests. The Make target also builds and checks the relocatable Runtime artifact. Changes to native execution require real-provider acceptance through Core and Runtime, including continuation and cancellation, followed by the repository's required `make check`. Use [Harness qualification](../../contracts/agents-api/harness-onboarding.md#qualify-the-view) for the qualified environment and Runtime build. ## Bridge and native lifecycle diff --git a/packages/mcode-harness/README.md b/packages/mcode-harness/README.md index 6136bf8c9..80a9d9c05 100644 --- a/packages/mcode-harness/README.md +++ b/packages/mcode-harness/README.md @@ -1,6 +1,6 @@ # MiniMax Code workspace bridge -This companion package lets the agent host run MiniMax Code with OpenAgentCore's workspace. MiniMax Code keeps its own ACP Session, model loop and history. The package supplies a trusted MCP server (`bridge.mjs`), which the daemon registers as `oac_workspace` (its MCP server info names it `oac-workspace`). It exposes six native MiniMax Code tools rooted at the Session's workspace: `workspace_read`, `workspace_write`, `workspace_edit`, `workspace_bash`, `workspace_grep` and `workspace_glob`. The bridge and its tools run beside the CLI in the Session's agent-host view, where Bash, `rg` and `git` run in the sandbox; the package adds no inner sandbox. The [MiniMax Code Runtime](../../services/core/deploy/mcode/README.md) guide owns the Runtime image, configuration and qualified deployment. +This companion package lets the agent host run MiniMax Code with OpenAgentCore's workspace. MiniMax Code keeps its own ACP Session, model loop and history. The package supplies a trusted MCP server (`bridge.mjs`), which the daemon registers as `oac_workspace` (its MCP server info names it `oac-workspace`). It exposes six native MiniMax Code tools rooted at the Session's workspace: `workspace_read`, `workspace_write`, `workspace_edit`, `workspace_bash`, `workspace_grep` and `workspace_glob`. The bridge and its tools run beside the CLI in the Session's agent-host view, where Bash, `rg` and `git` run in the sandbox; the package adds no inner sandbox. The [maintainer guide](../../docs/maintainers.md#runtime-images-and-helpers) owns the agent-host image build; [Harness qualification](../../contracts/agents-api/harness-onboarding.md#qualify-the-view) owns deployment evidence. One patch script (`patch-native.mjs`) makes four edits to the pinned native CLI source. The SQLite task-admission transaction enforces the daemon's Subagent concurrency limit before child work starts; foreground, background, nested and idle-child append admissions share that transaction, and terminal native tasks release capacity. ACP initialization reports `oac/subagents` metadata: its version, the applied workspace tool policy and the admission limit. The native tool catalog applies the `protected-mcp-v1` tool gate described under [Subagents and cancellation](#subagents-and-cancellation). Under the same policy, the CLI ignores the workspace's project `.mcp.json`, so the Session's MCP comes only from the daemon. No second model or scheduling loop is introduced. Hosted public execution is not qualified by this package alone. diff --git a/scripts/build-agent-host-images.sh b/scripts/build-agent-host-images.sh index 9c35bd12b..7c1fb8411 100755 --- a/scripts/build-agent-host-images.sh +++ b/scripts/build-agent-host-images.sh @@ -12,7 +12,7 @@ sandbox_image="${OAC_SANDBOX_IMAGE:-oac-sandbox:dev}" mkdir -p "$runtime_root/cache/oac-runtime-builds" context="$(mktemp -d "$runtime_root/cache/oac-runtime-builds/agent-host.XXXXXX")" trap 'rm -rf "$context"' EXIT -# The Runtime image builders check their pinned inputs and prepare each Harness's payload. +# The payload preparers validate each Harness's pinned inputs. AGENTS_RUNTIME_BUILD_DIR="$context/codex" bash "$repo_root/scripts/build-codex-runtime.sh" AGENTS_RUNTIME_BUILD_DIR="$context/claude" bash "$repo_root/scripts/build-claude-runtime.sh" AGENTS_RUNTIME_BUILD_DIR="$context/mcode" bash "$repo_root/scripts/build-mcode-runtime.sh" diff --git a/scripts/build-claude-runtime.sh b/scripts/build-claude-runtime.sh index c4fa06d90..4b4d4724b 100755 --- a/scripts/build-claude-runtime.sh +++ b/scripts/build-claude-runtime.sh @@ -16,12 +16,6 @@ archive=claude-sdk-runtime-linux-x64-glibc.tar.gz mkdir "$context/claude-sdk" tar -xzf "$sdk_dir/$archive" -C "$context/claude-sdk" node "$repo_root/scripts/check-claude-sdk-runtime.mjs" "$context/claude-sdk" -( - cd "$repo_root" - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ - -o "$context/" ./apps/daemon/cmd/oac-daemon ./apps/sandboxio/cmd/oac-sandbox-io -) -cp "$repo_root/services/core/deploy/claude/Dockerfile" "$context/Dockerfile" mkdir -p "$output_dir" cp -R "$context/." "$output_dir/" -printf 'Claude Runtime image context: %s\n' "$output_dir" +printf 'Claude Harness payload: %s\n' "$output_dir" diff --git a/scripts/build-codex-runtime.sh b/scripts/build-codex-runtime.sh index ae14f43ca..cac20fa7e 100755 --- a/scripts/build-codex-runtime.sh +++ b/scripts/build-codex-runtime.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" output_dir="${AGENTS_RUNTIME_BUILD_DIR:-$runtime_root/build/codex-runtime}" # Extract the official @openai/codex@0.153.4-linux-x64 npm package here. @@ -21,20 +20,11 @@ native_dir="$package_dir/vendor/x86_64-unknown-linux-musl" for executable in "$native_dir/bin/codex" "$native_dir/bin/codex-code-mode-host"; do test -x "$executable" || { printf 'Missing executable: %s\n' "$executable" >&2; exit 1; } done -test -d "$native_dir/codex-resources" mkdir -p "$runtime_root/cache/oac-runtime-builds" context="$(mktemp -d "$runtime_root/cache/oac-runtime-builds/codex.XXXXXX")" trap 'rm -rf "$context"' EXIT -( - cd "$repo_root" - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ - -o "$context/" ./apps/daemon/cmd/oac-daemon ./apps/sandboxio/cmd/oac-sandbox-io -) cp "$native_dir/bin/codex" "$native_dir/bin/codex-code-mode-host" "$context/" -cp -R "$native_dir/codex-resources" "$context/codex-resources" -cp "$repo_root/services/core/deploy/codex/Dockerfile" "$context/Dockerfile" -# Preserve the previous bundle if compilation or validation failed. +# Preserve the previous payload if validation failed. mkdir -p "$output_dir" cp -R "$context/." "$output_dir/" -printf 'Codex Runtime image context: %s\n' "$output_dir" -printf 'Build with: docker build --platform linux/amd64 -t oac-runtime:codex %q\n' "$output_dir" +printf 'Codex Harness payload: %s\n' "$output_dir" diff --git a/scripts/build-core-distribution.sh b/scripts/build-core-distribution.sh index 9972681aa..0089a588d 100755 --- a/scripts/build-core-distribution.sh +++ b/scripts/build-core-distribution.sh @@ -80,12 +80,7 @@ source_tree="$(git -C "$repo_root" rev-parse "$revision^{tree}")" source_epoch="$(git -C "$repo_root" show -s --format=%ct "$revision")" mkdir -p "$output_dir" stage="$(mktemp -d "$output_dir/.build.XXXXXX")" -image_tags=() -cleanup() { - if (( ${#image_tags[@]} )); then docker image rm "${image_tags[@]}" >/dev/null 2>&1 || true; fi - rm -rf "$stage" -} -trap cleanup EXIT +trap 'rm -rf "$stage"' EXIT source_dir="$stage/source" bundle="$stage/oac-$revision-linux-amd64" mkdir -p "$source_dir" "$bundle/images" "$stage/core/bin" "$stage/core/microsandbox" "$stage/web" "$stage/tmp" @@ -106,7 +101,7 @@ python3 scripts/core-distribution-manifest.py bootstraps "$bundle" "$source_epoc # The bundled docs (BUNDLED_DOCS); links that leave them point at this commit on GitHub. python3 scripts/core-distribution-manifest.py docs . "$bundle" "$revision" mkdir -p "$bundle/runtime" -cp services/core/deploy/codex/seccomp.json "$bundle/runtime/" +cp deploy/distribution/seccomp.json "$bundle/runtime/" cp LICENSE "$bundle/" OAC_DEV_BUILD_REVISION="$revision" scripts/build-core-image-context.sh "$stage/core" @@ -151,30 +146,18 @@ build_image web "$stage/web" CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$stage/" \ ./apps/daemon/cmd/oac-daemon ./apps/daemon/cmd/oac-process-shim ./apps/sandboxio/cmd/oac-sandbox-io -cp "$stage/oac-daemon" "$bundle/native/bin/oac-daemon" : "${CODEX_CLI_DIR:?Set the extracted pinned Codex Linux x64 package directory}" : "${MCODE_HARNESS_BUILD_DIR:?Set the existing built pinned MiniMax Code companion directory}" export CLAUDE_SDK_BUILD_DIR="$stage/claude-sdk" scripts/build-claude-sdk-runtime.sh -# Each Harness payload is its Runtime image's context and, laid out as -# scripts/build-agent-host-images.sh lays it out, part of the agent host's. -tag_suffix="${stage##*.}" +# Share the agent-host build context with the local qualification builder. for harness in codex claude mcode; do AGENTS_RUNTIME_BUILD_DIR="$stage/agent-host/$harness" bash "scripts/build-$harness-runtime.sh" - build_image "$harness" "$stage/agent-host/$harness" - image="$(cat "$stage/$harness.id")" - python3 scripts/core-distribution-manifest.py verify-runtime "$image" "$stage/oac-daemon" "$stage/oac-sandbox-io" "$source_dir" - tag="oac-distribution:$harness-$revision-$tag_suffix" - docker image tag "$image" "$tag" - image_tags+=("$tag") done -cp "$stage/oac-daemon" "$stage/oac-process-shim" "$stage/agent-host/" +cp "$stage/oac-daemon" "$stage/oac-process-shim" "$stage/oac-sandbox-io" "$stage/agent-host/" build_image agent-host --target agent-host --file deploy/distribution/AgentHost.Dockerfile "$stage/agent-host" -mkdir "$stage/combined" -cp deploy/distribution/Runtime.Dockerfile "$stage/combined/Dockerfile" -build_image runtime \ - --build-arg "CODEX_IMAGE=${image_tags[0]}" --build-arg "CLAUDE_IMAGE=${image_tags[1]}" \ - --build-arg "MCODE_IMAGE=${image_tags[2]}" "$stage/combined" +build_image runtime --target sandbox --file deploy/distribution/AgentHost.Dockerfile "$stage/agent-host" +python3 scripts/core-distribution-manifest.py verify-runtime "$(cat "$stage/runtime.id")" "$stage/oac-sandbox-io" # Pin the linux/amd64 platform manifest, not the multi-platform tag: the # containerd store keeps a pulled tag's whole index, whose export holds every @@ -244,21 +227,6 @@ mv "$stage/artifacts/"* "$output_dir/" if [[ -d "$stage/native-artifacts" ]]; then mv "$stage/native-artifacts/"* "$output_dir/"; fi mv "$bundle" "$output_dir/" -# Core, Web and initialization also run natively in ARM64 Linux containers. -# Node, hosted Runtime and agent-host payloads above remain linux/amd64. -export GOARCH=arm64 -arm_bundle="$stage/oac-$revision-linux-arm64" -mkdir -p "$arm_bundle/images" -OAC_DEV_BUILD_REVISION="$revision" scripts/build-core-image-context.sh "$stage/core" -OAC_DEV_WEB_BUILD_DIR="$stage/web" scripts/build-web.sh -cp "$stage/core/bin/oac" "$stage/ingress/oac" -for name in core web ingress; do - build_image "$name" "$stage/$name" - docker image save --output "$arm_bundle/images/$name.tar" "$(cat "$stage/$name.id")" -done -python3 scripts/core-distribution-manifest.py control-archive "$arm_bundle" "$stage" "$revision" arm64 -mv "$arm_bundle.tar.gz" "$arm_bundle.tar.gz.sha256" "$output_dir/" - # The launchers and operator commands use this same portable implementation. for platform in linux-amd64 linux-arm64 darwin-amd64 darwin-arm64 windows-amd64; do extension="" diff --git a/scripts/build-mcode-runtime.sh b/scripts/build-mcode-runtime.sh index 6f3211ade..85a48aa99 100644 --- a/scripts/build-mcode-runtime.sh +++ b/scripts/build-mcode-runtime.sh @@ -11,18 +11,18 @@ for directory in "$runtime_root" "$output" "$native" "$companion"; do done test -f "$companion/provenance.json" test -f "$companion/native-patch.json" +for file in launch.mjs bridge.mjs check.mjs tool-executor.mjs subagent-snapshot.mjs source.json; do + if ! cmp -s "$companion/$file" "$repo_root/packages/mcode-harness/$file"; then + printf 'MiniMax Code companion does not match the current source: %s\n' "$file" >&2 + exit 1 + fi +done test "$(node "$native/cli.js" --version)" = 0.4.12 mkdir -p "$runtime_root/cache/oac-runtime-builds" context="$(mktemp -d "$runtime_root/cache/oac-runtime-builds/mcode.XXXXXX")" trap 'rm -rf "$context"' EXIT mkdir "$context/mcode-harness" cp -RL "$companion/." "$context/mcode-harness/" -( - cd "$repo_root" - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ - -o "$context/" ./apps/daemon/cmd/oac-daemon ./apps/sandboxio/cmd/oac-sandbox-io -) -cp "$repo_root/services/core/deploy/mcode/Dockerfile" "$context/Dockerfile" mkdir -p "$output" cp -R "$context/." "$output/" -printf 'MiniMax Code Runtime image context: %s\n' "$output" +printf 'MiniMax Code Harness payload: %s\n' "$output" diff --git a/scripts/build-native-catalog.mjs b/scripts/build-native-catalog.mjs index ab97b7e96..64a3c144f 100644 --- a/scripts/build-native-catalog.mjs +++ b/scripts/build-native-catalog.mjs @@ -13,9 +13,9 @@ const protocol_version = (await readFile('internal/agentdaemon/proto/version.go' if (!protocol_version) throw new Error('Runtime protocol version is missing'); await mkdir(output, { recursive: true }); const artifacts = {}; -for (const [ci, platform] of Object.entries({ 'Linux-X64': 'linux-amd64', 'macOS-ARM64': 'darwin-arm64', 'Windows-X64': 'windows-amd64' })) { +for (const [ci, platform] of Object.entries({ 'Linux-X64': 'linux-amd64' })) { const archive = resolve(input, `oac-native-installer-${ci}.tar.gz`); - const bundle = JSON.parse(execFileSync('tar', ['-xOzf', archive, './bundle.json'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })); + const bundle = JSON.parse(execFileSync('tar', ['-xOzf', archive, './bundle.json'], { encoding: 'utf8', maxBuffer: 1024 * 1024 })); if (bundle.daemon_version !== version || `${bundle.os}-${bundle.arch}` !== platform) throw new Error(`Mismatched native artifact: ${platform}`); const hash = createHash('sha256'); for await (const chunk of createReadStream(archive)) hash.update(chunk); diff --git a/scripts/build-native-catalog.test.mjs b/scripts/build-native-catalog.test.mjs index 9328b3d35..5deb2c32c 100644 --- a/scripts/build-native-catalog.test.mjs +++ b/scripts/build-native-catalog.test.mjs @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import test from 'node:test'; -test('catalog accepts large native manifests and still rejects a foreign revision', async () => { +test('catalog publishes Linux amd64 and rejects a foreign revision', async () => { const directory = await mkdtemp(join(tmpdir(), 'oac-native-catalog-')); try { const input = join(directory, 'input'); @@ -14,15 +14,15 @@ test('catalog accepts large native manifests and still rejects a foreign revisio const bundle = join(directory, 'bundle'); await Promise.all([mkdir(input), mkdir(bundle)]); const version = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); - for (const [ci, os, arch] of [['Linux-X64', 'linux', 'amd64'], ['macOS-ARM64', 'darwin', 'arm64'], ['Windows-X64', 'windows', 'amd64']]) { - await writeFile(join(bundle, 'bundle.json'), JSON.stringify({ daemon_version: version, os, arch, files: { fixture: 'x'.repeat(2 * 1024 * 1024) } })); + for (const [ci, os, arch] of [['Linux-X64', 'linux', 'amd64']]) { + await writeFile(join(bundle, 'bundle.json'), JSON.stringify({ daemon_version: version, os, arch })); execFileSync('tar', ['-czf', join(input, `oac-native-installer-${ci}.tar.gz`), '-C', bundle, './bundle.json']); } const script = resolve('scripts/build-native-catalog.mjs'); execFileSync(process.execPath, [script, input, output]); const catalog = JSON.parse(await readFile(join(output, 'catalog.json'), 'utf8')); assert.equal(catalog.version, version); - assert.equal(Object.keys(catalog.artifacts).length, 3); + assert.equal(Object.keys(catalog.artifacts).length, 1); const linux = await readFile(join(input, 'oac-native-installer-Linux-X64.tar.gz')); assert.deepEqual(await readFile(join(output, 'linux-amd64.tar.gz')), linux); assert.equal(catalog.artifacts['linux-amd64'].sha256, createHash('sha256').update(linux).digest('hex')); diff --git a/scripts/build-native-installer-ci.mjs b/scripts/build-native-installer-ci.mjs index 0180a8b2a..29b4be9be 100644 --- a/scripts/build-native-installer-ci.mjs +++ b/scripts/build-native-installer-ci.mjs @@ -3,34 +3,15 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { access, mkdir, readdir, realpath, writeFile } from 'node:fs/promises'; -import { dirname, join, resolve } from 'node:path'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; import { buildBundle } from './build-native-installer.mjs'; -const root = resolve(process.env.RUNNER_TEMP ?? ''); if (!process.env.RUNNER_TEMP) throw new Error('RUNNER_TEMP is required'); -const windows = process.platform === 'win32'; -const daemon = join(root, windows ? 'oac-daemon.exe' : 'oac-daemon'); -// setup-node extracts the complete official Node distribution, including npm. -const node = windows ? dirname(process.execPath) : dirname(dirname(process.execPath)); -async function findCodex(directory, depth = 0) { - if (depth > 6) return []; - try { - await access(join(directory, 'bin', windows ? 'codex.exe' : 'codex')); - await access(join(directory, 'codex-resources')); - return [directory]; - } catch { /* Search the installed platform package rather than hard-code npm aliases. */ } - const results = []; - for (const entry of await readdir(directory, { withFileTypes: true })) { - if (entry.isDirectory()) results.push(...await findCodex(join(directory, entry.name), depth + 1)); - } - return results; -} -const candidates = await findCodex(join(root, 'native-tools', 'node_modules', '@openai')); -assert.equal(candidates.length, 1, 'Expected exactly one native Codex artifact'); +const root = resolve(process.env.RUNNER_TEMP); const bundle = join(root, 'native-installer'); -const receipt = await buildBundle({ daemon, node: await realpath(node), codex: candidates[0], claude: join(root, 'claude-runtime'), ...(!windows ? { minimax: join(root, 'minimax-runtime') } : {}), ...(process.platform === 'linux' ? { sandboxIo: join(root, 'oac-sandbox-io') } : {}), output: bundle }); -console.log(JSON.stringify({ stage: 'bundle', os: receipt.os, arch: receipt.arch, components: Object.keys(receipt.components), model_requests: 0 })); +const receipt = await buildBundle({ daemon: join(root, 'oac-daemon'), sandboxIo: join(root, 'oac-sandbox-io'), output: bundle }); +console.log(JSON.stringify({ stage: 'bundle', os: receipt.os, arch: receipt.arch, programs: ['oac-daemon', 'oac-sandbox-io'], model_requests: 0 })); const workspace = join(root, 'native-install-workspace'); await mkdir(workspace); const credential = join(root, 'native-install-credential.json'); const environment = randomUUID(); @@ -39,26 +20,19 @@ const installDir = join(root, 'native-install-smoke'); const args = ['install', '--non-interactive', '--bundle-dir', bundle, '--install-dir', installDir, '--remote', 'ws://localhost:1/api/v1/agent-daemon/ws', '--environment-id', environment, '--workspace', workspace, '--credential-file', credential]; -const command = join(bundle, windows ? 'oac-daemon.exe' : 'oac-daemon'); +const command = join(bundle, 'oac-daemon'); const env = { ...process.env, OAC_RUNTIME_HOME: join(root, 'native-install-state') }; -function install(extra, expected) { - const result = spawnSync(command, [...args, ...extra], { env, encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024 }); - // Keep native output out of CI logs, even for fixture-only credentials. +for (let attempt = 0; attempt < 2; attempt++) { + const result = spawnSync(command, args, { env, encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024 }); assert.equal(Boolean(result.error), false, 'Installation command did not settle'); - assert.equal(result.status === 0, expected, 'Unexpected installation result'); + assert.equal(result.status, 0, 'Installation or reuse failed'); assert.ok(!`${result.stdout}${result.stderr}`.includes('fixture-only-not-a-real-credential'), 'Installer echoed a credential'); } -install(['--harness', 'codex'], true); -const allHarnesses = windows ? 'codex,claude' : 'codex,claude,minimax'; -install(['--harness', allHarnesses], true); -install(['--harness', allHarnesses], true); -if (windows) install(['--harness', 'minimax'], false); -const installedCommand = join(installDir, 'bin', windows ? 'oac-daemon.exe' : 'oac-daemon'); const installedEnv = { ...process.env }; delete installedEnv.OAC_RUNTIME_HOME; -const installedVersion = spawnSync(installedCommand, ['version'], { env: installedEnv, encoding: 'utf8', timeout: 10000 }); +const installedVersion = spawnSync(join(installDir, 'bin', 'oac-daemon'), ['version'], { env: installedEnv, encoding: 'utf8', timeout: 10000 }); assert.equal(installedVersion.status, 0, 'Installed daemon could not start'); assert.equal(installedVersion.stdout.trim(), receipt.daemon_version, 'Installed daemon identity differs'); const incomplete = spawnSync(command, ['install', '--non-interactive'], { env, input: '', encoding: 'utf8', timeout: 10000 }); assert.equal(Boolean(incomplete.error), false, 'Missing arguments waited for interaction'); assert.notEqual(incomplete.status, 0, 'Missing required arguments succeeded'); -console.log(JSON.stringify({ stage: 'install', harnesses: allHarnesses.split(','), install: 'passed', additive: 'passed', reuse: 'passed', missing_arguments: 'rejected', connection: 'not_attempted', model: 'not_configured', model_requests: 0 })); +console.log(JSON.stringify({ stage: 'install', install: 'passed', reuse: 'passed', missing_arguments: 'rejected', connection: 'not_attempted', model_requests: 0 })); diff --git a/scripts/build-native-installer.mjs b/scripts/build-native-installer.mjs index 6ddf2d9e2..66b0573b8 100644 --- a/scripts/build-native-installer.mjs +++ b/scripts/build-native-installer.mjs @@ -1,183 +1,31 @@ #!/usr/bin/env node -// Package trusted, already-built native artifacts. This does not install on a host. -import { createHash } from 'node:crypto'; +// Package trusted, already-built Linux amd64 launcher and Sandbox I/O binaries. import { spawnSync } from 'node:child_process'; -import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'; -import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'; -import { createRequire } from 'node:module'; - -export const pins = { node: '22.22.0', codex: '0.153.4', claude: '0.3.269', minimax: '0.4.12' }; -const platforms = { linux: 'linux', darwin: 'darwin', win32: 'windows' }; -const architectures = { x64: 'amd64', arm64: 'arm64' }; -const inside = (root, path) => { - const rel = relative(root, path); - return rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel); -}; - -// Resolve existing parents even when the output does not exist yet. macOS -// temporary paths and Windows short paths can alias a component source. -async function outputRealPath(path) { - try { return await realpath(path); } - catch (error) { - if (error.code !== 'ENOENT' || dirname(path) === path) throw error; - return join(await outputRealPath(dirname(path)), basename(path)); - } -} - -// Flatten contained links, including directory links used by pnpm exports. Every -// traversal checks its resolved target; active ancestors detect directory cycles. -export async function copyComponent(source, destination) { - const root = await realpath(source); - if (!(await stat(root)).isDirectory()) throw new Error('Component source must be a directory'); - const files = {}; - async function visit(path, target, ancestors) { - const resolved = await realpath(path); - if (!inside(root, resolved)) throw new Error('Component link escapes its source'); - const info = await stat(resolved); - if (info.isDirectory()) { - if (ancestors.has(resolved)) throw new Error('Component contains a directory link cycle'); - const next = new Set([...ancestors, resolved]); - await mkdir(target); - for (const name of (await readdir(resolved)).sort()) { - // Manifest paths are portable slash paths, never Windows aliases/streams. - if (/[\\:\x00-\x1f]/.test(name) || /[. ]$/.test(name) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)) { - throw new Error('Component contains a non-portable file name'); - } - await visit(join(resolved, name), join(target, name), next); - } - return; - } - if (!info.isFile()) throw new Error('Component contains a non-regular file'); - await copyFile(resolved, target); - const executable = Boolean(info.mode & 0o111) || /\.(exe|cmd|bat)$/i.test(target); - await chmod(target, executable ? 0o755 : 0o644); - files[relative(destination, target).split(sep).join('/')] = { - sha256: createHash('sha256').update(await readFile(target)).digest('hex'), executable, - }; - } - await visit(root, destination, new Set()); - if (!Object.keys(files).length) throw new Error('Component is empty'); - return files; -} - -function probe(command, args, cwd, env) { - const result = spawnSync(command, args, { cwd, env, input: '', encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 }); - if (result.error || result.status !== 0) throw new Error('Native component compatibility probe failed'); - return result.stdout.trim(); -} -const json = async path => JSON.parse(await readFile(path, 'utf8')); -const requireFile = async path => { if (!(await stat(path)).isFile()) throw new Error('Required component file is missing'); }; - -// Flattening Node's Unix bin links moves npm's relative requires. Keep the -// original package entrypoints and publish relocatable regular-file launchers. -export async function prepareNodeEntrypoints(root, files) { - if (process.platform === 'win32') return; - for (const name of ['npm', 'npx']) { - const entry = `lib/node_modules/npm/bin/${name}-cli.js`; - await requireFile(join(root, entry)); - const launcher = '#!/bin/sh\n' + - 'basedir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || exit 1\n' + - `exec "$basedir/node" "$basedir/../${entry}" "$@"\n`; - const path = `bin/${name}`; - await writeFile(join(root, path), launcher, { mode: 0o755 }); - await chmod(join(root, path), 0o755); - files[path] = { sha256: createHash('sha256').update(launcher).digest('hex'), executable: true }; - } -} - -export async function validateNodeCommands(nodeRoot, cwd) { - const windows = process.platform === 'win32'; - const bin = windows ? nodeRoot : join(nodeRoot, 'bin'); - const npmRoot = join(nodeRoot, windows ? 'node_modules/npm' : 'lib/node_modules/npm'); - const version = (await json(join(npmRoot, 'package.json'))).version; - const env = { ...process.env, PATH: [bin, process.env.PATH ?? ''].join(windows ? ';' : ':') }; - for (const name of ['npm', 'npx']) { - await requireFile(join(bin, windows ? `${name}.cmd` : name)); - // Windows command scripts require cmd.exe; only these fixed command names - // enter the shell. Unix probes use executable lookup from the installed PATH. - const observed = windows - ? probe(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', `${name} --version`], cwd, env) - : probe(name, ['--version'], cwd, env); - if (observed !== version) throw new Error('Bundled npm entrypoint compatibility failed'); - } -} - -export async function validateComponents(root, names, daemonVersion) { - const windows = process.platform === 'win32'; - const nodeRoot = join(root, 'components', 'node'); - const node = join(nodeRoot, windows ? 'node.exe' : 'bin/node'); - const env = { ...process.env, PATH: [dirname(node), join(root, 'components', 'minimax', 'bin'), process.env.PATH ?? ''].join(sep === '\\' ? ';' : ':') }; - if (probe(node, ['--version'], root, env) !== `v${pins.node}`) throw new Error('Node version does not match the pin'); - const identity = JSON.parse(probe(node, ['-p', 'JSON.stringify([process.platform,process.arch])'], root, env)); - if (identity[0] !== process.platform || identity[1] !== process.arch) throw new Error('Node platform does not match the build host'); - await validateNodeCommands(nodeRoot, root); - const daemon = join(root, windows ? 'oac-daemon.exe' : 'oac-daemon'); - if (probe(daemon, ['version'], root, env) !== daemonVersion) throw new Error('Daemon identity changed during packaging'); - for (const name of names) { - const component = join(root, 'components', name); - if (name === 'codex') { - if (!(await stat(join(component, 'codex-resources'))).isDirectory()) throw new Error('Codex resources are missing'); - const binary = join(component, 'bin', windows ? 'codex.exe' : 'codex'); - if (probe(binary, ['--version'], component, env) !== `codex-cli ${pins.codex}`) throw new Error('Codex version does not match the pin'); - } else if (name === 'claude') { - const manifest = await json(join(component, 'package.json')); - if (manifest.dependencies?.['@anthropic-ai/claude-agent-sdk']?.split('(')[0] !== pins.claude) throw new Error('Claude SDK pin mismatch'); - await requireFile(join(component, 'pnpm-lock.yaml')); - await requireFile(join(component, 'dist/main.js')); - const info = JSON.parse(probe(node, [join(component, 'dist/runtime_check.js')], component, env)); - if (info.type !== 'runtime_ready' || info.protocol !== 3 || info.sdk !== pins.claude || info.native !== '2.1.269 (Claude Code)' || !info.features?.includes('local_runtime_v2')) throw new Error('Claude runtime compatibility failed'); - } else if (name === 'minimax') { - if (windows) throw new Error('MiniMax is not supported on Windows'); - const upstream = await json(join(component, 'source.json')); - const expected = await json(new URL('../packages/mcode-harness/source.json', import.meta.url)); - if (upstream.version !== pins.minimax || upstream.revision !== expected.revision) throw new Error('MiniMax source pin mismatch'); - for (const entry of ['launch.mjs', 'dist/worker.mjs', 'provenance.json', 'native-patch.json']) await requireFile(join(component, entry)); - if (probe(node, [join(component, 'native/cli.js'), '--version'], component, env) !== pins.minimax) throw new Error('MiniMax native pin mismatch'); - const info = JSON.parse(probe(node, [join(component, 'check.mjs')], component, env)); - if (info.protocol !== 2 || info.native !== pins.minimax || info.source !== expected.revision) throw new Error('MiniMax companion compatibility failed'); - } - } -} +import { chmod, copyFile, lstat, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, join } from 'node:path'; export async function buildBundle(options) { - if (!platforms[process.platform] || !architectures[process.arch]) throw new Error('Unsupported native bundle platform'); - for (const required of ['daemon', 'node', 'output']) if (!options[required]) throw new Error(`Missing --${required}`); - for (const path of Object.values(options)) if (!isAbsolute(path)) throw new Error('Bundle paths must be absolute'); - const names = ['codex', 'claude', 'minimax'].filter(name => options[name]); - if (!names.length) throw new Error('At least one Harness source is required'); - if (process.platform === 'win32' && options.minimax) throw new Error('MiniMax is not supported on Windows'); + if (process.platform !== 'linux' || process.arch !== 'x64') throw new Error('Native installations require Linux amd64'); + for (const required of ['daemon', 'sandboxIo', 'output']) if (!options[required]) throw new Error(`Missing --${required === 'sandboxIo' ? 'sandbox-io' : required}`); + for (const [name, path] of Object.entries(options)) { + if (!['daemon', 'sandboxIo', 'output'].includes(name)) throw new Error(`Unknown bundle option: ${name}`); + if (!isAbsolute(path)) throw new Error('Bundle paths must be absolute'); + } try { await lstat(options.output); throw new Error('Bundle output already exists'); } catch (error) { if (error.code !== 'ENOENT') throw error; } - const output = await outputRealPath(options.output); - // Refuse staging inside a source: recursive copying must not ingest its own output. - for (const name of ['node', ...names]) if (inside(await realpath(options[name]), output)) throw new Error('Output must be outside component sources'); - // Only Linux distributions carry oac-sandbox-io, the service a self-hosted machine runs. - if ((process.platform === 'linux') !== Boolean(options.sandboxIo)) throw new Error(options.sandboxIo ? '--sandbox-io is Linux-only' : 'Missing --sandbox-io'); - const daemonVersion = probe(options.daemon, ['version'], dirname(options.daemon), process.env); + const result = spawnSync(options.daemon, ['version'], { encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024 }); + if (result.error || result.status !== 0) throw new Error('Daemon identity probe failed'); + const daemonVersion = result.stdout.trim(); if (!/^[0-9A-Za-z][0-9A-Za-z.+_-]{0,127}$/.test(daemonVersion)) throw new Error('Invalid daemon version'); - await mkdir(dirname(output), { recursive: true }); - const staging = await mkdtemp(join(dirname(output), '.native-bundle-')); + await mkdir(dirname(options.output), { recursive: true }); + const staging = await mkdtemp(join(dirname(options.output), '.native-bundle-')); try { - const daemon = join(staging, process.platform === 'win32' ? 'oac-daemon.exe' : 'oac-daemon'); - await copyFile(options.daemon, daemon); await chmod(daemon, 0o755); - if (options.sandboxIo) { await copyFile(options.sandboxIo, join(staging, 'oac-sandbox-io')); await chmod(join(staging, 'oac-sandbox-io'), 0o755); } - await mkdir(join(staging, 'components')); - const components = {}; - for (const name of ['node', ...names]) components[name] = { version: pins[name], files: await copyComponent(options[name], join(staging, 'components', name)) }; - await prepareNodeEntrypoints(join(staging, 'components', 'node'), components.node.files); - if (components.minimax) { - const component = join(staging, 'components', 'minimax'); - const nativeRequire = createRequire(join(component, 'native', 'cli.js')); - const rg = await realpath(nativeRequire('@vscode/ripgrep').rgPath); - if (!inside(component, rg)) throw new Error('MiniMax ripgrep escaped the component'); - await requireFile(rg); - await mkdir(join(component, 'bin'), { recursive: true }); - await copyFile(rg, join(component, 'bin', 'rg')); await chmod(join(component, 'bin', 'rg'), 0o755); - components.minimax.files['bin/rg'] = { sha256: createHash('sha256').update(await readFile(rg)).digest('hex'), executable: true }; + for (const [source, name] of [[options.daemon, 'oac-daemon'], [options.sandboxIo, 'oac-sandbox-io']]) { + await copyFile(source, join(staging, name)); + await chmod(join(staging, name), 0o755); } - await validateComponents(staging, names, daemonVersion); - const manifest = { schema: 1, daemon_version: daemonVersion, os: platforms[process.platform], arch: architectures[process.arch], components }; + const manifest = { schema: 1, daemon_version: daemonVersion, os: 'linux', arch: 'amd64' }; await writeFile(join(staging, 'bundle.json'), JSON.stringify(manifest, null, 2) + '\n'); - await rename(staging, output); + await rename(staging, options.output); return manifest; } finally { await rm(staging, { recursive: true, force: true }); } } diff --git a/scripts/build-native-installer.test.mjs b/scripts/build-native-installer.test.mjs index 1f9481413..85424ec13 100644 --- a/scripts/build-native-installer.test.mjs +++ b/scripts/build-native-installer.test.mjs @@ -1,104 +1,38 @@ import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import { chmod, copyFile, cp, lstat, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; -import { buildBundle, copyComponent, prepareNodeEntrypoints, validateNodeCommands } from './build-native-installer.mjs'; +import { buildBundle } from './build-native-installer.mjs'; async function fixture(t) { - const root = await mkdtemp(join(tmpdir(), 'oac-native-bundle-test-')); + const parent = join(homedir(), '.oac', 'tests'); + await mkdir(parent, { recursive: true }); + const root = await mkdtemp(join(parent, 'native-bundle-')); t.after(() => rm(root, { recursive: true, force: true })); - const source = join(root, 'source'); await mkdir(source); - return { root, source, output: join(root, 'output') }; + const daemon = join(root, 'daemon'); + const sandboxIo = join(root, 'sandbox-io'); + await writeFile(daemon, '#!/bin/sh\necho test-version\n', { mode: 0o755 }); + await writeFile(sandboxIo, 'sandbox bytes', { mode: 0o755 }); + return { daemon, sandboxIo, output: join(root, 'output') }; } -test('component preserves bytes and executable metadata with contained links flattened', async t => { - const { source, output } = await fixture(t); - await mkdir(join(source, 'lib')); - await writeFile(join(source, 'lib', 'native'), 'native bytes'); - await chmod(join(source, 'lib', 'native'), 0o755); - await symlink(join(source, 'lib'), join(source, 'linked'), process.platform === 'win32' ? 'junction' : 'dir'); - const files = await copyComponent(source, output); - assert.equal(await readFile(join(output, 'linked/native'), 'utf8'), 'native bytes'); - assert.equal((await lstat(join(output, 'linked'))).isSymbolicLink(), false); - assert.equal(files['linked/native'].sha256, createHash('sha256').update('native bytes').digest('hex')); - if (process.platform !== 'win32') assert.equal(files['linked/native'].executable, true); - assert.deepEqual(Object.keys(files).sort(), ['lib/native', 'linked/native']); -}); - -test('component rejects escaping directory links', async t => { - const { root, source, output } = await fixture(t); - await mkdir(join(root, 'outside')); await writeFile(join(root, 'outside/secret'), 'not bundled'); - await symlink(join(root, 'outside'), join(source, 'external'), process.platform === 'win32' ? 'junction' : 'dir'); - await assert.rejects(copyComponent(source, output), /escapes/); -}); - -test('component rejects cyclic links without recursing forever', async t => { - const { source, output } = await fixture(t); - await symlink(source, join(source, 'cycle'), process.platform === 'win32' ? 'junction' : 'dir'); - await assert.rejects(copyComponent(source, output), /cycle/); -}); - -test('bundle does not replace an existing output or accept relative paths', async t => { - const { source, output } = await fixture(t); - await mkdir(output); await writeFile(join(output, 'keep'), 'existing install'); - await assert.rejects(buildBundle({ daemon: join(source, 'daemon'), node: source, codex: source, output }), /already exists/); - assert.equal(await readFile(join(output, 'keep'), 'utf8'), 'existing install'); - await assert.rejects(buildBundle({ daemon: 'relative', node: source, codex: source, output }), /absolute/); -}); - -test('bundle requires a selected harness and rejects output inside sources', async t => { - const { source, output } = await fixture(t); - await assert.rejects(buildBundle({ daemon: join(source, 'daemon'), node: source, output }), /Harness source/); - await assert.rejects(buildBundle({ daemon: join(source, 'daemon'), node: source, codex: source, output: join(source, 'out') }), /outside/); -}); - -test('bundle carries oac-sandbox-io exactly on Linux', async t => { - const { source, output } = await fixture(t); - const options = { daemon: join(source, 'daemon'), node: source, codex: source, output }; - if (process.platform === 'linux') await assert.rejects(buildBundle(options), /Missing --sandbox-io/); - else await assert.rejects(buildBundle({ ...options, sandboxIo: join(source, 'oac-sandbox-io') }), /Linux-only/); -}); - -test('component rejects nonportable paths', { skip: process.platform === 'win32' }, async t => { - const { source, output } = await fixture(t); - await writeFile(join(source, 'bad:name'), 'bad'); - await assert.rejects(copyComponent(source, output), /non-portable/); -}); - -test('bundle rejects output entering a component through an aliased parent', async t => { - const { root, source } = await fixture(t); - const alias = join(root, 'alias'); - await symlink(source, alias, process.platform === 'win32' ? 'junction' : 'dir'); - await assert.rejects(buildBundle({ daemon: join(source, 'daemon'), node: source, codex: source, output: join(alias, 'new-parent', 'out') }), /outside/); -}); - -test('real npm and npx remain runnable from PATH after regular-file copying and relocation', async t => { - const { root, source, output } = await fixture(t); - const windows = process.platform === 'win32'; - const nodeRoot = windows ? dirname(process.execPath) : dirname(dirname(process.execPath)); - const bin = windows ? source : join(source, 'bin'); - const npmRelative = windows ? 'node_modules/npm' : 'lib/node_modules/npm'; - await mkdir(bin, { recursive: true }); - await copyFile(process.execPath, join(bin, windows ? 'node.exe' : 'node')); - await chmod(join(bin, windows ? 'node.exe' : 'node'), 0o755); - await cp(join(nodeRoot, npmRelative), join(source, npmRelative), { recursive: true }); - for (const name of ['npm', 'npx']) { - if (windows) await copyFile(join(nodeRoot, `${name}.cmd`), join(bin, `${name}.cmd`)); - else await symlink(`../lib/node_modules/npm/bin/${name}-cli.js`, join(bin, name)); - } - const files = await copyComponent(source, output); - // This is the original failure: the copied Unix JS entrypoint lost its scope. - if (!windows) await assert.rejects(validateNodeCommands(output, root), /compatibility probe/); - await prepareNodeEntrypoints(output, files); - const relocated = join(root, 'relocated node'); - await rename(output, relocated); - await validateNodeCommands(relocated, root); - for (const name of ['npm', 'npx']) { - const path = windows ? `${name}.cmd` : `bin/${name}`; - assert.equal((await lstat(join(relocated, path))).isSymbolicLink(), false); - assert.equal(files[path].sha256, createHash('sha256').update(await readFile(join(relocated, path))).digest('hex')); - assert.equal(files[path].executable, true); +test('bundle contains only launcher, Sandbox I/O and platform metadata', async t => { + const options = await fixture(t); + if (process.platform !== 'linux' || process.arch !== 'x64') { + await assert.rejects(buildBundle(options), /Linux amd64/); + return; } + const manifest = await buildBundle(options); + assert.deepEqual(await readdir(options.output), ['bundle.json', 'oac-daemon', 'oac-sandbox-io']); + assert.deepEqual(manifest, { schema: 1, daemon_version: 'test-version', os: 'linux', arch: 'amd64' }); + assert.equal(await readFile(join(options.output, 'oac-sandbox-io'), 'utf8'), 'sandbox bytes'); + await assert.rejects(buildBundle(options), /already exists/); +}); + +test('bundle rejects missing programs, relative paths and obsolete components', { skip: process.platform !== 'linux' || process.arch !== 'x64' }, async t => { + const options = await fixture(t); + await assert.rejects(buildBundle({ ...options, sandboxIo: undefined }), /Missing --sandbox-io/); + await assert.rejects(buildBundle({ ...options, daemon: 'relative' }), /absolute/); + await assert.rejects(buildBundle({ ...options, node: options.daemon }), /Unknown bundle option/); }); diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py index af72ee26c..e07e9334c 100644 --- a/scripts/ci_plan.py +++ b/scripts/ci_plan.py @@ -8,7 +8,7 @@ import subprocess JOBS = ("hygiene", "distribution", "compose", "backend", "harness", "example", "web", "web-acceptance", "website", "api", "native", "lint") -NODE_JOBS = ("harness", "example", "web", "web-acceptance", "website", "native") +NODE_JOBS = ("harness", "example", "web", "web-acceptance", "website") GO_JOBS = ("distribution", "compose", "backend", "api", "native") # Exact file matches keep new workflows/actions conservative until classified. CI_INPUTS = { @@ -21,7 +21,6 @@ ".github/workflows/ci-review.yml": ("lint",), ".github/workflows/website.yml": ("website", "lint"), ".github/actions/node/action.yml": (*NODE_JOBS, "lint"), - ".github/actions/mcode-companion/action.yml": ("native", "lint"), ".github/actions/e2b-provider/action.yml": ("api", "lint"), ".github/workflows/cache-warm.yml": ("lint",), "scripts/ci_plan.py": JOBS, diff --git a/scripts/ci_plan_test.py b/scripts/ci_plan_test.py index f5a2a6f4a..67a25f77d 100644 --- a/scripts/ci_plan_test.py +++ b/scripts/ci_plan_test.py @@ -97,7 +97,6 @@ def test_workflow_changes_select_only_their_consumers(self): self.assertEqual(plan["image"], workflow == "api-acceptance") def test_cache_actions_select_their_consumers(self): - self.assertEqual(self.jobs(".github/actions/mcode-companion/action.yml"), {"hygiene", "native", "lint"}) plan = ci.select([".github/actions/e2b-provider/action.yml"]) self.assertEqual(set(plan["jobs"]), {"hygiene", "api", "lint"}) self.assertTrue(plan["image"]) @@ -120,7 +119,7 @@ def test_dependencies_are_scoped_to_language_consumers(self): self.assertTrue(ci.select([path])["image"]) for path in ("package.json", "pnpm-workspace.yaml", ".npmrc"): with self.subTest(path=path): - self.assertEqual(self.jobs(path), {"hygiene", "harness", "example", "web", "web-acceptance", "website", "native"}) + self.assertEqual(self.jobs(path), {"hygiene", "harness", "example", "web", "web-acceptance", "website"}) self.assertFalse(ci.select([path])["image"]) self.assertEqual(self.jobs("tsconfig.base.json"), {"hygiene", "example", "web", "web-acceptance"}) diff --git a/scripts/compose-smoke.py b/scripts/compose-smoke.py index 5706079ee..e427b2a8e 100644 --- a/scripts/compose-smoke.py +++ b/scripts/compose-smoke.py @@ -57,7 +57,7 @@ def prepare_pinned_payload(destination): def build_images(directory, tag): revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() protocol = re.search(r'const Version = "([^"]+)"', (ROOT / 'internal/agentdaemon/proto/version.go').read_text()).group(1) - go_env = {**os.environ, 'CGO_ENABLED': '0', 'GOOS': 'linux', 'GOARCH': os.environ.get('GOARCH', 'amd64')} + go_env = {**os.environ, 'CGO_ENABLED': '0', 'GOOS': 'linux', 'GOARCH': 'amd64'} def go_build(package, output, build_revision=revision): output.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index 6e351314f..5e54f020f 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -23,8 +23,6 @@ import provider_assets ARTIFACTS = {item["path"]: item["suffix"] for items in provider_assets.CATALOG.values() for item in items} -# The standalone daemon is a distribution artifact, independent of node providers. -ARTIFACTS["native/bin/oac-daemon"] = "daemon" @@ -170,36 +168,16 @@ def blob(descriptor, parse=False): return config_digest, manifest_digest -def control_archive(bundle, stage, revision, architecture): - bundle, stage = pathlib.Path(bundle), pathlib.Path(stage) - manifest = {"source_commit": revision, "platform": "linux/" + architecture, - "images": {}, "image_manifest_digests": {}} - for name in ("core", "web", "ingress"): - config, digest = image_identities(bundle / "images" / (name + ".tar"), - (stage / (name + ".id")).read_text().strip(), architecture) - manifest["images"][name], manifest["image_manifest_digests"][name] = config, digest - (bundle / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") - target = bundle.with_name(bundle.name + ".tar.gz") - with tarfile.open(target, "w:gz") as output: - output.add(bundle, arcname=bundle.name) - target.with_name(target.name + ".sha256").write_text(sha256(target) + " " + target.name + "\n") - - -def verify_runtime(image, daemon, sandbox_io, source): - details = verify_image(image) - source = pathlib.Path(source) - files = {"/usr/local/bin/oac-daemon": pathlib.Path(daemon), "/usr/local/bin/oac-sandbox-io": pathlib.Path(sandbox_io)} - environment = dict(value.split("=", 1) for value in details["Config"]["Env"] if "=" in value) - if "OAC_RUNTIME_MCODE_BIN" in environment: - for name in ("launch.mjs", "bridge.mjs", "check.mjs", "tool-executor.mjs", "subagent-snapshot.mjs", "source.json"): - files["/opt/mcode-harness/" + name] = source / "packages/mcode-harness" / name +def verify_runtime(image, sandbox_io): + verify_image(image) + guest_path = "/usr/local/bin/oac-sandbox-io" output = subprocess.check_output( - ["docker", "run", "--rm", "--network", "none", "--entrypoint", "sha256sum", image, *files], text=True + ["docker", "run", "--rm", "--label", "io.oac.build=distribution-verify", "--network", "none", + "--entrypoint", "sha256sum", image, guest_path], text=True ) actual = dict(reversed(line.split(None, 1)) for line in output.splitlines()) - for guest_path, local in files.items(): - if actual.get(guest_path) != sha256(local): - raise ValueError("Runtime image does not match the committed build: " + guest_path) + if actual.get(guest_path) != sha256(sandbox_io): + raise ValueError("Sandbox image does not match the committed build: " + guest_path) def extract_runtime(archive, destination): @@ -243,7 +221,7 @@ def native_catalog(bundle, stage, revision, source, artifact_base_url=""): assets = stage / "native-artifacts" assets.mkdir() for platform, entry in catalog["artifacts"].items(): - if not re.fullmatch(r"(linux|darwin|windows)-(amd64|arm64)", platform): + if platform != "linux-amd64": raise ValueError("Invalid native installer platform") archive = source / (platform + ".tar.gz") if archive.is_symlink() or sha256(archive) != entry["sha256"]: @@ -598,7 +576,7 @@ def check(image, link): if __name__ == "__main__": - commands = {"control-archive": control_archive, "extract-runtime": extract_runtime, "verify-runtime": verify_runtime, "verify-image": verify_image, + commands = {"extract-runtime": extract_runtime, "verify-runtime": verify_runtime, "verify-image": verify_image, "built-image": built_image, "node-payload": node_payload, "manifest": manifest, "archive": archive, "bootstraps": bootstraps, "release-base": release_base, "docs": docs, "native-catalog": native_catalog, "native-offline": native_offline} try: diff --git a/scripts/core-distribution-manifest.test.py b/scripts/core-distribution-manifest.test.py index 066d79090..bdcd5d03a 100644 --- a/scripts/core-distribution-manifest.test.py +++ b/scripts/core-distribution-manifest.test.py @@ -5,6 +5,7 @@ import importlib.util import io import json +import os import pathlib import subprocess import tarfile @@ -55,7 +56,7 @@ def test_native_payload_is_independent_and_offline_has_one_copy(self): source = self.stage / "qualified-native" source.mkdir() catalog = {"version": REVISION, "protocol_version": "fixture", "artifacts": {}} - for platform in ("linux-amd64", "darwin-arm64", "windows-amd64"): + for platform in ("linux-amd64",): raw = ("native:" + platform).encode() (source / (platform + ".tar.gz")).write_bytes(raw) catalog["artifacts"][platform] = {"sha256": hashlib.sha256(raw).hexdigest()} @@ -74,7 +75,7 @@ def test_native_payload_is_independent_and_offline_has_one_copy(self): distribution.archive(self.bundle, "1", "offline") with tarfile.open(self.bundle.with_name(self.bundle.name + "-offline.tar.gz")) as archive: native = [p for p in archive.getmembers() if "native-installers/" in p.name and p.name.endswith(".tar.gz")] - self.assertEqual(len(native), 3) + self.assertEqual(len(native), 1) for member in native: platform = pathlib.Path(member.name).name.removesuffix(".tar.gz") self.assertEqual(archive.extractfile(member).read(), (source / (platform + ".tar.gz")).read_bytes()) @@ -84,6 +85,59 @@ def test_native_payload_is_independent_and_offline_has_one_copy(self): self.assertEqual(entry["url"], RELEASE_BASE + "/" + filename) self.assertEqual(distribution.sha256(self.stage / "native-artifacts" / filename), entry["sha256"]) + def test_native_catalog_rejects_unsupported_installation_platforms(self): + for platform in ("linux-arm64", "darwin-arm64", "windows-amd64"): + with self.subTest(platform=platform): + source = self.stage / platform + source.mkdir() + (source / "catalog.json").write_text(json.dumps({ + "version": REVISION, "artifacts": {platform: {"sha256": "a" * 64}}, + })) + stage = self.stage / (platform + "-stage") + stage.mkdir() + with self.assertRaisesRegex(ValueError, "Invalid native installer platform"): + distribution.native_catalog(self.bundle, stage, REVISION, source) + + def test_sandbox_executable_must_match_build_input(self): + executable = self.stage / "oac-sandbox-io" + executable.write_bytes(b"sandbox service") + path = "/usr/local/bin/oac-sandbox-io" + for digest in (distribution.sha256(executable), "0" * 64): + with self.subTest(digest=digest), mock.patch.object(distribution, "verify_image"), \ + mock.patch.object(distribution.subprocess, "check_output", return_value=digest + " " + path + "\n"): + if digest == "0" * 64: + with self.assertRaisesRegex(ValueError, "Sandbox image does not match"): + distribution.verify_runtime("sha256:" + "a" * 64, executable) + else: + distribution.verify_runtime("sha256:" + "a" * 64, executable) + + def test_mcode_payload_rejects_stale_companion_at_the_same_version(self): + repository = pathlib.Path(__file__).resolve().parent.parent + companion = self.stage / "companion" + (companion / "native").mkdir(parents=True) + (companion / "native/cli.js").write_text('console.log("0.4.12");\n') + for receipt in ("provenance.json", "native-patch.json"): + (companion / receipt).write_text("{}") + files = ("launch.mjs", "bridge.mjs", "check.mjs", "tool-executor.mjs", "subagent-snapshot.mjs", "source.json") + for name in files: + (companion / name).write_bytes((repository / "packages/mcode-harness" / name).read_bytes()) + output = self.stage / "payload" + environment = {**os.environ, "OAC_DEV_HOME": str(self.stage / "dev"), + "MCODE_HARNESS_BUILD_DIR": str(companion), "AGENTS_RUNTIME_BUILD_DIR": str(output)} + command = ["bash", str(repository / "scripts/build-mcode-runtime.sh")] + result = subprocess.run(command, env=environment, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + for name in files: + with self.subTest(file=name): + current = (companion / name).read_bytes() + stale = current.replace(b'"revision": "', b'"revision": "stale-') if name == "source.json" else current + b"\n// stale companion\n" + (companion / name).write_bytes(stale) + result = subprocess.run(command, env=environment, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not match the current source: " + name, result.stderr) + self.assertEqual((output / "mcode-harness" / name).read_bytes(), current) + (companion / name).write_bytes(current) + def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) diff --git a/scripts/native-harness-smoke.mjs b/scripts/native-harness-smoke.mjs deleted file mode 100644 index 1a87d969b..000000000 --- a/scripts/native-harness-smoke.mjs +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env node -// No model requests: validate installed native engines and their startup protocols. -import { spawn, spawnSync } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, realpath, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { createInterface } from 'node:readline'; - -const options = {}; -for (let i = 2; i < process.argv.length; i += 2) { - const key = process.argv[i]; - if (!['--codex-binary', '--claude-runtime'].includes(key) || !process.argv[i + 1]) { - throw new Error('Expected --codex-binary PATH or --claude-runtime PATH'); - } - options[key] = process.argv[i + 1]; -} -const limit = 1024 * 1024; -const failure = code => Object.assign(new Error(code), { safeCode: code }); -const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); - -async function codexSmoke(root) { - const binary = options['--codex-binary'] ?? 'codex'; - const home = join(root, 'codex-home'); - const workspace = join(root, 'workspace'); - await mkdir(home); await mkdir(workspace); - const env = { ...process.env, CODEX_HOME: home }; - delete env.OPENAI_API_KEY; delete env.CODEX_API_KEY; - const version = spawnSync(binary, ['--version'], { env, encoding: 'utf8', timeout: 15000, maxBuffer: limit }); - if (version.error || version.status !== 0 || version.stdout.trim() !== 'codex-cli 0.153.4') throw failure('codex_native_version'); - const child = spawn(binary, ['app-server', '--listen', 'stdio://'], { env, cwd: workspace, stdio: ['pipe', 'pipe', 'pipe'] }); - const closed = new Promise(resolve => child.once('close', resolve)); - let pending; - let bytes = 0; - let broken; - const rejectPending = code => { broken = failure(code); pending?.reject(broken); }; - child.on('error', () => rejectPending('codex_spawn')); - child.stdin.on('error', () => rejectPending('codex_stdin')); - child.stderr.resume(); - child.stdout.on('data', chunk => { bytes += chunk.length; if (bytes > limit) rejectPending('codex_output_limit'); }); - const lines = createInterface({ input: child.stdout }); - lines.on('line', line => { - try { - const message = JSON.parse(line); - if (pending && message.id === pending.id) { - if (message.error) pending.reject(failure('codex_rpc')); - else pending.resolve(message.result); - } - } catch { rejectPending('codex_invalid_json'); } - }); - child.on('close', () => rejectPending('codex_early_exit')); - let sequence = 0; - const request = async (method, params) => { - if (broken) throw broken; - const id = ++sequence; - let timer; - try { - return await new Promise((resolve, reject) => { - pending = { id, resolve, reject }; - timer = setTimeout(() => reject(failure('codex_rpc_timeout')), 15000); - child.stdin.write(JSON.stringify({ id, method, params }) + '\n'); - }); - } finally { clearTimeout(timer); pending = undefined; } - }; - try { - await request('initialize', { clientInfo: { name: 'oac-native-smoke', version: '1' }, capabilities: { experimentalApi: true } }); - child.stdin.write(JSON.stringify({ method: 'initialized' }) + '\n'); - const result = await request('thread/start', { cwd: workspace, approvalPolicy: 'never', sandbox: 'danger-full-access', persistExtendedHistory: true }); - if (typeof result?.thread?.id !== 'string' || !result.thread.id) throw failure('codex_thread_identity'); - return { status: 'passed', version: '0.153.4', initialize: true, thread_start: true }; - } finally { - child.stdin.end(); - if (await Promise.race([closed.then(() => true), delay(5000).then(() => false)]) === false) { - if (process.platform === 'win32' && child.pid) spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000 }); - else child.kill('SIGKILL'); - await Promise.race([closed, delay(5000)]); - } - lines.close(); - } -} - -async function claudeSmoke() { - const root = resolve(options['--claude-runtime'] ?? 'packages/claude-sdk-adapter'); - const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); - if (manifest.dependencies?.['@anthropic-ai/claude-agent-sdk']?.split('(')[0] !== '0.3.269') throw failure('claude_package_version'); - const result = spawnSync(process.execPath, [join(root, 'dist', 'runtime_check.js')], { - cwd: root, encoding: 'utf8', timeout: 20000, maxBuffer: limit, - }); - if (result.error || result.status !== 0) throw failure('claude_runtime_unavailable'); - let info; - try { info = JSON.parse(result.stdout); } catch { throw failure('claude_invalid_receipt'); } - if (info.type !== 'runtime_ready' || info.sdk !== '0.3.269' || info.native !== '2.1.269 (Claude Code)' || !info.features?.includes('local_runtime_v2')) throw failure('claude_runtime_identity'); - return { status: 'passed', sdk: info.sdk, native: info.native }; -} - -const root = await realpath(await mkdtemp(join(tmpdir(), 'oac-native-smoke-'))); -const report = { platform: process.platform, arch: process.arch, model_requests: 0 }; -try { - for (const [name, run] of [['codex', () => codexSmoke(root)], ['claude', claudeSmoke]]) { - try { report[name] = await run(); } - catch (error) { report[name] = { status: 'failed', code: error.safeCode ?? 'native_smoke_unavailable' }; process.exitCode = 1; } - } -} finally { - await rm(root, { recursive: true, force: true }); -} -// Deliberately exclude child stdout/stderr and request contents from CI output. -console.log(JSON.stringify(report)); diff --git a/scripts/native-onboarding-smoke.mjs b/scripts/native-onboarding-smoke.mjs index 2ea34c32a..2ca08d9e9 100644 --- a/scripts/native-onboarding-smoke.mjs +++ b/scripts/native-onboarding-smoke.mjs @@ -29,11 +29,11 @@ const server = createServer(async (request, response) => { const json = (status, value) => { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(value)); }; if (url.pathname.endsWith('.sha256')) { response.setHeader('Content-Type', 'text/plain; charset=utf-8'); return response.end(checksum+'\n'); } if (url.pathname.endsWith('.tar.gz')) return createReadStream(archive).pipe(response); - if (url.pathname.endsWith('bootstrap.sh') || url.pathname.endsWith('bootstrap.ps1')) return createReadStream(resolve('services/core/internal/nativeinstaller/assets', url.pathname.split('/').at(-1))).pipe(response); + if (url.pathname.endsWith('bootstrap.sh')) return createReadStream(resolve('services/core/internal/nativeinstaller/assets', url.pathname.split('/').at(-1))).pipe(response); const body = []; for await (const chunk of request) body.push(chunk); if (url.pathname.endsWith('/installation') || url.pathname.endsWith('/claim')) { if (request.headers.authorization !== `Bearer ${authorization}`) return json(401, {}); - if (url.pathname.endsWith('/installation')) return json(200, { version: manifest.daemon_version, protocol_version: protocol, environment_id: environment, remote_url: remote, workspace_directory: workspace, harness: 'codex' }); + if (url.pathname.endsWith('/installation')) return json(200, { version: manifest.daemon_version, protocol_version: protocol, environment_id: environment, remote_url: remote, workspace_directory: workspace }); const input = JSON.parse(Buffer.concat(body)); if (secret && secret !== input.executor_token) return json(409, {}); secret = input.executor_token; @@ -76,18 +76,17 @@ function run(executable, args, input = '') { const executable = join(bundle, 'oac-daemon'); const args = ['install', '--onboard-url', `${origin}/api/v1/agent-daemon/installation`, '--authorization', authorization, '--install-dir', installation]; try { - assert.notEqual((await run(executable, [...args, '--authorization', 'expired', '--non-interactive', '--harness', 'codex'])).code, 0); - assert.notEqual((await run(executable, [...args, '--non-interactive'])).code, 0, 'Missing Harness must not prompt'); - assert.notEqual((await run(executable, [...args, '--non-interactive', '--harness', 'codex'])).code, 0, 'Lost claim response should fail safely'); + assert.notEqual((await run(executable, [...args, '--authorization', 'expired', '--non-interactive'])).code, 0); + assert.notEqual((await run(executable, [...args, '--non-interactive'])).code, 0, 'Lost claim response should fail safely'); const saved = JSON.parse(await readFile(join(installation, 'daemon', 'executor-credential.json'), 'utf8')); assert.equal(saved.executor_token, secret, 'Secret must survive a lost response'); const script = resolve('services/core/internal/nativeinstaller/assets/bootstrap.sh'); - const result = await run('bash', [script, base, authorization, '--install-dir', installation], '\n\n'); + const result = await run('bash', [script, base, authorization, '--install-dir', installation], '\n'); assert.equal(result.code, 0, `Interactive bootstrap failed: ${result.output}`); assert.match(result.output, /Host connection: connected/); assert.match(result.output, /Model configuration: not checked/); assert.equal(links, 1); - const repeat = await run(executable, [...args, '--non-interactive', '--harness', 'codex']); + const repeat = await run(executable, [...args, '--non-interactive']); assert.equal(repeat.code, 0, `Repeat failed: ${repeat.output}`); assert.equal(links, 1, 'Repeated installation created a duplicate daemon'); assert.equal(unexpected, 0, 'The daemon opened a connection other than the Sandbox Link'); diff --git a/scripts/publish-core-release.py b/scripts/publish-core-release.py index e26ef58fb..5caf5241d 100644 --- a/scripts/publish-core-release.py +++ b/scripts/publish-core-release.py @@ -109,42 +109,41 @@ def registry_image(reference): def publish_images(assets, repository, revision, tag, floating_latest=False): - """Verify both architectures before publishing immutable platform tags and indexes.""" + """Verify Linux amd64 images before publishing immutable tags and indexes.""" image_tag = tag.replace("+", "_") if not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,120}", image_tag): raise ValueError("Release version exceeds the container tag format") with tempfile.TemporaryDirectory(prefix="oac-ghcr-") as directory: directory = pathlib.Path(directory) entries = {} - for architecture in ("amd64", "arm64"): - stem = "oac-" + revision + "-linux-" + architecture - with tarfile.open(assets / (stem + ".tar.gz"), "r:gz") as archive: - manifest = json.load(archive.extractfile(stem + "/manifest.json")) - if manifest["source_commit"] != revision or manifest["platform"] != "linux/" + architecture: - raise ValueError("Registry images do not match the release") - names = IMAGE_NAMES if architecture == "amd64" else ("core", "web", "ingress") - for name in names: - path = directory / (name + "-" + architecture + ".tar") - if name == "runtime": - artifact = manifest["artifacts"]["images/runtime.tar.gz"] - filename = artifact["filename"] - if pathlib.Path(filename).name != filename: - raise ValueError("Invalid Runtime asset filename") - compressed = assets / filename - if compressed.is_symlink() or distribution.sha256(compressed) != artifact["sha256"]: - raise ValueError("Runtime image checksum mismatch") - with gzip.open(compressed, "rb") as source, path.open("wb") as target: - shutil.copyfileobj(source, target) - else: - member = archive.getmember(stem + "/images/" + name + ".tar") - if not member.isfile(): - raise ValueError("Expected a regular image archive") - with archive.extractfile(member) as source, path.open("wb") as target: - shutil.copyfileobj(source, target) - expected = (manifest["images"][name], manifest["image_manifest_digests"][name]) - if distribution.image_identities(path, expected[0], architecture) != expected: - raise ValueError("Release image identity mismatch: " + name) - entries[name, architecture] = (path, *expected) + architecture = "amd64" + stem = "oac-" + revision + "-linux-" + architecture + with tarfile.open(assets / (stem + ".tar.gz"), "r:gz") as archive: + manifest = json.load(archive.extractfile(stem + "/manifest.json")) + if manifest["source_commit"] != revision or manifest["platform"] != "linux/" + architecture: + raise ValueError("Registry images do not match the release") + for name in IMAGE_NAMES: + path = directory / (name + "-" + architecture + ".tar") + if name == "runtime": + artifact = manifest["artifacts"]["images/runtime.tar.gz"] + filename = artifact["filename"] + if pathlib.Path(filename).name != filename: + raise ValueError("Invalid Runtime asset filename") + compressed = assets / filename + if compressed.is_symlink() or distribution.sha256(compressed) != artifact["sha256"]: + raise ValueError("Runtime image checksum mismatch") + with gzip.open(compressed, "rb") as source, path.open("wb") as target: + shutil.copyfileobj(source, target) + else: + member = archive.getmember(stem + "/images/" + name + ".tar") + if not member.isfile(): + raise ValueError("Expected a regular image archive") + with archive.extractfile(member) as source, path.open("wb") as target: + shutil.copyfileobj(source, target) + expected = (manifest["images"][name], manifest["image_manifest_digests"][name]) + if distribution.image_identities(path, expected[0], architecture) != expected: + raise ValueError("Release image identity mismatch: " + name) + entries[name, architecture] = (path, *expected) references = {} for (name, architecture), (path, config, digest) in entries.items(): subprocess.run(["docker", "load", "--input", str(path)], check=True) @@ -234,7 +233,7 @@ def publish(assets, repository, revision, tag, mode): # The builder validates the manifest and Runtime assets. Verify archives again # after the Actions artifact transfer between jobs. stem = "oac-" + revision + "-linux-amd64" - archives = [assets / (stem + ".tar.gz"), assets / ("oac-" + revision + "-linux-arm64.tar.gz"), assets / "install.sh", assets / "install.ps1"] + archives = [assets / (stem + ".tar.gz"), assets / "install.sh", assets / "install.ps1"] archives.extend(assets / name for name in ("oac-linux-amd64", "oac-linux-arm64", "oac-darwin-amd64", "oac-darwin-arm64", "oac-windows-amd64.exe")) if mode == "publish" or (assets / (stem + "-offline.tar.gz")).exists(): archives.append(assets / (stem + "-offline.tar.gz")) @@ -250,7 +249,7 @@ def publish(assets, repository, revision, tag, mode): if catalog["version"] != revision or not catalog["artifacts"]: raise ValueError("Native installer catalog does not match the release") for platform, entry in catalog["artifacts"].items(): - if not re.fullmatch(r"(linux|darwin|windows)-(amd64|arm64)", platform): + if platform != "linux-amd64": raise ValueError("Invalid native installer platform") path = assets / f"oac-native-{revision}-{platform}.tar.gz" if (distribution.sha256(path) != entry["sha256"] diff --git a/scripts/publish-core-release.test.py b/scripts/publish-core-release.test.py index a4f161c86..ee3f4a0e5 100644 --- a/scripts/publish-core-release.test.py +++ b/scripts/publish-core-release.test.py @@ -29,12 +29,12 @@ def setUp(self): self.assets = pathlib.Path(self.temp.name) self.revision = "a" * 40 self.stem = "oac-" + self.revision + "-linux-amd64" - for name in (self.stem + ".tar.gz", self.stem + "-offline.tar.gz", "install.sh", "install.ps1", "oac-" + self.revision + "-linux-arm64.tar.gz", "oac-linux-amd64", "oac-linux-arm64", "oac-darwin-amd64", "oac-darwin-arm64", "oac-windows-amd64.exe"): + for name in (self.stem + ".tar.gz", self.stem + "-offline.tar.gz", "install.sh", "install.ps1", "oac-linux-amd64", "oac-linux-arm64", "oac-darwin-amd64", "oac-darwin-arm64", "oac-windows-amd64.exe"): (self.assets / name).write_bytes(b"archive fixture") (self.assets / (name + ".sha256")).write_text( hashlib.sha256(b"archive fixture").hexdigest() + " " + name + "\n") catalog = {"version": self.revision, "artifacts": {}} - for platform in ("linux-amd64", "darwin-arm64", "windows-amd64"): + for platform in ("linux-amd64",): name = f"oac-native-{self.revision}-{platform}.tar.gz" (self.assets / name).write_bytes(b"native archive") checksum = hashlib.sha256(b"native archive").hexdigest() @@ -68,10 +68,10 @@ def test_draft_publishes_images_and_stays_unpublished(self): self.publish(tag="build-" + self.revision, mode="draft") self.images.assert_called_once() self.assertTrue(self.release["draft"]) - self.assertEqual(len(self.release["assets"]), 28) + self.assertEqual(len(self.release["assets"]), 22) def test_missing_native_asset_refuses_release_creation(self): - (self.assets / f"oac-native-{self.revision}-windows-amd64.tar.gz").unlink() + (self.assets / f"oac-native-{self.revision}-linux-amd64.tar.gz").unlink() with self.assertRaises(FileNotFoundError): self.publish() self.assertEqual(self.writes(), []) @@ -142,7 +142,7 @@ def response(repo, endpoint, *args): return result if endpoint == "releases/7": self.assertEqual(active, 0) - self.assertIn(len(self.release["assets"]), (26, 28)) + self.assertIn(len(self.release["assets"]), (20, 22)) return self.response(repo, endpoint, *args) self.api.side_effect = response self.publish() @@ -153,7 +153,7 @@ def test_version_tag_publishes_complete_fixed_id(self): self.publish() self.assertFalse(self.release["draft"]) self.assertFalse(self.release["prerelease"]) - self.assertEqual(len(self.release["assets"]), 28) + self.assertEqual(len(self.release["assets"]), 22) self.assertEqual({a["name"] for a in self.release["assets"] if a["name"].endswith(".yaml")}, {"compose.yaml"}) self.assertEqual(self.api.call_args.args[1:], ("releases/7", "--method", "PATCH", "-F", "draft=false")) @@ -348,8 +348,8 @@ def setUp(self): self.digests = {'amd64': 'sha256:' + '3' * 64, 'arm64': 'sha256:' + '4' * 64} self.index_digest = 'sha256:' + '5' * 64 self.remote_images = {} - for arch in ('amd64', 'arm64'): - names = publisher.IMAGE_NAMES if arch == 'amd64' else ('core', 'web', 'ingress') + for arch in ('amd64',): + names = publisher.IMAGE_NAMES manifest = {'source_commit': self.revision, 'platform': 'linux/' + arch, 'images': dict.fromkeys(names, self.configs[arch]), 'image_manifest_digests': dict.fromkeys(names, self.digests[arch])} @@ -381,7 +381,7 @@ def execute(self, command, **kwargs): self.remote_images[ref] = {'config': {'digest': self.configs[arch]}} if command[1:4] == ['buildx', 'imagetools', 'create']: ref = command[5]; name = ref.rsplit('/', 1)[1].split(':')[0] - arches = ('amd64', 'arm64') if name in ('core', 'web', 'ingress') else ('amd64',) + arches = ('amd64',) self.remote_images[ref] = {'manifests': [{'platform': {'os': 'linux', 'architecture': arch}, 'digest': self.digests[arch]} for arch in arches]} def output(self, command, **kwargs): @@ -396,11 +396,11 @@ def publish(self, **kwargs): def pushes(self): return [call.args[0] for call in self.run.call_args_list if call.args[0][1] == 'push'] - def test_publishes_and_reuses_verified_multiarch_indexes(self): + def test_publishes_and_reuses_verified_indexes(self): result = self.publish() - self.assertEqual(len(self.pushes()), 8) + self.assertEqual(len(self.pushes()), 5) self.assertEqual(result['ingress']['digest'], 'ghcr.io/minimax-ai/openagentcore/ingress@' + self.index_digest) - self.assertEqual(len(self.remote_images['ghcr.io/minimax-ai/openagentcore/core:v1.2.3']['manifests']), 2) + self.assertEqual(len(self.remote_images['ghcr.io/minimax-ai/openagentcore/core:v1.2.3']['manifests']), 1) self.run.reset_mock(); self.publish(); self.assertEqual(self.pushes(), []) def test_latest_updates_after_all_version_indexes(self): @@ -410,7 +410,7 @@ def test_latest_updates_after_all_version_indexes(self): self.assertTrue(all(ref.endswith(':latest') for ref in creates[5:])) def test_conflicting_platform_prevents_every_push(self): - self.remote_images['ghcr.io/minimax-ai/openagentcore/web:v1.2.3-arm64'] = {'config': {'digest': 'different'}} + self.remote_images['ghcr.io/minimax-ai/openagentcore/web:v1.2.3-amd64'] = {'config': {'digest': 'different'}} with self.assertRaisesRegex(ValueError, 'different image'): self.publish() self.assertEqual(self.pushes(), []) @@ -419,8 +419,8 @@ def test_conflicting_index_prevents_every_push(self): with self.assertRaisesRegex(ValueError, 'unexpected platforms'): self.publish() self.assertEqual(self.pushes(), []) - def test_missing_arm_archive_prevents_loading(self): - (self.assets / ('oac-' + self.revision + '-linux-arm64.tar.gz')).unlink() + def test_missing_archive_prevents_loading(self): + (self.assets / ('oac-' + self.revision + '-linux-amd64.tar.gz')).unlink() with self.assertRaises(FileNotFoundError): self.publish() self.run.assert_not_called() diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index 433cf678f..24483316a 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -145,7 +145,7 @@ Provider input validation uses the adapter rules in `internal/harnessconfig`: on ## MCP -Accepted public MCP credential profiles are declared centrally by the service, separately from private adapter capabilities; a daemon capability alone never opens a profile. Frozen binding validation runs at admission and later input, and the same capability and placement checks run at device selection, the final preclaim check and request construction, before scoped decryption. Native configuration and token injection stay in the adapters; the [Codex adapter](deploy/codex/README.md#mcp-servers) and the [Claude SDK adapter](../../packages/claude-sdk-adapter/README.md#http-mcp) describe theirs. The shared resolver keeps omitted or null `allowed_tools` as unrestricted and an explicit empty list as deny-all. Saved HTTP transport output includes `headers: {}` while the effective Session transport omits headers, matching the two pinned resource types. An omitted or null `connection_origin` on HTTP transport is stored as `service` before the other checks. +Accepted public MCP credential profiles are declared centrally by the service, separately from private adapter capabilities; a daemon capability alone never opens a profile. Frozen binding validation runs at admission and later input, and the same capability and placement checks run at device selection, the final preclaim check and request construction, before scoped decryption. Native configuration and token injection stay in the adapters; the [model execution contract](../../contracts/agents-api/model-execution.md#credential-gateway) and the [Claude SDK adapter](../../packages/claude-sdk-adapter/README.md#http-mcp) describe theirs. The shared resolver keeps omitted or null `allowed_tools` as unrestricted and an explicit empty list as deny-all. Saved HTTP transport output includes `headers: {}` while the effective Session transport omits headers, matching the two pinned resource types. An omitted or null `connection_origin` on HTTP transport is stored as `service` before the other checks. ## Message text and result targets diff --git a/services/core/cmd/oac/install.go b/services/core/cmd/oac/install.go index efe7d5955..b8d831af6 100644 --- a/services/core/cmd/oac/install.go +++ b/services/core/cmd/oac/install.go @@ -152,9 +152,9 @@ func (i installer) installLocked(ctx context.Context, o installOptions) error { return fmt.Errorf("start Docker and check this account's access: %w", err) } switch strings.TrimSpace(string(info)) { - case "linux/x86_64", "linux/amd64", "linux/aarch64", "linux/arm64": + case "linux/x86_64", "linux/amd64": default: - return errors.New("Docker must run Linux amd64 or arm64 containers; on Windows select Linux containers in Docker Desktop") + return errors.New("installation requires a Linux amd64 Docker engine; arm64 installations are unsupported") } version, err := i.docker(ctx, "", "compose", "version", "--short") if err != nil { diff --git a/services/core/cmd/oac/install_test.go b/services/core/cmd/oac/install_test.go index be48b1bfc..8ce517f97 100644 --- a/services/core/cmd/oac/install_test.go +++ b/services/core/cmd/oac/install_test.go @@ -58,7 +58,7 @@ func newInstallFixture(t *testing.T) *installFixture { } switch command { case "info --format {{.OSType}}/{{.Architecture}}": - return []byte("linux/aarch64"), nil + return []byte("linux/amd64"), nil case "compose version --short": return []byte("v2.26.0"), nil case "version --format {{.Server.APIVersion}}": @@ -243,3 +243,27 @@ func TestInstallerInterruptedProcess(t *testing.T) { t.Fatalf("killed installer left unrecoverable state: %v", err) } } + +func TestInstallRejectsUnsupportedEngineBeforeDownload(t *testing.T) { + for _, platform := range []string{"linux/arm64", "linux/aarch64", "windows/amd64"} { + t.Run(platform, func(t *testing.T) { + f := newInstallFixture(t) + f.docker = func(_ context.Context, _ string, args ...string) ([]byte, error) { + if strings.Join(args, " ") != "info --format {{.OSType}}/{{.Architecture}}" { + t.Fatal("installation continued on unsupported platform") + } + return []byte(platform), nil + } + f.download = func(context.Context, string, string) error { + t.Fatal("downloaded unsupported installation") + return nil + } + if err := f.run(); err == nil || !strings.Contains(err.Error(), "Linux amd64") { + t.Fatalf("unexpected result: %v", err) + } + if _, err := os.Stat(f.options.dir); !os.IsNotExist(err) { + t.Fatalf("installation published: %v", err) + } + }) + } +} diff --git a/services/core/deploy/claude/Dockerfile b/services/core/deploy/claude/Dockerfile deleted file mode 100644 index ca50b3ebf..000000000 --- a/services/core/deploy/claude/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -# Build context contains only the daemon, shared helpers and pinned SDK bundle. -FROM node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27 -USER root -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates bash git python3 python3-pip ripgrep \ - && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /environment/workspace /workspace /home/runtime -COPY --chmod=0555 oac-daemon oac-sandbox-io /usr/local/bin/ -COPY claude-sdk /opt/claude-sdk -ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ - OAC_RUNTIME_CLAUDE_SDK_NODE=/usr/local/bin/node \ - OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT=/opt/claude-sdk/dist/main.js \ - OAC_RUNTIME_WORKSPACE=/environment/workspace \ - OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization \ - OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages -USER 1000:1000 -RUN node /opt/claude-sdk/dist/runtime_check.js /opt/claude-sdk/dist/main.js -WORKDIR /environment/workspace -ENTRYPOINT ["/usr/local/bin/oac-daemon"] -CMD ["connect", "--profile", "default"] diff --git a/services/core/deploy/claude/README.md b/services/core/deploy/claude/README.md deleted file mode 100644 index 327af0f16..000000000 --- a/services/core/deploy/claude/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Claude Code Runtime - -The Claude adapter runs Claude Code through the pinned Claude Agent SDK. The SDK owns the model and tool loop. Two parts make up the adapter: the private TypeScript bridge in [`packages/claude-sdk-adapter`](../../../../packages/claude-sdk-adapter/README.md), which owns the bridge protocol and native SDK configuration, and the Go adapter in [`agent/claudesdk`](../../../../apps/daemon/internal/agent/claudesdk), which owns the bridge process. This page holds the Runtime-level rules and the Claude Runtime image. [Harness onboarding](../../../../contracts/agents-api/harness-onboarding.md) owns the obligations shared by all adapters. - -Native tools run with the daemon user's permissions; the outer sandbox provides isolation ([Runtime and outer isolation](../../../../docs/concepts.md#runtime-and-outer-isolation)). - -## Native pin and readiness - -The bundle pins Claude Agent SDK `0.3.269` ([`package.json`](../../../../packages/claude-sdk-adapter/package.json)), which reports native Claude Code `2.1.269`. The bridge uses protocol 3 for a prepared Executor with separately identified Turns; the Go adapter's readiness and Executor checks reject any other protocol version ([`readiness.go`](../../../../apps/daemon/internal/agent/claudesdk/readiness.go), [`executor.go`](../../../../apps/daemon/internal/agent/claudesdk/executor.go)). - -Workspace execution requires the bundle to report the `workspace_tools`, `workspace_prepare`, `workspace_command_observations` and `local_runtime_v2` features; matching SDK versions alone do not establish compatibility. Function tools in workspace execution additionally require `workspace_functions`. The native installer rejects a bundle that lacks the workspace features ([`installation.go`](../../../../apps/daemon/internal/agent/claudesdk/installation.go)). - -Claude accepts only the `anthropic` model protocol ([`harnessconfig/claudesdk`](../../../../internal/harnessconfig/claudesdk/configuration.go)). [Model execution](../../../../contracts/agents-api/model-execution.md#deployment-defaults) owns provider selection. - -## Native state and recovery - -With a workspace binding, the SDK's history, home and scratch directories are `history`, `home` and `scratch` under `$OAC_RUNTIME_HOME/runtime/claude-sdk/` (mode 0700; `OAC_RUNTIME_HOME` defaults to `~/.oac`). The daemon's authentication stays under `$OAC_RUNTIME_HOME/daemon/`. These locations keep Session state apart; they do not restrict the tools. - -Recovery uses the SDK's history APIs ([`recovery.ts`](../../../../packages/claude-sdk-adapter/src/recovery.ts)). An explicitly supplied native Session ID must exist. When Core requires existing history but has no recorded ID, the adapter accepts only a single native Session whose recorded cwd equals the bound workspace and which has at least one message. Missing, foreign, ambiguous or empty history is rejected before any model input. - -## Native failure classification - -The bridge ([`native_failure.ts`](../../../../packages/claude-sdk-adapter/src/native_failure.ts)) classifies a failure only from root assistant messages (no parent tool use) of the same native Session that answer Core-submitted inputs not yet completed; replayed and synthetic messages are ignored. A classification is committed only when the matching native result is an error; a successful result clears it. The [Runtime protocol](../../../../docs/runtime-protocol.md#native-failure-classification) defines the codes. - -| SDK assistant error | Code | -| --- | --- | -| `authentication_failed`, `oauth_org_not_allowed`, `account_on_hold`, `verification_required`, `cloud_credential_error` | `authentication_error` | -| `billing_error` | `usage_limit_exceeded` | -| `rate_limit` | `rate_limit_exceeded` | -| `overloaded` | `server_overloaded` | -| `invalid_request` | `invalid_request` | -| `model_not_found` | `resource_not_found` | -| `server_error` | `server_error` | - -`unknown`, `max_output_tokens` and other results stay unclassified. The bridge drops the classification when any other error ends the Turn, when cleanup fails or when no native process ran. The Go adapter keeps it only for the reported failed result of the same native Session, and only after it received the Turn settlement ([`executor_turn.go`](../../../../apps/daemon/internal/agent/claudesdk/executor_turn.go)). - -## Runtime image - -[`Dockerfile`](Dockerfile) builds the Claude Runtime image from a prepared context that holds only `oac-daemon` and the exported SDK bundle; the [maintainer guide](../../../../docs/maintainers.md#runtime-images-and-helpers) builds it. Keep the exported bundle unchanged. - -| Item | Value | -| --- | --- | -| Base | Digest-pinned `node:22.23.1-bookworm-slim` with `ca-certificates`, `bash`, `git`, `python3`, `python3-pip` and `ripgrep` | -| Programs | `/usr/local/bin/oac-daemon` and the SDK bundle at `/opt/claude-sdk` | -| User | UID/GID 1000 with `HOME=/home/runtime` | -| Environment | `OAC_RUNTIME_HOME=/home/runtime/.oac`, `OAC_RUNTIME_CLAUDE_SDK_NODE=/usr/local/bin/node`, `OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT=/opt/claude-sdk/dist/main.js`, `OAC_RUNTIME_WORKSPACE=/environment/workspace`, `OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization`, `OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages` | -| Entry point | `oac-daemon connect --profile default`, working directory `/environment/workspace` | - -The build runs the bundle's `runtime_check.js` against its entry point. The distribution copies `/opt/claude-sdk` into the combined Runtime image. Sandboxes run the image with the [Docker sandbox settings](../../../../docs/sandbox-provider.md#docker-adapter). diff --git a/services/core/deploy/codex/Dockerfile b/services/core/deploy/codex/Dockerfile deleted file mode 100644 index 76d193f58..000000000 --- a/services/core/deploy/codex/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -# Build context is a prepared binary bundle, never the product checkout. -# Native package and resources must both be Codex 0.153.4 (linux/amd64). -# The base and package layer match the Claude and MiniMax images, so one build -# shares that layer and the combined Runtime image keeps the same base. -FROM node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27 -USER root -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates bash git python3 python3-pip ripgrep \ - && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /environment/workspace /workspace /home/runtime -COPY --chmod=0555 oac-daemon oac-sandbox-io codex /usr/local/bin/ -COPY codex-resources /usr/local/codex-resources -ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ - OAC_RUNTIME_CODEX_BIN=/usr/local/bin/codex \ - OAC_RUNTIME_WORKSPACE=/environment/workspace \ - OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization \ - OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages -USER 1000:1000 -RUN test "$(codex --version)" = "codex-cli 0.153.4" -WORKDIR /environment/workspace -ENTRYPOINT ["/usr/local/bin/oac-daemon"] -CMD ["connect", "--profile", "default"] diff --git a/services/core/deploy/codex/README.md b/services/core/deploy/codex/README.md deleted file mode 100644 index 19645bb22..000000000 --- a/services/core/deploy/codex/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# Codex Runtime - -The Codex adapter ([`agent/codex`](../../../../apps/daemon/internal/agent/codex)) runs the native Codex CLI through its app-server protocol inside the daemon. This page holds the Codex-specific adapter rules, the Codex Runtime image and the Docker sandbox settings that every Docker Runtime uses. [Harness onboarding](../../../../contracts/agents-api/harness-onboarding.md) owns the obligations shared by all adapters. - -Native tools run with the daemon user's permissions; the outer sandbox provides isolation ([Runtime and outer isolation](../../../../docs/concepts.md#runtime-and-outer-isolation)). - -## Native pin - -The adapter accepts only Codex `0.153.4`: installation and recovery checks require `codex --version` to report `codex-cli 0.153.4` ([`installation.go`](../../../../apps/daemon/internal/agent/codex/installation.go), [`recovery.go`](../../../../apps/daemon/internal/agent/codex/recovery.go)). The Runtime image carries the official Linux amd64 package of that version with its matching `codex-resources`; the [maintainer guide](../../../../docs/maintainers.md#runtime-images-and-helpers) builds it. - -## Model provider and native state - -Codex accepts only the `responses` model protocol ([`harnessconfig/codex`](../../../../internal/harnessconfig/codex/configuration.go)). A Chat Completions or Anthropic provider is rejected; nothing converts between protocols. [Model execution](../../../../contracts/agents-api/model-execution.md#deployment-defaults) owns provider selection, including the per-Harness deployment default. - -Each Session has its own `CODEX_HOME` at `$OAC_RUNTIME_HOME/daemon/agent-sessions//` (`OAC_RUNTIME_HOME` defaults to `~/.oac`). Native history stays there. The adapter regenerates that directory's `config.toml` on every prompt: the Session's frozen provider bundle becomes the `[model_providers.oac]` block, with `wire_api` `responses`, and the thread is pinned to that provider, so Codex never falls back to its built-in `openai` provider. - -## Execution controls - -The adapter applies Core's typed controls and opt-ins, described in the [Runtime protocol](../../../../docs/runtime-protocol.md#capability-declarations), through native Codex settings on both new and resumed Turns. - -- **`environment: none`.** The daemon sets `CODEX_EXEC_SERVER_URL=none` and confirms that Codex reports its `local` and `remote` environments as unknown before it starts or resumes a thread; a binary that cannot do this fails closed. The engine process runs on the bound device, which is not a user execution environment. -- **Web search.** The control becomes Codex's `web_search` option (`disabled`, `cached` or `live`); Core sends `disabled`. -- **Programmatic tool calling.** An explicit disable turns off the native `code_mode`, `code_mode_only` and `code_mode_prewarm` features and checks managed requirements before a thread starts or resumes, rejecting a conflicting requirement. -- **Text verbosity.** The adapter reads the model catalog with `codex debug models`, checks the model's support and pins that catalog snapshot for the execution. The probe needs Unix process-group cancellation, so other hosts do not declare `text_verbosity`. For a model without declared verbosity support, including Codex's unknown-model fallback, `medium` omits the override and keeps native default text; a supported model receives an explicit `medium`. Unsupported `low` or `high` and an unreadable catalog fail before model execution. -- **Subagents.** Disabling Subagents turns off both native multi-agent feature generations, `multi_agent` and `multi_agent_v2`, overriding the operator's feature preferences. - -## MCP servers - -The adapter renders the typed MCP declaration with the native renderer and the original tool names for `enabled_tools`, including `[]`. Before creating or resuming a thread it reads native `config/read` with the exact cwd and rejects additional servers or any difference in the effective configuration. It disables native plugins and apps, selects file-only MCP credentials, and rejects existing credentials in the private native home without deleting them or native history. Reserved native labels are an adapter restriction, not a rule of the saved resource. The check is a snapshot, not a barrier against concurrent operator configuration changes, and discovery of a deny-all server can still contact it. - -With `required: true`, which needs `mcp_http_required`, root thread creation and cold resume wait for required servers to initialize and send no native Turn until they do; a failed strict resume is never replaced with a new thread. Public work may already be accepted while initialization waits. - -A selected bearer credential (`mcp_http_bearer_auth`) becomes a fresh daemon-owned `bearer_token_env_var` reference for each server and native process. The secret enters only that app-server child's environment, after auxiliary launch probes, and never global environment, arguments, configuration, history, snapshots or logs. Preflight accepts only the expected server and reference pairing and keeps rejecting ambient credential sources. Empty values and bytes outside RFC 6750 `b64token` syntax are rejected with generic errors, and tokens are never trimmed. Core-managed OAuth delivers access tokens through the same path. - -## Function results and command output - -A function result counts as applied only when Codex reports a matching live dynamic-tool completion: root thread, Turn and call identity, function, success and ordered content. Writing the JSON-RPC response is not application. The adapter owns pending receipts without holding their lock across I/O or waits; terminal state, cancellation and native loss settle unconfirmed submissions before release, and an uncertain receipt timeout ends that native execution without resending the result. It records native confirmation before any potentially blocking observation publication, so output backpressure cannot turn a known application into an unknown one. - -With neutral tool observations, the adapter emits `command_output` fragments carrying the native command identity, filtered to the root thread and Turn. Codex `0.153.4` can miss early process output in both its notifications and its final aggregate; that output is lost, and Core never reconstructs it from model text. - -## Native failure classification - -The adapter classifies a failed Turn only from the exact root terminal Turn's `codexErrorInfo` ([`error_classification.go`](../../../../apps/daemon/internal/agent/codex/error_classification.go)); error notifications, including retry notifications, never classify it. The [Runtime protocol](../../../../docs/runtime-protocol.md#native-failure-classification) defines the codes. - -| `codexErrorInfo` | Code | -| --- | --- | -| `unauthorized` | `authentication_error` | -| `usageLimitExceeded` | `usage_limit_exceeded` | -| `rateLimitExceeded` | `rate_limit_exceeded` | -| `contextWindowExceeded` | `context_length_exceeded` | -| `serverOverloaded` | `server_overloaded` | -| `internalServerError` | `server_error` | -| `badRequest` | `invalid_request` | -| `cyberPolicy` | `cyber_policy` | -| `httpConnectionFailed`, `responseStreamConnectionFailed`, `responseStreamDisconnected` | `connection_failed`, with the upstream `httpStatusCode` | -| `responseTooManyFailedAttempts` | `rate_limit_exceeded` when `httpStatusCode` is 429, otherwise `connection_failed` | - -Every other variant stays unclassified. - -## Runtime image - -[`Dockerfile`](Dockerfile) builds the Codex Runtime image from a prepared context that holds only `oac-daemon`, the unmodified `codex` and `codex-code-mode-host` executables and `codex-resources`. The image leaves out `codex-code-mode-host`, which only the agent-host image installs. - -| Item | Value | -| --- | --- | -| Base | Digest-pinned `node:22.23.1-bookworm-slim` with `ca-certificates`, `bash`, `git`, `python3`, `python3-pip` and `ripgrep`, the same base and package layer as the Claude and MiniMax images | -| Programs | `/usr/local/bin/oac-daemon`, `/usr/local/bin/codex` (mode 0555) and `/usr/local/codex-resources` | -| User | UID/GID 1000 with `HOME=/home/runtime` | -| Environment | `OAC_RUNTIME_HOME=/home/runtime/.oac`, `OAC_RUNTIME_CODEX_BIN=/usr/local/bin/codex`, `OAC_RUNTIME_WORKSPACE=/environment/workspace`, `OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization`, `OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages` | -| Entry point | `oac-daemon connect --profile default`, working directory `/environment/workspace` | - -The build fails unless `codex --version` reports the pinned version. The image holds no credentials, workspace data or product software. The distribution copies the Codex executable and resources into the combined Runtime image; see the [maintainer guide](../../../../docs/maintainers.md#runtime-images-and-helpers). - -The [Docker adapter](../../../../docs/sandbox-provider.md#docker-adapter) owns the shared container lifecycle and isolation settings. diff --git a/services/core/deploy/e2b/README.md b/services/core/deploy/e2b/README.md index 75675ca23..f9b693755 100644 --- a/services/core/deploy/e2b/README.md +++ b/services/core/deploy/e2b/README.md @@ -1,24 +1,25 @@ # E2B sandbox template -An E2B template packages a qualified Runtime image for Core-managed E2B sandboxes: the deployment selects E2B as its Sandbox Provider, and Core creates, renews and destroys sandboxes from the template. [Sandbox deployment](../../../../contracts/agents-api/sandbox-deployment.md) owns the selection, and the [E2B helper](../../tools/e2b-provider/README.md) owns the adapter and the startup script, [`managed_init.py`](managed_init.py), that starts the Sandbox I/O service in each sandbox. +An E2B template packages a qualified sandbox image for Core-managed E2B sandboxes: the deployment selects E2B as its Sandbox Provider, and Core creates, renews and destroys sandboxes from the template. [Sandbox deployment](../../../../contracts/agents-api/sandbox-deployment.md) owns the selection, and the [E2B helper](../../tools/e2b-provider/README.md) owns the adapter and the startup script, [`managed_init.py`](managed_init.py), that starts the Sandbox I/O service in each sandbox. ## Build a template -Use Python 3.12 or newer, Docker and a qualified Linux amd64 Runtime image. Keep keys and build outputs outside the checkout, in private directories. Install the pinned SDK (`e2b` 2.51.0, [`requirements.txt`](requirements.txt)) and build: +Use Python 3.12 or newer, Docker and a qualified Linux amd64 sandbox image. Keep keys and build outputs outside the checkout, in private directories. Install the pinned SDK (`e2b` 2.51.0, [`requirements.txt`](requirements.txt)) and build: ```sh python -m venv "$HOME/.oac/build/e2b-sdk" "$HOME/.oac/build/e2b-sdk/bin/pip" install -r services/core/deploy/e2b/requirements.txt "$HOME/.oac/build/e2b-sdk/bin/python" services/core/deploy/e2b/build-template.py \ - --image sha256:QUALIFIED_RUNTIME_IMAGE_DIGEST \ - --name your-runtime-build \ + --image sha256:QUALIFIED_SANDBOX_IMAGE_DIGEST \ + --name your-sandbox-build \ --api-key-file "$HOME/.oac/secrets/e2b.key" \ --output "$HOME/.oac/build/e2b-template.json" ``` -[`build-template.py`](build-template.py) requires a `sha256:` image ID, a Linux amd64 image and the Runtime layout (`OAC_RUNTIME_WORKSPACE=/environment/workspace`). It copies the image's `/usr/local/bin`, `/opt` and, when present, `/usr/local/codex-resources` and `/etc/codex`, together with its `HOME` and `OAC_*` environment, onto a digest-pinned `node:22.23.1-bookworm-slim` base with `ca-certificates`, `bash`, `git`, `python3`, `python3-pip`, `ripgrep` and `util-linux`. It installs [`managed_init.py`](managed_init.py) read-only under `/opt/oac-e2b`, makes UID/GID 1000 (`runtime`, home `/home/runtime`) the template user and builds with 2 vCPUs and 2048 MiB. Only the `usr`, `usr/local` and `etc` archive ancestors it creates get traversable modes; Runtime file modes, private build contexts, key inputs and the output's umask stay unchanged. +[`build-template.py`](build-template.py) requires a `sha256:` image ID and a Linux amd64 image built from the `sandbox` target of [`AgentHost.Dockerfile`](../../../../deploy/distribution/AgentHost.Dockerfile). It copies only `/usr/local/bin/oac-sandbox-io` onto a digest-pinned `node:22.23.1-bookworm-slim` base with `ca-certificates`, `bash`, `git`, `python3`, `python3-pip`, `ripgrep` and `util-linux`. It installs [`managed_init.py`](managed_init.py) read-only under `/opt/oac-e2b`, makes UID/GID 1000 (`runtime`, home `/home/runtime`) the template user and builds with 2 vCPUs and 2048 MiB. The archive's `usr`, `usr/local` and `usr/local/bin` ancestors have traversable modes; the executable's mode, private build contexts, key inputs and the output's umask stay unchanged. Harnesses run on the separate agent host. -The output file records `template` (the immutable `templateID:build_UUID`), the source `image`, the packaged `runtime_sha256` and the `base`. Use that exact `template` value. The template must qualify every Harness its image advertises. Install system dependencies into the image; sandbox processes run as UID/GID 1000. No E2B account key or model credential belongs in a build, template environment, metadata, command argument or log. + +The output file records `template` (the immutable `templateID:build_UUID`), the source `image`, the packaged `runtime_sha256` and the `base`. Use that exact `template` value. Install system dependencies into the image; sandbox processes run as UID/GID 1000. No E2B account key or model credential belongs in a build, template environment, metadata, command argument or log. ## Tests diff --git a/services/core/deploy/e2b/build-template.py b/services/core/deploy/e2b/build-template.py index c2243aa02..6c163cf32 100644 --- a/services/core/deploy/e2b/build-template.py +++ b/services/core/deploy/e2b/build-template.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Package an already qualified Docker Runtime as a pinned E2B template build.""" +"""Package an already qualified Docker sandbox as a pinned E2B template build.""" import argparse import hashlib import json @@ -13,7 +13,7 @@ BASE = 'node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27' parser = argparse.ArgumentParser() -parser.add_argument('--image', required=True, help='Qualified linux/amd64 Runtime image digest') +parser.add_argument('--image', required=True, help='Qualified linux/amd64 sandbox image digest') parser.add_argument('--name', required=True) parser.add_argument('--api-key-file', type=Path, required=True) parser.add_argument('--output', type=Path, required=True) @@ -23,10 +23,6 @@ image = json.loads(subprocess.check_output(['docker', 'image', 'inspect', args.image]))[0] if image['Architecture'] != 'amd64' or image['Os'] != 'linux': parser.error('A qualified Linux amd64 image is required') -environment = dict(value.split('=', 1) for value in image['Config']['Env'] - if value.startswith(('HOME=', 'OAC_'))) -if environment.get('OAC_RUNTIME_WORKSPACE') != '/environment/workspace': - parser.error('Image does not use the colocated Runtime layout') dev_home = Path(os.environ.get('OAC_DEV_HOME') or Path.home() / '.oac') if not dev_home.is_absolute(): parser.error('OAC_DEV_HOME must be absolute') @@ -34,36 +30,28 @@ state.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(dir=state) as temporary: context = Path(temporary) - tree = context / 'runtime' + tree = context / 'sandbox' tree.mkdir() - # These public ancestors are synthesized, not extracted from the Runtime. + # These public ancestors are synthesized, not extracted from the sandbox. # Keep their archive modes independent of the caller's private umask. - for parent in ['usr', 'usr/local', 'etc']: + for parent in ['usr', 'usr/local', 'usr/local/bin']: directory = tree / parent directory.mkdir() directory.chmod(0o755) - container = subprocess.check_output(['docker', 'create', args.image], text=True).strip() + container = subprocess.check_output(['docker', 'create', '--label', 'io.oac.build=e2b-template', args.image], text=True).strip() try: - for path in ['/usr/local/bin', '/usr/local/codex-resources', '/etc/codex', '/opt']: - destination = tree / path.lstrip('/') - with tempfile.TemporaryFile() as copied: - result = subprocess.run(['docker', 'cp', container + ':' + path, '-'], - stdout=copied, stderr=subprocess.PIPE) - if result.returncode: - if path in ['/usr/local/codex-resources', '/etc/codex'] and b'Could not find the file' in result.stderr: - continue - raise RuntimeError('Cannot extract Runtime path: ' + path) - copied.seek(0) - with tarfile.open(fileobj=copied) as archive: - # Qualified images contain absolute native executable symlinks. - archive.extractall(destination.parent, filter='tar') + with tempfile.TemporaryFile() as copied: + path = '/usr/local/bin/oac-sandbox-io' + subprocess.run(['docker', 'cp', container + ':' + path, '-'], stdout=copied, check=True) + copied.seek(0) + with tarfile.open(fileobj=copied) as archive: + archive.extractall(tree / 'usr/local/bin', filter='tar') finally: subprocess.run(['docker', 'rm', container], check=True, stdout=subprocess.DEVNULL) bundle = context / 'runtime.tar.gz' with tarfile.open(bundle, 'w:gz') as archive: for entry in tree.iterdir(): archive.add(entry, arcname=entry.name) - (context / 'runtime-env.json').write_text(json.dumps(environment)) for name in ['managed_init.py', 'helper_contract_generated.py']: (context / name).write_bytes(Path(__file__).with_name(name).read_bytes()) template = (Template(file_context_path=context).from_image(BASE) @@ -71,15 +59,12 @@ 'ca-certificates bash git python3 python3-pip ripgrep util-linux ' '&& rm -rf /var/lib/apt/lists/*', user='root') .copy('runtime.tar.gz', '/root/runtime.tar.gz', user='root') - .copy('runtime-env.json', '/etc/oac-runtime-env.json', user='root') .copy('managed_init.py', '/opt/oac-e2b/managed_init.py', user='root') .copy('helper_contract_generated.py', '/opt/oac-e2b/helper_contract_generated.py', user='root') .run_cmd('tar --no-same-owner -xzf /root/runtime.tar.gz -C / && rm /root/runtime.tar.gz ' '&& usermod -l runtime -d /home/runtime node ' - '&& mkdir -p /home/runtime/.oac /environment/workspace /environment/staging /environment/initialization /environment/packages /workspace ' + '&& mkdir -p /home/runtime /environment/workspace /environment/initialization /environment/packages /workspace ' '&& chown -R 1000:1000 /home/runtime /environment ' - '&& chmod 0700 /home/runtime/.oac /environment/staging ' - '&& chmod 0444 /etc/oac-runtime-env.json ' '&& chmod 0555 /opt/oac-e2b /opt/oac-e2b/managed_init.py /opt/oac-e2b/helper_contract_generated.py', user='root') .set_user('runtime').set_workdir('/environment/workspace')) result = Template.build(template, name=args.name, cpu_count=2, memory_mb=2048, diff --git a/services/core/deploy/e2b/build_template_test.py b/services/core/deploy/e2b/build_template_test.py index 6c85a585e..6e5e57f23 100644 --- a/services/core/deploy/e2b/build_template_test.py +++ b/services/core/deploy/e2b/build_template_test.py @@ -1,4 +1,4 @@ -"""Exercise real Runtime tar metadata without Docker or provider calls.""" +"""Exercise real sandbox tar metadata without Docker or provider calls.""" import contextlib import io import json @@ -23,17 +23,14 @@ def test_public_parents_and_runtime_modes_under_both_umasks(self): key.write_text('fixture-only') key.chmod(0o600) output = root / 'private/result.json' - source_modes = {'bin': 0o755, 'bin/daemon': 0o751, - 'codex-resources': 0o755, 'codex': 0o700, - 'codex/config': 0o600, 'opt': 0o755, - 'opt/private': 0o700, 'opt/private/key': 0o600} + source_modes = {'oac-sandbox-io': 0o555} image = {'Architecture': 'amd64', 'Os': 'linux', 'Id': 'sha256:fixture', - 'Config': {'Env': ['OAC_RUNTIME_WORKSPACE=/environment/workspace']}} + 'Config': {'Env': ['HOME=/home/runtime']}} def check_output(argv, **kwargs): if argv == ['docker', 'image', 'inspect', 'sha256:fixture']: return json.dumps([image]).encode() - self.assertEqual(argv, ['docker', 'create', 'sha256:fixture']) + self.assertEqual(argv, ['docker', 'create', '--label', 'io.oac.build=e2b-template', 'sha256:fixture']) return 'fixture-container\n' def run(argv, **kwargs): @@ -48,12 +45,8 @@ def run(argv, **kwargs): continue entry = tarfile.TarInfo(path) entry.mode = mode - if path in ('bin', 'codex-resources', 'codex', 'opt', 'opt/private'): - entry.type = tarfile.DIRTYPE - archive.addfile(entry) - else: - entry.size = 7 - archive.addfile(entry, io.BytesIO(b'fixture')) + entry.size = 7 + archive.addfile(entry, io.BytesIO(b'fixture')) return SimpleNamespace(returncode=0) template = Mock() @@ -66,11 +59,11 @@ def build(instance, **kwargs): self.assertEqual(stat.S_IMODE(context.stat().st_mode), 0o700) with tarfile.open(context / 'runtime.tar.gz') as archive: modes = {m.name: stat.S_IMODE(m.mode) for m in archive.getmembers()} - for parent in ('usr', 'usr/local', 'etc'): + for parent in ('usr', 'usr/local', 'usr/local/bin'): self.assertEqual(modes[parent], 0o755) for path, mode in source_modes.items(): - prefix = 'usr/local/' if path.split('/')[0] in ('bin', 'codex-resources') else 'etc/' if path.startswith('codex') else '' - self.assertEqual(modes[prefix + path], mode) + self.assertEqual(modes['usr/local/bin/' + path], mode) + self.assertEqual(set(modes), {'usr', 'usr/local', 'usr/local/bin', 'usr/local/bin/oac-sandbox-io'}) self.assertEqual(stat.S_IMODE((context / 'runtime.tar.gz').stat().st_mode), 0o666 & ~mask) self.assertEqual(stat.S_IMODE(key.stat().st_mode), 0o600) projection = 'helper_contract_generated.py' diff --git a/services/core/deploy/mcode/Dockerfile b/services/core/deploy/mcode/Dockerfile deleted file mode 100644 index 4255fd014..000000000 --- a/services/core/deploy/mcode/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -# Build context contains the daemon, shared helpers and pinned native artifact. -FROM node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27 -USER root -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates bash git python3 python3-pip ripgrep \ - && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /environment/workspace /workspace /home/runtime -COPY --chmod=0555 oac-daemon oac-sandbox-io /usr/local/bin/ -COPY mcode-harness /opt/mcode-harness -ENV HOME=/home/runtime OAC_RUNTIME_HOME=/home/runtime/.oac \ - OAC_RUNTIME_MCODE_NODE=/usr/local/bin/node \ - OAC_RUNTIME_MCODE_BIN=/opt/mcode-harness/native/cli.js \ - OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE=/opt/mcode-harness/bridge.mjs \ - OAC_RUNTIME_WORKSPACE=/environment/workspace \ - OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization \ - OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages -USER 1000:1000 -RUN node /opt/mcode-harness/check.mjs && /opt/mcode-harness/native/cli.js --version -WORKDIR /environment/workspace -ENTRYPOINT ["/usr/local/bin/oac-daemon"] -CMD ["connect", "--profile", "default"] diff --git a/services/core/deploy/mcode/README.md b/services/core/deploy/mcode/README.md deleted file mode 100644 index 58cbbcea0..000000000 --- a/services/core/deploy/mcode/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# MiniMax Code Runtime - -The MiniMax Code adapter ([`agent/mcode`](../../../../apps/daemon/internal/agent/mcode)) runs the native MiniMax Code CLI over ACP inside the daemon. MiniMax Code keeps its own ACP Session, model loop and history. The companion package [`packages/mcode-harness`](../../../../packages/mcode-harness/README.md) owns the workspace tool bridge, the native patch and the Subagent history reader. This page holds the Runtime-level adapter rules and the MiniMax Code Runtime image. [Harness onboarding](../../../../contracts/agents-api/harness-onboarding.md) owns the obligations shared by all adapters. - -Native tools run with the daemon user's permissions; the outer sandbox provides isolation ([Runtime and outer isolation](../../../../docs/concepts.md#runtime-and-outer-isolation)). - -## Native pin and readiness - -The adapter accepts only `@minimax-ai/code` `0.4.12` ([`version.go`](../../../../apps/daemon/internal/agent/mcode/version.go)), built from the upstream source revision in [`source.json`](../../../../packages/mcode-harness/source.json) and run with Node.js 22. MiniMax Code runs on Linux and macOS. - -Workspace execution also requires the companion's readiness report: private protocol 2, the pinned native version and the pinned source revision ([`workspace_readiness.go`](../../../../apps/daemon/internal/agent/mcode/workspace_readiness.go)). An older companion is rejected even when the upstream version matches. - -## Model provider - -MiniMax Code accepts the `anthropic`, `responses` and `chat_completions` protocols, and each requires the provider's context window and maximum output tokens ([`harnessconfig/mcode`](../../../../internal/harnessconfig/mcode/configuration.go)). The adapter writes the frozen bundle as the native custom provider for the Session's exact `agent.model`, with those limits, using the native `anthropic-messages`, `openai-responses` or `openai-completions` API ([`model_provider.go`](../../../../apps/daemon/internal/agent/mcode/model_provider.go)). It selects that provider explicitly; there is no native account fallback. [Model execution](../../../../contracts/agents-api/model-execution.md#deployment-defaults) owns provider selection. - -## Execution profiles - -Each Session has its own native data directory, `$OAC_RUNTIME_HOME/runtime/mcode/state//` (`OAC_RUNTIME_HOME` defaults to `~/.oac`), holding the generated native configuration, instructions (`AGENTS.md`, at most 32 KiB), MCP configuration and history. The native process gets `MINIMAX_DATA_DIR`, `HOME` and `USERPROFILE` set to that directory on top of the daemon user's environment. - -| Profile | Where it runs | Native configuration | -| --- | --- | --- | -| Text (`environment: none`) | A private working directory inside the Session's data directory | No native tools, Skills, web search, browser tools, `mcode-tools`, thread goals or user questions. With Subagents enabled, the default agent gets only the task tools (`task`, `task_append`, `task_query`, `task_output`, `task_stop`) and delegation | -| Workspace | The Environment's workspace directory, for the native process, the ACP Session and the workspace tools | The text configuration plus permission mode `bypassPermissions`, the native sandbox off, the selected workspace Skills and the `oac_workspace` MCP bridge for file and shell tools | - -The text profile rejects workspace, function tools and MCP servers. Workspace execution requires Linux or macOS and an unrestricted Environment network policy ([`workspace.go`](../../../../apps/daemon/internal/agent/mcode/workspace.go)); the adapter enforces no network restriction. - -In the workspace profile, the frozen installation's Skills are linked into the Session's `skills` directory under their exact names and loaded through the native `skill` tool; external Skill discovery stays off. Public MCP servers use the Environment origin only, as the [MCP origin contract](../../../../contracts/agents-api/environments.md#public-mcp-connection-origin) describes. With Subagents enabled, the Session's native `mcp.json` holds only the `oac_workspace` server, so child agents get the same workspace tools. - -## Turns, cancellation and continuation - -- Normal Turns reuse one ACP connection and native Session. -- Active input uses ACP `mcode/session/steer`. Its receipt confirms that the active native Turn accepted the input, not that the model consumed it. Unknown outcomes are never replayed. -- Public input stays a model message: the adapter adds an empty text block so that ACP does not treat slash text as an operator command. -- A Turn that ends with an error, a cancellation, an unknown input outcome, an unfinished tool call or an unanswered permission or question makes the Executor non-reusable. The adapter stops the native process group and waits for it to exit before the Turn settles; later work loads the same native history in a new Executor. -- Continuation requires the exact recorded native Session ID and the same working directory. Recovery without a recorded ID is rejected. -- MiniMax Code reports no usage: ACP context occupancy and cumulative cost are not per-Turn usage. -- Workspace `workspace_bash` calls become command observations with the command text, output text and status; exit code and duration stay unknown. Native task and Skill utilities produce no public Items. -- The adapter attaches no [native failure classification](../../../../docs/runtime-protocol.md#native-failure-classification) to its errors. - -## Runtime image - -[`Dockerfile`](Dockerfile) builds the MiniMax Code Runtime image from a prepared context that holds `oac-daemon` and the companion artifact; the [maintainer guide](../../../../docs/maintainers.md#runtime-images-and-helpers) builds both. - -| Item | Value | -| --- | --- | -| Base | Digest-pinned `node:22.23.1-bookworm-slim` with `ca-certificates`, `bash`, `git`, `python3`, `python3-pip` and `ripgrep` | -| Programs | `/usr/local/bin/oac-daemon` and the companion at `/opt/mcode-harness` (native CLI at `native/cli.js`, bridge at `bridge.mjs`) | -| User | UID/GID 1000 with `HOME=/home/runtime` | -| Environment | `OAC_RUNTIME_HOME=/home/runtime/.oac`, `OAC_RUNTIME_MCODE_NODE`, `OAC_RUNTIME_MCODE_BIN`, `OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE`, `OAC_RUNTIME_WORKSPACE=/environment/workspace`, `OAC_RUNTIME_INITIALIZATION_DIRECTORY=/environment/initialization`, `OAC_RUNTIME_PACKAGE_DIRECTORY=/environment/packages` | -| Entry point | `oac-daemon connect --profile default`, working directory `/environment/workspace` | - -The build runs the companion's `check.mjs` and the native `--version`. The combined Runtime image uses this image as its base. Sandboxes run it with the [Docker sandbox settings](../../../../docs/sandbox-provider.md#docker-adapter). diff --git a/services/core/internal/api/environment_installation.go b/services/core/internal/api/environment_installation.go index 3899bbf4d..61624c84c 100644 --- a/services/core/internal/api/environment_installation.go +++ b/services/core/internal/api/environment_installation.go @@ -119,7 +119,7 @@ func (h *Handler) prepareNativeInstallation(w http.ResponseWriter, r *http.Reque writeSessionsError(w, r, err) return } - writeJSON(w, http.StatusOK, v1.NativeInstallationContext{Version: h.Execution.NativeInstaller.Version, ProtocolVersion: proto.Version, EnvironmentID: claim.Environment, RemoteURL: h.Execution.ExecutorURL, Workspace: response.Environment.WorkspaceDirectory, Harness: session.Engine}) + writeJSON(w, http.StatusOK, v1.NativeInstallationContext{Version: h.Execution.NativeInstaller.Version, ProtocolVersion: proto.Version, EnvironmentID: claim.Environment, RemoteURL: h.Execution.ExecutorURL, Workspace: response.Environment.WorkspaceDirectory}) } type NativeInstallationClaim struct { diff --git a/services/core/internal/api/environment_installation_test.go b/services/core/internal/api/environment_installation_test.go index 453bcd263..86a22ccd6 100644 --- a/services/core/internal/api/environment_installation_test.go +++ b/services/core/internal/api/environment_installation_test.go @@ -58,7 +58,10 @@ func TestSelfHostedCreationReturnsInstallationWithoutWebCredential(t *testing.T) if f.authorizedEnvironment != response.Environment.ID || w.Header().Get("Cache-Control") != "no-store" { t.Fatal("wrong authorization scope or caching") } - for _, shell := range []string{"posix", "powershell"} { + if len(response.XAgentsCore.Installation.Commands) != 1 { + t.Fatal("unexpected installation platforms") + } + for _, shell := range []string{"posix"} { command := response.XAgentsCore.Installation.Commands[shell] if !strings.Contains(command, "short-lived-install-grant") || strings.Contains(command, "project-key") || strings.Contains(command, "fixture-model") { t.Fatal("incorrect command authority") diff --git a/services/core/internal/nativeinstaller/assets/bootstrap.ps1 b/services/core/internal/nativeinstaller/assets/bootstrap.ps1 deleted file mode 100644 index 3a8cae90c..000000000 --- a/services/core/internal/nativeinstaller/assets/bootstrap.ps1 +++ /dev/null @@ -1,176 +0,0 @@ -param( - [Parameter(Mandatory=$true)][string]$Base, - [Parameter(Mandatory=$true)][string]$Authorization, - [Parameter(ValueFromRemainingArguments=$true)][string[]]$InstallArguments -) -$ErrorActionPreference = 'Stop' -$tar = Join-Path $env:SystemRoot 'System32\tar.exe' -if (!(Test-Path -LiteralPath $tar -PathType Leaf)) { throw 'Windows tar.exe is required. Install the Windows archive tools, then rerun this command.' } -Add-Type -AssemblyName System.Net.Http -if (!("OacNativeDownloadSpace" -as [type])) { - Add-Type -TypeDefinition @' -using System; -using System.ComponentModel; -using System.Runtime.InteropServices; -public static class OacNativeDownloadSpace { - [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] - static extern bool GetDiskFreeSpaceEx(string path, out ulong available, out ulong total, out ulong free); - public static ulong Available(string path) { - ulong available, total, free; - if (!GetDiskFreeSpaceEx(path, out available, out total, out free)) throw new Win32Exception(Marshal.GetLastWin32Error()); - return available; - } -} -'@ -} -$architecture = @{x64='amd64';arm64='arm64'}[[System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()] -if (!$architecture) { throw 'Unsupported processor architecture.' } -$root = if ($env:OAC_RUNTIME_HOME) { $env:OAC_RUNTIME_HOME } else { Join-Path $HOME '.oac' } -if (![IO.Path]::IsPathRooted($root)) { throw 'OAC_RUNTIME_HOME must be an absolute directory.' } -$cache = Join-Path ([IO.Path]::GetFullPath($root)) 'native-download' -$work = Join-Path $cache 'staging' -$lock = $null -$client = $null -$ownsStaging = $false -$terminal = ![Console]::IsOutputRedirected -$ProgressPreference = if ($terminal) { 'Continue' } else { 'SilentlyContinue' } -function Assert-OrdinaryPath([string]$Path) { - if ((Test-Path -LiteralPath $Path) -and ((Get-Item -Force -LiteralPath $Path).Attributes -band [IO.FileAttributes]::ReparsePoint)) { - throw 'Native download paths must not be symbolic links or junctions.' - } -} -function Assert-Space([long]$Required) { - $available = [OacNativeDownloadSpace]::Available($cache) - if ($available -lt ($Required + 64MB)) { throw 'Not enough disk space for the native installer. Free space in the Runtime home and retry.' } -} -function Show-Bytes([string]$Label, [long]$Bytes, [long]$Total) { - if (!$terminal) { return } - $percent = if ($Total -gt 0) { [Math]::Min(100, [int](100.0 * $Bytes / $Total)) } else { -1 } - Write-Progress -Activity $Label -Status ("{0:N1} MiB" -f ($Bytes / 1MB)) -PercentComplete $percent -} -function Get-NativeFile([string]$Uri, [string]$Destination, [long]$Limit) { - for ($attempt=1; $attempt -le 3; $attempt++) { - $response = $null; $inputStream = $null; $outputStream = $null; $retryable = $true - try { - $address = [Uri]$Uri - for ($redirect=0; $redirect -le 5; $redirect++) { - $response = $client.GetAsync($address, [Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() - $status = [int]$response.StatusCode - if ($status -notin @(301,302,303,307,308)) { break } - if ($redirect -eq 5 -or !$response.Headers.Location) { $retryable=$false; throw 'Installer download has too many or invalid redirects.' } - $next = [Uri]::new($address, $response.Headers.Location) - if ($next.Scheme -ne 'https' -or $next.UserInfo) { $retryable=$false; throw 'Installer download redirect must use HTTPS without credentials.' } - $response.Dispose(); $response=$null; $address=$next - } - if ($status -ne 200) { - $retryable = $status -in @(408,429,500,502,503,504) - if ($status -eq 404) { throw 'This Core has no qualified installer for this platform.' } - throw "Installer download failed (HTTP $status). Check Core and the download host." - } - $total = $response.Content.Headers.ContentLength - if ($null -eq $total) { $total = 0 } - $retryable=$false - if ($total -gt $Limit) { throw 'Installer download exceeds the available space or metadata size limit.' } - Assert-Space $total - $outputStream = [IO.File]::Open($Destination, [IO.FileMode]::Create, [IO.FileAccess]::Write, [IO.FileShare]::None) - $retryable=$true - $inputStream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() - $buffer = New-Object byte[] 65536 - [long]$received = 0 - $clock = [Diagnostics.Stopwatch]::StartNew() - [long]$lastProgress=0 - while ($true) { - if ($clock.Elapsed.TotalSeconds -gt 1200) { throw 'Installer download timed out; retry with a fresh command if it expired.' } - $cancel = [Threading.CancellationTokenSource]::new() - try { - $cancel.CancelAfter(60000) - $pending = $inputStream.ReadAsync($buffer, 0, $buffer.Length, $cancel.Token) - if (!$pending.Wait(60000)) { throw 'Installer download stalled; retry when the connection is available.' } - $read = $pending.GetAwaiter().GetResult() - } finally { $cancel.Dispose() } - if ($read -eq 0) { break } - $received += $read - $retryable=$false - if ($received -gt $Limit) { throw 'Installer download exceeds the available space or metadata size limit.' } - $outputStream.Write($buffer,0,$read) - $retryable=$true - if ($clock.ElapsedMilliseconds - $lastProgress -ge 200) { Show-Bytes 'Downloading installer' $received $total; $lastProgress=$clock.ElapsedMilliseconds } - } - if ($total -gt 0 -and $received -ne $total) { throw 'Installer download was interrupted.' } - $retryable=$false - $outputStream.Flush($true) - return - } catch { - if (!$retryable) { throw } - if ($attempt -eq 3) { throw 'Installer download failed after three attempts. Check network, proxy and certificates, then retry.' } - Write-Host "Download interrupted; retrying ($attempt/2)..." - Start-Sleep -Seconds $attempt - } finally { - if ($inputStream) { $inputStream.Dispose() } - if ($outputStream) { $outputStream.Dispose() } - if ($response) { $response.Dispose() } - if ($terminal) { Write-Progress -Activity 'Downloading installer' -Completed } - } - } -} -try { - Assert-OrdinaryPath $cache - [IO.Directory]::CreateDirectory($cache) | Out-Null - $lockPath = Join-Path $cache 'download.lock' - Assert-OrdinaryPath $lockPath - try { $lock = [IO.File]::Open($lockPath, [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) } - catch { throw 'Cannot lock native downloads. Another download may be running; check permissions or wait and retry.' } - Assert-OrdinaryPath $work - if (Test-Path -LiteralPath $work) { - # A killed PowerShell host can leave its native child alive. Do not remove its source bundle. - $children = Get-CimInstance Win32_Process -Filter "Name = 'tar.exe' OR Name = 'oac-daemon.exe'" - foreach ($child in $children) { - if (($child.ExecutablePath -and $child.ExecutablePath.StartsWith($work, [StringComparison]::OrdinalIgnoreCase)) -or ($child.CommandLine -and $child.CommandLine.IndexOf($work, [StringComparison]::OrdinalIgnoreCase) -ge 0)) { - throw 'A native installer is still using the download directory; wait for it to finish and retry.' - } - } - Remove-Item -LiteralPath $work -Recurse -Force - } - [IO.Directory]::CreateDirectory($work) | Out-Null - $ownsStaging = $true - $handler = [Net.Http.HttpClientHandler]::new() - $handler.AllowAutoRedirect = $false - $client = [Net.Http.HttpClient]::new($handler) - $client.Timeout = [TimeSpan]::FromSeconds(15) - Assert-Space 0 - Write-Host 'Downloading the installer matched to Core...' - $checksum = Join-Path $work 'checksum' - Get-NativeFile "$Base/windows-$architecture.sha256" $checksum 1024 - $expected = [IO.File]::ReadAllText($checksum).Trim() - if ($expected -cnotmatch '^[0-9a-f]{64}$') { throw 'Core returned an invalid installer checksum.' } - $archive = Join-Path $work 'bundle.tar.gz' - $available = [OacNativeDownloadSpace]::Available($cache) - Get-NativeFile "$Base/windows-$architecture.tar.gz" $archive ($available-64MB) - Write-Host 'Verifying the installer archive...' - if ((Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() -ne $expected) { throw 'Installer checksum mismatch; download again.' } - Write-Host 'Checking extraction space...' - $file = [IO.File]::OpenRead($archive) - $gzip = [IO.Compression.GZipStream]::new($file,[IO.Compression.CompressionMode]::Decompress) - try { - $buffer = New-Object byte[] 65536 - [long]$unpacked=0 - while (($count=$gzip.Read($buffer,0,$buffer.Length)) -gt 0) { $unpacked += $count } - Assert-Space $unpacked - } finally { $gzip.Dispose(); $file.Dispose() } - $bundle = Join-Path $work 'bundle' - [IO.Directory]::CreateDirectory($bundle) | Out-Null - Write-Host 'Extracting the installer...' - & $tar -xzf $archive -C $bundle - if ($LASTEXITCODE -ne 0) { throw 'Installer extraction failed. Check disk space, quota and filesystem permissions.' } - $endpoint = $Base -replace '/install/[^/]+$', '/installation' - Write-Host 'Starting installation...' - & (Join-Path $bundle 'oac-daemon.exe') install --onboard-url $endpoint --authorization $Authorization @InstallArguments - if ($LASTEXITCODE -ne 0) { throw 'Installation or connection failed; follow the installer guidance and retry.' } -} finally { - if ($client) { $client.Dispose() } - if ($lock) { - # Only clean staging created by this invocation, after its native child returned. - try { if ($ownsStaging -and (Test-Path -LiteralPath $work)) { Remove-Item -LiteralPath $work -Recurse -Force } } - finally { $lock.Dispose() } - } -} diff --git a/services/core/internal/nativeinstaller/assets/bootstrap.sh b/services/core/internal/nativeinstaller/assets/bootstrap.sh index c4bf10c9f..fbe157245 100644 --- a/services/core/internal/nativeinstaller/assets/bootstrap.sh +++ b/services/core/internal/nativeinstaller/assets/bootstrap.sh @@ -4,11 +4,11 @@ base=$1 authorization=$2 shift 2 fail() { printf '%s\n' "$*" >&2; exit 1; } -case "$(uname -s)" in Linux) os=linux;; Darwin) os=darwin;; *) fail 'Unsupported operating system.';; esac -case "$(uname -m)" in x86_64) arch=amd64;; arm64|aarch64) arch=arm64;; *) fail 'Unsupported processor architecture.';; esac +case "$(uname -s)" in Linux) os=linux;; *) fail 'Self-hosted installation requires Linux amd64.';; esac +case "$(uname -m)" in x86_64) arch=amd64;; *) fail 'Self-hosted installation requires Linux amd64.';; esac for tool in curl tar gzip df awk wc; do command -v "$tool" >/dev/null || fail "Required command missing: $tool"; done if command -v sha256sum >/dev/null; then hash=(sha256sum); else hash=(shasum -a 256); command -v shasum >/dev/null || fail 'Required command missing: shasum'; fi -if [ "$os" = darwin ]; then command -v lockf >/dev/null || fail 'Required command missing: lockf'; else command -v flock >/dev/null || fail 'Required command missing: flock (util-linux)'; fi +command -v flock >/dev/null || fail 'Required command missing: flock (util-linux)' umask 077 root=${OAC_RUNTIME_HOME:-${HOME:?HOME must be set}/.oac} case "$root" in /*) ;; *) fail 'OAC_RUNTIME_HOME must be an absolute directory.';; esac @@ -23,7 +23,7 @@ lock=$cache/download.lock [ ! -L "$lock" ] && { [ ! -e "$lock" ] || { [ -f "$lock" ] && [ -O "$lock" ]; }; } || fail 'Invalid native download lock.' # Children inherit this descriptor, so killing only the shell cannot expose an active download. exec 9>>"$lock" -if [ "$os" = darwin ]; then lockf -s -t 0 9; else flock -n 9; fi || fail 'Another native download is running; wait for it to finish and retry.' +flock -n 9 || fail 'Another native download is running; wait for it to finish and retry.' work=$cache/staging [ ! -L "$work" ] && { [ ! -e "$work" ] || { [ -d "$work" ] && [ -O "$work" ]; }; } || fail 'Invalid native download staging directory.' rm -rf "$work" diff --git a/services/core/internal/nativeinstaller/bootstrap_interrupt_unix_test.go b/services/core/internal/nativeinstaller/bootstrap_interrupt_unix_test.go index 6455c6234..7b199f70c 100644 --- a/services/core/internal/nativeinstaller/bootstrap_interrupt_unix_test.go +++ b/services/core/internal/nativeinstaller/bootstrap_interrupt_unix_test.go @@ -1,4 +1,4 @@ -//go:build unix +//go:build linux && amd64 package nativeinstaller diff --git a/services/core/internal/nativeinstaller/bootstrap_recovery_test.go b/services/core/internal/nativeinstaller/bootstrap_recovery_test.go index 441e0d3c6..c9daa6a7c 100644 --- a/services/core/internal/nativeinstaller/bootstrap_recovery_test.go +++ b/services/core/internal/nativeinstaller/bootstrap_recovery_test.go @@ -40,24 +40,21 @@ func bootstrapFixtureArchive(t *testing.T) []byte { func bootstrapCommand(t *testing.T, base, home string) *exec.Cmd { t.Helper() - var command *exec.Cmd - if runtime.GOOS == "windows" { - command = exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-File", "assets/bootstrap.ps1", "-Base", base, "-Authorization", "fixture-grant") - } else { - command = exec.Command("bash", "assets/bootstrap.sh", base, "fixture-grant") - } + command := exec.Command("bash", "assets/bootstrap.sh", base, "fixture-grant") command.Env = append(os.Environ(), "OAC_RUNTIME_HOME="+home, "NO_PROXY=127.0.0.1", "no_proxy=127.0.0.1") return command } func TestBootstrapRecovery(t *testing.T) { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + t.Skip("Linux amd64 bootstrap") + } archive := bootstrapFixtureArchive(t) for _, scenario := range []string{"metadata-503", "archive-truncated", "missing", "corrupt"} { t.Run(scenario, func(t *testing.T) { var metadata, downloads atomic.Int32 sum := fmt.Sprintf("%x", sha256.Sum256(archive)) - // Windows exercises the real downloader, then rejects the fixture before execution. - if scenario == "corrupt" || runtime.GOOS == "windows" { + if scenario == "corrupt" { sum = strings.Repeat("0", 64) } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -89,7 +86,7 @@ func TestBootstrapRecovery(t *testing.T) { if err == nil || !bytes.Contains(output, []byte("no qualified installer")) || downloads.Load() != 0 || metadata.Load() != 1 { t.Fatalf("missing: %v %s", err, output) } - } else if scenario == "corrupt" || runtime.GOOS == "windows" { + } else if scenario == "corrupt" { if err == nil || !bytes.Contains(output, []byte("checksum mismatch")) { t.Fatalf("corrupt: %v %s", err, output) } @@ -113,8 +110,8 @@ func TestBootstrapRecovery(t *testing.T) { } func TestBootstrapDiscardsStaleStagingOnly(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("POSIX executable fixture; Windows recovery is covered separately") + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + t.Skip("Linux amd64 bootstrap") } home := t.TempDir() staging := filepath.Join(home, "native-download", "staging") @@ -142,7 +139,7 @@ func TestBootstrapDiscardsStaleStagingOnly(t *testing.T) { } func TestBootstrapRejectsLowSpaceBeforeNetwork(t *testing.T) { - if runtime.GOOS == "windows" { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { t.Skip("POSIX disk-space fixture") } home := t.TempDir() @@ -165,7 +162,7 @@ func TestBootstrapRejectsLowSpaceBeforeNetwork(t *testing.T) { } func TestBootstrapExplainsUnexecutableInstaller(t *testing.T) { - if runtime.GOOS == "windows" { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { t.Skip("POSIX executable permissions") } var buffer bytes.Buffer @@ -202,25 +199,3 @@ func TestBootstrapExplainsUnexecutableInstaller(t *testing.T) { t.Fatal("staging remains") } } - -func TestWindowsBootstrapChecksArchiveToolBeforeDownload(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip("Windows archive prerequisite") - } - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); w.WriteHeader(500) })) - defer server.Close() - home := filepath.Join(t.TempDir(), "runtime") - command := bootstrapCommand(t, server.URL, home) - quote := func(value string) string { return "'" + strings.ReplaceAll(value, "'", "''") + "'" } - // Keep the real SystemRoot while PowerShell itself loads. - command.Args = []string{command.Path, "-NoProfile", "-NonInteractive", "-Command", - "$env:SystemRoot = " + quote(t.TempDir()) + "; & './assets/bootstrap.ps1' -Base " + quote(server.URL) + " -Authorization 'fixture-grant'"} - output, err := command.CombinedOutput() - if err == nil || !bytes.Contains(output, []byte("tar.exe is required")) || requests.Load() != 0 { - t.Fatalf("%v: %s", err, output) - } - if _, err := os.Stat(home); !os.IsNotExist(err) { - t.Fatal("home created before prerequisite check") - } -} diff --git a/services/core/internal/nativeinstaller/bootstrap_test.go b/services/core/internal/nativeinstaller/bootstrap_test.go index 35348770b..41e11bd20 100644 --- a/services/core/internal/nativeinstaller/bootstrap_test.go +++ b/services/core/internal/nativeinstaller/bootstrap_test.go @@ -21,7 +21,7 @@ import ( // Exercise the shipped script against two actual TLS origins. The downloaded // executable is a fixture so this test cannot install software or call a model. func TestBootstrapDownloadsVerifiedPlatformAcrossHTTPSRedirect(t *testing.T) { - if runtime.GOOS == "windows" { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { t.Skip("POSIX bootstrap") } for _, tool := range []string{"bash", "curl", "tar"} { @@ -82,7 +82,7 @@ func TestBootstrapDownloadsVerifiedPlatformAcrossHTTPSRedirect(t *testing.T) { })) defer core.Close() result := filepath.Join(t.TempDir(), "result") - command := exec.Command("bash", "assets/bootstrap.sh", core.URL+"/api/v1/agent-daemon/install/build", "private-grant", "--harness", "codex") + command := exec.Command("bash", "assets/bootstrap.sh", core.URL+"/api/v1/agent-daemon/install/build", "private-grant", "--non-interactive") command.Env = append(os.Environ(), "OAC_RUNTIME_HOME="+t.TempDir(), "CURL_CA_BUNDLE="+ca, "OAC_BOOTSTRAP_TEST_RESULT="+result, "NO_PROXY=127.0.0.1", "no_proxy=127.0.0.1") output, err := command.CombinedOutput() if valid { @@ -93,7 +93,7 @@ func TestBootstrapDownloadsVerifiedPlatformAcrossHTTPSRedirect(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(string(args), core.URL+"/api/v1/agent-daemon/installation\n--authorization\nprivate-grant\n--harness\ncodex") { + if !strings.Contains(string(args), core.URL+"/api/v1/agent-daemon/installation\n--authorization\nprivate-grant\n--non-interactive") { t.Fatalf("incorrect install handoff: %s", args) } } else { diff --git a/services/core/internal/nativeinstaller/catalog.go b/services/core/internal/nativeinstaller/catalog.go index 8c0049d88..1d857be62 100644 --- a/services/core/internal/nativeinstaller/catalog.go +++ b/services/core/internal/nativeinstaller/catalog.go @@ -38,8 +38,6 @@ type Catalog struct { var checksum = regexp.MustCompile(`^[0-9a-f]{64}$`) -var platformName = regexp.MustCompile(`^(linux|darwin|windows)-(amd64|arm64)$`) - // Load checks the matched catalog without downloading execution payloads. Local // offline archives are verified once; the directory stays immutable while serving. // A directory without catalog.json holds no installer and returns nil. @@ -57,7 +55,7 @@ func Load(directory, version string) (*Catalog, error) { } c.local = make(map[string]bool) for platform, artifact := range c.Artifacts { - if !platformName.MatchString(platform) || !checksum.MatchString(artifact.SHA256) { + if platform != "linux-amd64" || !checksum.MatchString(artifact.SHA256) { return nil, errors.New("invalid native installer platform") } if artifact.URL != "" { @@ -96,7 +94,7 @@ func (c *Catalog) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - if name == "bootstrap.sh" || name == "bootstrap.ps1" { + if name == "bootstrap.sh" { raw, _ := bootstrap.ReadFile("assets/" + name) w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write(raw) @@ -125,7 +123,6 @@ func (c *Catalog) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } -func psQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } // Commands installs this catalog's version from the installer base URL. func (c *Catalog) Commands(installerBase, authorization string) map[string]string { @@ -134,7 +131,6 @@ func (c *Catalog) Commands(installerBase, authorization string) map[string]strin // Only execute a complete successful response; preserve interactive stdin. posix := `set -e; script=; for attempt in 1 2 3; do if script=$(curl -fsS --connect-timeout 15 --max-time 60 --max-filesize 1048576 ` + shellQuote(base+"/bootstrap.sh") + `); then break; fi; [ "$attempt" -lt 3 ] || exit 1; sleep "$attempt"; done; bash -c "$script" -- "$@"` return map[string]string{ - "posix": "bash -c " + shellQuote(posix) + " -- " + shellQuote(base) + " " + shellQuote(authorization), - "powershell": "& { $source=$null; for ($attempt=1; $attempt -le 3; $attempt++) { try { $source=(Invoke-WebRequest -UseBasicParsing " + psQuote(base+"/bootstrap.ps1") + " -TimeoutSec 60 -ErrorAction Stop).Content; break } catch { if ($attempt -eq 3) { throw }; Start-Sleep -Seconds $attempt } }; & ([scriptblock]::Create($source)) -Base " + psQuote(base) + " -Authorization " + psQuote(authorization) + " @args }", + "posix": "bash -c " + shellQuote(posix) + " -- " + shellQuote(base) + " " + shellQuote(authorization), } } diff --git a/services/core/internal/nativeinstaller/catalog_test.go b/services/core/internal/nativeinstaller/catalog_test.go index ce6fd24f3..3478f6ea4 100644 --- a/services/core/internal/nativeinstaller/catalog_test.go +++ b/services/core/internal/nativeinstaller/catalog_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -35,7 +36,7 @@ func TestCatalogRequiresMatchedImmutableArtifacts(t *testing.T) { path string code int }{ - {"build/linux-amd64.tar.gz", 200}, {"build/linux-amd64.sha256", 200}, {"other/linux-amd64.tar.gz", 404}, {"build/windows-arm64.tar.gz", 404}, {"build/catalog.json", 404}, {"build/../../catalog.json", 404}, + {"build/linux-amd64.tar.gz", 200}, {"build/linux-amd64.sha256", 200}, {"other/linux-amd64.tar.gz", 404}, {"build/windows-arm64.tar.gz", 404}, {"build/bootstrap.ps1", 404}, {"build/bootstrap.sh", 200}, {"build/catalog.json", 404}, {"build/../../catalog.json", 404}, } { w := httptest.NewRecorder() catalog.ServeHTTP(w, httptest.NewRequest("GET", "/api/v1/agent-daemon/install/"+request.path, nil)) @@ -114,3 +115,19 @@ func TestMissingCatalogServesNoInstallers(t *testing.T) { t.Fatal(catalog, err) } } + +func TestCatalogRejectsUnsupportedPlatforms(t *testing.T) { + for _, platform := range []string{"linux-arm64", "darwin-arm64", "windows-amd64"} { + t.Run(platform, func(t *testing.T) { + dir := t.TempDir() + manifest := Catalog{Version: "build", ProtocolVersion: proto.Version, Artifacts: map[string]Artifact{platform: {SHA256: strings.Repeat("0", 64), URL: "https://downloads.example/v1/oac-native-build-" + platform + ".tar.gz"}}} + raw, _ := json.Marshal(manifest) + if err := os.WriteFile(filepath.Join(dir, "catalog.json"), raw, 0600); err != nil { + t.Fatal(err) + } + if _, err := Load(dir, "build"); err == nil { + t.Fatal("unsupported platform accepted") + } + }) + } +} diff --git a/services/core/internal/sandbox/docker/container_options.go b/services/core/internal/sandbox/docker/container_options.go index 7a7a23327..50dc80637 100644 --- a/services/core/internal/sandbox/docker/container_options.go +++ b/services/core/internal/sandbox/docker/container_options.go @@ -18,7 +18,7 @@ func runtimeContainerOptions(config Config, name string, labels map[string]strin return client.ContainerCreateOptions{Name: name, Image: config.Image, Config: &container.Config{User: "1000:1000", WorkingDir: "/environment/workspace", Labels: labels, Entrypoint: []string{"/usr/local/bin/oac-sandbox-io", "--bootstrap-file", "/home/runtime/sandbox-io-bootstrap.json"}}, - HostConfig: &container.HostConfig{ReadonlyRootfs: true, CapDrop: []string{"ALL"}, SecurityOpt: []string{"no-new-privileges", "seccomp=" + config.Seccomp, "apparmor=unconfined"}, NetworkMode: container.NetworkMode(config.Network), + HostConfig: &container.HostConfig{ReadonlyRootfs: true, CapDrop: []string{"ALL"}, SecurityOpt: []string{"no-new-privileges", "seccomp=" + config.Seccomp}, NetworkMode: container.NetworkMode(config.Network), Resources: container.Resources{PidsLimit: &limit, Memory: memory, NanoCPUs: cpus}, Tmpfs: map[string]string{"/tmp": "rw,nosuid,nodev,size=128m"}, Mounts: []mount.Mount{ {Type: mount.TypeVolume, Source: name + "-home", Target: "/home"}, diff --git a/services/core/internal/sandbox/docker/provider_test.go b/services/core/internal/sandbox/docker/provider_test.go index 5d87d1acc..73a83dda0 100644 --- a/services/core/internal/sandbox/docker/provider_test.go +++ b/services/core/internal/sandbox/docker/provider_test.go @@ -45,7 +45,7 @@ func TestDockerProviderLifecycle(t *testing.T) { if image == "" { t.Skip("explicit Docker fixture image required") } - seccomp, e := os.ReadFile("../../../deploy/codex/seccomp.json") + seccomp, e := os.ReadFile("../../../../../deploy/distribution/seccomp.json") if e != nil { t.Fatal(e) } diff --git a/services/core/internal/sandbox/docker/recovery_test.go b/services/core/internal/sandbox/docker/recovery_test.go index 68fb54447..490817a54 100644 --- a/services/core/internal/sandbox/docker/recovery_test.go +++ b/services/core/internal/sandbox/docker/recovery_test.go @@ -28,7 +28,7 @@ func TestDockerProviderRecoveryObservations(t *testing.T) { if image == "" { t.Skip("explicit Docker fixture image required") } - seccomp, err := os.ReadFile("../../../deploy/codex/seccomp.json") + seccomp, err := os.ReadFile("../../../../../deploy/distribution/seccomp.json") if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/docker/serve_test.go b/services/core/internal/sandbox/docker/serve_test.go index a6ded9285..b4226c4c8 100644 --- a/services/core/internal/sandbox/docker/serve_test.go +++ b/services/core/internal/sandbox/docker/serve_test.go @@ -43,7 +43,7 @@ func TestDockerSandboxServesItsAllocation(t *testing.T) { if base == "" { t.Skip("explicit Docker fixture image required") } - seccomp, err := os.ReadFile("../../../deploy/codex/seccomp.json") + seccomp, err := os.ReadFile("../../../../../deploy/distribution/seccomp.json") if err != nil { t.Fatal(err) } diff --git a/services/core/internal/sandbox/node/docker_live_test.go b/services/core/internal/sandbox/node/docker_live_test.go index 27cd8e587..1fcf7006a 100644 --- a/services/core/internal/sandbox/node/docker_live_test.go +++ b/services/core/internal/sandbox/node/docker_live_test.go @@ -24,7 +24,7 @@ func TestDockerNodeTransportLifecycle(t *testing.T) { if image == "" { t.Skip("explicit Docker fixture image required") } - seccomp, err := os.ReadFile("../../../deploy/codex/seccomp.json") + seccomp, err := os.ReadFile("../../../../../deploy/distribution/seccomp.json") if err != nil { t.Fatal(err) }