From b8b3493bb7019833c7894422ff148f488a3d39b9 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 17 Oct 2026 10:00:56 +0000 Subject: [PATCH 1/3] Enable admin password creation and reset --- internal/server/account_contract_test.go | 96 ++++++++++++++++++++++- internal/server/accounts.go | 55 +++++++++++-- internal/server/auth_e2e_test.go | 28 +++++-- internal/server/recovery_contract_test.go | 3 +- internal/store/account_model.go | 47 +++++++++++ 5 files changed, 212 insertions(+), 17 deletions(-) diff --git a/internal/server/account_contract_test.go b/internal/server/account_contract_test.go index e0d11f6..429d519 100644 --- a/internal/server/account_contract_test.go +++ b/internal/server/account_contract_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/url" + "strings" "testing" "agent-system-backend/internal/store" @@ -113,7 +114,6 @@ func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) { h := srv.Handler() adminCookie := bootAdmin(t, h, st) for _, body := range []string{ - `{"owner":"blocked-password","password":"old-pass-9"}`, `{"owner":"blocked-admin","isAdmin":true}`, } { if rec := do(h, "POST", "/api/admin/users", body, adminCookie); rec.Code != http.StatusBadRequest { @@ -152,3 +152,97 @@ func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) { t.Fatalf("state/profile after disable: %+v %v", account, err) } } + +func TestAdminPasswordCreateAndResetHTTP(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + + created := do(h, "POST", "/api/admin/users", `{"owner":"alice","profile":{"name":"爱丽丝"},"password":"alice-initial-9"}`, admin) + if created.Code != http.StatusOK { + t.Fatalf("password create: %d %s", created.Code, created.Body.String()) + } + if strings.Contains(created.Body.String(), "alice-initial-9") { + t.Fatal("supplied password was echoed") + } + var createdBody struct { + UserID string `json:"userId"` + PasswordSet bool `json:"passwordSet"` + MustChangePassword bool `json:"mustChangePassword"` + InitialPassword string `json:"initialPassword"` + } + if err := json.Unmarshal(created.Body.Bytes(), &createdBody); err != nil { + t.Fatal(err) + } + if createdBody.UserID == "" || !createdBody.PasswordSet || !createdBody.MustChangePassword || createdBody.InitialPassword != "" { + t.Fatalf("supplied password projection: %+v", createdBody) + } + account, err := st.GetAccountByOwner("alice") + if err != nil || !account.HasPassword || !account.MustChangePassword || account.InitializationState != store.InitializationInitialized { + t.Fatalf("stored account state: %+v %v", account, err) + } + if state, ok := st.VerifyLogin("alice", "alice-initial-9"); !ok || !state.MustChangePassword { + t.Fatalf("supplied password login: ok=%v state=%+v", ok, state) + } + + generated := do(h, "POST", "/api/admin/users", `{"owner":"bob"}`, admin) + if generated.Code != http.StatusOK { + t.Fatalf("generated password create: %d %s", generated.Code, generated.Body.String()) + } + var generatedBody struct { + InitialPassword string `json:"initialPassword"` + MustChangePassword bool `json:"mustChangePassword"` + } + if err := json.Unmarshal(generated.Body.Bytes(), &generatedBody); err != nil { + t.Fatal(err) + } + if generatedBody.InitialPassword == "" || !generatedBody.MustChangePassword { + t.Fatalf("generated password projection: %+v", generatedBody) + } + if state, ok := st.VerifyLogin("bob", generatedBody.InitialPassword); !ok || !state.MustChangePassword { + t.Fatalf("generated password login: ok=%v state=%+v", ok, state) + } + + login := do(h, "POST", "/api/auth/login", `{"owner":"alice","password":"alice-initial-9"}`, "") + if login.Code != http.StatusOK { + t.Fatalf("alice login: %d %s", login.Code, login.Body.String()) + } + aliceSession := cookieOf(login) + key, _, err := st.IssueLoginKey("alice", store.LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + if rejected := do(h, "POST", "/api/admin/users/alice/password", `{"password":"client-supplied"}`, admin); rejected.Code != http.StatusBadRequest { + t.Fatalf("client reset password accepted: %d %s", rejected.Code, rejected.Body.String()) + } + if _, ok := st.VerifyLogin("alice", "alice-initial-9"); !ok { + t.Fatal("rejected reset changed the password") + } + + reset := do(h, "POST", "/api/admin/users/alice/password", `{}`, admin) + if reset.Code != http.StatusOK || reset.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("admin reset: %d %s", reset.Code, reset.Body.String()) + } + var resetBody struct { + TemporaryPassword string `json:"temporaryPassword"` + MustChangePassword bool `json:"mustChangePassword"` + } + if err := json.Unmarshal(reset.Body.Bytes(), &resetBody); err != nil { + t.Fatal(err) + } + if resetBody.TemporaryPassword == "" || resetBody.TemporaryPassword == "alice-initial-9" || !resetBody.MustChangePassword { + t.Fatalf("reset projection: %+v", resetBody) + } + if rec := do(h, "GET", "/api/me", "", aliceSession); rec.Code != http.StatusUnauthorized { + t.Fatalf("reset did not revoke session: %d %s", rec.Code, rec.Body.String()) + } + if _, ok := st.VerifyLogin("alice", "alice-initial-9"); ok { + t.Fatal("old password remained usable after reset") + } + if state, ok := st.VerifyLogin("alice", resetBody.TemporaryPassword); !ok || !state.MustChangePassword { + t.Fatalf("temporary password login: ok=%v state=%+v", ok, state) + } + if _, _, err := st.RedeemLoginKey(key); err == nil { + t.Fatal("admin reset left unused login key redeemable") + } +} diff --git a/internal/server/accounts.go b/internal/server/accounts.go index 1b0b734..8e68ddc 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -324,8 +324,9 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { return } var req struct { - Owner string `json:"owner"` - Profile store.AccountProfile `json:"profile"` + Owner string `json:"owner"` + Profile store.AccountProfile `json:"profile"` + Password *string `json:"password"` } if err := decodeStrictJSON(r, &req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -336,12 +337,31 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { http.Error(w, "owner required (no spaces or slashes)", http.StatusBadRequest) return } - account, err := s.st.CreatePendingAccount(actor, req.Owner, req.Profile) + password := RandomPassword() + generated := req.Password == nil + if req.Password != nil { + password = *req.Password + if err := validPassword(password); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + hash, err := store.HashPassword(password) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + account, err := s.st.CreateAdminPasswordAccount(actor, req.Owner, req.Profile, hash) if err != nil { writeStoreErr(w, err) return } - writeJSON(w, http.StatusOK, accountProfileJSON(account)) + out := accountProfileJSON(account) + if generated { + w.Header().Set("Cache-Control", "no-store") + out["initialPassword"] = password + } + writeJSON(w, http.StatusOK, out) } func accountProfileJSON(account store.Account) map[string]any { @@ -349,7 +369,8 @@ func accountProfileJSON(account store.Account) map[string]any { "userId": account.ID, "owner": account.Owner, "profile": account.Profile, "profileVersion": account.ProfileVersion, "enabled": account.Enabled, "stateVersion": account.StateVersion, "isAdmin": account.IsAdmin, - "passwordSet": account.HasPassword, "initializationState": account.InitializationState, + "passwordSet": account.HasPassword, "mustChangePassword": account.MustChangePassword, + "initializationState": account.InitializationState, "initializationGeneration": account.InitializationGeneration, } } @@ -466,9 +487,29 @@ func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) { // 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用 // 登录 Key、吊销全部会话、审计。 func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requireAdmin(w, r); ok { - http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone) + actor, ok := s.requireAdmin(w, r) + if !ok { + return } + var req struct{} + if err := decodeStrictJSON(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + password := RandomPassword() + hash, err := store.HashPassword(password) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := s.st.AdminResetUserPassword(r.PathValue("owner"), hash, actor, "admin-http"); err != nil { + writeStoreErr(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]any{ + "owner": r.PathValue("owner"), "temporaryPassword": password, "mustChangePassword": true, + }) } // ---- 管理员:一次性登录 Key(恢复流程主入口)---- diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index 91d2a22..c3c2e34 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -423,19 +423,33 @@ func TestAdminAccountFlow(t *testing.T) { t.Fatalf("non-admin create must 403: %d", rec.Code) } - // 旧管理员重置入口关闭,不能动 alice 的密码与当前会话。 - if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusGone { - t.Fatalf("admin reset must be closed: %d %s", rec.Code, rec.Body.String()) + // 管理员重置总是生成临时密码,销毁 alice 的旧会话并强制首登改密。 + reset := session("POST", "/api/admin/users/alice/password", `{}`) + if reset.Code != http.StatusOK || reset.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("admin reset: %d %s", reset.Code, reset.Body.String()) } - if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusOK { - t.Fatalf("closed reset must preserve alice session: %d", rec.Code) + var resetBody struct { + TemporaryPassword string `json:"temporaryPassword"` + } + if err := json.Unmarshal(reset.Body.Bytes(), &resetBody); err != nil || resetBody.TemporaryPassword == "" { + t.Fatalf("admin reset response: %+v %v", resetBody, err) + } + if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { + t.Fatalf("reset must revoke alice session: %d", rec.Code) } req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { - t.Fatalf("closed reset must preserve normal login: %d %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("old password remained usable after reset: %d %s", rec.Code, rec.Body.String()) + } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"`+resetBody.TemporaryPassword+`"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { + t.Fatalf("temporary password login: %d %s", rec.Code, rec.Body.String()) } // API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。 diff --git a/internal/server/recovery_contract_test.go b/internal/server/recovery_contract_test.go index ad983bf..9918ffc 100644 --- a/internal/server/recovery_contract_test.go +++ b/internal/server/recovery_contract_test.go @@ -99,7 +99,7 @@ func TestOwnerConfirmedRecoveryHTTPKeepsGrantFromAdministrator(t *testing.T) { func jsonNumber(v int64) string { return strconv.FormatInt(v, 10) } -func TestLegacyAdministratorRecoveryEntrancesAreClosed(t *testing.T) { +func TestLegacyAdministratorLoginKeyIssuanceEntrancesAreClosed(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() admin := bootAdmin(t, h, st) @@ -114,7 +114,6 @@ func TestLegacyAdministratorRecoveryEntrancesAreClosed(t *testing.T) { t.Fatal(err) } for _, route := range []struct{ path, body string }{ - {"/api/admin/users/victim/password", `{"password":"attacker-pass"}`}, {"/api/admin/users/victim/login-keys", `{"purpose":"recover"}`}, {"/api/admin/users/victim/login-keys", `{"purpose":"activate"}`}, } { diff --git a/internal/store/account_model.go b/internal/store/account_model.go index 7c81fb6..cd945c9 100644 --- a/internal/store/account_model.go +++ b/internal/store/account_model.go @@ -299,6 +299,53 @@ func (s *Store) CreatePendingAccount(actorOwner, owner string, profile AccountPr return a, nil } +// CreateAdminPasswordAccount creates an ordinary user with an administrator- +// supplied password hash. The account row, hash, and audit entries commit +// together so a failed write cannot leave a credential without its account. +func (s *Store) CreateAdminPasswordAccount(actorOwner, owner string, profile AccountProfile, passwordHash string) (Account, error) { + if passwordHash == "" { + return Account{}, ErrConflict + } + tx, err := s.db.Begin() + if err != nil { + return Account{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner) + if errors.Is(err, ErrNotFound) { + return Account{}, ErrAccountForbidden + } + if err != nil { + return Account{}, err + } + actor := locked[actorOwner] + if !actor.Enabled || !actor.IsAdmin { + return Account{}, ErrAccountForbidden + } + a, err := s.insertPendingAccountTx(tx, owner, profile) + if err != nil { + return Account{}, err + } + if _, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,credential_origin=?,initialization_state=? WHERE id=?`), + passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, a.ID); err != nil { + return Account{}, err + } + a, err = s.accountByOwner(tx, owner) + if err != nil { + return Account{}, err + } + if err := s.insertAccountEvent(tx, actorOwner, "account-management", owner, "account_created_password", a.ID); err != nil { + return Account{}, err + } + if err := s.auditAccountMutation(tx, owner, actorOwner, "account.create_password"); err != nil { + return Account{}, err + } + if err := tx.Commit(); err != nil { + return Account{}, err + } + return a, nil +} + // insertPendingAccountTx lets the trusted local initializer create and grant in // one transaction without impersonating an administrator. The caller owns its // authorization, audit and commit; this helper always inserts an ordinary user. -- 2.54.0 From 5f65059bc28196c9b2621bf90c80b185146a0006 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 17 Oct 2026 11:56:33 +0000 Subject: [PATCH 2/3] Clarify admin reset contract --- internal/server/accounts.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/server/accounts.go b/internal/server/accounts.go index 8e68ddc..d7971fe 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -483,9 +483,8 @@ func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, accountProfileJSON(account)) } -// resetAdminUserPassword:管理员直接重置密码(兼容保留的既有入口;常规恢复 -// 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用 -// 登录 Key、吊销全部会话、审计。 +// resetAdminUserPassword:管理员生成随机临时密码。单事务:must_change=1、bump +// auth_version、作废未用登录 Key、吊销全部会话、审计。 func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) { actor, ok := s.requireAdmin(w, r) if !ok { -- 2.54.0 From b00e1924407989e52350ecdfa17f16640815ed89 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 14:43:13 +0000 Subject: [PATCH 3/3] Execute the managed interpreter through its real path Why: the sandbox hides /home and only remounts the resolved Python prefix, so executing python/current/bin/python3 failed before the base layer could be imported. Evidence: go test ./internal/tools/ -run TestResolveInterpreterFollowsManagedSymlink passed, and env doctor against the built base layer printed doctor ok (SELF). Impact: run_python and env doctor follow the symlink into the mounted prefix or /usr/bin/python3. The layer and the prefix mount stay the same. Refs: Agent-System-Backend PR 207 Co-authored-by: Cursor (cherry picked from commit 28544cfe5da90e97474c42244ad8796e8029eed0) --- cmd/agent-system/env.go | 4 ++++ internal/tools/exec_interpreter.go | 8 +++++++- internal/tools/exec_interpreter_test.go | 22 ++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/cmd/agent-system/env.go b/cmd/agent-system/env.go index 818d51d..845169b 100644 --- a/cmd/agent-system/env.go +++ b/cmd/agent-system/env.go @@ -6,6 +6,7 @@ import ( "flag" "fmt" "os" + "path/filepath" "time" "agent-system-backend/internal/execenv" @@ -137,6 +138,9 @@ func runDoctor(ctx context.Context, root *execenv.Root, py string, sandbox bool) if prefix := root.PythonPrefix(); prefix != "" { mounts = append(mounts, prefix) } + if resolved, err := filepath.EvalSymlinks(py); err == nil { + py = resolved + } if err := execenv.Doctor(ctx, py, pyPath, nil, sandbox, mounts); err != nil { fmt.Fprintln(os.Stderr, "env doctor:", err) return 1 diff --git a/internal/tools/exec_interpreter.go b/internal/tools/exec_interpreter.go index 55f2917..d44aa9f 100644 --- a/internal/tools/exec_interpreter.go +++ b/internal/tools/exec_interpreter.go @@ -203,7 +203,13 @@ func resolveInterpreter(name string) (string, error) { case "python3": if execPythonBin != "" { if st, err := os.Stat(execPythonBin); err == nil && !st.IsDir() { - return execPythonBin, nil + // current/bin/python3 是符号链接。沙箱用 tmpfs 盖住 /home 后只挂真实前缀, + // 执行未解析的 current 路径会找不到文件。 + resolved, err := filepath.EvalSymlinks(execPythonBin) + if err != nil { + return "", err + } + return resolved, nil } return "", fmt.Errorf("python3: configured interpreter missing: %s", execPythonBin) } diff --git a/internal/tools/exec_interpreter_test.go b/internal/tools/exec_interpreter_test.go index da0aa29..90b4364 100644 --- a/internal/tools/exec_interpreter_test.go +++ b/internal/tools/exec_interpreter_test.go @@ -213,6 +213,28 @@ func TestExecEnvIncludesSkillsRoot(t *testing.T) { } } +func TestResolveInterpreterFollowsManagedSymlink(t *testing.T) { + prev := execPythonBin + t.Cleanup(func() { execPythonBin = prev }) + dir := t.TempDir() + link := filepath.Join(dir, "python3") + if err := os.Symlink("/usr/bin/python3", link); err != nil { + t.Fatal(err) + } + execPythonBin = link + got, err := resolveInterpreter("python3") + if err != nil { + t.Fatal(err) + } + want, err := filepath.EvalSymlinks("/usr/bin/python3") + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("resolved %q, want %q", got, want) + } +} + func TestInterpreterExecConfigForKernel(t *testing.T) { cfg := InterpreterExecConfig() if cfg == nil { -- 2.54.0