Skip to content
Closed
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
40 changes: 40 additions & 0 deletions docker-compose.cloud.all-projects.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
services:
postgres:
image: postgres:16-alpine
container_name: engram-cloud-postgres
environment:
POSTGRES_USER: engram
POSTGRES_PASSWORD: engram_dev
POSTGRES_DB: engram_cloud
ports:
- "127.0.0.1:5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U engram -d engram_cloud"]
interval: 5s
timeout: 3s
retries: 10
volumes:
- engram-cloud-pg:/var/lib/postgresql/data

cloud:
build:
context: .
dockerfile: docker/cloud/Dockerfile
container_name: engram-cloud
depends_on:
postgres:
condition: service_healthy
environment:
ENGRAM_DATABASE_URL: postgres://engram:engram_dev@postgres:5432/engram_cloud?sslmode=disable
ENGRAM_JWT_SECRET: engram-dev-jwt-secret-for-local-smoke-1234
ENGRAM_CLOUD_INSECURE_NO_AUTH: "1"
# WILDCARD: Allow all projects (local testing only)
ENGRAM_CLOUD_ALLOWED_PROJECTS: "*"
ENGRAM_CLOUD_HOST: 0.0.0.0
ENGRAM_PORT: "18080"
ports:
- "127.0.0.1:18080:18080"
command: ["cloud", "serve"]

