From be661531d8bd551df0877ecbe328d27a9adf0074 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 15:46:46 +0800 Subject: [PATCH 1/5] feat(runtime): bootstrap self-hosted Sessions with one installation command --- .github/workflows/native.yml | 13 +- .github/workflows/release.yml | 14 ++ CONTRIBUTING.md | 19 +- apps/parsar-daemon/internal/cli/connect.go | 16 ++ .../internal/cli/native_install.go | 34 ++- .../internal/cli/native_install_input.go | 36 ++- .../internal/cli/native_onboarding.go | 222 ++++++++++++++++++ apps/web/e2e/fixture-console.mjs | 2 + apps/web/e2e/monitoring.spec.ts | 6 +- .../sessions/ExecutorCredentialsSection.tsx | 6 +- .../sessions/ExecutorInstallPanel.tsx | 19 +- .../sessions/executor-install.test.ts | 25 -- .../src/features/sessions/executor-install.ts | 19 -- apps/web/src/i18n/locales/en/sessions.ts | 12 +- apps/web/src/i18n/locales/zh-CN/sessions.ts | 8 +- contracts/agents-api/core.openapi.yaml | 61 +++++ .../environment-executor-credentials.md | 44 ++-- contracts/agents-api/openapi.yaml | 25 ++ contracts/agents-api/runtime.openapi.yaml | 108 +++++++++ contracts/agents-api/v1/installation.go | 26 ++ contracts/agents-api/v1/sessions.go | 1 + deploy/distribution/Dockerfile | 1 + deploy/install/configuration.py | 2 + docs/api/README.md | 11 + docs/self-hosted-native.md | 143 +++++------ packages/agents-client/src/admin-client.ts | 7 + packages/agents-client/src/client.test.ts | 9 +- packages/agents-client/src/client.ts | 10 +- .../src/installation-projection.ts | 10 + packages/agents-client/src/types.ts | 9 + scripts/build-core-distribution.sh | 5 + scripts/build-native-catalog.mjs | 25 ++ scripts/native-onboarding-smoke.mjs | 100 ++++++++ services/agents-api/cmd/server/http_routes.go | 3 + services/agents-api/cmd/server/main.go | 16 +- .../api/environment_executor_management.go | 1 + .../internal/api/environment_installation.go | 183 +++++++++++++++ .../api/environment_installation_test.go | 78 ++++++ services/agents-api/internal/api/errors.go | 2 + services/agents-api/internal/api/handler.go | 8 + .../internal/api/session_creation_stream.go | 4 + .../nativeinstaller/assets/bootstrap.ps1 | 26 ++ .../nativeinstaller/assets/bootstrap.sh | 23 ++ .../internal/nativeinstaller/catalog.go | 113 +++++++++ .../internal/nativeinstaller/catalog_test.go | 52 ++++ .../store/environment_installation.go | 119 ++++++++++ .../store/environment_installation_test.go | 103 ++++++++ 47 files changed, 1597 insertions(+), 182 deletions(-) create mode 100644 apps/parsar-daemon/internal/cli/native_onboarding.go delete mode 100644 apps/web/src/features/sessions/executor-install.test.ts create mode 100644 contracts/agents-api/v1/installation.go create mode 100644 packages/agents-client/src/installation-projection.ts create mode 100644 scripts/build-native-catalog.mjs create mode 100644 scripts/native-onboarding-smoke.mjs create mode 100644 services/agents-api/internal/api/environment_installation.go create mode 100644 services/agents-api/internal/api/environment_installation_test.go create mode 100644 services/agents-api/internal/nativeinstaller/assets/bootstrap.ps1 create mode 100644 services/agents-api/internal/nativeinstaller/assets/bootstrap.sh create mode 100644 services/agents-api/internal/nativeinstaller/catalog.go create mode 100644 services/agents-api/internal/nativeinstaller/catalog_test.go create mode 100644 services/agents-api/internal/store/environment_installation.go create mode 100644 services/agents-api/internal/store/environment_installation_test.go diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 027caed1c..2010d4a9f 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -11,6 +11,8 @@ on: - 'packages/tsconfig/**' - 'scripts/build-native-installer*' - 'scripts/native-harness-smoke.mjs' + - 'scripts/native-onboarding-smoke.mjs' + - 'services/agents-api/internal/nativeinstaller/**' - 'scripts/build-mcode-harness.sh' - 'go.mod' - 'go.sum' @@ -23,6 +25,11 @@ on: - '.npmrc' - '.github/workflows/native.yml' workflow_dispatch: + workflow_call: + inputs: + ref: + type: string + required: true permissions: contents: read @@ -44,6 +51,8 @@ jobs: shell: bash steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.sha }} - uses: actions/setup-go@v7 with: go-version-file: go.mod @@ -52,7 +61,7 @@ jobs: node-version: '22.22.0' - name: Build the native daemon and test bundle boundaries run: | - go build -ldflags "-X github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/cli.Version=$GITHUB_SHA" -o "$RUNNER_TEMP/oac-daemon${{ runner.os == 'Windows' && '.exe' || '' }}" ./apps/parsar-daemon/cmd/parsar-daemon + go build -ldflags "-X github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/cli.Version=$(git rev-parse HEAD)" -o "$RUNNER_TEMP/oac-daemon${{ runner.os == 'Windows' && '.exe' || '' }}" ./apps/parsar-daemon/cmd/parsar-daemon node --test scripts/build-native-installer.test.mjs - name: Native filesystem, authentication and process lifecycle run: >- @@ -116,6 +125,8 @@ jobs: 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 + run: node scripts/native-onboarding-smoke.mjs - name: Verify native Harness protocols without model requests run: | if [[ "$RUNNER_OS" == Windows ]]; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0fcdde539..bb61d4c94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,13 @@ jobs: with: ref: ${{ inputs.ref || github.sha }} + native: + uses: ./.github/workflows/native.yml + with: + ref: ${{ inputs.ref || github.sha }} + build: + needs: native runs-on: ubuntu-22.04 timeout-minutes: 120 outputs: @@ -92,8 +98,16 @@ jobs: run: | PYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py bash scripts/prepare-release-runtimes.sh + - uses: actions/download-artifact@v6 + with: + pattern: oac-native-installer-* + merge-multiple: true + path: native-artifacts + - name: Assemble the native installation catalog + run: node scripts/build-native-catalog.mjs native-artifacts "$RUNNER_TEMP/native-installers" - name: Build matched artifacts env: + OAC_NATIVE_INSTALLER_BUILD_DIR: ${{ runner.temp }}/native-installers RELEASE_REVISION: ${{ steps.source.outputs.revision }} RELEASE_TAG: ${{ steps.source.outputs.release_tag }} RELEASE_REPOSITORY: ${{ github.repository }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bce9d793a..193c6a910 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -195,8 +195,8 @@ existing ownership boundary rather than adding unrelated responsibilities. The [API documentation index](docs/api/README.md) lists the three namespaces: `/v1` for applications (Project API key), `/core/v1` for Core Web's server and -operator scripts (Core key) and `/api/v1` for machine connections (credentials -issued through `/core/v1`). New or changed routes must identify their caller and +operator scripts (Core key) and `/api/v1` for machine connections (executor credentials issued through `/core/v1` +or claimed using a short-lived Session installation authorization). New or changed routes must identify their caller and credential there, and link their detailed contract. Keep current integration guidance separate from historical qualification evidence. @@ -3773,6 +3773,21 @@ The release bundles pinned Node/npm, native Harnesses and required adapter asset registration lives in CLI and native activation/readiness in each adapter's optional `agent.Installation` descriptor. Core never selects native paths or OS-specific installation steps. See [native installation](docs/self-hosted-native.md). +Self-hosted onboarding extends authenticated Session creation/detail responses with +`x_agents_core.installation`; Web displays the same Core-produced commands. Lists +and durable event journals never retain installation authorizations. The command +uses a 30-minute, Environment- and build-scoped grant to claim one connect-only +credential. The installer persists its generated secret before claiming it; retries +must prove that same secret. Reserve the Environment UUID as the onboarding key ID. +Existing, rotated or revoked credentials are never replaced by onboarding. Machine +bootstrap routes use this grant, not an Environment ID as authentication. Public +artifact routes contain no credentials. Native bundles must match the Core source +revision and Runtime wire version. Core release qualification consumes the same +three-platform native CI artifacts and includes them in its distribution. +Bootstrap scripts own platform download/extraction only; installation, startup, +connection verification and Runtime execution remain common. Serialize background +PID inspection and publication so concurrent starts cannot create duplicate daemons. + An installed daemon discovers and registers only the adapter kinds named by its verified installation manifest. The host PATH stays available to tools; its other Harness executables and activation variables cannot extend that installation. diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index c6f216504..dfdf69815 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "os" + "path/filepath" "strings" "time" @@ -19,6 +20,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/transport" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" obslog "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) const ( @@ -218,6 +220,20 @@ func spawnBackground(ctx context.Context, rc *runContext, profile string, argv [ if err != nil { return fmt.Errorf("connect: %w", err) } + // Serialize the live-process check and publication across concurrent starts. + if err := runtimefs.EnsurePrivateDir(filepath.Dir(pidPath)); err != nil { + return err + } + root, err := os.OpenRoot(filepath.Dir(pidPath)) + if err != nil { + return err + } + defer root.Close() + unlock, err := runtimefs.LockDirectory(root) + if err != nil { + return errors.New("connect: startup is busy; wait and retry") + } + defer unlock() // Refuse to start a second background daemon for the same profile. if pid, err := daemonize.ReadPIDFile(pidPath); err == nil { return fmt.Errorf("connect: background daemon already running (pid=%d); run `oac-daemon stop` first", pid) diff --git a/apps/parsar-daemon/internal/cli/native_install.go b/apps/parsar-daemon/internal/cli/native_install.go index 17ee20d0c..9cc860051 100644 --- a/apps/parsar-daemon/internal/cli/native_install.go +++ b/apps/parsar-daemon/internal/cli/native_install.go @@ -97,6 +97,16 @@ func runInstall(rc *runContext, args []string) error { } ctx, stop := daemonize.NotifyContext(context.Background()) defer stop() + if err := installNativeOptions(ctx, rc, &o); err != nil { + return err + } + if o.OnboardURL != "" { + return finishOnboarding(ctx, rc, o) + } + return nil +} + +func installNativeOptions(ctx context.Context, rc *runContext, o *nativeInstallOptions) error { 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") } @@ -104,13 +114,13 @@ func runInstall(rc *runContext, args []string) error { 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") } - if err = validateNativeInstallation(o.nativeInstallation); err != nil { - return err - } bundle, err := readNativeBundle(o.Bundle, selected) if err != nil { return err @@ -120,6 +130,20 @@ func runInstall(rc *runContext, args []string) error { return err } defer unlock() + 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 + } var previous nativeInstallation raw, err := runtimefs.ReadPrivate(held, "installation.json", 1<<20) if err == nil { @@ -172,7 +196,9 @@ func runInstall(rc *runContext, args []string) error { return err } fmt.Fprintln(rc.stdout, "Installation: ready; verified Harnesses:", all) - fmt.Fprintln(rc.stdout, "Daemon connection: not checked by install; run the installed oac-daemon start, then check Host connection in Core.") + if o.OnboardURL == "" { + fmt.Fprintln(rc.stdout, "Daemon connection: not checked by install; run the installed oac-daemon start, then check Host connection in Core.") + } fmt.Fprintln(rc.stdout, "Model configuration: not checked; configure the Session model provider in Core and send a Turn.") return nil } diff --git a/apps/parsar-daemon/internal/cli/native_install_input.go b/apps/parsar-daemon/internal/cli/native_install_input.go index 1ac4c9431..bf95895bd 100644 --- a/apps/parsar-daemon/internal/cli/native_install_input.go +++ b/apps/parsar-daemon/internal/cli/native_install_input.go @@ -15,13 +15,16 @@ import ( type nativeInstallOptions struct { nativeInstallation - Directory, Bundle, Harness string - Interactive, NonInteractive bool + Directory, Bundle, Harness string + OnboardURL, Authorization, RequiredHarness string + Interactive, NonInteractive bool } func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, error) { var o nativeInstallOptions flags := newFlagSet("install") + flags.StringVar(&o.OnboardURL, "onboard-url", "", "Core installation endpoint") + 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") @@ -45,6 +48,9 @@ func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, er if flags.NArg() != 0 || (o.Interactive && o.NonInteractive) { return o, errors.New("install: unexpected arguments or conflicting interaction modes") } + if err := prepareOnboarding(&o); err != nil { + return o, err + } if o.Directory == "" { var err error o.Directory, err = paths.Root() @@ -78,6 +84,9 @@ func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, er func promptNativeInstall(input io.Reader, output io.Writer, o *nativeInstallOptions) error { r := bufio.NewReader(input) + if o.OnboardURL != "" { + return promptOnboarding(r, output, o) + } // Only paths and connection identifiers are requested; tokens are read later // from the private credential file, never entered or echoed by this prompt. for _, p := range []struct { @@ -112,3 +121,26 @@ func promptNativeInstall(input io.Reader, output io.Writer, o *nativeInstallOpti } return nil } + +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}, + } { + 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") + } + if value := strings.TrimSpace(line); value != "" { + *item.value = value + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/cli/native_onboarding.go b/apps/parsar-daemon/internal/cli/native_onboarding.go new file mode 100644 index 000000000..b76f6a204 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_onboarding.go @@ -0,0 +1,222 @@ +package cli + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" +) + +func installationRequest(ctx context.Context, endpoint, authorization string, input any, output any) error { + body, err := json.Marshal(input) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return errors.New("install: invalid Core installation endpoint") + } + req.Header.Set("Authorization", "Bearer "+authorization) + req.Header.Set("Content-Type", "application/json") + response, err := environmentClient().Do(req) + if err != nil { + return errors.New("install: could not reach Core; check its address and retry") + } + defer response.Body.Close() + switch response.StatusCode { + case http.StatusOK, http.StatusNoContent: + case http.StatusUnauthorized: + return errors.New("install: authorization rejected or expired; copy a fresh command from the Session") + case http.StatusConflict: + return errors.New("install: this Environment already has a different or revoked credential; reuse the original installation or resolve its credential in Core") + default: + return fmt.Errorf("install: Core rejected installation (HTTP %d); check the Session and obtain a fresh command", response.StatusCode) + } + if output == nil { + return nil + } + raw, err := io.ReadAll(io.LimitReader(response.Body, 16385)) + if err != nil || len(raw) > 16384 || decodeEnvironmentJSON(raw, output) != nil { + return errors.New("install: invalid Core installation response") + } + return nil +} + +func prepareOnboarding(o *nativeInstallOptions) error { + if o.OnboardURL == "" && o.Authorization == "" { + return nil + } + if o.OnboardURL == "" || o.Authorization == "" { + return errors.New("install: --onboard-url and --authorization are required together") + } + const suffix = "/api/v1/agent-daemon/installation" + if !strings.HasSuffix(o.OnboardURL, suffix) { + return errors.New("install: invalid Core installation endpoint") + } + remote := strings.TrimSuffix(o.OnboardURL, suffix) + "/api/v1/agent-daemon/ws" + remote = strings.Replace(strings.Replace(remote, "https://", "wss://", 1), "http://", "ws://", 1) + if _, err := environmentBase(remote); err != nil { + return err + } + var info v1.NativeInstallationContext + if err := installationRequest(context.Background(), o.OnboardURL, o.Authorization, nil, &info); err != nil { + return err + } + if info.Version != Version || !proto.VersionCompatible(info.ProtocolVersion) { + return errors.New("install: installer does not match Core; obtain a fresh command from the target Core") + } + if info.RemoteURL != remote || !environmentUUID(info.EnvironmentID) { + return errors.New("install: Core returned an invalid connection binding") + } + if (o.Remote != "" && o.Remote != info.RemoteURL) || (o.Environment != "" && o.Environment != info.EnvironmentID) || (o.Workspace != "" && o.Workspace != info.Workspace) || o.Credential != "" { + 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 { + return err + } + o.Directory = filepath.Join(root, "environments", o.Environment) + } + if !o.NonInteractive { + o.Interactive = true + } + return nil +} + +// prepareOnboardingCredential runs under the existing installation lock. The +// credential is committed locally before claiming it so any response can be lost +// without losing the only usable secret. No authorization is retained on disk. +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 { + return errors.New("install: this directory belongs to an incompatible installation; choose a separate directory") + } + o.Credential = previous.Credential + // A completed installation already owns its key. Rotation and revocation + // are checked by normal enrollment, never undone by the installer. + _, _, err := executorCredential(o.Credential, o.Environment) + return err + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + o.Credential = filepath.Join(o.Directory, "daemon", "executor-credential.json") + var secret string + if _, err := held.Stat("executor-credential.json"); err == nil { + keyID, value, err := executorCredential(o.Credential, o.Environment) + if err != nil || keyID != o.Environment { + return errors.New("install: existing credential belongs to a different Environment") + } + secret = value + } else if errors.Is(err, os.ErrNotExist) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return err + } + secret = base64.RawURLEncoding.EncodeToString(raw) + encoded, _ := json.Marshal(map[string]string{"key_id": o.Environment, "environment_id": o.Environment, "executor_token": secret}) + if err := runtimefs.WritePrivateAtomic(held, "executor-credential.json", encoded); err != nil { + return err + } + } else { + return err + } + return installationRequest(ctx, o.OnboardURL+"/claim", o.Authorization, map[string]string{"executor_token": secret}, nil) +} + +func finishOnboarding(ctx context.Context, rc *runContext, o nativeInstallOptions) error { + executable := filepath.Join(o.Directory, "bin", nativeExe("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) { + return err + } + // 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.Stdout, command.Stderr = rc.stdout, rc.stderr + if err := command.Run(); err != nil { + fmt.Fprintln(rc.stderr, "Daemon connection: start failed. Rerun this command to resume, or run the installed oac-daemon start.") + return errors.New("install: daemon startup failed") + } + } + _, secret, err := executorCredential(o.Credential, o.Environment) + if err != nil { + return err + } + base, _ := environmentBase(o.Remote) + deadline, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + connected, err := installedEnvironmentConnected(deadline, base, o.Environment, secret) + if err != nil { + return err + } + if connected { + fmt.Fprintln(rc.stdout, "Daemon connection: connected to Core.") + return nil + } + select { + case <-deadline.Done(): + fmt.Fprintf(rc.stderr, "Daemon connection: not confirmed. The installed daemon will keep reconnecting. Check %s and Core connectivity, then rerun this command.\n", filepath.Join(o.Directory, "daemon", paths.DefaultProfile, "connect.log")) + return errors.New("install: connection verification timed out") + case <-ticker.C: + } + } +} + +func installedEnvironmentConnected(ctx context.Context, base, environment, secret string) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/agent-daemon/connection?environment_id="+environment, nil) + if err != nil { + return false, err + } + req.Header.Set("Authorization", "Bearer "+secret) + response, err := environmentClient().Do(req) + if err != nil { + return false, nil + } + defer response.Body.Close() + if response.StatusCode == 401 || response.StatusCode == 409 { + return false, errors.New("Daemon connection: credential rejected; check the Environment credential in Core") + } + if response.StatusCode != http.StatusOK { + return false, nil + } + var result struct { + Environment string `json:"environment_id"` + Status string `json:"status"` + } + if json.NewDecoder(io.LimitReader(response.Body, 4096)).Decode(&result) != nil || result.Environment != environment { + return false, errors.New("Daemon connection: invalid status returned by Core") + } + return result.Status == "connected", nil +} diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index af794ea97..07f4e49ab 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -634,6 +634,8 @@ http.createServer(async (request, response) => { if (url.pathname.startsWith("/core/v1/")) { 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" } }); 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 84ceb0b11..ec4795aba 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -78,13 +78,13 @@ test("shows a self-hosted Session's install command, issues its credential once, await expect(section).toContainText("No executor credentials yet"); const environmentId = await page.getByLabel("Session facts").locator("div").filter({ hasText: /^Environment/ }).locator("code").getAttribute("title"); - // Connect a host: the exact install command, which carries no secret. + // Web displays Core-provided commands with short-lived installation authority. const install = section.getByRole("region", { name: "Connect a host" }); - await expect(install.getByLabel("Executor install command").locator("pre")).toHaveText(`./oac-daemon install --interactive --remote 'wss://core.example.com/api/v1/agent-daemon/ws' --environment-id '${environmentId}' --workspace '/srv/work'`); + 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\/self-hosted-native.md$/); await install.getByRole("combobox", { name: "Host platform" }).click(); await page.getByRole("option", { name: "Windows · PowerShell" }).click(); - await expect(install.locator("pre")).toContainText(".\\oac-daemon.exe install --interactive"); + await expect(install.locator("pre")).toContainText("fixture-bootstrap.ps1 -Authorization fixture-short-lived"); 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/ExecutorCredentialsSection.tsx b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx index 9b9d29e24..9a6449023 100644 --- a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx +++ b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx @@ -73,13 +73,13 @@ function seconds(value: string | null): number | null { * revoked but neither issued nor rotated. Below the list, Connect a host gives * the command that installs the executor with one of these credentials. */ -export function ExecutorCredentialsSection({ projectId, sessionId, environmentId, remoteUrl, workspaceDirectory = "" }: { projectId: string; sessionId: string; environmentId: string; remoteUrl: string; workspaceDirectory?: string }) { +export function ExecutorCredentialsSection({ projectId, sessionId, environmentId }: { projectId: string; sessionId: string; environmentId: string; remoteUrl: string; workspaceDirectory?: string }) { const { t, i18n } = useTranslation("sessions"); const locale = i18n.resolvedLanguage; const toast = useToast(); const { byId, refresh: refreshProjects } = useProjects(); const archived = byId.get(projectId)?.status === "archived"; - const install = useExecutorInstall(environmentId, remoteUrl, workspaceDirectory); + const install = useExecutorInstall(projectId, environmentId, archived); const query = useQuery(executorConnectionQuery(projectId, sessionId, environmentId)); const credentials = query.data?.data ?? null; const connectionStale = failedLast(query) || query.isStale; @@ -299,8 +299,8 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId ) : null} - {body} + {body} admin.environmentInstallation(projectId, environmentId, { signal }), + enabled: !archived, + staleTime: 20 * 60_000, + refetchInterval: 20 * 60_000, + gcTime: 0, + retry: false, + }); + const data = query.data; + return !archived && data?.status === "available" && data.commands && (data.expires_at ?? 0) > Date.now() / 1000 + ? { kind: "ready", commands: data.commands } : { kind: "unavailable" }; } /** One home for native installation instructions; issuing a credential does not establish a connection. */ diff --git a/apps/web/src/features/sessions/executor-install.test.ts b/apps/web/src/features/sessions/executor-install.test.ts deleted file mode 100644 index 7b2a375f6..000000000 --- a/apps/web/src/features/sessions/executor-install.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { executorInstall } from "./executor-install"; -const input = { environmentId: "5b1e2c3d-0000-4000-8000-000000000001", remoteUrl: "wss://core.example/api/v1/agent-daemon/ws", workspaceDirectory: "/srv/work" }; -describe("native host installation", () => { - it("uses Session facts without installer assets, credentials or automatic start", () => { - const result = executorInstall(input); - expect(result.kind).toBe("ready"); - if (result.kind !== "ready") return; - expect(result.commands.posix).toBe(`./oac-daemon install --interactive --remote '${input.remoteUrl}' --environment-id '${input.environmentId}' --workspace '/srv/work'`); - expect(result.commands.powershell).toContain(".\\oac-daemon.exe install --interactive"); - expect(result.commands.posix).not.toMatch(/python|docker|token|start/); - }); - it("quotes shell metacharacters in workspace paths as literal values", () => { - const result = executorInstall({ ...input, workspaceDirectory: "/srv/it's $HOME `work`" }); - if (result.kind !== "ready") throw new Error("expected command"); - expect(result.commands.posix).toContain("--workspace '/srv/it'\\''s $HOME `work`'"); - expect(result.commands.powershell).toContain("--workspace '/srv/it''s $HOME `work`'"); - }); - it.each(["ws://localhost:8091/ws", "ws://127.0.0.1:8091/ws", "ws://[::1]:8091/ws"])("accepts native loopback connection %s", remoteUrl => { - expect(executorInstall({ ...input, remoteUrl }).kind).toBe("ready"); - }); - it.each([{ remoteUrl: "ws://core.example/ws" }, { remoteUrl: "https://core.example" }, { remoteUrl: "wss://user:secret@core.example" }, { environmentId: "" }, { workspaceDirectory: "" }, { workspaceDirectory: "/srv/\nwork" }])("withholds commands with invalid facts %j", change => { - expect(executorInstall({ ...input, ...change })).toEqual({ kind: "unavailable" }); - }); -}); diff --git a/apps/web/src/features/sessions/executor-install.ts b/apps/web/src/features/sessions/executor-install.ts index 130516ec9..941ce1565 100644 --- a/apps/web/src/features/sessions/executor-install.ts +++ b/apps/web/src/features/sessions/executor-install.ts @@ -1,21 +1,2 @@ export type HostShell = "posix" | "powershell"; export type ExecutorInstall = { kind: "unavailable" } | { kind: "ready"; commands: Record }; - -/** Quote Core-owned values as literal arguments; credential contents never enter a command. */ -function quote(value: string, shell: HostShell): string { - return "'" + value.replaceAll("'", shell === "powershell" ? "''" : "'\\''") + "'"; -} - -/** Native installation needs Session connection facts, not console installer assets. */ -export function executorInstall({ environmentId, remoteUrl, workspaceDirectory }: { - environmentId: string; remoteUrl: string; workspaceDirectory: string; -}): ExecutorInstall { - if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(environmentId) || !workspaceDirectory || /[\x00-\x1f\x7f]/.test(workspaceDirectory + remoteUrl)) return { kind: "unavailable" }; - try { - const url = new URL(remoteUrl); - const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); - if (url.username || url.password || url.hash || !(url.protocol === "wss:" || (url.protocol === "ws:" && loopback))) return { kind: "unavailable" }; - } catch { return { kind: "unavailable" }; } - const command = (shell: HostShell) => `${shell === "powershell" ? ".\\oac-daemon.exe" : "./oac-daemon"} install --interactive --remote ${quote(remoteUrl, shell)} --environment-id ${quote(environmentId, shell)} --workspace ${quote(workspaceDirectory, shell)}`; - return { kind: "ready", commands: { posix: command("posix"), powershell: command("powershell") } }; -} diff --git a/apps/web/src/i18n/locales/en/sessions.ts b/apps/web/src/i18n/locales/en/sessions.ts index 924cbd2be..21b0d5886 100644 --- a/apps/web/src/i18n/locales/en/sessions.ts +++ b/apps/web/src/i18n/locales/en/sessions.ts @@ -201,13 +201,13 @@ export const sessions = { hostDone: "Host connected", hostPending: "Awaiting connection", title: "Connect a host", - lifecycle: "Use a native distribution matching the host OS and architecture. Choose harnesses in the installer; MiniMax is unavailable on Windows. The workspace must already exist and match this Session. Tools run with your user permissions; this is not a sandbox. To reconnect after rotation, run the installed daemon’s stop command, replace the credential file, then run start.", - steps: "Save the credential file on the host, then run this command from the extracted distribution. Enter the file’s absolute path when prompted.", - archived: "This project is archived. Installation requires an existing credential file; new credentials cannot be issued or rotated.", - guide: "Get the native distribution · Installation guide", + 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.", + archived: "This project is archived. New installation authorizations are unavailable.", + guide: "Installation guide", platform: "Host platform", - start: "After installation, run start using oac-daemon in the installation’s bin directory. Installation does not start it automatically.", - unavailable: "The Session’s connection address, Environment ID or workspace is missing or invalid. Check the Session before installing.", + 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", command: "Executor install command", copy: "Copy command", diff --git a/apps/web/src/i18n/locales/zh-CN/sessions.ts b/apps/web/src/i18n/locales/zh-CN/sessions.ts index b4b983ac1..3c8addcf3 100644 --- a/apps/web/src/i18n/locales/zh-CN/sessions.ts +++ b/apps/web/src/i18n/locales/zh-CN/sessions.ts @@ -198,13 +198,13 @@ export const sessions = { hostDone: "主机已连接", hostPending: "等待连接", title: "连接主机", - lifecycle: "使用与主机系统和架构匹配的原生发行包,在安装器中选择执行框架;Windows 不支持 MiniMax。工作目录须已存在且与此 Session 一致。工具以当前用户权限运行,不提供沙箱隔离。轮换后请先运行已安装程序的 stop 命令,再替换凭据文件,最后运行 start。", - steps: "将凭据文件保存到主机,再从解压后的发行包目录运行命令。在提示中填写文件的绝对路径。", + lifecycle: "使用当前账户安装和运行 daemon。可选择多个 Harness,其中必须包含此 Session 使用的 Harness;Windows 不支持 MiniMax。工作目录与此 Session 的配置保持一致。", + steps: "复制命令并在自己的机器上运行,即可下载匹配的安装包、安装 Harness、启动 daemon 并验证连接。", archived: "此项目已归档。安装需要已有的凭据文件,无法签发或轮换新凭据。", guide: "获取原生发行包 · 安装指南", platform: "主机系统", - start: "安装完成后,使用安装目录 bin 中的 oac-daemon 运行 start。安装不会自动启动程序。", - unavailable: "此 Session 的连接地址、环境 ID 或工作目录缺失或无效,请核对后再安装。", + start: "命令中的安装授权在 30 分钟后过期。连接成功不代表模型调用可用。", + unavailable: "安装命令暂不可用。请刷新 Session,或请 Core 管理员检查原生安装包。", terminal: "终端", command: "Executor 安装命令", copy: "复制命令", diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml index 6def1066c..c47c65fdb 100644 --- a/contracts/agents-api/core.openapi.yaml +++ b/contracts/agents-api/core.openapi.yaml @@ -1671,6 +1671,24 @@ definitions: - has_more - object type: object + v1.EnvironmentInstallation: + properties: + commands: + additionalProperties: + type: string + type: object + expires_at: + type: integer + message: + type: string + status: + enum: + - available + - unavailable + type: string + version: + type: string + type: object v1.EnvironmentNetwork: properties: access: @@ -2698,6 +2716,8 @@ definitions: items: type: string type: array + x_agents_core: + $ref: '#/definitions/v1.SessionCore' required: - agent - created_at @@ -2778,6 +2798,11 @@ definitions: - has_more - object type: object + v1.SessionCore: + properties: + installation: + $ref: '#/definitions/v1.EnvironmentInstallation' + type: object v1.SessionDeleted: properties: deleted: @@ -4181,6 +4206,42 @@ paths: summary: Revoke a self_hosted Environment executor credential tags: - Executor Credentials + /core/v1/projects/{project_id}/environments/{environment_id}/installation: + get: + description: Core key only. The commands contain a 30-minute installation authorization, never an executor secret. Web displays these same commands provided in public Session creation and detail responses. + parameters: + - description: Project UUID + in: path + name: project_id + required: true + type: string + - description: Environment UUID + in: path + name: environment_id + required: true + type: string + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.EnvironmentInstallation' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.CoreErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.CoreErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/api.CoreErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: Get a self_hosted Session's installation commands + tags: + - Native Installation /core/v1/projects/{project_id}/files: get: description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. diff --git a/contracts/agents-api/environment-executor-credentials.md b/contracts/agents-api/environment-executor-credentials.md index c7b903528..e9f8a185f 100644 --- a/contracts/agents-api/environment-executor-credentials.md +++ b/contracts/agents-api/environment-executor-credentials.md @@ -1,12 +1,12 @@ # Environment executor credentials -An executor credential lets one self-hosted executor host enroll its daemon and -connect for one `self_hosted` Environment. The application creates the -`self_hosted` Session with its Project API key; the operator then issues the -credential with the Core key, through Web or a Core-key script, and gives the -returned credential file to the executor host. Project API keys cannot issue -credentials; the former Project-key route -`/core/v1/environments/{environment_id}/executor-credentials` is removed. +An executor credential lets a daemon enroll and connect for one `self_hosted` +Environment. An application creates the Session with its Project API key and +receives an Environment-scoped installation command. The installer claims its +connect-only key and connects without requiring Web or a Core key. Operators +retain the explicit Core-key issuance, rotation and revocation routes below. +The command's short-lived authorization and the daemon's long-term credential +are separate; their lifecycle is defined in [native installation](../../docs/self-hosted-native.md). ## Routes @@ -112,25 +112,17 @@ can read it. ## Executor host -The [native installer](../../docs/self-hosted-native.md) installs the same daemon -and pinned Harness adapters on Linux, macOS and Windows. It neither identifies -the machine's supplier nor creates a Docker container or Core allocation. The -operator supplies a user-writable installation directory, an existing workspace, -the unchanged Environment ID and remote URL, and a private executor credential file. -Files, native history and machine lifecycle remain the operator's responsibility. -Session deletion, cancellation and disconnect do not reclaim them. - -Interactive multi-selection and command-line-only installation share one flow. -`install --non-interactive --harness codex,claude --install-dir ABS --remote URL ---environment-id UUID --workspace ABS --credential-file ABS` requires all inputs -without prompting. Readiness checks do not authenticate a model or prove a daemon -connection. The installed `bin/oac-daemon start` connects; verify connection through -the route below and send a Turn to verify the Session model configuration. - -Web's **Connect a host** panel links the native distribution instructions and -prepares a secret-free interactive command using the Session's remote URL, -Environment ID and workspace. Save the one-time credential JSON as a private file -and supply its absolute path. Credentials never belong in the command itself. +The [native installer](../../docs/self-hosted-native.md) uses the same daemon and +pinned adapters on every supported platform. Session responses provide commands +in `x_agents_core.installation`; Web displays them without reconstructing them. +The bootstrap only downloads and extracts a qualified distribution, then invokes +the common installer to select Harnesses, install, start and verify connection. +The Session's Environment identity and workspace are fixed inputs. Installation +never creates or reclaims the user's machine, workspace or native history. + +Explicit distribution installation with `--credential-file` remains available +for operator-managed credentials. Readiness and connection checks do not validate +model access. Runtime preparation and execution use the existing common protocol. ### Revoked or rotated credential diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 104711fb8..44decf9af 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -490,6 +490,24 @@ definitions: - status - type type: object + v1.EnvironmentInstallation: + properties: + commands: + additionalProperties: + type: string + type: object + expires_at: + type: integer + message: + type: string + status: + enum: + - available + - unavailable + type: string + version: + type: string + type: object v1.EnvironmentNetwork: properties: access: @@ -1273,6 +1291,8 @@ definitions: items: type: string type: array + x_agents_core: + $ref: '#/definitions/v1.SessionCore' required: - agent - created_at @@ -1353,6 +1373,11 @@ definitions: - has_more - object type: object + v1.SessionCore: + properties: + installation: + $ref: '#/definitions/v1.EnvironmentInstallation' + type: object v1.SessionDeleted: properties: deleted: diff --git a/contracts/agents-api/runtime.openapi.yaml b/contracts/agents-api/runtime.openapi.yaml index 756977ace..329d7fce1 100644 --- a/contracts/agents-api/runtime.openapi.yaml +++ b/contracts/agents-api/runtime.openapi.yaml @@ -1,5 +1,39 @@ basePath: / definitions: + api.CoreAPIError: + properties: + code: + type: string + x-nullable: true + details: + description: |- + Details contains only documented, Core-owned facts: string, finite number, + boolean, null or string array values. Never include request echoes, secrets + or native/provider error text. Empty or invalid details are omitted. + type: object + message: + type: string + param: + type: string + x-nullable: true + type: + type: string + required: + - message + - type + type: object + api.CoreErrorResponse: + properties: + error: + $ref: '#/definitions/api.CoreAPIError' + required: + - error + type: object + api.NativeInstallationClaim: + properties: + executor_token: + type: string + type: object sandbox.DeploymentSpec: properties: resources: @@ -133,6 +167,21 @@ definitions: required: - error type: object + v1.NativeInstallationContext: + properties: + environment_id: + type: string + harness: + type: string + protocol_version: + type: string + remote_url: + type: string + version: + type: string + workspace_directory: + type: string + type: object info: contact: {} description: Machine connection routes under /api/v1. Sandbox nodes authenticate with a one-use enrollment token or their node credential; Project API keys and the Core key are not accepted. See each operation's security requirements. @@ -142,6 +191,65 @@ info: title: OpenAgentCore Machine Connections version: "1" paths: + /api/v1/agent-daemon/installation: + post: + description: Accepts a short-lived Environment installation Bearer authorization, not a Project or Core key. Returns frozen connection constraints; it does not claim or rotate credentials. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.NativeInstallationContext' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.CoreErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.CoreErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/api.CoreErrorResponse' + summary: Resolve a native installation authorization + tags: + - Native Installation + /api/v1/agent-daemon/installation/claim: + post: + consumes: + - application/json + description: A valid installation Bearer authorization can claim one connect-only key. The client persists its generated secret before submitting it. Retries must present that same secret; a different, rotated or revoked credential is never replaced. + parameters: + - description: Locally persisted executor secret + in: body + name: body + required: true + schema: + $ref: '#/definitions/api.NativeInstallationClaim' + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.CoreErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/api.CoreErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/api.CoreErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/api.CoreErrorResponse' + summary: Claim an Environment's installation credential + tags: + - Native Installation /api/v1/sandbox-node/configuration: get: description: Authenticates with an unconsumed enrollment token, or a retained node credential with X-OAC-Node-ID. Does not consume the token or expose E2B credentials. Node files cannot override this specification. diff --git a/contracts/agents-api/v1/installation.go b/contracts/agents-api/v1/installation.go new file mode 100644 index 000000000..9ae3a993e --- /dev/null +++ b/contracts/agents-api/v1/installation.go @@ -0,0 +1,26 @@ +package v1 + +// SessionCore exposes optional Core additions without changing official fields. +type SessionCore struct { + Installation *EnvironmentInstallation `json:"installation,omitempty"` +} + +// EnvironmentInstallation contains short-lived, Environment-scoped commands. +// Only authenticated creation and detail responses include this authorization. +type EnvironmentInstallation struct { + Status string `json:"status" enums:"available,unavailable"` + Version string `json:"version"` + ExpiresAt int64 `json:"expires_at,omitempty"` + Commands map[string]string `json:"commands,omitempty"` + Message string `json:"message,omitempty"` +} + +// NativeInstallationContext is bootstrap metadata, not an execution protocol. +type NativeInstallationContext struct { + Version string `json:"version"` + ProtocolVersion string `json:"protocol_version"` + 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/v1/sessions.go b/contracts/agents-api/v1/sessions.go index 892316dfb..ac2400877 100644 --- a/contracts/agents-api/v1/sessions.go +++ b/contracts/agents-api/v1/sessions.go @@ -99,6 +99,7 @@ type TextFormat struct { } type Session struct { + XAgentsCore *SessionCore `json:"x_agents_core,omitempty"` ID string `json:"id" binding:"required"` Agent Agent `json:"agent" binding:"required"` CreatedAt int64 `json:"created_at" binding:"required"` diff --git a/deploy/distribution/Dockerfile b/deploy/distribution/Dockerfile index 55d838d8b..3483197c1 100644 --- a/deploy/distribution/Dockerfile +++ b/deploy/distribution/Dockerfile @@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates COPY --chmod=0555 bin/oac-core bin/oac-core-migrate bin/oac-core-device bin/oac-core-environment-key /usr/local/bin/ COPY e2b/ /opt/oac/e2b/ +COPY native-installers/ /opt/oac/native-installers/ ENV OAC_ADDR=:8091 ENV OAC_E2B_PROVIDER_BIN=/opt/oac/e2b/oac-e2b-provider diff --git a/deploy/install/configuration.py b/deploy/install/configuration.py index a3f013978..948ac7dc2 100644 --- a/deploy/install/configuration.py +++ b/deploy/install/configuration.py @@ -183,6 +183,8 @@ def core_environment(root, config, state): } if native: result["OAC_E2B_PROVIDER_BIN"] = str(root / "native/e2b/oac-e2b-provider") + if (root / "native/native-installers/catalog.json").is_file(): + result["OAC_NATIVE_INSTALLER_DIR"] = str(root / "native/native-installers") if core["oauth_trusted_origins"]: result["OAC_OAUTH_TRUSTED_ORIGINS"] = ",".join(core["oauth_trusted_origins"]) if core["runtime_history"] is not None: diff --git a/docs/api/README.md b/docs/api/README.md index 2fd197893..b23a9499d 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -99,3 +99,14 @@ relevant contract and this index when adding or moving an API surface. Core administration failures use the [Core error envelope](../../contracts/agents-api/core-errors.md), including typed optional safe details and distinct console proxy rejection codes. The public and machine error contracts remain unchanged. + +### Self-hosted installation + +Authenticated Session creation/detail responses include short-lived commands in +`x_agents_core.installation`. Core Web reads the same commands at +`GET /core/v1/projects/{project_id}/environments/{environment_id}/installation`. +Machine installers use `POST /api/v1/agent-daemon/installation` and its `/claim` +subroute with the installation Bearer authorization. Qualified artifacts beneath +`/api/v1/agent-daemon/install/{version}/` are public, immutable release content. +See [native self-hosted installation](../self-hosted-native.md) for expiry, retry, +credential ownership and platform rules. diff --git a/docs/self-hosted-native.md b/docs/self-hosted-native.md index baee388d0..56b099022 100644 --- a/docs/self-hosted-native.md +++ b/docs/self-hosted-native.md @@ -40,94 +40,75 @@ by a Session's capability dependencies, must be available. Missing system components are reported. Install those through the host's normal administration process; the daemon never runs apt, sudo or an elevation command. -## Obtain a distribution - -Before a public release exists, build a native package with the repository's -`native-installer` workflow and download its artifact for the target platform. -Extract the archive before running the executable. The workflow publishes CI -artifacts, not a production release or an automatic update channel. - -For a local release build, use `scripts/build-native-installer.mjs --daemon PATH ---node DIR --codex DIR --claude DIR --minimax DIR --output ABS`. Select the -components to include; omit MiniMax on Windows. Build on the target OS, using -the existing pinned native artifacts. The builder verifies native startup and -creates `bundle.json`, `oac-daemon` (or `.exe`) and `components/`. -`bundle.json` describes release files and checksums. It does not accept user -installation options, connection settings or credentials. - -The Claude source is the existing compiled adapter export from modern -`pnpm deploy`. Reify its dedicated frozen lock with -`pnpm install --prod --frozen-lockfile --config.node-linker=hoisted --ignore-scripts` -in a fresh export before packaging. This preserves dependency resolution when -the builder converts contained links to regular files. MiniMax must be built -from the pinned source with this revision's patches and native dependencies; -copying a Linux companion onto macOS does not produce a macOS distribution. - -## Install and start - -Create a `self_hosted` Session with its model provider and an existing absolute -`workspace_directory` on the executor host. Save its `environment.id` and -unchanged `environment.remote_url`. Have the administrator issue an -[executor credential](getting-started/self-hosted.md#without-web) and save the JSON -as a private file. It contains `key_id`, `environment_id` and `executor_token`. -Pass the file path, never the token. Use `wss://` outside loopback; `ws://` is -accepted only for a loopback Core. The workspace must match the Session. - -From the extracted distribution, interactive installation asks for Harnesses -(comma-separated, multiple selections allowed), installation directory, workspace -and connection settings. Existing command-line values also work with the prompts: +## Install and connect + +Create a Session using the public Agents API with `environment.type: "self_hosted"`. +The response retains the official Environment `id` and `remote_url`, and adds +`x_agents_core.installation` with `commands.posix`, `commands.powershell` and +`expires_at`. Execute the command for your target platform. Core Web shows the +same commands in the **Self-hosted** Session's connection section; Web is not a +prerequisite for API callers. + +The command downloads the distribution matched to this Core, verifies its archive, +asks which Harnesses to install and where, installs them, starts the daemon and +checks its authenticated connection. The Session's required Harness must remain +selected. Its workspace is frozen at Session creation; the installer creates that +directory if necessary using your existing permissions. To choose a different +workspace, create a Session with that path. No administrator privileges or Docker +are required. + +For automation, append `--non-interactive --harness codex` and optionally +`--install-dir ABS` to the command. Multiple Harnesses use a comma-separated value, +for example `--harness codex,claude`. Missing required input fails without prompting. +Interactive installation defaults to a separate directory for each Environment: +`~/.oac/environments/` (or beneath `OAC_RUNTIME_HOME`). + +The command carries a 30-minute authorization restricted to this Environment and +Core build. Treat it as a temporary credential. Refresh the Session detail or +copy a fresh Web command after expiry. It cannot execute tasks or read files. +The installer generates a private connect-only credential file before claiming +its key, so a lost response can be retried without losing the credential. The +long-term secret never appears in the command or terminal. A different machine +cannot use the command to replace an already claimed key. Session deletion, +Project archival, expiry or a different Core build invalidates the authorization; +new commands never revive revoked credentials. + +Installation reports three separate results: **Installation**, **Daemon +connection**, and **Model configuration**. This workflow does not configure or +validate model access. If connection is not confirmed, inspect the reported local +log and the Session's connection status. Rerun with the same installation directory +to resume; completed components and credentials are retained and an existing +daemon is reused. After authentication failures, check the Environment credential +in Core. Do not remove the workspace or Session history to retry. + +Qualified release distributions contain Linux amd64, macOS arm64 and Windows +amd64 installers. Core serves these matched artifacts directly, including in a +private repository deployment. Unsupported platforms fail explicitly. Operators +running a standalone Core binary can set `OAC_NATIVE_INSTALLER_DIR` to its matched +`native-installers` directory. Without qualified artifacts, Session responses +report installation unavailable instead of selecting another version. + +## Manual distribution installation + +The same installer also accepts an already-extracted distribution and an explicitly +supplied private executor credential, without the bootstrap command: ```sh -./oac-daemon install --interactive -``` - -Noninteractive installation accepts command-line arguments only and never waits -for input. On Linux or macOS: - -```sh -mkdir -p "$HOME/agent-workspace" -chmod 600 "$HOME/executor-credential.json" -./oac-daemon install --non-interactive \ - --harness codex,claude,minimax \ - --install-dir "$HOME/.oac/runtime-example" \ +./oac-daemon install --non-interactive --harness codex \ + --install-dir "$HOME/.oac/my-runtime" \ --remote 'wss://core.example/api/v1/agent-daemon/ws' \ --environment-id '11111111-2222-4333-8444-555555555555' \ - --workspace "$HOME/agent-workspace" \ + --workspace "$HOME/workspace" \ --credential-file "$HOME/executor-credential.json" -"$HOME/.oac/runtime-example/bin/oac-daemon" start -``` - -On Windows, use native absolute paths in PowerShell: - -```powershell -New-Item -ItemType Directory -Force "$HOME\agent-workspace" | Out-Null -.\oac-daemon.exe install --non-interactive ` - --harness codex,claude ` - --install-dir "$HOME\.oac\runtime-example" ` - --remote 'wss://core.example/api/v1/agent-daemon/ws' ` - --environment-id '11111111-2222-4333-8444-555555555555' ` - --workspace "$HOME\agent-workspace" ` - --credential-file "$HOME\executor-credential.json" -& "$HOME\.oac\runtime-example\bin\oac-daemon.exe" start +"$HOME/.oac/my-runtime/bin/oac-daemon" start ``` -Replace the URL and UUID with the Session's values. Keep the credential file in -the account's private storage. The installer does not print credential contents -or pass them in the background daemon's arguments. - -| Option | Meaning | -| --- | --- | -| `--harness` | Comma-separated `codex`, `claude`, `minimax`; unsupported combinations fail | -| `--install-dir ABS` | User-writable installation; defaults to `OAC_RUNTIME_HOME`, then `~/.oac` | -| `--bundle-dir ABS` | Extracted release directory; defaults beside the executable | -| `--remote`, `--environment-id`, `--workspace`, `--credential-file` | Required connection inputs, equally available in both modes | -| `--capability-directory ABS` | Capability snapshot destination, default `capabilities` under the installation | -| `--tool-env-file ABS` | Optional JSON object of string-valued tool/MCP variables, not installation options | - -Installation reports three independent facts: local installation readiness, -connection not yet checked, and model configuration not yet checked. It does not -start a daemon, provision a machine, configure a model or create an OS service. -`start --foreground` runs under an operator's preferred service manager. +Use `.\oac-daemon.exe` and native absolute paths in PowerShell. This manual mode +requires an existing workspace and starts only when `start` is invoked. Optional +`--capability-directory ABS` selects snapshot storage; `--tool-env-file ABS` +supplies tool/MCP variables. They do not introduce another installation workflow. +Build distributions on their target OS with `scripts/build-native-installer.mjs`; +`bundle.json` describes release content and checksums, not installation options. ## Add Harnesses and operate the installation diff --git a/packages/agents-client/src/admin-client.ts b/packages/agents-client/src/admin-client.ts index 94c2e85bd..b61764638 100644 --- a/packages/agents-client/src/admin-client.ts +++ b/packages/agents-client/src/admin-client.ts @@ -1,3 +1,5 @@ +import { projectEnvironmentInstallation } from "./installation-projection"; +import type { EnvironmentInstallation } from "./types"; import { projectSessionDiagnostics, projectTurnDiagnostics } from "./session-diagnostics"; import { addVaultPageOptions, projectAgentSession, projectRuntimeObservation, @@ -266,6 +268,11 @@ export class AdminClient { return projectWriteOperations(await this.#json(path, options)); } + /** Short-lived installation commands; never persist these beyond the current view. */ + async environmentInstallation(projectId: string, environmentId: string, options?: ReadOptions): Promise { + return projectEnvironmentInstallation(await this.#json(`${scope(projectId)}/environments/${segment(environmentId)}/installation`, options)) ?? invalidAdminResponse(); + } + /** Credential metadata for one self_hosted Environment; the credentials themselves are never listed. */ async listExecutorCredentials(projectId: string, environmentId: string, options?: ReadOptions): Promise { return projectExecutorCredentials(await this.#json(`${scope(projectId)}/environments/${segment(environmentId)}/executor-credentials`, options)); diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts index 707fca6c0..ff7fff607 100644 --- a/packages/agents-client/src/client.test.ts +++ b/packages/agents-client/src/client.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { AdminClient } from "./admin-client"; -import { AgentCoreError, CreationStreamRetryError, createIdempotencyKey, isSessionDeletionConflict, OpenAIAgentsClient } from "./client"; +import { AgentCoreError, projectAgentSession, CreationStreamRetryError, createIdempotencyKey, isSessionDeletionConflict, OpenAIAgentsClient } from "./client"; import hostedDadf64 from "./fixtures/parsar-dadf64a7/openai-hosted.json"; import eventBatchDadf64 from "./fixtures/parsar-dadf64a7/session-event-batch.json"; import type { @@ -2879,3 +2879,10 @@ 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 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/client.ts b/packages/agents-client/src/client.ts index d2d7ad0ba..9fd5a30e9 100644 --- a/packages/agents-client/src/client.ts +++ b/packages/agents-client/src/client.ts @@ -1,3 +1,4 @@ +import { projectEnvironmentInstallation } from "./installation-projection"; import { exactFields, onlyFields, isRecord, hasOwn, canonicalUuid, isNonnegativeInteger, sameResourceId } from "./response-projection"; import { projectTokenUsage } from "./usage-projection"; import { projectAgentTurn, projectSessionItem, projectItemContent, projectHistoryPage, validateHistoryPageOptions } from "./history-projection"; @@ -903,7 +904,13 @@ export function projectAgentSession( expectedEnvironment?: ExpectedCreationEnvironment, expectedImmutable?: ImmutableSessionProjection, ): AgentSession { - if (!isRecord(value) || !exactFields(value, sessionFields)) return invalidSessionResource(); + if (!isRecord(value) || !exactFields(Object.fromEntries(Object.entries(value).filter(([key]) => key !== "x_agents_core")), sessionFields)) return invalidSessionResource(); + let installation; + if (value.x_agents_core !== undefined) { + if (!isRecord(value.x_agents_core) || !exactFields(value.x_agents_core, new Set(["installation"]))) return invalidSessionResource(); + installation = projectEnvironmentInstallation(value.x_agents_core.installation); + if (!installation) return invalidSessionResource(); + } if ( typeof value.id !== "string" || value.id.trim() === "" || (expectedSessionId !== undefined && !sameResourceId(value.id, expectedSessionId)) || @@ -964,6 +971,7 @@ export function projectAgentSession( created_at: value.created_at, last_active_at: value.last_active_at, }; + if (installation) session.x_agents_core = { installation }; if (expectedEnvironment !== undefined) bindCreatedEnvironment(session.environment, expectedEnvironment); if (expectedImmutable !== undefined && !matchesImmutableSession(session, expectedImmutable)) { return invalidSessionResource("OpenAgentCore changed immutable Session configuration in the event stream."); diff --git a/packages/agents-client/src/installation-projection.ts b/packages/agents-client/src/installation-projection.ts new file mode 100644 index 000000000..a548c7b6f --- /dev/null +++ b/packages/agents-client/src/installation-projection.ts @@ -0,0 +1,10 @@ +import { isNonnegativeInteger, isRecord } from "./response-projection"; +import type { EnvironmentInstallation } from "./types"; + +/** Preserve Core-generated commands; clients never reconstruct authorization. */ +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 } }; +} diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index 4f40edc0f..ce43bc0e6 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -665,7 +665,16 @@ export interface TokenUsage { }; } +export interface EnvironmentInstallation { + status: "available" | "unavailable"; + version: string; + expires_at?: number; + commands?: { posix: string; powershell: string }; + message?: string; +} + export interface AgentSession { + x_agents_core?: { installation: EnvironmentInstallation }; id: string; object: "agent.session"; agent: AgentSnapshot; diff --git a/scripts/build-core-distribution.sh b/scripts/build-core-distribution.sh index d443f5eec..2424fceed 100755 --- a/scripts/build-core-distribution.sh +++ b/scripts/build-core-distribution.sh @@ -137,6 +137,11 @@ mkdir -p "$stage/core/e2b" tar -xzf "$stage/e2b-build/oac-e2b-provider-linux-amd64.tar.gz" \ --strip-components=1 -C "$stage/core/e2b" cp -R "$stage/core/e2b" "$bundle/native/e2b" +mkdir -p "$stage/core/native-installers" +if [[ -n "${OAC_NATIVE_INSTALLER_BUILD_DIR:-}" ]]; then + cp -R "$OAC_NATIVE_INSTALLER_BUILD_DIR/." "$stage/core/native-installers/" + cp -R "$stage/core/native-installers" "$bundle/native/" +fi cp deploy/distribution/Dockerfile "$stage/core/Dockerfile" build_image core "$stage/core" core_image="$(cat "$stage/core.id")" diff --git a/scripts/build-native-catalog.mjs b/scripts/build-native-catalog.mjs new file mode 100644 index 000000000..c7e5b24c0 --- /dev/null +++ b/scripts/build-native-catalog.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +// Assemble already-qualified native artifacts; never select a latest release. +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { createReadStream } from 'node:fs'; +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +const [input, output] = process.argv.slice(2); +if (!input || !output) throw new Error('Usage: build-native-catalog.mjs INPUT OUTPUT'); +const version = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); +const protocol_version = (await readFile('internal/agentdaemon/proto/version.go', 'utf8')).match(/const Version = "([^"]+)"/)?.[1]; +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' })) { + const archive = resolve(input, `oac-native-installer-${ci}.tar.gz`); + const bundle = JSON.parse(execFileSync('tar', ['-xOzf', archive, './bundle.json'], { encoding: 'utf8' })); + 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); + artifacts[platform] = { sha256: hash.digest('hex') }; + await copyFile(archive, join(output, `${platform}.tar.gz`)); +} +await writeFile(join(output, 'catalog.json'), JSON.stringify({ version, protocol_version, artifacts }, null, 2)+'\n'); diff --git a/scripts/native-onboarding-smoke.mjs b/scripts/native-onboarding-smoke.mjs new file mode 100644 index 000000000..05f340998 --- /dev/null +++ b/scripts/native-onboarding-smoke.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// Native end-to-end bootstrap against a local transport fixture. No model calls. +import assert from 'node:assert/strict'; +import { createHash, randomUUID } from 'node:crypto'; +import { createServer } from 'node:http'; +import { spawn, execFileSync } from 'node:child_process'; +import { createReadStream } from 'node:fs'; +import { mkdir, readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +const root = process.env.RUNNER_TEMP; +if (!root) throw new Error('RUNNER_TEMP is required'); +const windows = process.platform === 'win32'; +const bundle = join(root, 'native-installer'); +const manifest = JSON.parse(await readFile(join(bundle, 'bundle.json'), 'utf8')); +const protocol = (await readFile('internal/agentdaemon/proto/version.go', 'utf8')).match(/const Version = "([^"]+)"/)[1]; +const environment = randomUUID(), session = randomUUID(), device = randomUUID(); +const workspace = join(root, 'onboarding-workspace'); +const installation = join(root, 'onboarding-installation'); +const archive = join(root, 'onboarding.tar.gz'); +execFileSync('tar', ['-czf', archive, '-C', bundle, '.']); +const digest = createHash('sha256'); +for await (const chunk of createReadStream(archive)) digest.update(chunk); +const checksum = digest.digest('hex'); +let secret, connected = false, connections = 0, failClaim = true; +const authorization = 'fixture-install-authorization'; +const server = createServer(async (request, response) => { + const url = new URL(request.url, origin); + const json = (status, value) => { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(value)); }; + if (url.pathname.endsWith('.sha256')) 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/agents-api/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' }); + const input = JSON.parse(Buffer.concat(body)); + if (secret && secret !== input.executor_token) return json(409, {}); + secret = input.executor_token; + if (failClaim) { failClaim = false; request.socket.destroy(); return; } + response.writeHead(204); return response.end(); + } + if (!secret || request.headers.authorization !== `Bearer ${secret}`) return json(401, {}); + if (url.pathname.endsWith('/enroll')) return json(200, { device_id: device, session_id: session, environment_id: environment, workspace_directory: workspace }); + if (url.pathname.endsWith('/bootstrap')) return json(200, { device_id: device, workspace_id: session, ws_url: remote, heartbeat_seconds: 15, protocol_version: protocol }); + if (url.pathname.endsWith('/connection')) return json(200, { environment_id: environment, status: connected ? 'connected' : 'disconnected' }); + return json(404, {}); +}); +const sockets = new Set(); +server.on('upgrade', (request, socket) => { + if (request.headers.authorization !== `Bearer ${secret}`) { socket.destroy(); return; } + const accept = createHash('sha1').update(request.headers['sec-websocket-key']+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); + socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`); + sockets.add(socket); connected = true; connections++; + socket.on('data', () => {}); + socket.on('error', () => {}); + socket.on('close', () => { sockets.delete(socket); connected = sockets.size > 0; }); +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +const origin = `http://127.0.0.1:${server.address().port}`; +const remote = origin.replace('http:', 'ws:')+'/api/v1/agent-daemon/ws'; +const base = `${origin}/api/v1/agent-daemon/install/${manifest.daemon_version}`; +function run(executable, args, input = '') { + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { env: { ...process.env, OAC_RUNTIME_HOME: join(root, 'unused-onboarding-home') }, stdio: ['pipe', 'pipe', 'pipe'] }); + let output = ''; + child.stdout.on('data', chunk => { output += chunk; }); child.stderr.on('data', chunk => { output += chunk; }); + child.stdin.on('error', () => {}); child.stdin.end(input); + const timer = setTimeout(() => { child.kill(); reject(new Error('Native onboarding did not settle')); }, 180000); + child.on('error', reject); + child.on('close', code => { clearTimeout(timer); if (secret) assert.ok(!output.includes(secret), 'Credential leaked into terminal'); resolve({ code, output }); }); + }); +} +const executable = join(bundle, windows ? 'oac-daemon.exe' : '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'); + 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/agents-api/internal/nativeinstaller/assets/bootstrap.${windows ? 'ps1' : 'sh'}`); + const bootstrapArgs = windows ? ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Base', base, '-Authorization', authorization] : [script, base, authorization]; + const result = await run(windows ? 'powershell.exe' : 'bash', [...bootstrapArgs, '--install-dir', installation], '\n\n'); + assert.equal(result.code, 0, `Interactive bootstrap failed: ${result.output}`); + assert.match(result.output, /Daemon connection: connected/); + assert.match(result.output, /Model configuration: not checked/); + assert.equal(connections, 1); + const repeat = await run(executable, [...args, '--non-interactive', '--harness', 'codex']); + assert.equal(repeat.code, 0, `Repeat failed: ${repeat.output}`); + assert.equal(connections, 1, 'Repeated installation created a duplicate daemon'); + console.log(JSON.stringify({ installation: 'passed', connection: 'passed', interactive: 'passed', retry: 'passed', repeat: 'passed', authentication: 'passed', model_requests: 0 })); +} finally { + // The installed executable resolves its own home unless explicitly overridden. + delete process.env.OAC_RUNTIME_HOME; + const installed = join(installation, 'bin', windows ? 'oac-daemon.exe' : 'oac-daemon'); + try { execFileSync(installed, ['stop'], { env: { ...process.env, OAC_RUNTIME_HOME: installation }, stdio: 'ignore', timeout: 15000 }); } catch { /* A failed installation may never have started. */ } + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(resolve)); +} diff --git a/services/agents-api/cmd/server/http_routes.go b/services/agents-api/cmd/server/http_routes.go index 84fefe2de..a905f083d 100644 --- a/services/agents-api/cmd/server/http_routes.go +++ b/services/agents-api/cmd/server/http_routes.go @@ -26,6 +26,9 @@ func serverHandler(apiHandler http.Handler, daemon *daemonRoutes) http.Handler { mux.Handle("/api/v1/agent-daemon/", daemon.gateway) mux.Handle("/api/v1/agent-daemon/enroll", daemon.enrollment) mux.Handle("/api/v1/agent-daemon/connection", daemon.connection) + mux.Handle("/api/v1/agent-daemon/install/", apiHandler) + mux.Handle("/api/v1/agent-daemon/installation", apiHandler) + mux.Handle("/api/v1/agent-daemon/installation/", apiHandler) if daemon.nodeConnect != nil { mux.Handle("/api/v1/sandbox-node/connect", daemon.nodeConnect) } diff --git a/services/agents-api/cmd/server/main.go b/services/agents-api/cmd/server/main.go index b3e86ac51..8fbbad997 100644 --- a/services/agents-api/cmd/server/main.go +++ b/services/agents-api/cmd/server/main.go @@ -36,6 +36,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/coremetrics" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/databaseurl" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/nativeinstaller" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtime" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimeenrollment" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/runtimehistory" @@ -222,7 +223,20 @@ func run() error { return err } defer runtime.CloseConnections(registry) - options = append(options, api.WithEnvironmentRemoteURL(wsURL)) + var catalog *nativeinstaller.Catalog + directory := os.Getenv("OAC_NATIVE_INSTALLER_DIR") + if directory == "" { + if _, err := os.Stat("/opt/oac/native-installers/catalog.json"); err == nil { + directory = "/opt/oac/native-installers" + } + } + if directory != "" { + catalog, err = nativeinstaller.Load(directory, buildRevision) + if err != nil { + return err + } + } + options = append(options, api.WithEnvironmentRemoteURL(wsURL), api.WithNativeInstaller(catalog, buildRevision)) } options = append(options, api.WithExecutorConnections(func(ctx context.Context, environment, digest string) (bool, error) { return runtimeenrollment.RuntimeConnected(ctx, executionStore, registry, environment, digest) diff --git a/services/agents-api/internal/api/environment_executor_management.go b/services/agents-api/internal/api/environment_executor_management.go index 88ac376ba..c41b1de85 100644 --- a/services/agents-api/internal/api/environment_executor_management.go +++ b/services/agents-api/internal/api/environment_executor_management.go @@ -58,6 +58,7 @@ func (h *Handler) registerExecutorCredentialRoutes(r chi.Router) { return } const path = "/projects/{project_id}/environments/{environment_id}/executor-credentials" + r.Get("/projects/{project_id}/environments/{environment_id}/installation", h.getEnvironmentInstallation) r.Get(path, func(w http.ResponseWriter, r *http.Request) { h.listExecutorCredentials(w, r, s) }) r.Post(path, func(w http.ResponseWriter, r *http.Request) { h.issueExecutorCredential(w, r, s) }) r.Delete(path+"/{key_id}", func(w http.ResponseWriter, r *http.Request) { h.revokeExecutorCredential(w, r, s) }) diff --git a/services/agents-api/internal/api/environment_installation.go b/services/agents-api/internal/api/environment_installation.go new file mode 100644 index 000000000..27d2d1666 --- /dev/null +++ b/services/agents-api/internal/api/environment_installation.go @@ -0,0 +1,183 @@ +package api + +import ( + "context" + "net/http" + "strings" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/nativeinstaller" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/go-chi/chi/v5" +) + +type environmentInstallationStore interface { + AuthorizeEnvironmentInstallation(context.Context, identity.Principal, string, string) (string, int64, error) + ValidateEnvironmentInstallation(context.Context, string, string) (store.InstallationAuthorization, error) + ClaimEnvironmentInstallation(context.Context, string, string, string) error +} + +func WithNativeInstaller(catalog *nativeinstaller.Catalog, version string) Option { + return func(h *Handler) { h.nativeInstaller, h.nativeVersion = catalog, version } +} + +func (h *Handler) installationFor(ctx context.Context, principal identity.Principal, environment string) (*v1.EnvironmentInstallation, error) { + result := &v1.EnvironmentInstallation{Status: "unavailable", Version: h.nativeVersion, Message: "This Core has no matching native installation distribution. Ask its operator to install the qualified release artifacts."} + s, ok := h.store.(environmentInstallationStore) + if !ok || h.nativeInstaller == nil { + return result, nil + } + token, expires, err := s.AuthorizeEnvironmentInstallation(ctx, principal, environment, h.nativeVersion) + if err != nil { + return nil, err + } + origin := strings.TrimSuffix(h.executorURL, "/api/v1/agent-daemon/ws") + origin = strings.Replace(strings.Replace(origin, "wss://", "https://", 1), "ws://", "http://", 1) + return &v1.EnvironmentInstallation{Status: "available", Version: h.nativeVersion, ExpiresAt: expires, Commands: h.nativeInstaller.Commands(origin, token)}, nil +} + +func (h *Handler) addSessionInstallation(w http.ResponseWriter, r *http.Request, response *v1.Session) error { + if response.Environment.Type != "self_hosted" || h.nativeVersion == "" { + return nil + } + principal, ok := r.Context().Value(principalContextKey{}).(identity.Principal) + if !ok { + return nil + } + installation, err := h.installationFor(r.Context(), principal, response.Environment.ID) + if err != nil { + return err + } + w.Header().Set("Cache-Control", "no-store") + response.XAgentsCore = &v1.SessionCore{Installation: installation} + return nil +} + +func (h *Handler) registerNativeInstallationRoutes(r chi.Router) { + if h.nativeVersion == "" { + return + } + if h.nativeInstaller != nil { + r.Handle("/api/v1/agent-daemon/install/*", h.nativeInstaller) + } + r.Post("/api/v1/agent-daemon/installation", h.prepareNativeInstallation) + r.Post("/api/v1/agent-daemon/installation/claim", h.claimNativeInstallation) +} + +func (h *Handler) installationAuthorization(w http.ResponseWriter, r *http.Request) (environmentInstallationStore, store.InstallationAuthorization, string, bool) { + w.Header().Set("Cache-Control", "no-store") + s, ok := h.store.(environmentInstallationStore) + if !ok || h.nativeInstaller == nil { + writeError(w, 503, "installation_unavailable", "Matching native installation artifacts are unavailable.") + return nil, store.InstallationAuthorization{}, "", false + } + parts := strings.Fields(r.Header.Get("Authorization")) + if len(r.Header.Values("Authorization")) != 1 || len(parts) != 2 || parts[0] != "Bearer" { + writeStoreError(w, r, store.ErrInstallationAuthorization) + return nil, store.InstallationAuthorization{}, "", false + } + claim, err := s.ValidateEnvironmentInstallation(r.Context(), parts[1], h.nativeVersion) + if err != nil { + writeStoreError(w, r, err) + return nil, claim, "", false + } + return s, claim, parts[1], true +} + +// @Summary Resolve a native installation authorization +// @Description Accepts a short-lived Environment installation Bearer authorization, not a Project or Core key. Returns frozen connection constraints; it does not claim or rotate credentials. +// @Tags Native Installation +// @Produce json +// @Success 200 {object} v1.NativeInstallationContext +// @Failure 401,404,503 {object} CoreErrorResponse +// @Router /api/v1/agent-daemon/installation [post] +func (h *Handler) prepareNativeInstallation(w http.ResponseWriter, r *http.Request) { + _, claim, _, ok := h.installationAuthorization(w, r) + if !ok { + return + } + environment, err := h.store.GetEnvironment(r.Context(), claim.Principal.TenantID, claim.Environment) + if err != nil { + writeStoreError(w, r, err) + return + } + session, err := h.store.GetSession(r.Context(), claim.Principal.TenantID, environment.SessionID) + if err != nil { + writeStoreError(w, r, err) + return + } + response, err := sessionResponse(session, h.executorURL) + if err != nil { + writeStoreError(w, r, err) + return + } + writeJSON(w, http.StatusOK, v1.NativeInstallationContext{Version: h.nativeVersion, ProtocolVersion: proto.Version, EnvironmentID: claim.Environment, RemoteURL: h.executorURL, Workspace: response.Environment.WorkspaceDirectory, Harness: session.Engine}) +} + +type NativeInstallationClaim struct { + ExecutorToken string `json:"executor_token"` +} + +// @Summary Claim an Environment's installation credential +// @Description A valid installation Bearer authorization can claim one connect-only key. The client persists its generated secret before submitting it. Retries must present that same secret; a different, rotated or revoked credential is never replaced. +// @Tags Native Installation +// @Accept json +// @Param body body api.NativeInstallationClaim true "Locally persisted executor secret" +// @Success 204 +// @Failure 400,401,409,503 {object} CoreErrorResponse +// @Router /api/v1/agent-daemon/installation/claim [post] +func (h *Handler) claimNativeInstallation(w http.ResponseWriter, r *http.Request) { + s, _, token, ok := h.installationAuthorization(w, r) + if !ok { + return + } + raw, ok := readJSONBody(w, r) + if !ok { + return + } + var input NativeInstallationClaim + if decodeInputObject(raw, &input, "executor_token") != nil { + writeStoreError(w, r, store.ErrInvalidInput) + return + } + if err := s.ClaimEnvironmentInstallation(r.Context(), token, h.nativeVersion, input.ExecutorToken); err != nil { + writeStoreError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// @Summary Get a self_hosted Session's installation commands +// @Description Core key only. The commands contain a 30-minute installation authorization, never an executor secret. Web displays these same commands provided in public Session creation and detail responses. +// @Tags Native Installation +// @Security DeploymentAdminAuth +// @Param project_id path string true "Project UUID" +// @Param environment_id path string true "Environment UUID" +// @Success 200 {object} v1.EnvironmentInstallation +// @Failure 401,404,409 {object} CoreErrorResponse +// @Router /core/v1/projects/{project_id}/environments/{environment_id}/installation [get] +func (h *Handler) getEnvironmentInstallation(w http.ResponseWriter, r *http.Request) { + binding, ok := h.adminProjectScope(w, r) + if !ok { + return + } + environment := chi.URLParam(r, "environment_id") + s, ok := h.store.(EnvironmentExecutorStore) + if !ok { + writeStoreError(w, r, store.ErrNotFound) + return + } + if _, err := s.ProjectExecutorCredentialState(r.Context(), binding.Principal, environment); err != nil { + writeStoreError(w, r, err) + return + } + result, err := h.installationFor(r.Context(), binding.Principal, environment) + if err != nil { + writeStoreError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, result) +} diff --git a/services/agents-api/internal/api/environment_installation_test.go b/services/agents-api/internal/api/environment_installation_test.go new file mode 100644 index 000000000..6e25ce03a --- /dev/null +++ b/services/agents-api/internal/api/environment_installation_test.go @@ -0,0 +1,78 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/nativeinstaller" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +type installationFixture struct { + environmentCreationFixture + authorizedEnvironment string +} + +func (f *installationFixture) AuthorizeEnvironmentInstallation(_ context.Context, p identity.Principal, environment, version string) (string, int64, error) { + if p.TenantID != f.session.TenantID || environment != f.session.Environment.ID || version != "build" { + return "", 0, store.ErrNotFound + } + f.authorizedEnvironment = environment + return "short-lived-install-grant", 2000000000, nil +} +func (f *installationFixture) ValidateEnvironmentInstallation(context.Context, string, string) (store.InstallationAuthorization, error) { + return store.InstallationAuthorization{}, store.ErrInstallationAuthorization +} +func (f *installationFixture) ClaimEnvironmentInstallation(context.Context, string, string, string) error { + return store.ErrInstallationAuthorization +} + +func TestSelfHostedCreationReturnsInstallationWithoutWebCredential(t *testing.T) { + f := &installationFixture{} + auth, err := NewAuthenticator([]APIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: device.HashCredential("project-key"), TenantID: uuid.NewString()}}) + if err != nil { + t.Fatal(err) + } + handler, err := NewHandler(f, auth, "codex", withFixtureDeploymentProvider(), WithExecution(&inputRecorder{}), WithEnvironmentRemoteURL("wss://core.example/api/v1/agent-daemon/ws"), WithNativeInstaller(&nativeinstaller.Catalog{Version: "build"}, "build")) + if err != nil { + t.Fatal(err) + } + body := `{"agent":{"model":"model"},"environment":{"type":"self_hosted","workspace_directory":"/workspace"},"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://model.example/v1","api_key":"fixture-model"}}}` + r := httptest.NewRequest(http.MethodPost, "/v1/agents/sessions", strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer project-key") + r.Header.Set("OpenAI-Beta", "agents=v1") + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Code != http.StatusCreated { + t.Fatal(w.Code, w.Body.String()) + } + var response v1.Session + if json.Unmarshal(w.Body.Bytes(), &response) != nil || response.XAgentsCore == nil || response.XAgentsCore.Installation == nil { + t.Fatal("installation omitted") + } + 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"} { + 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") + } + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/agent-daemon/installation", nil) + request.Header.Set("Authorization", "Bearer "+response.Environment.ID) + w = httptest.NewRecorder() + handler.ServeHTTP(w, request) + if w.Code != http.StatusUnauthorized { + t.Fatal("Environment ID authenticated installation", w.Code) + } +} diff --git a/services/agents-api/internal/api/errors.go b/services/agents-api/internal/api/errors.go index 60047d0ba..bddc9e93f 100644 --- a/services/agents-api/internal/api/errors.go +++ b/services/agents-api/internal/api/errors.go @@ -137,6 +137,8 @@ func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFound writeError(w, http.StatusConflict, "project_exists", "This Project ID already exists.") case errors.Is(err, store.ErrProjectAPIKeyExists): writeError(w, http.StatusConflict, "project_api_key_exists", "This API key ID already exists. List its metadata and revoke it explicitly if the secret was not saved.") + case errors.Is(err, store.ErrInstallationAuthorization): + writeError(w, http.StatusUnauthorized, "installation_authorization_invalid", store.ErrInstallationAuthorization.Error()) case errors.Is(err, store.ErrExecutorCredentialExists): writeError(w, http.StatusConflict, "executor_credential_exists", "This executor key ID already exists. Explicitly rotate it to replace the secret.") case errors.Is(err, store.ErrSandboxCredentialUnavailable): diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index 42e4f4079..0d38abb61 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -13,6 +13,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/nativeinstaller" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -37,6 +38,8 @@ type ResourceStore interface { } type Handler struct { + nativeInstaller *nativeinstaller.Catalog + nativeVersion string executorConnections func(context.Context, string, string) (bool, error) coreMetrics CoreMetricsService sandboxStore *store.Store @@ -107,6 +110,7 @@ func (h *Handler) routes() *chi.Mux { }) h.registerSandboxNodeRoutes(router) h.registerCoreRoutes(router) + h.registerNativeInstallationRoutes(router) router.Route("/v1", func(r chi.Router) { r.Use(h.authenticate) r.Post("/vaults", h.createVault) @@ -336,6 +340,10 @@ func (h *Handler) respondSessionStatus(w http.ResponseWriter, r *http.Request, s writeStoreError(w, r, err) return } + if err := h.addSessionInstallation(w, r, &response); err != nil { + writeStoreError(w, r, err) + return + } writeJSON(w, status, response) } diff --git a/services/agents-api/internal/api/session_creation_stream.go b/services/agents-api/internal/api/session_creation_stream.go index dc4c8e02c..d98843852 100644 --- a/services/agents-api/internal/api/session_creation_stream.go +++ b/services/agents-api/internal/api/session_creation_stream.go @@ -71,6 +71,10 @@ func (h *Handler) respondSessionCreationStream(w http.ResponseWriter, r *http.Re return } created := v1.SessionEvent{Type: "agent.session.created", EventID: uuid.NewString(), Session: &response} + if err := h.addSessionInstallation(w, r, &response); err != nil { + writeStoreError(w, r, err) + return + } if session := result.Session; sessionSettled(session, response) && session.LastTurn == nil && session.EnvironmentInputActivity == nil { // Nothing was admitted, e.g. self_hosted creation without input. if write := openEventStream(w, http.StatusCreated); write != nil { diff --git a/services/agents-api/internal/nativeinstaller/assets/bootstrap.ps1 b/services/agents-api/internal/nativeinstaller/assets/bootstrap.ps1 new file mode 100644 index 000000000..45eb26f85 --- /dev/null +++ b/services/agents-api/internal/nativeinstaller/assets/bootstrap.ps1 @@ -0,0 +1,26 @@ +param( + [Parameter(Mandatory=$true)][string]$Base, + [Parameter(Mandatory=$true)][string]$Authorization, + [Parameter(ValueFromRemainingArguments=$true)][string[]]$InstallArguments +) +$ErrorActionPreference = 'Stop' +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() +$architecture = @{x64='amd64';arm64='arm64'}[$architecture] +if (!$architecture) { throw 'Unsupported processor architecture.' } +$work = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString()) +New-Item -ItemType Directory -Path $work | Out-Null +try { + Write-Host 'Downloading the installer matched to Core...' + try { $expected = (Invoke-WebRequest -UseBasicParsing "$Base/windows-$architecture.sha256").Content.Trim() } + catch { throw 'This Core has no qualified installer for this platform.' } + $archive = Join-Path $work 'bundle.tar.gz' + Invoke-WebRequest -UseBasicParsing "$Base/windows-$architecture.tar.gz" -OutFile $archive + if ((Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() -ne $expected) { throw 'Installer checksum mismatch; download again.' } + $bundle = Join-Path $work 'bundle' + New-Item -ItemType Directory -Path $bundle | Out-Null + & tar.exe -xzf $archive -C $bundle + if ($LASTEXITCODE -ne 0) { throw 'Installer extraction failed.' } + $endpoint = $Base -replace '/install/[^/]+$', '/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 { Remove-Item -LiteralPath $work -Recurse -Force } diff --git a/services/agents-api/internal/nativeinstaller/assets/bootstrap.sh b/services/agents-api/internal/nativeinstaller/assets/bootstrap.sh new file mode 100644 index 000000000..ef27d2f61 --- /dev/null +++ b/services/agents-api/internal/nativeinstaller/assets/bootstrap.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +base=$1 +authorization=$2 +shift 2 +case "$(uname -s)" in Linux) os=linux;; Darwin) os=darwin;; *) echo 'Unsupported operating system.' >&2; exit 1;; esac +case "$(uname -m)" in x86_64) arch=amd64;; arm64|aarch64) arch=arm64;; *) echo 'Unsupported processor architecture.' >&2; exit 1;; esac +umask 077 +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +echo 'Downloading the installer matched to Core...' +curl -fsS "$base/$os-$arch.sha256" -o "$work/checksum" || { echo 'This Core has no qualified installer for this platform.' >&2; exit 1; } +curl -fsS "$base/$os-$arch.tar.gz" -o "$work/bundle.tar.gz" +if command -v sha256sum >/dev/null; then + actual=$(sha256sum "$work/bundle.tar.gz"); actual=${actual%% *} +else + actual=$(shasum -a 256 "$work/bundle.tar.gz"); actual=${actual%% *} +fi +expected=$(cat "$work/checksum") +if [ "$actual" != "$expected" ]; then echo 'Installer checksum mismatch; download again.' >&2; exit 1; fi +mkdir "$work/bundle" +tar -xzf "$work/bundle.tar.gz" -C "$work/bundle" +"$work/bundle/oac-daemon" install --onboard-url "${base%/install/*}/installation" --authorization "$authorization" "$@" diff --git a/services/agents-api/internal/nativeinstaller/catalog.go b/services/agents-api/internal/nativeinstaller/catalog.go new file mode 100644 index 000000000..355404b20 --- /dev/null +++ b/services/agents-api/internal/nativeinstaller/catalog.go @@ -0,0 +1,113 @@ +// Package nativeinstaller serves qualified native distributions. It has no +// execution responsibilities; every installed daemon uses the common protocol. +package nativeinstaller + +import ( + "crypto/sha256" + "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +//go:embed assets/* +var bootstrap embed.FS + +type Artifact struct { + SHA256 string `json:"sha256"` +} + +type Catalog struct { + Version string `json:"version"` + ProtocolVersion string `json:"protocol_version"` + Artifacts map[string]Artifact `json:"artifacts"` + directory string +} + +var platformName = regexp.MustCompile(`^(linux|darwin|windows)-(amd64|arm64)$`) + +// Load refuses a different build or protocol, and verifies all archives once +// before exposing them. The distribution directory is immutable while serving. +func Load(directory, version string) (*Catalog, error) { + raw, err := os.ReadFile(filepath.Join(directory, "catalog.json")) + if err != nil { + return nil, err + } + var c Catalog + if json.Unmarshal(raw, &c) != nil || c.Version != version || !proto.VersionCompatible(c.ProtocolVersion) || len(c.Artifacts) == 0 { + return nil, errors.New("native installer catalog does not match this Core build and protocol") + } + for platform, artifact := range c.Artifacts { + if !platformName.MatchString(platform) { + return nil, errors.New("invalid native installer platform") + } + file, err := os.Open(filepath.Join(directory, platform+".tar.gz")) + if err != nil { + return nil, err + } + hash := sha256.New() + _, err = io.Copy(hash, file) + file.Close() + if err != nil || artifact.SHA256 != hex.EncodeToString(hash.Sum(nil)) { + return nil, errors.New("native installer archive checksum mismatch") + } + } + c.directory = directory + return &c, nil +} + +func (c *Catalog) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + name := strings.TrimPrefix(r.URL.Path, "/api/v1/agent-daemon/install/"+c.Version+"/") + if strings.Contains(name, "/") { + http.NotFound(w, r) + return + } + if name == "bootstrap.sh" || name == "bootstrap.ps1" { + raw, _ := bootstrap.ReadFile("assets/" + name) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write(raw) + return + } + platform := strings.TrimSuffix(strings.TrimSuffix(name, ".sha256"), ".tar.gz") + artifact, ok := c.Artifacts[platform] + if !ok { + http.NotFound(w, r) + return + } + if name == platform+".sha256" { + fmt.Fprintln(w, artifact.SHA256) + return + } + if name != platform+".tar.gz" { + http.NotFound(w, r) + return + } + http.ServeFile(w, r, filepath.Join(c.directory, name)) +} + +func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } +func psQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" } + +func (c *Catalog) Commands(origin, authorization string) map[string]string { + base := origin + "/api/v1/agent-daemon/install/" + c.Version + // Download to a private temporary file so failure cannot become an empty, + // successful shell program. Keep stdin available for installer interaction. + posix := "set -e; f=$(mktemp); trap 'rm -f \"$f\"' EXIT; curl -fsS " + shellQuote(base+"/bootstrap.sh") + " -o \"$f\"; bash \"$f\" \"$@\"" + return map[string]string{ + "posix": "bash -c " + shellQuote(posix) + " -- " + shellQuote(base) + " " + shellQuote(authorization), + "powershell": "& ([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing " + psQuote(base+"/bootstrap.ps1") + " -ErrorAction Stop).Content)) -Base " + psQuote(base) + " -Authorization " + psQuote(authorization), + } +} diff --git a/services/agents-api/internal/nativeinstaller/catalog_test.go b/services/agents-api/internal/nativeinstaller/catalog_test.go new file mode 100644 index 000000000..e74d5dec2 --- /dev/null +++ b/services/agents-api/internal/nativeinstaller/catalog_test.go @@ -0,0 +1,52 @@ +package nativeinstaller + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" +) + +func TestCatalogRequiresMatchedImmutableArtifacts(t *testing.T) { + dir := t.TempDir() + data := []byte("archive fixture") + hash := sha256.Sum256(data) + manifest := Catalog{Version: "build", ProtocolVersion: proto.Version, Artifacts: map[string]Artifact{"linux-amd64": {SHA256: hex.EncodeToString(hash[:])}}} + raw, _ := json.Marshal(manifest) + if err := os.WriteFile(filepath.Join(dir, "catalog.json"), raw, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "linux-amd64.tar.gz"), data, 0600); err != nil { + t.Fatal(err) + } + if _, err := Load(dir, "wrong-build"); err == nil { + t.Fatal("accepted mismatched Core") + } + catalog, err := Load(dir, "build") + if err != nil { + t.Fatal(err) + } + for _, request := range []struct { + 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}, + } { + w := httptest.NewRecorder() + catalog.ServeHTTP(w, httptest.NewRequest("GET", "/api/v1/agent-daemon/install/"+request.path, nil)) + if w.Code != request.code { + t.Fatalf("%s: %d", request.path, w.Code) + } + } + if err := os.WriteFile(filepath.Join(dir, "linux-amd64.tar.gz"), []byte("changed"), 0600); err != nil { + t.Fatal(err) + } + if _, err := Load(dir, "build"); err == nil { + t.Fatal("accepted corrupt archive") + } +} diff --git a/services/agents-api/internal/store/environment_installation.go b/services/agents-api/internal/store/environment_installation.go new file mode 100644 index 000000000..b5639a5d2 --- /dev/null +++ b/services/agents-api/internal/store/environment_installation.go @@ -0,0 +1,119 @@ +package store + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/identity" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" +) + +var ErrInstallationAuthorization = errors.New("installation authorization is invalid or expired; obtain a new command from the Session") + +// InstallationAuthorization permits claiming one Environment's connect-only key. +// The Environment UUID is reserved as that key's ID. Reissuing an authorization +// never rotates or revives the key, and a retry must prove the same local secret. +type InstallationAuthorization struct { + Principal identity.Principal `json:"principal"` + Environment string `json:"environment_id"` + Version string `json:"version"` + ExpiresAt int64 `json:"expires_at"` +} + +func (s *Store) AuthorizeEnvironmentInstallation(ctx context.Context, principal identity.Principal, environment, version string) (string, int64, error) { + if err := s.selfHostedExecutorTarget(ctx, principal, environment); err != nil { + return "", 0, err + } + if err := activeProject(ctx, s.queries, principal); err != nil { + return "", 0, err + } + claim := InstallationAuthorization{Principal: principal, Environment: environment, Version: version, ExpiresAt: time.Now().Add(30 * time.Minute).Unix()} + payload, err := json.Marshal(claim) + if err != nil { + return "", 0, err + } + encoded := base64.RawURLEncoding.EncodeToString(payload) + signature, err := s.credentialCipher.Fingerprint("environment-installation", encoded) + if err != nil { + return "", 0, err + } + return encoded + "." + signature, claim.ExpiresAt, nil +} + +func (s *Store) ValidateEnvironmentInstallation(ctx context.Context, token, version string) (InstallationAuthorization, error) { + var claim InstallationAuthorization + encoded, signature, ok := strings.Cut(token, ".") + if !ok || len(token) > 4096 { + return claim, ErrInstallationAuthorization + } + want, err := s.credentialCipher.Fingerprint("environment-installation", encoded) + if err != nil || !hmac.Equal([]byte(want), []byte(signature)) { + return claim, ErrInstallationAuthorization + } + payload, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil || json.Unmarshal(payload, &claim) != nil || claim.Version != version || claim.ExpiresAt <= time.Now().Unix() { + return InstallationAuthorization{}, ErrInstallationAuthorization + } + if err := s.selfHostedExecutorTarget(ctx, claim.Principal, claim.Environment); err != nil { + return InstallationAuthorization{}, ErrInstallationAuthorization + } + if err := activeProject(ctx, s.queries, claim.Principal); err != nil { + return InstallationAuthorization{}, ErrInstallationAuthorization + } + return claim, nil +} + +// ClaimEnvironmentInstallation stores only the digest of a secret generated and +// persisted by the installer before this request. A lost HTTP response is safe +// to retry; another machine or a revoked/rotated key cannot claim it again. +func (s *Store) ClaimEnvironmentInstallation(ctx context.Context, token, version, secret string) error { + claim, err := s.ValidateEnvironmentInstallation(ctx, token, version) + if err != nil { + return err + } + decoded, err := base64.RawURLEncoding.DecodeString(secret) + if err != nil || len(decoded) != 32 || base64.RawURLEncoding.EncodeToString(decoded) != secret { + return ErrInvalidInput + } + principal := claim.Principal + tenant, id, err := executorCredentialIdentity(principal, claim.Environment) + if err != nil { + return err + } + hash := sha256.Sum256([]byte(secret)) + digest := hex.EncodeToString(hash[:]) + return s.withExecutorCredentialTarget(ctx, principal, id, func(ctx context.Context, q *sqlc.Queries) error { + if err := activeProject(ctx, q, principal); err != nil { + return err + } + keys, err := q.ListEnvironmentExecutorCredentials(ctx, sqlc.ListEnvironmentExecutorCredentialsParams{TenantID: tenant, EnvironmentID: id, SubjectKind: pgtype.Text{String: principal.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: principal.SubjectID, Valid: true}}) + if err != nil { + return err + } + if len(keys) > 0 { + // Existing operator-issued keys must not be replaced by onboarding. + if len(keys) != 1 || keys[0].KeyID != id || keys[0].RevokedAt.Valid { + return ErrExecutorCredentialExists + } + _, err := q.AuthenticateEnvironmentExecutor(ctx, sqlc.AuthenticateEnvironmentExecutorParams{EnvironmentID: id, TokenSha256: digest}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrExecutorCredentialExists + } + return err + } + _, err = q.IssueExecutorCredential(ctx, sqlc.IssueExecutorCredentialParams{KeyID: id, TenantID: tenant, SubjectKind: pgtype.Text{String: principal.SubjectKind, Valid: true}, SubjectID: pgtype.Text{String: principal.SubjectID, Valid: true}, OrganizationID: principal.OrganizationID, ProjectID: principal.ProjectID, EnvironmentID: id, TokenSha256: digest}) + if errors.Is(err, pgx.ErrNoRows) { + return ErrExecutorCredentialExists + } + return err + }) +} diff --git a/services/agents-api/internal/store/environment_installation_test.go b/services/agents-api/internal/store/environment_installation_test.go new file mode 100644 index 000000000..a8d7f77f7 --- /dev/null +++ b/services/agents-api/internal/store/environment_installation_test.go @@ -0,0 +1,103 @@ +package store + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func TestEnvironmentInstallationClaimLifetimeAndRetries(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{37}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + ctx := t.Context() + project := createTestProject(t, s) + binding, err := s.GetProject(ctx, project.ID) + if err != nil { + t.Fatal(err) + } + p := binding.Principal + input := environmentInput(uuid.NewString(), "self_hosted", "/workspace") + input.Creator = p.Subject() + session, err := s.CreateSession(ctx, p.TenantID, input) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(ctx, p.TenantID, session.ID) + if err != nil { + t.Fatal(err) + } + token, expires, err := s.AuthorizeEnvironmentInstallation(ctx, p, environment.ID, "build") + if err != nil || expires <= time.Now().Unix() || expires > time.Now().Add(31*time.Minute).Unix() { + t.Fatal("authorization", err) + } + for _, pair := range [][2]string{{token + "x", "build"}, {token, "other-build"}, {"", "build"}} { + if _, err := s.ValidateEnvironmentInstallation(ctx, pair[0], pair[1]); !errors.Is(err, ErrInstallationAuthorization) { + t.Fatal("accepted invalid authorization", err) + } + } + payload, _, _ := strings.Cut(token, ".") + raw, _ := base64.RawURLEncoding.DecodeString(payload) + var expired InstallationAuthorization + _ = json.Unmarshal(raw, &expired) + expired.ExpiresAt = time.Now().Add(-time.Second).Unix() + raw, _ = json.Marshal(expired) + payload = base64.RawURLEncoding.EncodeToString(raw) + signature, _ := cipher.Fingerprint("environment-installation", payload) + if _, err := s.ValidateEnvironmentInstallation(ctx, payload+"."+signature, "build"); !errors.Is(err, ErrInstallationAuthorization) { + t.Fatal("accepted expired grant", err) + } + one, _, _ := newExecutorSecret() + two, _, _ := newExecutorSecret() + secrets := []string{one, two} + results := make([]error, 2) + var wg sync.WaitGroup + for i := range secrets { + wg.Add(1) + go func() { defer wg.Done(); results[i] = s.ClaimEnvironmentInstallation(ctx, token, "build", secrets[i]) }() + } + wg.Wait() + winner := -1 + for i, err := range results { + if err == nil { + if winner >= 0 { + t.Fatal("two machines claimed one Environment") + } + winner = i + } else if !errors.Is(err, ErrExecutorCredentialExists) { + t.Fatal(err) + } + } + if winner < 0 { + t.Fatal("no claim succeeded") + } + if err := s.ClaimEnvironmentInstallation(ctx, token, "build", secrets[winner]); err != nil { + t.Fatal("lost-response retry", err) + } + if _, err := s.AuthenticateEnvironmentExecutor(ctx, environment.ID, executorDigest(secrets[winner])); err != nil { + t.Fatal(err) + } + if err := s.RevokeExecutorCredential(ctx, p, environment.ID); err != nil { + t.Fatal(err) + } + if err := s.ClaimEnvironmentInstallation(ctx, token, "build", secrets[winner]); !errors.Is(err, ErrExecutorCredentialExists) { + t.Fatal("revoked key resurrected", err) + } + if err := s.DeleteSession(ctx, p.TenantID, session.ID); err != nil { + t.Fatal(err) + } + if _, err := s.ValidateEnvironmentInstallation(ctx, token, "build"); !errors.Is(err, ErrInstallationAuthorization) { + t.Fatal("deleted Session grant accepted", err) + } +} From 8db6cc8ee34f79cdde7f0a75d713d05f5e81c008 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 15:59:27 +0800 Subject: [PATCH 2/5] fix(onboarding): qualify native bootstrap packaging and refresh guides --- .github/workflows/release.yml | 4 +- .../content/docs/api-reference/core/index.mdx | 1 + .../content/docs/api-reference/core/meta.json | 1 + .../core/native-installation.mdx | 23 +++ .../docs/api-reference/machine/index.mdx | 1 + .../docs/api-reference/machine/meta.json | 1 + .../machine/native-installation.mdx | 26 ++++ apps/docs/content/docs/configure.mdx | 8 + apps/docs/content/docs/public-api.mdx | 20 ++- .../content/docs/self-hosted-execution.mdx | 59 +++----- apps/docs/content/docs/self-hosted-native.mdx | 143 ++++++++---------- apps/docs/content/guide-sources.json | 16 +- apps/docs/openapi/core-api.yaml | 73 +++++++++ apps/docs/openapi/public-api.yaml | 25 +++ apps/docs/openapi/runtime-api.yaml | 124 +++++++++++++++ apps/docs/openapi/sources.json | 22 +-- apps/docs/scripts/verify-docs-facts.mjs | 1 - apps/web/src/i18n/locales/zh-CN/sessions.ts | 4 +- docs/api/README.md | 9 +- docs/configuration.md | 8 + docs/getting-started/self-hosted.md | 59 +++----- scripts/native-onboarding-smoke.mjs | 3 +- .../nativeinstaller/assets/bootstrap.ps1 | 2 +- 23 files changed, 443 insertions(+), 190 deletions(-) create mode 100644 apps/docs/content/docs/api-reference/core/native-installation.mdx create mode 100644 apps/docs/content/docs/api-reference/machine/native-installation.mdx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb61d4c94..fe48769a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,9 +102,9 @@ jobs: with: pattern: oac-native-installer-* merge-multiple: true - path: native-artifacts + path: ${{ runner.temp }}/native-artifacts - name: Assemble the native installation catalog - run: node scripts/build-native-catalog.mjs native-artifacts "$RUNNER_TEMP/native-installers" + run: node scripts/build-native-catalog.mjs "$RUNNER_TEMP/native-artifacts" "$RUNNER_TEMP/native-installers" - name: Build matched artifacts env: OAC_NATIVE_INSTALLER_BUILD_DIR: ${{ runner.temp }}/native-installers diff --git a/apps/docs/content/docs/api-reference/core/index.mdx b/apps/docs/content/docs/api-reference/core/index.mdx index a80a06dbc..ad2fc6c57 100644 --- a/apps/docs/content/docs/api-reference/core/index.mdx +++ b/apps/docs/content/docs/api-reference/core/index.mdx @@ -17,6 +17,7 @@ Operator scripts use Core’s loopback port. The public entry routes management - [agents](/api-reference/core/agents) - [environment-templates](/api-reference/core/environment-templates) - [executor-credentials](/api-reference/core/executor-credentials) +- [native-installation](/api-reference/core/native-installation) - [files](/api-reference/core/files) - [write-audit](/api-reference/core/write-audit) - [sessions](/api-reference/core/sessions) diff --git a/apps/docs/content/docs/api-reference/core/meta.json b/apps/docs/content/docs/api-reference/core/meta.json index 87f295cfb..fb07e650d 100644 --- a/apps/docs/content/docs/api-reference/core/meta.json +++ b/apps/docs/content/docs/api-reference/core/meta.json @@ -8,6 +8,7 @@ "agents", "environment-templates", "executor-credentials", + "native-installation", "files", "write-audit", "sessions", diff --git a/apps/docs/content/docs/api-reference/core/native-installation.mdx b/apps/docs/content/docs/api-reference/core/native-installation.mdx new file mode 100644 index 000000000..a03fdfca8 --- /dev/null +++ b/apps/docs/content/docs/api-reference/core/native-installation.mdx @@ -0,0 +1,23 @@ +--- +title: Native Installation +description: >- + Native Installation. Core administration API: Core key held by Web’s server or + an operator script. Generated local management contract. This is not part of + the public OpenAI API. +full: true +_openapi: + method: GET + route: /core/v1/projects/{project_id}/environments/{environment_id}/installation + toc: [] + structuredData: + headings: [] + contents: + - content: >- + Core key only. The commands contain a 30-minute installation + authorization, never an executor secret. Web displays these same + commands provided in public Session creation and detail responses. +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + + \ No newline at end of file diff --git a/apps/docs/content/docs/api-reference/machine/index.mdx b/apps/docs/content/docs/api-reference/machine/index.mdx index db719d415..a6d699b4d 100644 --- a/apps/docs/content/docs/api-reference/machine/index.mdx +++ b/apps/docs/content/docs/api-reference/machine/index.mdx @@ -11,4 +11,5 @@ This schema covers node configuration, enrollment and identity. Daemon WebSocket [API namespaces and credentials](/public-api) · [Application reference](/api-reference) · [Administration reference](/api-reference/core) · [Machine reference](/api-reference/machine) +- [native-installation](/api-reference/machine/native-installation) - [sandbox-node](/api-reference/machine/sandbox-node) diff --git a/apps/docs/content/docs/api-reference/machine/meta.json b/apps/docs/content/docs/api-reference/machine/meta.json index 717a2b47d..5d07906a3 100644 --- a/apps/docs/content/docs/api-reference/machine/meta.json +++ b/apps/docs/content/docs/api-reference/machine/meta.json @@ -2,6 +2,7 @@ "title": "Machine connection API", "pages": [ "index", + "native-installation", "sandbox-node" ] } diff --git a/apps/docs/content/docs/api-reference/machine/native-installation.mdx b/apps/docs/content/docs/api-reference/machine/native-installation.mdx new file mode 100644 index 000000000..80f373a71 --- /dev/null +++ b/apps/docs/content/docs/api-reference/machine/native-installation.mdx @@ -0,0 +1,26 @@ +--- +title: Native Installation +description: >- + Native Installation. Machine connection API: Route-specific node enrollment, + node, daemon, or executor credential. Generated local machine contract. These + connections reach Core directly, never through Web. +full: true +_openapi: + toc: [] + structuredData: + headings: [] + contents: + - content: >- + Accepts a short-lived Environment installation Bearer authorization, + not a Project or Core key. Returns frozen connection constraints; it + does not claim or rotate credentials. + - content: >- + A valid installation Bearer authorization can claim one connect-only + key. The client persists its generated secret before submitting it. + Retries must present that same secret; a different, rotated or revoked + credential is never replaced. +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + + \ No newline at end of file diff --git a/apps/docs/content/docs/configure.mdx b/apps/docs/content/docs/configure.mdx index eb700179a..862bf167b 100644 --- a/apps/docs/content/docs/configure.mdx +++ b/apps/docs/content/docs/configure.mdx @@ -269,4 +269,12 @@ paths must be canonical absolute paths without control characters, quotes, backs or wildcards. Keep the installation ID and the database together; Core refuses a missing installation ID when its database already has a deployment. +### Native daemon distributions + +Release Core images include matched self-hosted installers. A standalone Core +process can set `OAC_NATIVE_INSTALLER_DIR` to the release's `native-installers` +directory. Core checks the catalog's source revision, Runtime protocol and archive +checksums before serving it. This setting supplies installation artifacts only; +it does not change Runtime preparation, permissions or execution. + [Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e4d5a1d30520a967b3a44a257ed1b6c21e388388/docs/configuration.md) diff --git a/apps/docs/content/docs/public-api.mdx b/apps/docs/content/docs/public-api.mdx index 6783eac44..51e2a1acd 100644 --- a/apps/docs/content/docs/public-api.mdx +++ b/apps/docs/content/docs/public-api.mdx @@ -3,14 +3,15 @@ title: "API namespaces and credentials" description: "The public application API, private administration API and machine connection interface." --- -Core serves three namespaces. Each has one kind of caller and its own credential; -no credential works in another namespace. +Core serves three namespaces. Protected operations authenticate their own callers; +credentials cannot be substituted across these boundaries. Versioned native +installer downloads are public release content. | Namespace | Caller | Credential | Contents | Reference | | --- | --- | --- | --- | --- | -| `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`) | [Public API](/sessions) | +| `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`, Session `installation`) | [Public API](/sessions) | | `/core/v1` | Core Web's server and operator scripts | [Core key](/troubleshooting#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](/admin-api), [Core OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e4d5a1d30520a967b3a44a257ed1b6c21e388388/contracts/agents-api/core.openapi.yaml) | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/e4d5a1d30520a967b3a44a257ed1b6c21e388388/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/e4d5a1d30520a967b3a44a257ed1b6c21e388388/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e4d5a1d30520a967b3a44a257ed1b6c21e388388/contracts/agents-api/runtime.openapi.yaml) | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: short-lived Session installation grants, node enrollment tokens and executor credentials issued through `/core/v1` or claimed by installation, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine bootstrap and connections: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](https://github.com/MiniMax-AI/parsar-core/blob/e4d5a1d30520a967b3a44a257ed1b6c21e388388/services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](https://github.com/MiniMax-AI/parsar-core/blob/e4d5a1d30520a967b3a44a257ed1b6c21e388388/contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](https://github.com/MiniMax-AI/parsar-core/blob/e4d5a1d30520a967b3a44a257ed1b6c21e388388/contracts/agents-api/runtime.openapi.yaml) | A Project API key gets 401 on `/core/v1` and `/api/v1`; the Core key gets 401 on `/v1` and `/api/v1`. Projects own assets. Multiple equally privileged keys share @@ -103,4 +104,15 @@ Core administration failures use the [Core error envelope](https://github.com/Mi including typed optional safe details and distinct console proxy rejection codes. The public and machine error contracts remain unchanged. +### Self-hosted installation + +Authenticated Session creation/detail responses include short-lived commands in +`x_agents_core.installation`. Core Web reads the same commands at +`GET /core/v1/projects/{project_id}/environments/{environment_id}/installation`. +Machine installers use `POST /api/v1/agent-daemon/installation` and its `/claim` +subroute with the installation Bearer authorization. Qualified artifacts beneath +`/api/v1/agent-daemon/install/{version}/` are public, immutable release content. +See [native self-hosted installation](/self-hosted-native) for expiry, retry, +credential ownership and platform rules. + [Repository source](https://github.com/MiniMax-AI/parsar-core/blob/e4d5a1d30520a967b3a44a257ed1b6c21e388388/docs/api/README.md) diff --git a/apps/docs/content/docs/self-hosted-execution.mdx b/apps/docs/content/docs/self-hosted-execution.mdx index 23f909331..b0f7b3756 100644 --- a/apps/docs/content/docs/self-hosted-execution.mdx +++ b/apps/docs/content/docs/self-hosted-execution.mdx @@ -4,8 +4,8 @@ description: "Connect a user-owned Runtime to its Environment with a restricted --- A `self_hosted` Session runs on a machine the application owns. The application -creates the Session through `/v1`; the administrator issues an executor credential -in Web or through `/core/v1`; the host runs `oac-daemon` with that credential. +creates the Session through `/v1` and receives a command that installs and connects +`oac-daemon`. Web displays the same command in the Session; it is optional. Linux, macOS and Windows use the same Runtime protocol. Core-managed Providers remain Linux-only. @@ -23,8 +23,8 @@ Otherwise creation fails with 400 `model_provider_required`. ## Connect a host -1. Create an existing workspace on the executor host. The application creates a - Session using that host's absolute path and its own Project API key: +1. Choose an absolute workspace path on the target host. Create a Session with + that path and the application's Project API key: ```python import os @@ -45,23 +45,20 @@ Otherwise creation fails with 400 `model_provider_required`. }}, }, ) - print(session.id, session.environment.id, session.environment.remote_url) + installation = session.model_dump()["x_agents_core"]["installation"] + print(installation["commands"]["posix"]) # use "powershell" for Windows ``` -2. In Web, open **Session log**, then the Session's **Executor credentials** - section. Choose **Issue credential**, then **Download credential file**. Core - returns the credential only once; retain it privately before choosing **Done**. -3. Extract the native distribution and run `oac-daemon install --interactive` - (or supply all options with `--non-interactive --harness`). Use the returned - remote URL, Environment ID, matching workspace and credential file path, then - start the installed `bin/oac-daemon`. - The [native guide](/self-hosted-native#install-and-start) has Linux/macOS and - PowerShell examples. No Docker installation is required for this native path. - -The console's **Connect a host** flow provides native installation guidance and -the executor credential download. Obtain the matching native distribution before -running its command. Core must be reachable from the host: `wss://` is required -outside loopback, while a local Core can use a loopback `ws://` URL. +2. Run the returned command on the target machine. Select the Harnesses and + installation directory when prompted. Installation creates the workspace if + needed, starts the daemon and checks its connection. For automation, append + `--non-interactive --harness codex` and optionally `--install-dir ABS`. + +In Web, open the **Self-hosted** Session and copy the command under **Connect a +host**. A command expires after 30 minutes; fetch the Session again for a fresh +one. The [native guide](/self-hosted-native#install-and-connect) covers retry, +platform prerequisites and credential storage. Core must be reachable from the +host with TLS outside loopback. Native installation does not require Docker. A connected Environment proves only the machine connection. Send a Turn to check the selected harness and model. Core supplies the Session's model provider over @@ -122,26 +119,12 @@ Stopping the daemon keeps its workspace and native history. Deleting a Session does not remove host files. In an archived Project, credentials cannot be issued or rotated; revocation remains available. -## Without Web - -Scripts on the Core host can issue credentials with the Core key through Core's -loopback port (`ports.core` in `config.json`, 8091 by default). Choose a new UUID for -the credential and keep it: - -```sh -key_id=$(python3 -c 'import uuid; print(uuid.uuid4())'); echo "credential ID: $key_id" -(umask 077; curl -fsS -X POST \ - -H @<(printf 'Authorization: Bearer %s\n' "$(cat "$HOME/.oac/core/secrets/core.key")") \ - -H 'Content-Type: application/json' -d "{\"key_id\":\"$key_id\"}" \ - "http://127.0.0.1:8091/core/v1/projects/$PROJECT_ID/environments/$ENVIRONMENT_ID/executor-credentials" \ - -o executor-key.json) -``` +## Operator credential management -Rotate with `{"key_id":"…","rotate":true}` on the same route; revoke with -`DELETE …/executor-credentials/`. After an uncertain response, list the -credentials with `GET` before trying again. The -[credential contract](https://github.com/MiniMax-AI/parsar-core/blob/e4d5a1d30520a967b3a44a257ed1b6c21e388388/contracts/agents-api/environment-executor-credentials.md) -has every rule and error. +Operators can still issue, rotate or revoke executor credentials using a Core key +through Core's loopback port. This is not required for one-command onboarding. +See the [credential contract](https://github.com/MiniMax-AI/parsar-core/blob/e4d5a1d30520a967b3a44a257ed1b6c21e388388/contracts/agents-api/environment-executor-credentials.md) +for those routes and uncertain-response handling. ## Historical executor installations diff --git a/apps/docs/content/docs/self-hosted-native.mdx b/apps/docs/content/docs/self-hosted-native.mdx index da4a662e5..748b2cbbf 100644 --- a/apps/docs/content/docs/self-hosted-native.mdx +++ b/apps/docs/content/docs/self-hosted-native.mdx @@ -43,94 +43,75 @@ by a Session's capability dependencies, must be available. Missing system components are reported. Install those through the host's normal administration process; the daemon never runs apt, sudo or an elevation command. -## Obtain a distribution - -Before a public release exists, build a native package with the repository's -`native-installer` workflow and download its artifact for the target platform. -Extract the archive before running the executable. The workflow publishes CI -artifacts, not a production release or an automatic update channel. - -For a local release build, use `scripts/build-native-installer.mjs --daemon PATH ---node DIR --codex DIR --claude DIR --minimax DIR --output ABS`. Select the -components to include; omit MiniMax on Windows. Build on the target OS, using -the existing pinned native artifacts. The builder verifies native startup and -creates `bundle.json`, `oac-daemon` (or `.exe`) and `components/`. -`bundle.json` describes release files and checksums. It does not accept user -installation options, connection settings or credentials. - -The Claude source is the existing compiled adapter export from modern -`pnpm deploy`. Reify its dedicated frozen lock with -`pnpm install --prod --frozen-lockfile --config.node-linker=hoisted --ignore-scripts` -in a fresh export before packaging. This preserves dependency resolution when -the builder converts contained links to regular files. MiniMax must be built -from the pinned source with this revision's patches and native dependencies; -copying a Linux companion onto macOS does not produce a macOS distribution. - -## Install and start - -Create a `self_hosted` Session with its model provider and an existing absolute -`workspace_directory` on the executor host. Save its `environment.id` and -unchanged `environment.remote_url`. Have the administrator issue an -[executor credential](/self-hosted-execution#without-web) and save the JSON -as a private file. It contains `key_id`, `environment_id` and `executor_token`. -Pass the file path, never the token. Use `wss://` outside loopback; `ws://` is -accepted only for a loopback Core. The workspace must match the Session. - -From the extracted distribution, interactive installation asks for Harnesses -(comma-separated, multiple selections allowed), installation directory, workspace -and connection settings. Existing command-line values also work with the prompts: +## Install and connect + +Create a Session using the public Agents API with `environment.type: "self_hosted"`. +The response retains the official Environment `id` and `remote_url`, and adds +`x_agents_core.installation` with `commands.posix`, `commands.powershell` and +`expires_at`. Execute the command for your target platform. Core Web shows the +same commands in the **Self-hosted** Session's connection section; Web is not a +prerequisite for API callers. + +The command downloads the distribution matched to this Core, verifies its archive, +asks which Harnesses to install and where, installs them, starts the daemon and +checks its authenticated connection. The Session's required Harness must remain +selected. Its workspace is frozen at Session creation; the installer creates that +directory if necessary using your existing permissions. To choose a different +workspace, create a Session with that path. No administrator privileges or Docker +are required. + +For automation, append `--non-interactive --harness codex` and optionally +`--install-dir ABS` to the command. Multiple Harnesses use a comma-separated value, +for example `--harness codex,claude`. Missing required input fails without prompting. +Interactive installation defaults to a separate directory for each Environment: +`~/.oac/environments/` (or beneath `OAC_RUNTIME_HOME`). + +The command carries a 30-minute authorization restricted to this Environment and +Core build. Treat it as a temporary credential. Refresh the Session detail or +copy a fresh Web command after expiry. It cannot execute tasks or read files. +The installer generates a private connect-only credential file before claiming +its key, so a lost response can be retried without losing the credential. The +long-term secret never appears in the command or terminal. A different machine +cannot use the command to replace an already claimed key. Session deletion, +Project archival, expiry or a different Core build invalidates the authorization; +new commands never revive revoked credentials. + +Installation reports three separate results: **Installation**, **Daemon +connection**, and **Model configuration**. This workflow does not configure or +validate model access. If connection is not confirmed, inspect the reported local +log and the Session's connection status. Rerun with the same installation directory +to resume; completed components and credentials are retained and an existing +daemon is reused. After authentication failures, check the Environment credential +in Core. Do not remove the workspace or Session history to retry. + +Qualified release distributions contain Linux amd64, macOS arm64 and Windows +amd64 installers. Core serves these matched artifacts directly, including in a +private repository deployment. Unsupported platforms fail explicitly. Operators +running a standalone Core binary can set `OAC_NATIVE_INSTALLER_DIR` to its matched +`native-installers` directory. Without qualified artifacts, Session responses +report installation unavailable instead of selecting another version. + +## Manual distribution installation + +The same installer also accepts an already-extracted distribution and an explicitly +supplied private executor credential, without the bootstrap command: ```sh -./oac-daemon install --interactive -``` - -Noninteractive installation accepts command-line arguments only and never waits -for input. On Linux or macOS: - -```sh -mkdir -p "$HOME/agent-workspace" -chmod 600 "$HOME/executor-credential.json" -./oac-daemon install --non-interactive \ - --harness codex,claude,minimax \ - --install-dir "$HOME/.oac/runtime-example" \ +./oac-daemon install --non-interactive --harness codex \ + --install-dir "$HOME/.oac/my-runtime" \ --remote 'wss://core.example/api/v1/agent-daemon/ws' \ --environment-id '11111111-2222-4333-8444-555555555555' \ - --workspace "$HOME/agent-workspace" \ + --workspace "$HOME/workspace" \ --credential-file "$HOME/executor-credential.json" -"$HOME/.oac/runtime-example/bin/oac-daemon" start -``` - -On Windows, use native absolute paths in PowerShell: - -```powershell -New-Item -ItemType Directory -Force "$HOME\agent-workspace" | Out-Null -.\oac-daemon.exe install --non-interactive ` - --harness codex,claude ` - --install-dir "$HOME\.oac\runtime-example" ` - --remote 'wss://core.example/api/v1/agent-daemon/ws' ` - --environment-id '11111111-2222-4333-8444-555555555555' ` - --workspace "$HOME\agent-workspace" ` - --credential-file "$HOME\executor-credential.json" -& "$HOME\.oac\runtime-example\bin\oac-daemon.exe" start +"$HOME/.oac/my-runtime/bin/oac-daemon" start ``` -Replace the URL and UUID with the Session's values. Keep the credential file in -the account's private storage. The installer does not print credential contents -or pass them in the background daemon's arguments. - -| Option | Meaning | -| --- | --- | -| `--harness` | Comma-separated `codex`, `claude`, `minimax`; unsupported combinations fail | -| `--install-dir ABS` | User-writable installation; defaults to `OAC_RUNTIME_HOME`, then `~/.oac` | -| `--bundle-dir ABS` | Extracted release directory; defaults beside the executable | -| `--remote`, `--environment-id`, `--workspace`, `--credential-file` | Required connection inputs, equally available in both modes | -| `--capability-directory ABS` | Capability snapshot destination, default `capabilities` under the installation | -| `--tool-env-file ABS` | Optional JSON object of string-valued tool/MCP variables, not installation options | - -Installation reports three independent facts: local installation readiness, -connection not yet checked, and model configuration not yet checked. It does not -start a daemon, provision a machine, configure a model or create an OS service. -`start --foreground` runs under an operator's preferred service manager. +Use `.\oac-daemon.exe` and native absolute paths in PowerShell. This manual mode +requires an existing workspace and starts only when `start` is invoked. Optional +`--capability-directory ABS` selects snapshot storage; `--tool-env-file ABS` +supplies tool/MCP variables. They do not introduce another installation workflow. +Build distributions on their target OS with `scripts/build-native-installer.mjs`; +`bundle.json` describes release content and checksums, not installation options. ## Add Harnesses and operate the installation diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 1d50de046..5c3bbc2f1 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -6,16 +6,16 @@ "docs/web/architecture.md": "80bdb8053c06c2b2030b193aae17d5434545f9c72af50e9d3df3ade522fe25ed", "docs/getting-started/install.md": "a437b90c2cc4755e0fd760066837bfec96e9aa4a4a4bc5bc865b8ad02162c197", "docs/getting-started/install-options.md": "aac219f367619036aa01573a295802fb8f0418e3d6db931efcfc88f17cf7734c", - "docs/configuration.md": "39cfa7f17025a615ebe2806651800ce8437cea741dd43227828b0b311c4c174b", + "docs/configuration.md": "9b18d65f83cc270a937ea4da9aeb6b46645c75635b44a52f3ab1a07f882b440a", "docs/web/core-connection.md": "46f86520e70bf41079708e2c398655aa9087ad9488c8e661d9a73035d11098c4", "docs/getting-started/quickstart.md": "238b4de1137edb8c2998b38f4525fea345a19fd4de7a455aa9c4c9e4fc3c9b36", - "docs/api/README.md": "592fc2aef474e36eb760be0dd1b2269b6766c0d984f826eaf6f47711ea139387", + "docs/api/README.md": "37867a2a1fa5660f343ceed779bf034f2a19a2cd5f516627282c4a87ab68c165", "contracts/agents-api/execution-tools.md": "e0b61f6c0c236c362186c5f6d0ad69a16dd8a8d9afabe1329a1fe22e5f4a71e9", "docs/api/public-agent-api.md": "5b04e5ec9311df851376f66fc1584dd48ca123cd8c26ca63277c10b9172e9955", "contracts/agents-api/environments.md": "4c4b9423b22919d6aa6bd98d96422e1a8d610d3442d4cf7731a575bd31fd43f0", "docs/getting-started/nodes.md": "e50a2ca588c9d7cb836aa58e7b86ca685cd9da57742ceaee83ee0d549e538a94", - "docs/getting-started/self-hosted.md": "99d8851399bde2310196ac89dae1c365bba144421dbb33153094d30072de1783", - "docs/self-hosted-native.md": "e69819c3201109c8be5413716853eafa22d9d84b57aac780a4d335149b6a926b", + "docs/getting-started/self-hosted.md": "71aa6e0cb590c94cda98647a4867b611f325a51aff4b56689ed3e1a9531e1290", + "docs/self-hosted-native.md": "8fc6beccf28ace62e126faa03a923c669298b35213159c5d67b9ac3c7272741b", "apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "docs/web/README.md": "9fce295f6f6ff04acefe338679c1e54723e123dd0027a778202adfa9ab95b6c4", "docs/api/web-management.md": "d5f982922320069be5017cd524095d86eee0d66fd0d1cf97d1cdd97544d0dce5", @@ -34,16 +34,16 @@ "content/docs/execution-model.mdx": "582e06b1c59fb6fb55da0849db753c6280585e636f68cbb34a812071cd11c4fb", "content/docs/install.mdx": "1bd34e90289187eb072753c2f0a3161b7682916e36c90dc4d90400d6ed7ea221", "content/docs/install-options.mdx": "8979ba5464db33310a3623d1e9bb8a33f74883e5ec7c4e7412354e62620f247d", - "content/docs/configure.mdx": "6ad08ae6de1e1d6376fbafa2c658338022cca202e36a0b31847167b196b607bd", + "content/docs/configure.mdx": "d6eea975362926679173ed7753fece1f0be4ef12368790832f76039888dc0b14", "content/docs/bootstrap-projects-keys.mdx": "174d0e61e72e51b08b558fb81fd413f89af58c423435d189d84ba6cb67976d34", "content/docs/quickstart.mdx": "c92e0eba73e8725c44062be063e245704de7002e15f92ea0a4eb17498853c171", - "content/docs/public-api.mdx": "2136bb9a96ffe2bfde504df9833d1147c3d22548bbf510c42523cecea2f49e29", + "content/docs/public-api.mdx": "198e171ea4e36695fe346a8226b0371f4b3c4ab7caab46b60c6986a042160194", "content/docs/agents-and-tools.mdx": "a3a6a669e25af073bf17cfb9e9699a6fd875dc31cd770fd28c12401078396ae4", "content/docs/sessions.mdx": "c4abe10b9115174d10c10c73a373088460276f99f6c2cfe6df6b6aba8d6c7382", "content/docs/environments-and-files.mdx": "ce1bab795e30dd4d9d33e5c528fde09f440f9e40aa73fa1461d76e84dfa65008", "content/docs/hosted-providers.mdx": "1076b39c59b0b63cc57a92eb738f4b5bd6b842787b16f51f9da0faa3e184ff9b", - "content/docs/self-hosted-execution.mdx": "dc94873d1b1bb5aa49380b095e92b988699f79c1ae497e0a28dab54aac4957c1", - "content/docs/self-hosted-native.mdx": "80185731954638a697652650112a8e2ebc348a6c8b42712a34f9755912774d14", + "content/docs/self-hosted-execution.mdx": "f44c344bdc85cbd115b0f80e13e1d811b46364812c1591fee290ca58717bba3f", + "content/docs/self-hosted-native.mdx": "1f77cf942853d13b9e7be04bde9f9ba072ee8d1b4bcb7d6a05266674c6105df6", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "content/docs/console.mdx": "f190022b65fa2ca1ac6dc9cac8e743f63bcbfb6278e8c85b7773f2fc6ca62728", "content/docs/admin-api.mdx": "f9711580e69545daae9f364d4d5e246b2a7cf364fb8d8d7691576c63cde0e7b3", diff --git a/apps/docs/openapi/core-api.yaml b/apps/docs/openapi/core-api.yaml index edaa9d7a3..6a0fca892 100644 --- a/apps/docs/openapi/core-api.yaml +++ b/apps/docs/openapi/core-api.yaml @@ -1034,6 +1034,52 @@ paths: summary: Revoke a self_hosted Environment executor credential tags: - Executor Credentials + /core/v1/projects/{project_id}/environments/{environment_id}/installation: + get: + description: Core key only. The commands contain a 30-minute installation authorization, never an executor secret. Web displays these same commands provided in public Session creation and detail responses. + parameters: + - schema: + type: string + description: Project UUID + in: path + name: project_id + required: true + - schema: + type: string + description: Environment UUID + in: path + name: environment_id + required: true + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/v1.EnvironmentInstallation' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + security: + - DeploymentAdminAuth: [] + summary: Get a self_hosted Session's installation commands + tags: + - Native Installation /core/v1/projects/{project_id}/files: get: description: Core key only. Reuses the public resource projection and operation rules; the Project ID selects the target space and does not authenticate. @@ -5863,6 +5909,24 @@ components: - has_more - object type: object + v1.EnvironmentInstallation: + properties: + commands: + additionalProperties: + type: string + type: object + expires_at: + type: integer + message: + type: string + status: + enum: + - available + - unavailable + type: string + version: + type: string + type: object v1.EnvironmentNetwork: properties: access: @@ -6940,6 +7004,8 @@ components: items: type: string type: array + x_agents_core: + $ref: '#/components/schemas/v1.SessionCore' required: - agent - created_at @@ -7022,6 +7088,11 @@ components: - has_more - object type: object + v1.SessionCore: + properties: + installation: + $ref: '#/components/schemas/v1.EnvironmentInstallation' + type: object v1.SessionDeleted: properties: deleted: @@ -7607,6 +7678,8 @@ tags: description: 'Environment Templates. Core administration API: Core key held by Web’s server or an operator script. Generated local management contract. This is not part of the public OpenAI API.' - name: Executor Credentials description: 'Executor Credentials. Core administration API: Core key held by Web’s server or an operator script. Generated local management contract. This is not part of the public OpenAI API.' + - name: Native Installation + description: 'Native Installation. Core administration API: Core key held by Web’s server or an operator script. Generated local management contract. This is not part of the public OpenAI API.' - name: Files description: 'Files. Core administration API: Core key held by Web’s server or an operator script. Generated local management contract. This is not part of the public OpenAI API.' - name: Write Audit diff --git a/apps/docs/openapi/public-api.yaml b/apps/docs/openapi/public-api.yaml index 716a944c6..4b2b205c4 100644 --- a/apps/docs/openapi/public-api.yaml +++ b/apps/docs/openapi/public-api.yaml @@ -3948,6 +3948,24 @@ components: - status - type type: object + v1.EnvironmentInstallation: + properties: + commands: + additionalProperties: + type: string + type: object + expires_at: + type: integer + message: + type: string + status: + enum: + - available + - unavailable + type: string + version: + type: string + type: object v1.EnvironmentNetwork: properties: access: @@ -4777,6 +4795,8 @@ components: items: type: string type: array + x_agents_core: + $ref: '#/components/schemas/v1.SessionCore' required: - agent - created_at @@ -4859,6 +4879,11 @@ components: - has_more - object type: object + v1.SessionCore: + properties: + installation: + $ref: '#/components/schemas/v1.EnvironmentInstallation' + type: object v1.SessionDeleted: properties: deleted: diff --git a/apps/docs/openapi/runtime-api.yaml b/apps/docs/openapi/runtime-api.yaml index 050619b1b..18835818d 100644 --- a/apps/docs/openapi/runtime-api.yaml +++ b/apps/docs/openapi/runtime-api.yaml @@ -8,6 +8,77 @@ info: title: OpenAgentCore Machine Connections version: '1' paths: + /api/v1/agent-daemon/installation: + post: + description: Accepts a short-lived Environment installation Bearer authorization, not a Project or Core key. Returns frozen connection constraints; it does not claim or rotate credentials. + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/v1.NativeInstallationContext' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '503': + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + summary: Resolve a native installation authorization + tags: + - Native Installation + /api/v1/agent-daemon/installation/claim: + post: + description: A valid installation Bearer authorization can claim one connect-only key. The client persists its generated secret before submitting it. Retries must present that same secret; a different, rotated or revoked credential is never replaced. + responses: + '204': + description: No Content + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + '503': + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/api.CoreErrorResponse' + summary: Claim an Environment's installation credential + tags: + - Native Installation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/api.NativeInstallationClaim' + description: Locally persisted executor secret + required: true /api/v1/sandbox-node/configuration: get: description: Authenticates with an unconsumed enrollment token, or a retained node credential with X-OAC-Node-ID. Does not consume the token or expose E2B credentials. Node files cannot override this specification. @@ -189,6 +260,42 @@ servers: description: Documentation placeholder; substitute your Core public origin. components: schemas: + api.CoreAPIError: + properties: + code: + anyOf: + - type: string + - type: 'null' + details: + description: |- + Details contains only documented, Core-owned facts: string, finite number, + boolean, null or string array values. Never include request echoes, secrets + or native/provider error text. Empty or invalid details are omitted. + type: object + message: + type: string + param: + anyOf: + - type: string + - type: 'null' + type: + type: string + required: + - message + - type + type: object + api.CoreErrorResponse: + properties: + error: + $ref: '#/components/schemas/api.CoreAPIError' + required: + - error + type: object + api.NativeInstallationClaim: + properties: + executor_token: + type: string + type: object sandbox.DeploymentSpec: properties: resources: @@ -324,6 +431,21 @@ components: required: - error type: object + v1.NativeInstallationContext: + properties: + environment_id: + type: string + harness: + type: string + protocol_version: + type: string + remote_url: + type: string + version: + type: string + workspace_directory: + type: string + type: object securitySchemes: NodeAuth: type: http @@ -332,5 +454,7 @@ components: type: http scheme: bearer tags: + - name: Native Installation + description: 'Native Installation. Machine connection API: Route-specific node enrollment, node, daemon, or executor credential. Generated local machine contract. These connections reach Core directly, never through Web.' - name: Sandbox Node description: 'Sandbox Node. Machine connection API: Route-specific node enrollment, node, daemon, or executor credential. Generated local machine contract. These connections reach Core directly, never through Web.' diff --git a/apps/docs/openapi/sources.json b/apps/docs/openapi/sources.json index fcc2735a4..fc1985b82 100644 --- a/apps/docs/openapi/sources.json +++ b/apps/docs/openapi/sources.json @@ -1,8 +1,8 @@ { "sources": { - "contracts/agents-api/openapi.yaml": "5a0c64f040ff30d0bbc51cf2c235657e38cb2b49dcc7b1fb3bf691819be8785d", - "contracts/agents-api/core.openapi.yaml": "6c2d6053b3a7e973fd8248074a83e39ce8dca527589aa6f1ad21f214f01dca5f", - "contracts/agents-api/runtime.openapi.yaml": "23852205d3b45892f2de9ecf3d52518c0dd0808962c0c512f219b565aa7ccef2" + "contracts/agents-api/openapi.yaml": "8f569f6b7629ba1d81558b384b985b1dd7598365c162bd27cb8d65a31cb5b83e", + "contracts/agents-api/core.openapi.yaml": "f036df1154a7fd29a900291c06345754dc3d30c6d04a0f8e23a3f673fce1d51f", + "contracts/agents-api/runtime.openapi.yaml": "505286d5eacf94a14f9527fcf4e7beb4fba4f792681be26ba966254cbf49b4aa" }, "outputs": { "content/docs/api-reference/agents.mdx": "1f7697959e9c52c9d24fbe70e116e807f49912b9a27ac638b86782e2f326af44", @@ -18,7 +18,7 @@ "content/docs/api-reference/skills.mdx": "774ea05a7a8250fe5af5164388b98dafca99d56dc2eae686e8f628acb4e7c10b", "content/docs/api-reference/vaults.mdx": "d80eade1c2f8100030866abbb0fb6b7a09c94250a859c97cdf1abce32fdfdf1e", "content/docs/api-reference/credentials.mdx": "f129a51f74a3c7021c016600518e2350023f8d29571666ac5d64b7584cd7c7d0", - "openapi/public-api.yaml": "c96a42ffe0d11710f9c6df6b3c995344efdfcb3993d167094a0942ce74ebb94f", + "openapi/public-api.yaml": "f9a5955085f075b9bbd81be58a0e37ded8fd9c3ea32275e6ec43676c2f417f78", "content/docs/api-reference/meta.json": "56bf1dc145ccc8adde38c5f956f1a06f18ef18b646122250d01a294c516f8f83", "content/docs/api-reference/index.mdx": "9fd6b1c54149874d2999235067c101326f9e8a2e707a70b6672c81eb3ebfe63f", "content/docs/api-reference/core/core-administration.mdx": "5fc3ada51d2190a141f13738151e4d513390cc3bd66ce6f5013994bda68569b0", @@ -27,6 +27,7 @@ "content/docs/api-reference/core/agents.mdx": "b6c089fbea4b07bc4249e7c7ca5699d47f1512a4fc2d8ee01cb26910ae541a84", "content/docs/api-reference/core/environment-templates.mdx": "05907f35d7f02d66af9da8b1efaa3765badb4cdf7b94d0f6c3ab0413a2cfa1a9", "content/docs/api-reference/core/executor-credentials.mdx": "352dc9cb02cceab6a7fa4fe13356e9532c329277224e7e01d18ae0c02cad1a3e", + "content/docs/api-reference/core/native-installation.mdx": "4399eb6c36a221c9459d6773b0bd3b49ce92df120fbca86f99377bcbb1f77811", "content/docs/api-reference/core/files.mdx": "05ce1d2296dbb3fc76cf4cf98d24dff249114c73529335214cee97363229f384", "content/docs/api-reference/core/write-audit.mdx": "1298590ab7037077e56f6ab8adb020277763f7a670be796d24142323b2f95411", "content/docs/api-reference/core/sessions.mdx": "52db14e7c88cb0521a12a9b3d19b14dd38583b97e716b261a44b2bd6bf05140a", @@ -40,12 +41,13 @@ "content/docs/api-reference/core/vaults.mdx": "12d0aac96cd4a3472ef4eaa287f7945f78862017956e79bfc3ed0ef8e772e78a", "content/docs/api-reference/core/credentials.mdx": "27d586b9a8fd738b9947802f32c80458195370252886bc26abedccf2125ee981", "content/docs/api-reference/core/sandbox-manager.mdx": "6be7c52e057365949f0247c18c710561d680847a955f78f1bbe57825d4d5f549", - "openapi/core-api.yaml": "3ddfcac1de36f44d1d35dca11c90d1da69a6c037522fe15cd6497e00aa6f71dd", - "content/docs/api-reference/core/meta.json": "588b163ed2de2100b0f8376688274f80698a6bf6ac037eef56d17c93fa8a5062", - "content/docs/api-reference/core/index.mdx": "ddf0a8d87a6c21761beac703a32f2a40486a1c74abb341bd726f40c44a866405", + "openapi/core-api.yaml": "326da8a30dc26633d73b2d769e53ab131c0b897cd89c449e941bbc4cfc814406", + "content/docs/api-reference/core/meta.json": "d0aef1f54c4cfc30b4e1d974119ae1cdce1c87d5ecba723afbeb4599119482a7", + "content/docs/api-reference/core/index.mdx": "57cf9ea947ef45e79575c25a0a3d4e5e856dde6d14698ccc2c51b86201c249e9", + "content/docs/api-reference/machine/native-installation.mdx": "421a858685b79fdcf8d7eff308c5768d1b62385c3988c6089f380afe80aaed69", "content/docs/api-reference/machine/sandbox-node.mdx": "5b367a08d43cea82686345d2ba1c75cc90627a7b2970fe42a0df9d77fb3e1e80", - "openapi/runtime-api.yaml": "32adc347bfa8effd501b9c0089a2a4d114cab43d5c26c7947b3a79fdc6ae1f1a", - "content/docs/api-reference/machine/meta.json": "12a1a6534c1cae32918282da2110e4b64c43f17abb868486b5eff50ceb0b01c1", - "content/docs/api-reference/machine/index.mdx": "792c1ce86a6653943021eb5cd03a2c6b5c604f7f012da5ef06f3ffa027b12973" + "openapi/runtime-api.yaml": "00be7da2088c0660ec27e656063a0b74eafe97716426ce6aec756e9e942986e8", + "content/docs/api-reference/machine/meta.json": "aa7e1045fb250535d77cdf54b4d2de12a1403e12439e74ce0e16890aadcd93dc", + "content/docs/api-reference/machine/index.mdx": "335881803f4603a759ab88085e8c46754d6b2450da9b1d9338a87968af5871a2" } } diff --git a/apps/docs/scripts/verify-docs-facts.mjs b/apps/docs/scripts/verify-docs-facts.mjs index 7ee9cce0d..ccb356a2a 100644 --- a/apps/docs/scripts/verify-docs-facts.mjs +++ b/apps/docs/scripts/verify-docs-facts.mjs @@ -17,7 +17,6 @@ const source = slug => fs.readFileSync(path.join(app, 'content/docs', slug + '.m for (const token of ['/v1', '/core/v1', '/api/v1', 'Project API key', 'Core key', 'executor']) assert.ok(source('public-api').includes(token), 'Credential matrix omits ' + token) for (const token of ['config.json', 'oac apply']) assert.ok(source('configure').includes(token), 'Configuration guide omits ' + token) for (const token of ['oac-node', '/var/lib/oac-node/.oac/nodes', 'Node installation and removal require root.']) assert.ok(source('hosted-providers').includes(token), 'Node guide omits ' + token) -for (const token of ['oac-daemon install', 'OAC_RUNTIME_HOME', 'Linux, macOS and Windows']) assert.ok(source('self-hosted-execution').includes(token), 'Executor guide omits ' + token) // Keep the installation policy visible in the operator guides. for (const [slug, tokens] of [ ['troubleshooting', ['In-place version upgrades, downgrades and historical conversions are not supported.', '.oac.lock']], diff --git a/apps/web/src/i18n/locales/zh-CN/sessions.ts b/apps/web/src/i18n/locales/zh-CN/sessions.ts index 3c8addcf3..0f2cae2b9 100644 --- a/apps/web/src/i18n/locales/zh-CN/sessions.ts +++ b/apps/web/src/i18n/locales/zh-CN/sessions.ts @@ -200,8 +200,8 @@ export const sessions = { title: "连接主机", lifecycle: "使用当前账户安装和运行 daemon。可选择多个 Harness,其中必须包含此 Session 使用的 Harness;Windows 不支持 MiniMax。工作目录与此 Session 的配置保持一致。", steps: "复制命令并在自己的机器上运行,即可下载匹配的安装包、安装 Harness、启动 daemon 并验证连接。", - archived: "此项目已归档。安装需要已有的凭据文件,无法签发或轮换新凭据。", - guide: "获取原生发行包 · 安装指南", + archived: "此项目已归档,无法获取新的安装授权。", + guide: "安装指南", platform: "主机系统", start: "命令中的安装授权在 30 分钟后过期。连接成功不代表模型调用可用。", unavailable: "安装命令暂不可用。请刷新 Session,或请 Core 管理员检查原生安装包。", diff --git a/docs/api/README.md b/docs/api/README.md index b23a9499d..fbba78b51 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,13 +1,14 @@ # API documentation -Core serves three namespaces. Each has one kind of caller and its own credential; -no credential works in another namespace. +Core serves three namespaces. Protected operations authenticate their own callers; +credentials cannot be substituted across these boundaries. Versioned native +installer downloads are public release content. | Namespace | Caller | Credential | Contents | Reference | | --- | --- | --- | --- | --- | -| `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`) | [Public API](public-agent-api.md) | +| `/v1` | Applications (business systems, SDKs) | Project API key | Exactly the pinned official Agents API routes. Core-only fields live only in `x_agents_core` (`harness`, `model_provider`, Session `installation`) | [Public API](public-agent-api.md) | | `/core/v1` | Core Web's server and operator scripts | [Core key](../getting-started/operations.md#core-key) | Installation facts, Projects and keys, resource reads and deletion, Session archive, credential issuance, metrics, audit, sandbox deployment and nodes, deployment model providers | [Core API](#core-api), [Web API](web-management.md), [Core OpenAPI](../../contracts/agents-api/core.openapi.yaml) | -| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: node enrollment tokens and executor credentials issued through `/core/v1`, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine connections only: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](../../contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](../../contracts/agents-api/runtime.openapi.yaml) | +| `/api/v1` | Nodes, Runtime daemons, self-hosted executors | Machine credentials: short-lived Session installation grants, node enrollment tokens and executor credentials issued through `/core/v1` or claimed by installation, node credentials registered with an enrollment token, and daemon credentials Core writes into hosted sandboxes | Machine bootstrap and connections: `/api/v1/sandbox-node/*` and `/api/v1/agent-daemon/*`, including WebSockets; each credential works only on its own routes | [Node operations](../../services/agents-api/HOSTED-SANDBOX-MANAGER.md#register-a-host), [executor credentials](../../contracts/agents-api/environment-executor-credentials.md), [machine OpenAPI](../../contracts/agents-api/runtime.openapi.yaml) | A Project API key gets 401 on `/core/v1` and `/api/v1`; the Core key gets 401 on `/v1` and `/api/v1`. Projects own assets. Multiple equally privileged keys share diff --git a/docs/configuration.md b/docs/configuration.md index f9de5edb3..46382dada 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -265,3 +265,11 @@ file paths it loads, never environment values or file contents. Native installat paths must be canonical absolute paths without control characters, quotes, backslashes or wildcards. Keep the installation ID and the database together; Core refuses a missing installation ID when its database already has a deployment. + +### Native daemon distributions + +Release Core images include matched self-hosted installers. A standalone Core +process can set `OAC_NATIVE_INSTALLER_DIR` to the release's `native-installers` +directory. Core checks the catalog's source revision, Runtime protocol and archive +checksums before serving it. This setting supplies installation artifacts only; +it does not change Runtime preparation, permissions or execution. diff --git a/docs/getting-started/self-hosted.md b/docs/getting-started/self-hosted.md index 0900bf7ed..324d774fc 100644 --- a/docs/getting-started/self-hosted.md +++ b/docs/getting-started/self-hosted.md @@ -1,8 +1,8 @@ # Self-hosted executors A `self_hosted` Session runs on a machine the application owns. The application -creates the Session through `/v1`; the administrator issues an executor credential -in Web or through `/core/v1`; the host runs `oac-daemon` with that credential. +creates the Session through `/v1` and receives a command that installs and connects +`oac-daemon`. Web displays the same command in the Session; it is optional. Linux, macOS and Windows use the same Runtime protocol. Core-managed Providers remain Linux-only. @@ -20,8 +20,8 @@ Otherwise creation fails with 400 `model_provider_required`. ## Connect a host -1. Create an existing workspace on the executor host. The application creates a - Session using that host's absolute path and its own Project API key: +1. Choose an absolute workspace path on the target host. Create a Session with + that path and the application's Project API key: ```python import os @@ -42,23 +42,20 @@ Otherwise creation fails with 400 `model_provider_required`. }}, }, ) - print(session.id, session.environment.id, session.environment.remote_url) + installation = session.model_dump()["x_agents_core"]["installation"] + print(installation["commands"]["posix"]) # use "powershell" for Windows ``` -2. In Web, open **Session log**, then the Session's **Executor credentials** - section. Choose **Issue credential**, then **Download credential file**. Core - returns the credential only once; retain it privately before choosing **Done**. -3. Extract the native distribution and run `oac-daemon install --interactive` - (or supply all options with `--non-interactive --harness`). Use the returned - remote URL, Environment ID, matching workspace and credential file path, then - start the installed `bin/oac-daemon`. - The [native guide](../self-hosted-native.md#install-and-start) has Linux/macOS and - PowerShell examples. No Docker installation is required for this native path. - -The console's **Connect a host** flow provides native installation guidance and -the executor credential download. Obtain the matching native distribution before -running its command. Core must be reachable from the host: `wss://` is required -outside loopback, while a local Core can use a loopback `ws://` URL. +2. Run the returned command on the target machine. Select the Harnesses and + installation directory when prompted. Installation creates the workspace if + needed, starts the daemon and checks its connection. For automation, append + `--non-interactive --harness codex` and optionally `--install-dir ABS`. + +In Web, open the **Self-hosted** Session and copy the command under **Connect a +host**. A command expires after 30 minutes; fetch the Session again for a fresh +one. The [native guide](../self-hosted-native.md#install-and-connect) covers retry, +platform prerequisites and credential storage. Core must be reachable from the +host with TLS outside loopback. Native installation does not require Docker. A connected Environment proves only the machine connection. Send a Turn to check the selected harness and model. Core supplies the Session's model provider over @@ -119,26 +116,12 @@ Stopping the daemon keeps its workspace and native history. Deleting a Session does not remove host files. In an archived Project, credentials cannot be issued or rotated; revocation remains available. -## Without Web - -Scripts on the Core host can issue credentials with the Core key through Core's -loopback port (`ports.core` in `config.json`, 8091 by default). Choose a new UUID for -the credential and keep it: - -```sh -key_id=$(python3 -c 'import uuid; print(uuid.uuid4())'); echo "credential ID: $key_id" -(umask 077; curl -fsS -X POST \ - -H @<(printf 'Authorization: Bearer %s\n' "$(cat "$HOME/.oac/core/secrets/core.key")") \ - -H 'Content-Type: application/json' -d "{\"key_id\":\"$key_id\"}" \ - "http://127.0.0.1:8091/core/v1/projects/$PROJECT_ID/environments/$ENVIRONMENT_ID/executor-credentials" \ - -o executor-key.json) -``` +## Operator credential management -Rotate with `{"key_id":"…","rotate":true}` on the same route; revoke with -`DELETE …/executor-credentials/`. After an uncertain response, list the -credentials with `GET` before trying again. The -[credential contract](../../contracts/agents-api/environment-executor-credentials.md) -has every rule and error. +Operators can still issue, rotate or revoke executor credentials using a Core key +through Core's loopback port. This is not required for one-command onboarding. +See the [credential contract](../../contracts/agents-api/environment-executor-credentials.md) +for those routes and uncertain-response handling. ## Historical executor installations diff --git a/scripts/native-onboarding-smoke.mjs b/scripts/native-onboarding-smoke.mjs index 05f340998..79ea27c0d 100644 --- a/scripts/native-onboarding-smoke.mjs +++ b/scripts/native-onboarding-smoke.mjs @@ -18,7 +18,8 @@ const environment = randomUUID(), session = randomUUID(), device = randomUUID(); const workspace = join(root, 'onboarding-workspace'); const installation = join(root, 'onboarding-installation'); const archive = join(root, 'onboarding.tar.gz'); -execFileSync('tar', ['-czf', archive, '-C', bundle, '.']); +const tar = windows ? join(process.env.SystemRoot, 'System32', 'tar.exe') : 'tar'; +execFileSync(tar, ['-czf', archive, '-C', bundle, '.']); const digest = createHash('sha256'); for await (const chunk of createReadStream(archive)) digest.update(chunk); const checksum = digest.digest('hex'); diff --git a/services/agents-api/internal/nativeinstaller/assets/bootstrap.ps1 b/services/agents-api/internal/nativeinstaller/assets/bootstrap.ps1 index 45eb26f85..1f9c05f14 100644 --- a/services/agents-api/internal/nativeinstaller/assets/bootstrap.ps1 +++ b/services/agents-api/internal/nativeinstaller/assets/bootstrap.ps1 @@ -18,7 +18,7 @@ try { if ((Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() -ne $expected) { throw 'Installer checksum mismatch; download again.' } $bundle = Join-Path $work 'bundle' New-Item -ItemType Directory -Path $bundle | Out-Null - & tar.exe -xzf $archive -C $bundle + & (Join-Path $env:SystemRoot 'System32\tar.exe') -xzf $archive -C $bundle if ($LASTEXITCODE -ne 0) { throw 'Installer extraction failed.' } $endpoint = $Base -replace '/install/[^/]+$', '/installation' & (Join-Path $bundle 'oac-daemon.exe') install --onboard-url $endpoint --authorization $Authorization @InstallArguments From 531433d560d1a3cd87a153729369f82afc2e8d1d Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 16:16:19 +0800 Subject: [PATCH 3/5] fix(onboarding): handle native workflow and contract qualification --- .github/workflows/native.yml | 2 +- contracts/agents-api/v1/upstream_contract_test.go | 3 +++ scripts/native-onboarding-smoke.mjs | 2 +- services/agents-api/internal/nativeinstaller/catalog.go | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index 2010d4a9f..79dc7bf44 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -35,7 +35,7 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: native-check-${{ github.ref }} cancel-in-progress: true jobs: diff --git a/contracts/agents-api/v1/upstream_contract_test.go b/contracts/agents-api/v1/upstream_contract_test.go index ca853eef0..cd67ce3d3 100644 --- a/contracts/agents-api/v1/upstream_contract_test.go +++ b/contracts/agents-api/v1/upstream_contract_test.go @@ -232,6 +232,9 @@ func (a *fieldAudit) compare(name string, schema map[string]any, official []stri case property == "x_agents_core" && slices.Contains(coreExtensionOwners, name): _, extension := a.resolve(name+"."+property, value) for member := range a.properties(extension) { + if name == "v1.Session" && member == "installation" { + continue + } if !slices.Contains(coreExtensionMembers, member) { a.violations[name+".x_agents_core."+member] = coreExtensionMembers } diff --git a/scripts/native-onboarding-smoke.mjs b/scripts/native-onboarding-smoke.mjs index 79ea27c0d..0efced261 100644 --- a/scripts/native-onboarding-smoke.mjs +++ b/scripts/native-onboarding-smoke.mjs @@ -28,7 +28,7 @@ const authorization = 'fixture-install-authorization'; const server = createServer(async (request, response) => { const url = new URL(request.url, origin); const json = (status, value) => { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(value)); }; - if (url.pathname.endsWith('.sha256')) return response.end(checksum+'\n'); + 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/agents-api/internal/nativeinstaller/assets', url.pathname.split('/').at(-1))).pipe(response); const body = []; for await (const chunk of request) body.push(chunk); diff --git a/services/agents-api/internal/nativeinstaller/catalog.go b/services/agents-api/internal/nativeinstaller/catalog.go index 355404b20..0e7ab9efe 100644 --- a/services/agents-api/internal/nativeinstaller/catalog.go +++ b/services/agents-api/internal/nativeinstaller/catalog.go @@ -88,6 +88,7 @@ func (c *Catalog) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } if name == platform+".sha256" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintln(w, artifact.SHA256) return } From 65a0dafcd2328ed8f49a89fa7d5817fef5f36836 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 16:26:40 +0800 Subject: [PATCH 4/5] fix(onboarding): avoid PowerShell download progress overhead --- scripts/native-onboarding-smoke.mjs | 2 +- .../agents-api/internal/nativeinstaller/assets/bootstrap.ps1 | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/native-onboarding-smoke.mjs b/scripts/native-onboarding-smoke.mjs index 0efced261..7150b2b44 100644 --- a/scripts/native-onboarding-smoke.mjs +++ b/scripts/native-onboarding-smoke.mjs @@ -67,7 +67,7 @@ function run(executable, args, input = '') { let output = ''; child.stdout.on('data', chunk => { output += chunk; }); child.stderr.on('data', chunk => { output += chunk; }); child.stdin.on('error', () => {}); child.stdin.end(input); - const timer = setTimeout(() => { child.kill(); reject(new Error('Native onboarding did not settle')); }, 180000); + const timer = setTimeout(() => { child.kill(); reject(new Error('Native onboarding did not settle: '+(secret ? output.replaceAll(secret, '[redacted]') : output).slice(-4096))); }, 180000); child.on('error', reject); child.on('close', code => { clearTimeout(timer); if (secret) assert.ok(!output.includes(secret), 'Credential leaked into terminal'); resolve({ code, output }); }); }); diff --git a/services/agents-api/internal/nativeinstaller/assets/bootstrap.ps1 b/services/agents-api/internal/nativeinstaller/assets/bootstrap.ps1 index 1f9c05f14..bca81cdf4 100644 --- a/services/agents-api/internal/nativeinstaller/assets/bootstrap.ps1 +++ b/services/agents-api/internal/nativeinstaller/assets/bootstrap.ps1 @@ -4,6 +4,7 @@ param( [Parameter(ValueFromRemainingArguments=$true)][string[]]$InstallArguments ) $ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() $architecture = @{x64='amd64';arm64='arm64'}[$architecture] if (!$architecture) { throw 'Unsupported processor architecture.' } @@ -15,12 +16,15 @@ try { catch { throw 'This Core has no qualified installer for this platform.' } $archive = Join-Path $work 'bundle.tar.gz' Invoke-WebRequest -UseBasicParsing "$Base/windows-$architecture.tar.gz" -OutFile $archive + Write-Host 'Verifying the installer archive...' if ((Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() -ne $expected) { throw 'Installer checksum mismatch; download again.' } $bundle = Join-Path $work 'bundle' New-Item -ItemType Directory -Path $bundle | Out-Null + Write-Host 'Extracting the installer...' & (Join-Path $env:SystemRoot 'System32\tar.exe') -xzf $archive -C $bundle if ($LASTEXITCODE -ne 0) { throw 'Installer extraction failed.' } $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 { Remove-Item -LiteralPath $work -Recurse -Force } From d81efe53659f498abff26847393ed04e4176c59b Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 16:52:14 +0800 Subject: [PATCH 5/5] test(web): verify Core-owned self-hosted installation commands --- apps/web/e2e/monitoring.spec.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index ec4795aba..b158037ac 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -137,23 +137,35 @@ test("shows a self-hosted Session's install command, issues its credential once, await expect(section.getByRole("button", { name: "Issue credential" })).toHaveCount(0); await expect(credentials.getByRole("button", { name: /^Rotate credential / })).toHaveCount(0); await expect(credentials.getByRole("button", { name: /^Revoke credential / })).toBeVisible(); - // The command stays; the note says the host still needs a credential. - await expect(install).toContainText("Installation requires an existing credential file"); + // Archiving removes the short-lived installation command, while existing credentials remain manageable. + await expect(install).toContainText("This project is archived. New installation authorizations are unavailable."); + await expect(install.getByLabel("Executor install command")).toHaveCount(0); }); -test("offers the native command for a loopback Core", async ({ page, request }) => { +test("displays Core's installation command for a loopback Core", async ({ page, request }) => { await openConsole(page, request, "sessions", { installation: "local" }); + const installationResponse = page.waitForResponse((response) => response.url().includes("/environments/") && response.url().endsWith("/installation")); await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); + const response = await installationResponse; + expect(response.ok()).toBe(true); + const installation = await response.json(); + expect(installation.status).toBe("available"); const install = page.getByRole("region", { name: "Connect a host" }); - await expect(install.locator("pre")).toContainText("ws://127.0.0.1:8091/api/v1/agent-daemon/ws"); + // Connection details belong to Core's authorization, not a command rebuilt in Web. + await expect(install.getByLabel("Executor install command").locator("pre")).toHaveText(installation.commands.posix); }); -test("offers native installation without console installer assets", async ({ page, request }) => { +test("displays Core's installation command without console installer assets", async ({ page, request }) => { await openConsole(page, request, "sessions", { installers: "none" }); + const installationResponse = page.waitForResponse((response) => response.url().includes("/environments/") && response.url().endsWith("/installation")); await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); + const response = await installationResponse; + expect(response.ok()).toBe(true); + const installation = await response.json(); + expect(installation.status).toBe("available"); const section = page.getByRole("region", { name: "Executor credentials" }); await expect(section).toContainText("No executor credentials yet"); - await expect(section.getByRole("region", { name: "Connect a host" })).toContainText("install --interactive"); + await expect(section.getByLabel("Executor install command").locator("pre")).toHaveText(installation.commands.posix); await expect(section.getByRole("button", { name: "Issue credential" })).toBeVisible(); });