diff --git a/db-connector.go b/db-connector.go index cffc4870..e6b43427 100755 --- a/db-connector.go +++ b/db-connector.go @@ -5056,7 +5056,7 @@ func GetOpenApiDatastore(ctx context.Context, id string) (ParsedOpenApi, error) } // Index = Username -func SetSession(ctx context.Context, user User, value string) error { +func SetSession(ctx context.Context, user *User, value string) error { //parsedKey := strings.ToLower(user.Username) // Non indexed User data parsedKey := user.Id @@ -5068,6 +5068,12 @@ func SetSession(ctx context.Context, user User, value string) error { user.Session = value + now := time.Now().Unix() + if user.SessionCreatedAt == 0 { + user.SessionCreatedAt = now + } + user.SessionLastActivityAt = now + nameKey := "Users" if project.DbType == "opensearch" { data, err := json.Marshal(user) @@ -5119,6 +5125,15 @@ func SetSession(ctx context.Context, user User, value string) error { } } + // Always invalidate the cache entry for the (possibly reused) current + // session token after a successful write. Without this, reusing an + // existing token (e.g. SSO re-login for a user with an active session) + // left the previous cache invalidation branch above a no-op, allowing + // GetSessionNew to keep serving a stale SessionLastActivityAt snapshot + // for up to the cache TTL - which could incorrectly trip the idle/max + // session lifetime check and bounce the user back to /login. + DeleteCache(ctx, fmt.Sprintf("session_%s", user.Session)) + return nil } @@ -9955,6 +9970,72 @@ func GetPipelines(ctx context.Context, OrgId string) ([]Pipeline, error) { return pipelines, nil } +// getSessionUserRealtime looks up the User owning sessionId via two +// real-time (non-search) OpenSearch Document.Get calls: first the "sessions" +// index (keyed by the session token itself as document ID, see SetSession), +// then the "Users" index (keyed by user ID). Both Document.Get reads are +// immediately consistent, unlike _search, which is only eventually +// consistent up to the index refresh_interval. Returns (User{}, false) for +// any failure (not found, decode error, mismatch) so callers can fall back +// to the _search-based lookup. +func getSessionUserRealtime(ctx context.Context, sessionId string) (User, bool) { + sessionResp, err := project.Es.Document.Get(ctx, opensearchapi.DocumentGetReq{ + Index: strings.ToLower(GetESIndexPrefix("sessions")), + DocumentID: sessionId, + }) + if err != nil { + return User{}, false + } + + sessionRes := sessionResp.Inspect().Response + defer sessionRes.Body.Close() + if sessionRes.StatusCode != 200 && sessionRes.StatusCode != 201 { + return User{}, false + } + + sessionBody, err := ioutil.ReadAll(sessionRes.Body) + if err != nil { + return User{}, false + } + + wrappedSession := SessionWrapper{} + if err := json.Unmarshal(sessionBody, &wrappedSession); err != nil || len(wrappedSession.Source.Id) == 0 { + return User{}, false + } + + userResp, err := project.Es.Document.Get(ctx, opensearchapi.DocumentGetReq{ + Index: strings.ToLower(GetESIndexPrefix("Users")), + DocumentID: wrappedSession.Source.Id, + }) + if err != nil { + return User{}, false + } + + userRes := userResp.Inspect().Response + defer userRes.Body.Close() + if userRes.StatusCode != 200 && userRes.StatusCode != 201 { + return User{}, false + } + + userBody, err := ioutil.ReadAll(userRes.Body) + if err != nil { + return User{}, false + } + + wrappedUser := UserWrapper{} + if err := json.Unmarshal(userBody, &wrappedUser); err != nil { + return User{}, false + } + + // Guard against a stale/mismatched "sessions" doc pointing at a user + // whose session has since changed (e.g. logged out, or session rotated). + if wrappedUser.Source.Session != sessionId { + return User{}, false + } + + return wrappedUser.Source, true +} + func GetSessionNew(ctx context.Context, sessionId string) (User, error) { cacheKey := fmt.Sprintf("session_%s", sessionId) user := &User{} @@ -9978,6 +10059,24 @@ func GetSessionNew(ctx context.Context, sessionId string) (User, error) { nameKey := "Users" var users []User if project.DbType == "opensearch" { + // Real-time lookup path: session tokens are indexed with the token + // itself as the document ID in the "sessions" index (see SetSession), + // so a direct Document.Get is immediately consistent (unlike _search, + // below, which only sees documents after the next index refresh - + // default refresh_interval ~1s). Without this, a session created by a + // fresh login (e.g. SSO) could be briefly invisible to the very next + // request (getinfo right after the login redirect), making a + // just-logged-in user look logged out - surfacing as a bounce back to + // /login that required a second login attempt to succeed once the + // document became searchable. Falls back to the _search-based lookup + // below if this fails for any reason (e.g. a legacy session predating + // the "sessions" index, or a transient error). + if user, ok := getSessionUserRealtime(ctx, sessionId); ok { + users = []User{user} + } + } + + if len(users) == 0 && project.DbType == "opensearch" { var buf bytes.Buffer query := map[string]interface{}{ "from": 0, @@ -10056,7 +10155,7 @@ func GetSessionNew(ctx context.Context, sessionId string) (User, error) { users = append(users, hit.Source) } - } else { + } else if project.DbType != "opensearch" { //log.Printf("[DEBUG] Searching for session %s", sessionId) q := datastore.NewQuery(nameKey).Filter("session =", sessionId).Limit(1) _, err := project.Dbclient.GetAll(ctx, q, &users) diff --git a/go.mod b/go.mod index d28b4a46..9c45bd24 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/google/go-github/v28 v28.1.1 github.com/google/go-querystring v1.1.0 github.com/google/uuid v1.6.0 + github.com/klauspost/compress v1.19.2 github.com/microcosm-cc/bluemonday v1.0.27 github.com/openai/openai-go/v3 v3.8.1 github.com/patrickmn/go-cache v2.1.0+incompatible @@ -35,7 +36,6 @@ require ( github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e golang.org/x/crypto v0.48.0 golang.org/x/oauth2 v0.34.0 - golang.org/x/sys v0.41.0 google.golang.org/api v0.236.0 google.golang.org/appengine v1.6.8 gopkg.in/yaml.v2 v2.4.0 @@ -145,6 +145,7 @@ require ( go4.org v0.0.0-20230225012048-214862532bf5 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.11.0 // indirect diff --git a/go.sum b/go.sum index 5ee18671..5f263dcf 100644 --- a/go.sum +++ b/go.sum @@ -70,6 +70,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I= github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= github.com/bradfitz/slice v0.0.0-20180809154707-2b758aa73013 h1:/P9/RL0xgWE+ehnCUUN5h3RpG3dmoMCOONO1CCvq23Y= @@ -230,6 +232,8 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -247,6 +251,8 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -261,6 +267,8 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= diff --git a/shared.go b/shared.go index 0274db4d..33ff59dd 100644 --- a/shared.go +++ b/shared.go @@ -3,6 +3,7 @@ package shuffle import ( "bytes" "context" + "crypto/sha256" "crypto/tls" "crypto/x509" "errors" @@ -17,16 +18,16 @@ import ( "path/filepath" "reflect" - "sync" "hash/fnv" neturl "net/url" "path" "sort" + "sync" "unicode" - openai "github.com/sashabaranov/go-openai" "github.com/go-git/go-billy/v5" "github.com/go-git/go-billy/v5/memfs" + openai "github.com/sashabaranov/go-openai" "google.golang.org/api/cloudfunctions/v1" "google.golang.org/api/googleapi" "google.golang.org/api/iterator" @@ -77,8 +78,11 @@ import ( "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" + "runtime" + "github.com/Masterminds/semver" "github.com/klauspost/compress/gzhttp" + "github.com/shirou/gopsutil/v3/process" dockerclient "github.com/docker/docker/client" ) @@ -277,6 +281,34 @@ func isLoop(arg string) bool { return false } +func getSessionIdleTimeout() time.Duration { + val := os.Getenv("SHUFFLE_SESSION_IDLE_TIMEOUT") + if val == "" { + return 3600 * time.Second + } + seconds, err := strconv.Atoi(val) + if err != nil || seconds <= 0 { + return 3600 * time.Second + } + return time.Duration(seconds) * time.Second +} + +func getSessionMaxLifetime() time.Duration { + val := os.Getenv("SHUFFLE_SESSION_MAX_LIFETIME") + if val == "" { + return 0 + } + seconds, err := strconv.Atoi(val) + if err != nil || seconds <= 0 { + return 0 + } + return time.Duration(seconds) * time.Second +} + +func getSessionExpiration() time.Time { + return time.Now().Add(getSessionIdleTimeout()) +} + func ConstructSessionCookie(value string, expires time.Time) *http.Cookie { c := http.Cookie{ Name: "session_token", @@ -319,6 +351,23 @@ func constructSessionDeleteCookie() *http.Cookie { return c } +// expireSession clears the session on both the server (DB, cache) and client (cookie). +func expireSession(ctx context.Context, resp http.ResponseWriter, user *User, reason string) error { + if resp != nil { + newCookie := constructSessionDeleteCookie() + http.SetCookie(resp, newCookie) + newCookie.Name = "__session" + http.SetCookie(resp, newCookie) + } + go DeleteCache(ctx, fmt.Sprintf("session_%s", user.Session)) + user.Session = "" + user.SessionCreatedAt = 0 + user.SessionLastActivityAt = 0 + user.ValidatedSessionOrgs = []string{} + go SetUser(ctx, user, false) + return errors.New(reason) +} + func HandleSet2fa(resp http.ResponseWriter, request *http.Request) { cors := HandleCors(resp, request) if cors { @@ -558,7 +607,7 @@ func HandleSet2fa(resp http.ResponseWriter, request *http.Request) { if len(user.Session) != 0 { log.Printf("[INFO] User session exists - resetting session") - expiration := time.Now().Add(8 * time.Hour) + expiration := getSessionExpiration() newCookie := ConstructSessionCookie(user.Session, expiration) @@ -568,25 +617,12 @@ func HandleSet2fa(resp http.ResponseWriter, request *http.Request) { http.SetCookie(resp, newCookie) //log.Printf("SESSION LENGTH MORE THAN 0 IN LOGIN: %s", user.Session) - returnValue.Cookies = append(returnValue.Cookies, SessionCookie{ - Key: "session_token", - Value: user.Session, - Expiration: expiration.Unix(), - }) - - returnValue.Cookies = append(returnValue.Cookies, SessionCookie{ - Key: "__session", - Value: user.Session, - Expiration: expiration.Unix(), - }) - - loginData = fmt.Sprintf(`{"success": true, "cookies": [{"key": "session_token", "value": "%s", "expiration": %d}]}`, user.Session, expiration.Unix()) newData, err := json.Marshal(returnValue) if err == nil { loginData = string(newData) } - err = SetSession(ctx, user, user.Session) + err = SetSession(ctx, &user, user.Session) if err != nil { log.Printf("[WARNING] Error adding session to database: %s", err) } else { @@ -610,7 +646,7 @@ func HandleSet2fa(resp http.ResponseWriter, request *http.Request) { log.Printf("[INFO] User session for %s (%s) is empty - create one!", user.Username, user.Id) sessionToken := uuid.NewV4().String() - expiration := time.Now().Add(8 * time.Hour) + expiration := getSessionExpiration() newCookie := ConstructSessionCookie(sessionToken, expiration) // Does it not set both? @@ -620,24 +656,13 @@ func HandleSet2fa(resp http.ResponseWriter, request *http.Request) { http.SetCookie(resp, newCookie) // ADD TO DATABASE - err = SetSession(ctx, user, sessionToken) + err = SetSession(ctx, &user, sessionToken) if err != nil { log.Printf("[DEBUG] Error adding session to database: %s", err) } user.Session = sessionToken - returnValue.Cookies = append(returnValue.Cookies, SessionCookie{ - Key: "session_token", - Value: sessionToken, - Expiration: expiration.Unix(), - }) - - returnValue.Cookies = append(returnValue.Cookies, SessionCookie{ - Key: "__session", - Value: sessionToken, - Expiration: expiration.Unix(), - }) user.MFA = foundUser.MFA err = SetUser(ctx, &user, true) if err != nil { @@ -647,7 +672,6 @@ func HandleSet2fa(resp http.ResponseWriter, request *http.Request) { return } - loginData = fmt.Sprintf(`{"success": true, "cookies": [{"key": "session_token", "value": "%s", "expiration": %d}]}`, sessionToken, expiration.Unix()) newData, err := json.Marshal(returnValue) if err == nil { loginData = string(newData) @@ -1772,6 +1796,8 @@ func HandleLogout(resp http.ResponseWriter, request *http.Request) { userInfo.UsersLastSession = userInfo.Session userInfo.Session = "" + userInfo.SessionCreatedAt = 0 + userInfo.SessionLastActivityAt = 0 userInfo.ValidatedSessionOrgs = []string{} err := SetUser(ctx, &userInfo, false) if err != nil { @@ -1906,7 +1932,7 @@ func GetAppAuthentication(resp http.ResponseWriter, request *http.Request) { newAuthField := auth for index, _ := range auth.Fields { - // Allowing these fields specifically, as they typically aren't + // Allowing these fields specifically, as they typically aren't // sensitive, and the API is authenticated. if auth.Fields[index].Key == "url" || auth.Fields[index].Key == "model" { @@ -2381,23 +2407,23 @@ func AddAppAuthentication(resp http.ResponseWriter, request *http.Request) { // Removing as being strict on EXTRA fields don't matter much // Apps can handle this anyway /* - // Check if the items are correct - for _, field := range appAuth.Fields { - found := false - for _, param := range app.Authentication.Parameters { - //log.Printf("Fields: %s - %s", field, param.Name) - if field.Key == param.Name { - found = true + // Check if the items are correct + for _, field := range appAuth.Fields { + found := false + for _, param := range app.Authentication.Parameters { + //log.Printf("Fields: %s - %s", field, param.Name) + if field.Key == param.Name { + found = true + } } - } - if !found { - log.Printf("[WARNING] Failed finding field '%s' in appauth fields for %s", field.Key, appAuth.App.Name) - resp.WriteHeader(409) - resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "All auth fields required"}`))) - return + if !found { + log.Printf("[WARNING] Failed finding field '%s' in appauth fields for %s", field.Key, appAuth.App.Name) + resp.WriteHeader(409) + resp.Write([]byte(fmt.Sprintf(`{"success": false, "reason": "All auth fields required"}`))) + return + } } - } */ } } @@ -3335,7 +3361,7 @@ func HandleGetEnvironments(resp http.ResponseWriter, request *http.Request) { if len(environments) == 0 { resp.WriteHeader(404) resp.Write([]byte(`{"success": false, "reason": "Can't find environment. Does it exist?"}`)) - return + return } } @@ -3726,7 +3752,7 @@ func HandleApiAuthentication(resp http.ResponseWriter, request *http.Request) (U if err != nil { // Due to execution auth if !strings.Contains(request.URL.String(), "authorization=") && !strings.Contains(request.URL.String(), "execution_id=") { - if debug { + if debug { log.Printf("[DEBUG] Apikey '%s' doesn't exist. URL: %#v: %s", apikeyCheck[1], request.URL.String(), err) } } @@ -3828,7 +3854,7 @@ func HandleApiAuthentication(resp http.ResponseWriter, request *http.Request) (U } else { // Check if both session tokens are set // Compatibility issues - //expiration := time.Now().Add(8 * time.Hour) + //expiration := getSessionExpiration() newCookie := ConstructSessionCookie(sessionToken, c.Expires) newCookie.MaxAge = c.MaxAge @@ -3903,6 +3929,39 @@ func HandleApiAuthentication(resp http.ResponseWriter, request *http.Request) (U user.SessionLogin = true + // ── Session timeout enforcement ────────────────────────────────── + now := time.Now() + idleTimeout := getSessionIdleTimeout() + maxLifetime := getSessionMaxLifetime() + + if user.Session != "" { + // Idle timeout: expire if the user hasn't made any request within the configured window + if user.SessionLastActivityAt > 0 && now.Unix()-user.SessionLastActivityAt > int64(idleTimeout.Seconds()) { + return User{}, expireSession(ctx, resp, &user, "Session expired due to inactivity") + } + + // Max lifetime: expire if the session has existed longer than the absolute maximum, + // regardless of activity. Only enforced when SHUFFLE_SESSION_MAX_LIFETIME is set. + if maxLifetime > 0 && user.SessionCreatedAt > 0 && now.Unix()-user.SessionCreatedAt > int64(maxLifetime.Seconds()) { + return User{}, expireSession(ctx, resp, &user, "Session max lifetime exceeded") + } + + // Refresh the session after every successful authenciation, but at most once every 60 seconds per session, + // to avoid hammering Opensearch on every keystroke or rapid-fire API call. + if now.Unix()-user.SessionLastActivityAt >= 60 { + user.SessionLastActivityAt = now.Unix() + go SetUser(ctx, &user, false) + + // Slide the cookie's Expires forward so the browser also enforces the idle timeout at the HTTP level. + if resp != nil { + refreshedCookie := ConstructSessionCookie(user.Session, getSessionExpiration()) + http.SetCookie(resp, refreshedCookie) + refreshedCookie.Name = "__session" + http.SetCookie(resp, refreshedCookie) + } + } + } + // Means session exists, but return user, nil } @@ -4580,7 +4639,7 @@ func GetWorkflowExecutionsV2(resp http.ResponseWriter, request *http.Request) { cursor := "" cursorList, cursorOk := request.URL.Query()["cursor"] - if cursorOk && len(cursorList) > 60{ + if cursorOk && len(cursorList) > 60 { cursor = cursorList[0] } @@ -12988,7 +13047,7 @@ func HandleChangeUserOrg(resp http.ResponseWriter, request *http.Request) { return } - expiration := time.Now().Add(8 * time.Hour) + expiration := getSessionExpiration() newCookie := ConstructSessionCookie(user.Session, expiration) http.SetCookie(resp, newCookie) @@ -16700,26 +16759,10 @@ func HandleLogin(resp http.ResponseWriter, request *http.Request) { } } - regionUrl := "" - if project.Environment == "cloud" { - if len(userdata.ActiveOrg.RegionUrl) > 0 { - regionUrl = userdata.ActiveOrg.RegionUrl - } else { - org, err := GetOrg(ctx, userdata.ActiveOrg.Id) - if err != nil { - log.Printf("[ERROR] Failed getting org %s during login for %s (%s): %s", userdata.ActiveOrg.Id, userdata.Username, userdata.Id, err) - } else { - if strings.Contains(strings.ToLower(org.RegionUrl), "http") { - regionUrl = strings.ToLower(org.RegionUrl) - } - } - } - } - // Had to set this due to session hashing rollback if len(userdata.Session) != 0 && len(userdata.Session) == 36 && !changeActiveOrg { log.Printf("[INFO] User session exists - resetting session") - expiration := time.Now().Add(8 * time.Hour) + expiration := getSessionExpiration() newCookie := ConstructSessionCookie(userdata.Session, expiration) http.SetCookie(resp, newCookie) @@ -16727,19 +16770,6 @@ func HandleLogin(resp http.ResponseWriter, request *http.Request) { newCookie.Name = "__session" http.SetCookie(resp, newCookie) - //log.Printf("SESSION LENGTH MORE THAN 0 IN LOGIN: %s", userdata.Session) - returnValue.Cookies = append(returnValue.Cookies, SessionCookie{ - Key: "session_token", - Value: userdata.Session, - Expiration: expiration.Unix(), - }) - - returnValue.Cookies = append(returnValue.Cookies, SessionCookie{ - Key: "__session", - Value: userdata.Session, - Expiration: expiration.Unix(), - }) - // Singul handler if project.Environment == "cloud" { newCookie.Name = "__session" @@ -16755,13 +16785,12 @@ func HandleLogin(resp http.ResponseWriter, request *http.Request) { http.SetCookie(resp, newCookie) } - loginData = fmt.Sprintf(`{"success": true, "cookies": [{"key": "session_token", "value": "%s", "expiration": %d}], "region_url": "%s"}`, userdata.Session, expiration.Unix(), regionUrl) newData, err := json.Marshal(returnValue) if err == nil { loginData = string(newData) } - err = SetSession(ctx, userdata, userdata.Session) + err = SetSession(ctx, &userdata, userdata.Session) if err != nil { log.Printf("[WARNING] Error adding session to database: %s", err) } else { @@ -16783,7 +16812,7 @@ func HandleLogin(resp http.ResponseWriter, request *http.Request) { log.Printf("[INFO] User session for %s (%s) is empty - create one!", userdata.Username, userdata.Id) sessionToken := uuid.NewV4().String() - expiration := time.Now().Add(8 * time.Hour) + expiration := getSessionExpiration() newCookie := ConstructSessionCookie(sessionToken, expiration) // Does it not set both? @@ -16793,25 +16822,13 @@ func HandleLogin(resp http.ResponseWriter, request *http.Request) { http.SetCookie(resp, newCookie) // ADD TO DATABASE - err = SetSession(ctx, userdata, sessionToken) + err = SetSession(ctx, &userdata, sessionToken) if err != nil { log.Printf("[DEBUG] Error adding session to database: %s", err) } userdata.Session = sessionToken - returnValue.Cookies = append(returnValue.Cookies, SessionCookie{ - Key: "session_token", - Value: sessionToken, - Expiration: expiration.Unix(), - }) - - returnValue.Cookies = append(returnValue.Cookies, SessionCookie{ - Key: "__session", - Value: sessionToken, - Expiration: expiration.Unix(), - }) - // Singul handler if project.Environment == "cloud" { newCookie.Name = "__session" @@ -16831,7 +16848,6 @@ func HandleLogin(resp http.ResponseWriter, request *http.Request) { return } - loginData = fmt.Sprintf(`{"success": true, "cookies": [{"key": "session_token", "value": "%s", "expiration": %d}], "region_url": "%s"}`, sessionToken, expiration.Unix(), regionUrl) newData, err := json.Marshal(returnValue) if err == nil { loginData = string(newData) @@ -18325,61 +18341,61 @@ func ParsedExecutionResult(ctx context.Context, workflowExecution WorkflowExecut // Special handler for AI Agent -> App run setCache := true - if skipAgentWait == "true" && actionResult.Action.AppName == "openai" && len(workflowExecution.ExecutionParent) > 0 { + if skipAgentWait == "true" && actionResult.Action.AppName == "openai" && len(workflowExecution.ExecutionParent) > 0 { foundParentExec, err := GetWorkflowExecution(ctx, workflowExecution.ExecutionParent) - if err != nil || len(foundParentExec.ExecutionId) == 0 { + if err != nil || len(foundParentExec.ExecutionId) == 0 { log.Printf("[ERROR][%s] Failed to find AI Parent exec %s", workflowExecution.ExecutionId, workflowExecution.ExecutionParent) } else { // Question: How does it know where to send it? - // None of these methods work. + // None of these methods work. // Since it's based on Parent => Node, it should be ExecutionSourceNode being the AI one // ExecutionSourceNode string `json:"execution_source_node" yaml:"execution_source_node"` startNode := Action{} - if len(workflowExecution.ExecutionSourceNode) == 0 { + if len(workflowExecution.ExecutionSourceNode) == 0 { log.Printf("[ERROR][%s] Agent run is missing ExecutionSourceNode from parent execution %s", workflowExecution.ExecutionId, foundParentExec.ExecutionId) } else { // This doesn't work due to e.g. having multiple nodes in the same one // AKA it's guessing - for _, action := range foundParentExec.Workflow.Actions { - if action.ID == workflowExecution.ExecutionSourceNode { + for _, action := range foundParentExec.Workflow.Actions { + if action.ID == workflowExecution.ExecutionSourceNode { startNode = action break } } } - if startNode.Name != "" { + if startNode.Name != "" { skipAgentContinue := false - if strings.Contains(actionResult.Result, "success") { + if strings.Contains(actionResult.Result, "success") { quickUnmarshal := ResultChecker{} err := json.Unmarshal([]byte(actionResult.Result), &quickUnmarshal) if err == nil && quickUnmarshal.Success == false { skipAgentContinue = true oldAgentOutput := AgentOutput{} foundError := fmt.Sprintf("LLM received call failed from app: ") - if len(quickUnmarshal.Reason) > 0 { + if len(quickUnmarshal.Reason) > 0 { foundError += fmt.Sprintf(quickUnmarshal.Reason) } - // Tries to map it in from the openai request + // Tries to map it in from the openai request if len(oldAgentOutput.OriginalInput) == 0 { - for _, param := range actionResult.Action.Parameters { - if param.Name != "body" { + for _, param := range actionResult.Action.Parameters { + if param.Name != "body" { continue } - // Marshal into openai conversation request + // Marshal into openai conversation request openaiReq := openai.ChatCompletionRequest{} unmarshalledErr := json.Unmarshal([]byte(param.Value), &openaiReq) if unmarshalledErr != nil { log.Printf("[ERROR] Failed unmarshalling body into openai request: %s", unmarshalledErr) break - } + } if len(openaiReq.Messages) > 0 { for _, userMessage := range openaiReq.Messages { - if !strings.HasPrefix(userMessage.Content, "USER REQUEST:") { + if !strings.HasPrefix(userMessage.Content, "USER REQUEST:") { continue } @@ -18396,13 +18412,13 @@ func ParsedExecutionResult(ctx context.Context, workflowExecution WorkflowExecut } } - if !skipAgentContinue { + if !skipAgentContinue { callerName := "ParsedExecutionResult" marshalledResult, err := json.Marshal(actionResult) - if err != nil { + if err != nil { log.Printf("[ERROR] AI Agent (10): Failed marshalling actionResult: %s", err) } else { - go HandleAiAgentExecutionStart(*foundParentExec, startNode, false, callerName, marshalledResult) + go HandleAiAgentExecutionStart(*foundParentExec, startNode, false, callerName, marshalledResult) } } } else { @@ -18750,12 +18766,12 @@ func ParsedExecutionResult(ctx context.Context, workflowExecution WorkflowExecut workflowExecution.ExecutionVariables = append(workflowExecution.ExecutionVariables, actionResult.Action.ExecutionVariable) } // @yashsinghcodes: Something to force the executionVars to update. Not needed rn -// for i, executionVariable := range workflowExecution.Workflow.ExecutionVariables { -// if executionVariable.Name == actionResult.Action.ExecutionVariable.Name { -// workflowExecution.Workflow.ExecutionVariables[i] = actionResult.Action.ExecutionVariable -// break -// } -// } + // for i, executionVariable := range workflowExecution.Workflow.ExecutionVariables { + // if executionVariable.Name == actionResult.Action.ExecutionVariable.Name { + // workflowExecution.Workflow.ExecutionVariables[i] = actionResult.Action.ExecutionVariable + // break + // } + // } } else { log.Printf("[DEBUG] NOT updating exec variable %s with new value of length %d. Check previous errors, or if action was successful (success: true)", actionResult.Action.ExecutionVariable.Name, len(actionResult.Result)) @@ -19780,7 +19796,7 @@ func setExecutionVariable(actionResult ActionResult) bool { // Finds execution results and parameters that are too large to manage and reduces them / saves data partly func compressExecution(ctx context.Context, workflowExecution WorkflowExecution, saveLocationInfo string) (WorkflowExecution, bool) { workerCompressExecution := os.Getenv("SHUFFLE_WORKER_COMPRESS") - if project.Environment == "worker" && (len(workerCompressExecution) == 0 || workerCompressExecution == "false"){ + if project.Environment == "worker" && (len(workerCompressExecution) == 0 || workerCompressExecution == "false") { log.Printf("[DEBUG][%s] No need to make this execution any smaller", workflowExecution.ExecutionId) return workflowExecution, false } @@ -21150,8 +21166,8 @@ func CheckHookAuth(request *http.Request, auth string) error { func PrepareSingleAction(ctx context.Context, parentRequest *http.Request, user User, appId string, body []byte, runValidationAction bool, decision ...string) (WorkflowExecution, error) { workflowExecution := WorkflowExecution{} if ctx == nil { - ctx = context.Background() - } + ctx = context.Background() + } var action Action err := json.Unmarshal(body, &action) @@ -21328,20 +21344,20 @@ func PrepareSingleAction(ctx context.Context, parentRequest *http.Request, user // Fallback if no group is supplied found := false for _, sensor := range env.SensorHosts { - for _, foundHost := range foundHosts { - if sensor.Hostname == foundHost { + for _, foundHost := range foundHosts { + if sensor.Hostname == foundHost { found = true break } } // Fallback - if found { + if found { parsedEnv = fmt.Sprintf("%s_%s", strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(env.Name, " ", "-"), "_", "-")), env.OrgId) break } } - + continue } @@ -21709,12 +21725,12 @@ func PrepareSingleAction(ctx context.Context, parentRequest *http.Request, user app.ID = action.AppID } - // Prevents overwriting of URL if auth injection is done - shuffleAuthInjected := false + // Prevents overwriting of URL if auth injection is done + shuffleAuthInjected := false // Fallback to inject creds if the user don't have any. This is for internal + // AI oriented APIs only. Check IsShuffleApp() for details - isShuffleApp := IsShuffleApp(app) + isShuffleApp := IsShuffleApp(app) if isShuffleApp && app.Generated && len(workflowExecution.OrgId) > 0 && len(action.AuthenticationId) == 0 && strings.ToLower(app.Name) != "openai" && strings.ToLower(action.Environment) == "cloud" { shuffleAuthInjected = true @@ -21831,9 +21847,9 @@ func PrepareSingleAction(ctx context.Context, parentRequest *http.Request, user action.Parameters[headerIndex].Value = fmt.Sprintf("%s\nOrg-Id: %s", action.Parameters[headerIndex].Value, workflowExecution.OrgId) } - // Custom AI injection when necessary + // Custom AI injection when necessary } else if strings.ToLower(app.Name) == "openai" && len(action.AuthenticationId) == 0 { - shuffleAuthInjected = true + shuffleAuthInjected = true // cloud => only do it on cloud location // This prevents local users from being able to see it if project.Environment != "cloud" || (project.Environment == "cloud" && strings.ToLower(action.Environment) == "cloud") { @@ -22074,8 +22090,8 @@ func PrepareSingleAction(ctx context.Context, parentRequest *http.Request, user // Makes them 'required' to run. Makes it possible to have conditions // for AI Agents in workflows primarily - for _, branch := range oldExec.Workflow.Branches { - if branch.DestinationID != parentActionId { + for _, branch := range oldExec.Workflow.Branches { + if branch.DestinationID != parentActionId { continue } @@ -22285,7 +22301,7 @@ func HandleRetValidation(ctx context.Context, workflowExecution WorkflowExecutio // VERY short sleeptime here on purpose // Increased to 30 seconds because a lot of APIs can take ~longish - maxSeconds := 30 + maxSeconds := 30 startTime := time.Now().Unix() if project.Environment != "cloud" { maxSeconds = 180 @@ -23672,7 +23688,7 @@ func handleOpenIdCloud(resp http.ResponseWriter, request *http.Request) { } // Session management - expiration := time.Now().Add(8 * time.Hour) + expiration := getSessionExpiration() if len(user.Session) == 0 { log.Printf("[INFO] User does NOT have session - creating - (1)") sessionToken := uuid.NewV4().String() @@ -23681,7 +23697,7 @@ func handleOpenIdCloud(resp http.ResponseWriter, request *http.Request) { newCookie.Name = "__session" http.SetCookie(resp, newCookie) - err = SetSession(ctx, user, sessionToken) + err = SetSession(ctx, &user, sessionToken) if err != nil { log.Printf("[WARNING] Error creating session for user: %s", err) resp.WriteHeader(401) @@ -23697,7 +23713,7 @@ func handleOpenIdCloud(resp http.ResponseWriter, request *http.Request) { newCookie.Name = "__session" http.SetCookie(resp, newCookie) - err = SetSession(ctx, user, sessionToken) + err = SetSession(ctx, &user, sessionToken) if err != nil { log.Printf("[WARNING] Error creating session for user: %s", err) resp.WriteHeader(401) @@ -23927,7 +23943,7 @@ func handleOpenIdCloud(resp http.ResponseWriter, request *http.Request) { user.SetSSOInfo(org.Id, orgSSOInfo) // Session management - expiration := time.Now().Add(8 * time.Hour) + expiration := getSessionExpiration() if len(user.Session) == 0 { log.Printf("[INFO] User does NOT have session - creating - (2)") sessionToken := uuid.NewV4().String() @@ -23936,7 +23952,7 @@ func handleOpenIdCloud(resp http.ResponseWriter, request *http.Request) { newCookie.Name = "__session" http.SetCookie(resp, newCookie) - err = SetSession(ctx, user, sessionToken) + err = SetSession(ctx, &user, sessionToken) if err != nil { log.Printf("[WARNING] Error creating session for user: %s", err) resp.WriteHeader(401) @@ -23952,7 +23968,7 @@ func handleOpenIdCloud(resp http.ResponseWriter, request *http.Request) { newCookie.Name = "__session" http.SetCookie(resp, newCookie) - err = SetSession(ctx, user, sessionToken) + err = SetSession(ctx, &user, sessionToken) if err != nil { log.Printf("[WARNING] Error creating session for user: %s", err) resp.WriteHeader(401) @@ -24366,7 +24382,7 @@ func HandleOpenId(resp http.ResponseWriter, request *http.Request) { Role: role, } - expiration := time.Now().Add(8 * time.Hour) + expiration := getSessionExpiration() if len(user.Session) == 0 { log.Printf("[INFO] User does NOT have session - creating - (1)") sessionToken := uuid.NewV4().String() @@ -24377,7 +24393,7 @@ func HandleOpenId(resp http.ResponseWriter, request *http.Request) { newCookie.Name = "__session" http.SetCookie(resp, newCookie) - err = SetSession(ctx, user, sessionToken) + err = SetSession(ctx, &user, sessionToken) if err != nil { log.Printf("[WARNING] Error creating session for user: %s", err) resp.WriteHeader(401) @@ -24395,7 +24411,7 @@ func HandleOpenId(resp http.ResponseWriter, request *http.Request) { newCookie.Name = "__session" http.SetCookie(resp, newCookie) - err = SetSession(ctx, user, sessionToken) + err = SetSession(ctx, &user, sessionToken) if err != nil { log.Printf("[WARNING] Error creating session for user: %s", err) resp.WriteHeader(401) @@ -24541,7 +24557,7 @@ func HandleOpenId(resp http.ResponseWriter, request *http.Request) { Role: role, } - expiration := time.Now().Add(8 * time.Hour) + expiration := getSessionExpiration() if len(user.Session) == 0 { log.Printf("[INFO] User does NOT have session - creating - (2)") sessionToken := uuid.NewV4().String() @@ -24551,7 +24567,7 @@ func HandleOpenId(resp http.ResponseWriter, request *http.Request) { newCookie.Name = "__session" http.SetCookie(resp, newCookie) - err = SetSession(ctx, user, sessionToken) + err = SetSession(ctx, &user, sessionToken) if err != nil { log.Printf("[WARNING] Error creating session for user: %s", err) resp.WriteHeader(401) @@ -24569,7 +24585,7 @@ func HandleOpenId(resp http.ResponseWriter, request *http.Request) { newCookie.Name = "__session" http.SetCookie(resp, newCookie) - err = SetSession(ctx, user, sessionToken) + err = SetSession(ctx, &user, sessionToken) if err != nil { log.Printf("[WARNING] Error creating session for user: %s", err) resp.WriteHeader(401) @@ -24723,7 +24739,7 @@ func HandleOpenId(resp http.ResponseWriter, request *http.Request) { newUser.Id = ID.String() newUser.VerificationToken = verifyToken.String() - expiration := time.Now().Add(8 * time.Hour) + expiration := getSessionExpiration() //if len(user.Session) == 0 { log.Printf("[INFO] User does NOT have session - creating") sessionToken := uuid.NewV4().String() @@ -24734,7 +24750,7 @@ func HandleOpenId(resp http.ResponseWriter, request *http.Request) { newCookie.Name = "__session" http.SetCookie(resp, newCookie) - err = SetSession(ctx, *newUser, sessionToken) + err = SetSession(ctx, newUser, sessionToken) if err != nil { log.Printf("[WARNING] Error creating session for user: %s", err) resp.WriteHeader(401) @@ -25187,7 +25203,7 @@ func PrepareWorkflowExecution(ctx context.Context, workflow Workflow, request *h var execution ExecutionRequest err = json.Unmarshal(body, &execution) if err != nil { - if debug { + if debug { log.Printf("[DEBUG] JSON parsing problem in run workflow: %s", err) } @@ -25201,7 +25217,7 @@ func PrepareWorkflowExecution(ctx context.Context, workflow Workflow, request *h // Ensuring it works even if startpoint isn't defined if execution.Start == "" && len(body) > 0 && len(execution.ExecutionSource) == 0 && len(execution.ExecutionArgument) == 0 { // Check if "execution_argument" in body - if debug { + if debug { log.Printf("[DEBUG] Fallback to full body usage for exec arg") } @@ -25214,7 +25230,7 @@ func PrepareWorkflowExecution(ctx context.Context, workflow Workflow, request *h workflowExecution.ExecutionArgument = execution.ExecutionArgument } - //if debug { + //if debug { // log.Printf("\n\n\n\n\n[DEBUG] INPUT BODY: %s \n\n\n\n\n", string(body)) //} @@ -25883,8 +25899,8 @@ func PrepareWorkflowExecution(ctx context.Context, workflow Workflow, request *h // Parse response to get execution ID and update result var subflowResp struct { - Success bool `json:"success"` - ExecutionID string `json:"execution_id"` + Success bool `json:"success"` + ExecutionID string `json:"execution_id"` Authorization string `json:"authorization"` } if jsonErr := json.Unmarshal(respBody, &subflowResp); jsonErr == nil && len(subflowResp.ExecutionID) > 0 { @@ -25912,17 +25928,17 @@ func PrepareWorkflowExecution(ctx context.Context, workflow Workflow, request *h } // Update result with decline subflow info - updatedResult, marshalErr := json.Marshal(userinputResp) - if marshalErr == nil { - result.Result = string(updatedResult) - for newresIndex, newres := range oldExecution.Results { - if newres.Action.ID == result.Action.ID { - oldExecution.Results[newresIndex] = result - break + updatedResult, marshalErr := json.Marshal(userinputResp) + if marshalErr == nil { + result.Result = string(updatedResult) + for newresIndex, newres := range oldExecution.Results { + if newres.Action.ID == result.Action.ID { + oldExecution.Results[newresIndex] = result + break + } } } } - } log.Printf("[INFO][%s] Decline subflow execution: %s, URL: %s", oldExecution.ExecutionId, subflowResp.ExecutionID, userinputResp.DeclineSubflowURL) } @@ -27044,7 +27060,7 @@ func PrepareWorkflowExecution(ctx context.Context, workflow Workflow, request *h workflowExecution.Workflow.Actions[actionIndex].Environment = "Cloud" cloudExec = true } else { - if project.Environment == "cloud" { + if project.Environment == "cloud" { action.Environment = "Cloud" workflowExecution.Workflow.Actions[actionIndex].Environment = "Cloud" cloudExec = true @@ -30214,7 +30230,7 @@ func GetPriorities(ctx context.Context, user User, org *Org) ([]Priority, error) org, updated = AddPriority(*org, Priority{ Name: fmt.Sprintf("Try Shuffle Security"), - Description: fmt.Sprintf("Automatically handle alerts and vulnerabilities!"), + Description: fmt.Sprintf("Automatically handle alerts and vulnerabilities!"), Type: "security", Active: true, URL: fmt.Sprintf("https://security.shuffler.io"), @@ -35891,8 +35907,8 @@ func getPrioritisedAppActions(ctx context.Context, inputApp string, maxAmount in if !found { returnActions = append(returnActions, action) } else { - if debug { - log.Printf("[DEBUG] NOT adding priority; %#v", action.Name) + if debug { + log.Printf("[DEBUG] NOT adding priority; %#v", action.Name) } } } @@ -36331,6 +36347,233 @@ func ValidateExecutionChronology(ctx context.Context, execution *WorkflowExecuti return violations } +func listProcessesWindows() ([]ProcessInfo, error) { + return collect() +} + +func listProcessesDarwin() ([]ProcessInfo, error) { + return collect() +} + +func listProcessesLinux() ([]ProcessInfo, error) { + return collect() +} + +type cacheEntry struct { + hash string + mtime time.Time + size int64 +} + +var ( + hashCache = make(map[string]cacheEntry) + hashCacheMu sync.Mutex +) + +// cachedHashFile returns the SHA256 of the file at path. +// It only re-hashes if the file's mtime or size has changed since last call. +func cachedHashFile(path string) string { + if path == "" { + return "" + } + + info, err := os.Stat(path) + if err != nil { + return "" + } + mtime := info.ModTime() + size := info.Size() + + hashCacheMu.Lock() + entry, ok := hashCache[path] + hashCacheMu.Unlock() + + if ok && entry.mtime.Equal(mtime) && entry.size == size { + return entry.hash + } + + // Cache miss or file changed — hash it. + hash := hashFile(path) + if hash == "" { + return "" + } + + hashCacheMu.Lock() + hashCache[path] = cacheEntry{hash: hash, mtime: mtime, size: size} + hashCacheMu.Unlock() + + return hash +} + +// hashFile computes the SHA256 of a file by streaming it — +// large binaries never fully land in memory. +func hashFile(path string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "" + } + return hex.EncodeToString(h.Sum(nil)) +} + +func scrubArgs(args []string) []string { + if len(args) == 0 { + return args + } + + out := make([]string, len(args)) + copy(out, args) + + for i, arg := range out { + // Style 1: --flag=value or -f=value + if eq := indexByte(arg, '='); eq >= 0 { + key := arg[:eq] + if isSecretKey(key) { + out[i] = key + "=[REDACTED]" + } + continue + } + + // Style 2/3: --flag value or -f value — redact the next element. + if isSecretKey(arg) && i+1 < len(out) { + out[i+1] = "[REDACTED]" + } + } + + return out +} + +var secretKeywords = []string{ + "token", + "secret", + "password", + "passwd", + "apikey", + "api_key", + "api-key", + "auth", + "credential", + "private_key", + "private-key", + "access_key", + "access-key", + "signing_key", + "signing-key", +} + +// isSecretKey returns true if the flag name contains a secret keyword. +func isSecretKey(flag string) bool { + // Strip leading dashes so "--api-key" and "api-key" both match. + lower := strings.ToLower(strings.TrimLeft(flag, "-")) + for _, kw := range secretKeywords { + if strings.Contains(lower, kw) { + return true + } + } + return false +} + +// indexByte returns the index of the first occurrence of c in s, or -1. +// Using this instead of strings.IndexByte to avoid an extra import. +func indexByte(s string, c byte) int { + for i := 0; i < len(s); i++ { + if s[i] == c { + return i + } + } + return -1 +} + +// collect is identical on both platforms — gopsutil handles the syscall difference. +func collect() ([]ProcessInfo, error) { + procs, err := process.Processes() + if err != nil { + return nil, fmt.Errorf("listing processes: %w", err) + } + + out := make([]ProcessInfo, 0, len(procs)) + for _, p := range procs { + ppid, err := p.Ppid() + if err != nil { + ppid = 0 + } + + tty, err := p.Terminal() // "" if no controlling terminal + if err != nil { + tty = "" + } + + cmd, err := p.Name() // argv[0] basename + if err != nil { + cmd = "" + } + + user, err := p.Username() + if err != nil { + user = "" + } + + exePath, err := p.Exe() + if err != nil { + exePath = "" + } + + // kernel threads and SIP-protected processes. + args, err := p.CmdlineSlice() + if err != nil { + args = nil + } + args = scrubArgs(args) + + createdAt, err := p.CreateTime() + if err != nil { + createdAt = 0 + } + + out = append(out, ProcessInfo{ + PID: p.Pid, + PPID: ppid, + TTY: tty, + CommandLine: cmd, + User: user, + + Args: args, + CreationTime: createdAt, + ExePath: exePath, + + // Hash the binary on disk. Note: this is the file at rest, not the + // in-memory image — a binary replaced after launch won't be caught here. + SHA256: cachedHashFile(exePath), + }) + } + + if debug { + log.Printf("[INFO] Found %d processes", len(out)) + } + + return out, nil +} + +// ListProcesses returns all running processes. +// On macOS this calls sysctl kern.proc under the hood. +// On Linux this reads /proc. +func ListProcesses() ([]ProcessInfo, error) { + switch runtime.GOOS { + case "darwin": + return listProcessesDarwin() + case "linux": + return listProcessesLinux() + case "windows": + return listProcessesWindows() + default: + return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } +} func getOrgAppSummaries(ctx context.Context, user User) ([]AppSummary, error) { // Get prioritized apps @@ -36739,7 +36982,7 @@ func GetWorkflowMinimal(resp http.ResponseWriter, request *http.Request) { // Permission check: user owns it OR user is in same org if user.Id != workflow.Owner { if workflow.OrgId != user.ActiveOrg.Id { - log.Printf("[WARNING] User %s (%s) unauthorized to view workflow %s (owner: %s, org: %s)", + log.Printf("[WARNING] User %s (%s) unauthorized to view workflow %s (owner: %s, org: %s)", user.Username, user.Id, workflowId, workflow.Owner, workflow.OrgId) resp.WriteHeader(403) resp.Write([]byte(`{"success": false, "reason": "Unauthorized"}`)) @@ -37691,7 +37934,6 @@ func findNodePosition(wf *Workflow, nodeID string) (string, int, error) { return "", -1, fmt.Errorf("node %s not found", nodeID) } - func opAddNodeWithMapping(ctx context.Context, user User, wf *Workflow, op *WorkflowOperation, tempIDMap map[string]string) error { // IDEMPOTENCY: if this temp_id was already resolved in a prior agent loop, // the node already exists in the workflow (loaded from DB). Skip the add and @@ -37777,7 +38019,7 @@ func opAddNode(ctx context.Context, user User, wf *Workflow, op *WorkflowOperati if err != nil { return fmt.Errorf("failed to enrich action: %w", err) } - // Commented out parameter validation to allow agents to add new parameters dynamically + // Commented out parameter validation to allow agents to add new parameters dynamically // for _, param := range newAction.Parameters { // if param.Required && param.Value == "" { // return fmt.Errorf("required parameter '%s' not provided for action %s", param.Name, realApp.Name) @@ -38065,7 +38307,6 @@ func opDeleteNode(wf *Workflow, op *WorkflowOperation) error { return nil } - func opAddBranchWithMapping(wf *Workflow, op *WorkflowOperation, tempIDMap map[string]string) error { var branchData struct { SourceID string `json:"source_id"` @@ -38204,11 +38445,10 @@ func opDeleteBranch(wf *Workflow, op *WorkflowOperation) error { if debug { log.Printf("[DEBUG] delete_branch: branch %s not found, already removed (likely cascade from delete_node) - skipping", op.ID) } - + return nil } - func opAddCondition(wf *Workflow, op *WorkflowOperation) error { var condData struct { Conditions []struct { @@ -38306,7 +38546,6 @@ func opDeleteCondition(wf *Workflow, op *WorkflowOperation) error { return nil } - func findActionIndexByID(wf *Workflow, id string) int { for i, act := range wf.Actions { if act.ID == id { diff --git a/structs.go b/structs.go index 409365de..0585978c 100755 --- a/structs.go +++ b/structs.go @@ -752,6 +752,8 @@ type User struct { SessionLogin bool `datastore:"session_login" json:"session_login"` // Whether it's a login with session or API (used to verify access) ValidatedSessionOrgs []string `datastore:"validated_session_orgs" json:"validated_session_orgs"` // Orgs that have been used in the current session for the user UsersLastSession string `datastore:"users_last_session" json:"users_last_session"` + SessionCreatedAt int64 `datastore:"session_created_at,noindex" json:"session_created_at,omitempty"` + SessionLastActivityAt int64 `datastore:"session_last_activity_at,noindex" json:"session_last_activity_at,omitempty"` Theme string `datastore:"theme" json:"theme"` PublicProfile PublicProfile `datastore:"public_profile" json:"public_profile"` @@ -788,6 +790,8 @@ type Session struct { Id string `datastore:"Id,noindex"` UserId string `datastore:"user_id,noindex"` Session string `datastore:"session,noindex"` + SessionCreatedAt int64 `datastore:"session_created_at,noindex" json:"session_created_at,omitempty"` + SessionLastActivityAt int64 `datastore:"session_last_activity_at,noindex" json:"session_last_activity_at,omitempty"` } type Contact struct { @@ -3158,28 +3162,27 @@ type Tutorial struct { } type HandleInfo struct { - Success bool `json:"success"` - Admin string `json:"admin"` - Username string `json:"username"` - PublicUsername string `json:"public_username"` - Name string `json:"name"` - ActiveApps []string `json:"active_apps"` - Id string `json:"id"` - Avatar string `json:"avatar"` - Orgs []OrgMini `json:"orgs"` - ActiveOrg OrgMini `json:"active_org"` - EthInfo EthInfo `json:"eth_info,omitempty"` - ChatDisabled bool `json:"chat_disabled"` - Interests []Priority `json:"interests"` - Priorities []Priority `json:"priorities"` - Cookies []SessionCookie `json:"cookies"` - AppExecutionsLimit int64 `json:"app_execution_limit"` - AppExecutionsSuborgs int64 `json:"app_executions_suborgs"` - AppExecutionsUsage int64 `json:"app_execution_usage"` - RegionUrl string `json:"region_url"` - Support bool `json:"support"` - Tutorials []Tutorial `json:"tutorials"` - OrgStatus []string `json:"org_status"` + Success bool `json:"success"` + Admin string `json:"admin"` + Username string `json:"username"` + PublicUsername string `json:"public_username"` + Name string `json:"name"` + ActiveApps []string `json:"active_apps"` + Id string `json:"id"` + Avatar string `json:"avatar"` + Orgs []OrgMini `json:"orgs"` + ActiveOrg OrgMini `json:"active_org"` + EthInfo EthInfo `json:"eth_info,omitempty"` + ChatDisabled bool `json:"chat_disabled"` + Interests []Priority `json:"interests"` + Priorities []Priority `json:"priorities"` + AppExecutionsLimit int64 `json:"app_execution_limit"` + AppExecutionsSuborgs int64 `json:"app_executions_suborgs"` + AppExecutionsUsage int64 `json:"app_execution_usage"` + RegionUrl string `json:"region_url"` + Support bool `json:"support"` + Tutorials []Tutorial `json:"tutorials"` + OrgStatus []string `json:"org_status"` HasCardAvailable bool `json:"has_card_available,omitempty"` ActivatedPayasyougo bool `json:"activated_pay_as_you_go,omitempty"`