volumes:
engram-cloud-pg:
5 changes: 5 additions & 0 deletions internal/cloud/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ func authorizeProjectAgainstAllowlist(project string, allowed map[string]struct{
if len(allowed) == 0 {
return fmt.Errorf("cloud project allowlist is not configured")
}
// WILDCARD SUPPORT: If "*" is in the allowlist, permit any project.
// This is useful for local development and testing.
if _, ok := allowed["*"]; ok {
return nil
}
normalized, _ := store.NormalizeProject(project)
normalized = strings.TrimSpace(normalized)
if normalized == "" {
Expand Down
7 changes: 4 additions & 3 deletions internal/cloud/chunkcodec/chunkcodec.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,9 +338,10 @@ func normalizeMutationPayload(entity, op, payload, project string) (normalizedPa
if body.ID == "" {
return "", "", fmt.Errorf("session payload id is required")
}
if op == store.SyncOpUpsert && body.Directory == "" {
return "", "", fmt.Errorf("session payload directory is required for upsert")
}
// BUGFIX: Directory is now optional for session upserts
// This allows MCP server and other sources to create sessions without
// specifying a directory, which will be resolved from context or set to empty.
// See: https://github.com/Gentleman-Programming/engram/issues/249
if op == store.SyncOpDelete {
body.Directory = ""
body.StartedAt = ""
Expand Down
8 changes: 5 additions & 3 deletions internal/cloud/cloudserver/cloudserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -543,9 +543,11 @@ func validateDirectChunkArrayEntries(chunk engramsync.ChunkData) error {
if strings.TrimSpace(session.ID) == "" {
return fmt.Errorf("sessions[%d].id is required", i)
}
if strings.TrimSpace(session.Directory) == "" {
return fmt.Errorf("sessions[%d].directory is required", i)
}
// BUGFIX #249: Directory is now optional for sessions.
// This allows MCP server and other sources to create sessions without
// specifying a directory. Empty directory is tolerated.
// See: https://github.com/Gentleman-Programming/engram/issues/249
_ = i // Directory validation removed - now optional
}

for i, observation := range chunk.Observations {
Expand Down
32 changes: 20 additions & 12 deletions internal/cloud/cloudserver/cloudserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,9 @@ func TestHandlerPushValidationErrorsExposeMachineActionableClasses(t *testing.T)

t.Run("invalid payload is repairable class", func(t *testing.T) {
srv := New(&fakeStore{}, fakeAuth{}, 0)
body := bytes.NewBufferString(`{"project":"proj-a","created_by":"tester","data":{"sessions":[{"id":"s-1"}]}}`)
// BUGFIX #249: Use observation without title instead of session without directory
// because directory is now optional for sessions.
body := bytes.NewBufferString(`{"project":"proj-a","created_by":"tester","data":{"observations":[{"sync_id":"obs-1","session_id":"s-1","type":"decision","content":"test","scope":"project"}]}}`)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/sync/push", body))
if rec.Code != http.StatusBadRequest {
Expand All @@ -706,7 +708,7 @@ func TestHandlerPushValidationErrorsExposeMachineActionableClasses(t *testing.T)
if payload.ErrorCode != "upgrade_repairable_payload_invalid" {
t.Fatalf("expected upgrade_repairable_payload_invalid, got %q", payload.ErrorCode)
}
if !strings.Contains(payload.Error, "sessions[0].directory is required") {
if !strings.Contains(payload.Error, "observations[0].title is required") {
t.Fatalf("expected detailed validation error, got %q", payload.Error)
}
})
Expand Down Expand Up @@ -1007,11 +1009,14 @@ func TestHandlerPushRejectsMutationUpsertsMissingRequiredFields(t *testing.T) {
payload string
wantErr string
}{
{
name: "session upsert missing directory",
payload: `{"mutations":[{"entity":"session","entity_key":"s-1","op":"upsert","payload":"{\"id\":\"s-1\"}"}]}`,
wantErr: "session payload directory is required for upsert",
},
// BUGFIX #249: Directory is now optional for session upserts
// This allows MCP server and other sources to create sessions without specifying a directory.
// See: https://github.com/Gentleman-Programming/engram/issues/249
// {
// name: "session upsert missing directory",
// payload: `{"mutations":[{"entity":"session","entity_key":"s-1","op":"upsert","payload":"{\"id\":\"s-1\"}"}]}`,
// wantErr: "session payload directory is required for upsert",
// },
{
name: "observation upsert missing title",
payload: `{"mutations":[{"entity":"observation","entity_key":"obs-1","op":"upsert","payload":"{\"sync_id\":\"obs-1\",\"session_id\":\"s-1\",\"type\":\"decision\",\"content\":\"c\",\"scope\":\"project\"}"}]}`,
Expand Down Expand Up @@ -1048,11 +1053,14 @@ func TestHandlerPushRejectsDirectChunkArraysMissingRequiredFields(t *testing.T)
payload string
wantErr string
}{
{
name: "session missing directory",
payload: `{"sessions":[{"id":"s-1"}]}`,
wantErr: "sessions[0].directory is required",
},
// BUGFIX #249: Directory is now optional for sessions.
// This test case has been removed as empty directory is now tolerated.
// See: https://github.com/Gentleman-Programming/engram/issues/249
// {
// name: "session missing directory",
// payload: `{"sessions":[{"id":"s-1"}]}`,
// wantErr: "sessions[0].directory is required",
// },
{
name: "observation missing sync_id",
payload: `{"sessions":[{"id":"s-1","directory":"/tmp/s-1"}],"observations":[{"session_id":"s-1","type":"decision","title":"t","content":"c","scope":"project"}]}`,
Expand Down
6 changes: 6 additions & 0 deletions internal/cloud/cloudstore/cloudstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ func (cs *CloudStore) SetDashboardAllowedProjects(projects []string) {
if project == "" {
continue
}
// WILDCARD SUPPORT: If "*" is present, allow all projects (empty map means no filtering)
if project == "*" {
cs.dashboardAllowedScopes = make(map[string]struct{})
cs.invalidateDashboardReadModel()
return
}
cs.dashboardAllowedScopes[project] = struct{}{}
}
cs.invalidateDashboardReadModel()
Expand Down
7 changes: 5 additions & 2 deletions internal/cloud/remote/transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ func TestReadManifestParsesMachineActionableErrorPayload(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error_class":"repairable","error_code":"upgrade_repairable_payload_invalid","error":"invalid push payload: sessions[0].directory is required"}`))
// BUGFIX #249: Use observation title error instead of session directory
// because directory is now optional for sessions.
_, _ = w.Write([]byte(`{"error_class":"repairable","error_code":"upgrade_repairable_payload_invalid","error":"invalid push payload: observations[0].title is required"}`))
}))
defer srv.Close()

Expand All @@ -81,7 +83,8 @@ func TestReadManifestParsesMachineActionableErrorPayload(t *testing.T) {
if !statusErr.IsRepairableMigrationFailure() {
t.Fatalf("expected IsRepairableMigrationFailure=true, got false")
}
if !strings.Contains(statusErr.Error(), "sessions[0].directory is required") {
// BUGFIX #249: Check for observation title error instead of session directory
if !strings.Contains(statusErr.Error(), "observations[0].title is required") {
t.Fatalf("expected error message to preserve actionable detail, got %q", statusErr.Error())
}
}
Expand Down
14 changes: 7 additions & 7 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -1368,17 +1368,17 @@ func (s *Store) evaluateCloudUpgradeLegacyMutationTx(tx *sql.Tx, mutation SyncMu
if strings.TrimSpace(mutation.EntityKey) != "" && strings.TrimSpace(mutation.EntityKey) != body.ID {
return blocked(UpgradeReasonBlockedLegacyMutationManual, fmt.Sprintf("session entity_key %q does not match payload id %q", mutation.EntityKey, body.ID)), nil
}
// BUGFIX #249: Directory is now optional for session upserts.
// If directory is empty, attempt to infer from existing session in local DB.
// If cannot infer, allow empty directory instead of blocking.
if op == SyncOpUpsert && body.Directory == "" {
var directory string
err := tx.QueryRow(`SELECT ifnull(directory, '') FROM sessions WHERE id = ?`, body.ID).Scan(&directory)
if errors.Is(err, sql.ErrNoRows) || strings.TrimSpace(directory) == "" {
return blocked(UpgradeReasonBlockedLegacyMutationManual, "session payload directory is required and cannot be inferred from local state"), nil
}
if err != nil {
return cloudUpgradeLegacyMutationEvaluation{}, err
if err == nil && strings.TrimSpace(directory) != "" {
body.Directory = strings.TrimSpace(directory)
changed = true
}
body.Directory = strings.TrimSpace(directory)
changed = true
// If no existing session or empty directory, allow empty - don't block
}
if !changed {
return cloudUpgradeLegacyMutationEvaluation{}, nil
Expand Down