Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ The platform listens on `LOOPFORGE_BIND`, `0.0.0.0` by default, without promptin

### Docker sandboxes

The platform exposes a built-in default sandbox (`local`) even when the catalog SQL has not been installed. It is an in-memory fallback and does not insert database rows. An existing saved `local` profile takes priority, including its configured driver and environment. The default cannot be deleted; edit its configuration or create another profile to change how tasks run. The full installer still imports starter SOPs and the welcome project.

For native services running as an ordinary host user, default/root Docker images run with that service user's UID/GID so private workspaces remain accessible without relaxing their permissions or adding capabilities. Images that explicitly select a non-root user retain that identity; operators must arrange compatible bind-mount ownership for those images.

The Compose deployment mounts the host Docker socket into the platform container so built-in AI SOPs run out of the box. `AGENTFLOW_ISOLATED_IMAGE` points to the installed platform image: default sandboxes start separate sibling containers from that same image, with no second CLI download or image build. They do not mount the Docker socket. Custom Dockerfiles still build and cache their own images. The run workspace is mounted at `/workspace` on the Compose network. Because bind mounts are resolved by the host Docker daemon, `LOOPFORGE_DATA_DIR` (written to `.env` by `scripts/configure.sh`, default `./.data`) is mounted into the platform container at the same absolute path; workspaces and CLI state live under it.
Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,7 @@ func filterEnvironmentAssets[T any](items []T, projectID string, scope func(T) (
out := make([]T, 0, len(items))
for _, item := range items {
owner, shared := scope(item)
if owner == projectID || (owner != "" && shared) {
if owner == projectID || shared {
out = append(out, item)
}
}
Expand Down
64 changes: 64 additions & 0 deletions internal/httpapi/default_sandbox_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package httpapi

import (
"encoding/json"
"net/http"
"reflect"
"testing"

"agentflow/internal/domain"
)

func TestProjectEnvironmentAssetsIncludeSharedGlobalDefaults(t *testing.T) {
profiles := []domain.SandboxProfile{
{ID: "own", ProjectID: "project"},
{ID: "foreign-shared", ProjectID: "other", Shared: true},
{ID: "foreign-private", ProjectID: "other"},
{ID: "global-shared", Shared: true},
{ID: "global-private"},
}
visible := filterEnvironmentAssets(profiles, "project", func(profile domain.SandboxProfile) (string, bool) {
return profile.ProjectID, profile.Shared
})
var ids []string
for _, profile := range visible {
ids = append(ids, profile.ID)
}
if !reflect.DeepEqual(ids, []string{"own", "foreign-shared", "global-shared"}) {
t.Fatalf("incorrect project visibility: %v", ids)
}
}

func TestDefaultSandboxWithoutCatalogRowsSupportsSOPCreation(t *testing.T) {
f := cvBNewHTTPFixture(t)
if err := f.s.DB.Exec("DELETE FROM sandboxes").Error; err != nil {
t.Fatal(err)
}
const projectID = "builtin-default-project"
if err := f.s.PutProject(domain.Project{ID: projectID, Name: "Default sandbox check", OwnerID: f.user.ID}); err != nil {
t.Fatal(err)
}
bootstrap := cvBRequest(t, f, http.MethodGet, "/api/bootstrap", nil, true)
var catalog struct {
Sandboxes []domain.SandboxProfile `json:"sandboxes"`
}
if bootstrap.Code != http.StatusOK || json.Unmarshal(bootstrap.Body.Bytes(), &catalog) != nil || len(catalog.Sandboxes) != 1 || catalog.Sandboxes[0].ID != "local" {
t.Fatalf("bootstrap does not expose the default: %d %s", bootstrap.Code, bootstrap.Body.String())
}
listed := cvBRequest(t, f, http.MethodGet, "/api/sandboxes?projectId="+projectID, nil, true)
var profiles []domain.SandboxProfile
if listed.Code != http.StatusOK || json.Unmarshal(listed.Body.Bytes(), &profiles) != nil || len(profiles) != 1 || profiles[0].ID != "local" {
t.Fatalf("project cannot select the default: %d %s", listed.Code, listed.Body.String())
}
created := cvBRequest(t, f, http.MethodPut, "/api/workflows", map[string]any{
"id": "builtin-default-sop", "name": "Blank SOP", "projectId": projectID,
"status": "draft", "sandboxProfileId": "local", "nodes": []any{}, "edges": []any{},
}, true)
if created.Code != http.StatusOK {
t.Fatalf("blank SOP creation failed: %d %s", created.Code, created.Body.String())
}
var count int64
if err := f.s.DB.Table("sandboxes").Count(&count).Error; err != nil || count != 0 {
t.Fatalf("API requests seeded rows: rows=%d err=%v", count, err)
}
}
27 changes: 25 additions & 2 deletions internal/repo/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,11 +318,31 @@ func (r *CatalogRepo) DeleteEnvironment(id string) error {
}

func (r *CatalogRepo) Sandboxes() []domain.SandboxProfile {
return payloadList[domain.SandboxProfile](r.DB, "sandboxes", "")
profiles := payloadList[domain.SandboxProfile](r.DB, "sandboxes", "")
for _, profile := range profiles {
if profile.ID == DefaultSandboxProfileID {
return profiles
}
}
if profile, ok := r.Sandbox(DefaultSandboxProfileID); ok {
profiles = append(profiles, profile)
}
return profiles
}

func (r *CatalogRepo) Sandbox(id string) (domain.SandboxProfile, bool) {
return payloadOne[domain.SandboxProfile](r.DB, "sandboxes", id)
var profile domain.SandboxProfile
var row payloadRow
err := r.DB.Table("sandboxes").Where("id = ?", id).Take(&row).Error
// A missing row may use the built-in default. Database failures or invalid
// stored configuration must not silently switch a run's execution policy.
if errors.Is(err, gorm.ErrRecordNotFound) && id == DefaultSandboxProfileID {
return DefaultSandboxProfile(), true
}
if err != nil || json.Unmarshal(row.Payload, &profile) != nil {
return domain.SandboxProfile{}, false
}
return profile, true
}

func (r *CatalogRepo) PutSandbox(v domain.SandboxProfile) error {
Expand Down Expand Up @@ -357,6 +377,9 @@ func (r *CatalogRepo) PutSandbox(v domain.SandboxProfile) error {
}

func (r *CatalogRepo) DeleteSandbox(id string) error {
if id == DefaultSandboxProfileID {
return errors.New("默认运行沙箱不能删除;可编辑配置或新建其他沙箱")
}
if _, ok := r.Sandbox(id); !ok {
return fmt.Errorf("sandbox not found")
}
Expand Down
18 changes: 18 additions & 0 deletions internal/repo/sandbox_defaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package repo

import "agentflow/internal/domain"

const DefaultSandboxProfileID = "local"

// DefaultSandboxProfile is available without catalog initialization. It is a
// read-only fallback, not a seed row. Operator-persisted profiles take priority.
// The runtime applies AGENTFLOW_LOCAL_SANDBOX=docker in Compose deployments;
// native installations retain their explicitly configured execution policy.
func DefaultSandboxProfile() domain.SandboxProfile {
return domain.SandboxProfile{
ID: DefaultSandboxProfileID,
Name: "默认运行沙箱",
Shared: true,
Driver: domain.SandboxDriverLocal,
}
}
94 changes: 94 additions & 0 deletions internal/repo/sandbox_defaults_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package repo

import (
"reflect"
"strings"
"testing"

"agentflow/internal/domain"
)

func TestDefaultSandboxWithoutSeedDoesNotWriteRows(t *testing.T) {
s, err := OpenTest()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
if err := s.DB.Exec("DELETE FROM sandboxes").Error; err != nil {
t.Fatal(err)
}
profile, ok := s.Sandbox(DefaultSandboxProfileID)
if !ok || profile.ID != "local" || profile.Driver != domain.SandboxDriverLocal || !profile.Shared || profile.ProjectID != "" {
t.Fatalf("missing shared default: %+v, found=%v", profile, ok)
}
profiles := s.Sandboxes()
if len(profiles) != 1 || !reflect.DeepEqual(profiles[0], profile) {
t.Fatalf("list and lookup disagree: %+v / %+v", profiles, profile)
}
var count int64
if err := s.DB.Table("sandboxes").Count(&count).Error; err != nil || count != 0 {
t.Fatalf("default lookup wrote catalog data: rows=%d, err=%v", count, err)
}
if _, ok := s.Sandbox("not-configured"); ok {
t.Fatal("an unknown sandbox must not use the default")
}
}

func TestStoredDefaultSandboxWinsWithoutDuplicatesAndCannotBeDeleted(t *testing.T) {
s, err := OpenTest()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
configured := domain.SandboxProfile{ID: "local", Name: "Operator configured", ProjectID: "platform", Shared: true, Driver: "native", Environment: map[string]string{"PRESERVE": "yes"}}
if err := s.PutSandbox(configured); err != nil {
t.Fatal(err)
}
profile, ok := s.Sandbox(configured.ID)
if !ok || !reflect.DeepEqual(profile, configured) {
t.Fatalf("operator configuration changed: %+v", profile)
}
count := 0
for _, candidate := range s.Sandboxes() {
if candidate.ID == configured.ID {
count++
if !reflect.DeepEqual(candidate, configured) {
t.Fatalf("list replaced operator configuration: %+v", candidate)
}
}
}
if count != 1 {
t.Fatalf("default appears %d times", count)
}
if err := s.DeleteSandbox(configured.ID); err == nil || !strings.Contains(err.Error(), "不能删除") {
t.Fatalf("default deletion must be explicit: %v", err)
}
profile, _ = s.Sandbox(configured.ID)
if !reflect.DeepEqual(profile, configured) {
t.Fatal("rejected deletion changed the configured execution policy")
}
}

func TestDefaultSandboxDoesNotMaskCorruptConfigurationOrDatabaseFailure(t *testing.T) {
s, err := OpenTest()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
if err := s.DB.Table("sandboxes").Where("id = ?", DefaultSandboxProfileID).Update("payload", `"invalid profile"`).Error; err != nil {
t.Fatal(err)
}
if _, ok := s.Sandbox(DefaultSandboxProfileID); ok {
t.Fatal("invalid stored configuration must not fall back to local execution")
}
if err := s.DB.Exec("DELETE FROM sandboxes").Error; err != nil {
t.Fatal(err)
}
s.Close()
if _, ok := s.Sandbox(DefaultSandboxProfileID); ok {
t.Fatal("database failure must not fall back to local execution")
}
if profiles := s.Sandboxes(); len(profiles) != 0 {
t.Fatalf("database failure reported available sandboxes: %+v", profiles)
}
}
Loading
Loading