agi.vm_registry.go 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. package agi
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "log"
  6. "net/http"
  7. "sync"
  8. "time"
  9. "imuslab.com/arozos/mod/utils"
  10. )
  11. /*
  12. AGI VM Registry
  13. Tracks every Otto VM that is actively executing a script so that
  14. administrators (and users for their own scripts) can inspect running
  15. VMs and force-stop any that are stuck in an infinite loop or
  16. otherwise unresponsive.
  17. Force-stop works by sending a function into the VM's interrupt
  18. channel; Otto checks that channel between JS operations and, when a
  19. value is found, calls the function — which panics with errForceStop.
  20. The panic is caught by a deferred recovery block in each Execute*
  21. function and results in a 503 response rather than a goroutine crash.
  22. */
  23. // errForceStop is the sentinel value panicked inside a forcibly stopped VM.
  24. var errForceStop = errors.New("errForceStop")
  25. // VMRecord holds metadata about one live AGI VM instance.
  26. type VMRecord struct {
  27. ExecID string
  28. ScriptFile string
  29. Username string
  30. StartTime time.Time
  31. interruptCh chan func() // alias to vm.Interrupt — never nil after registration
  32. }
  33. // VMInfo is the JSON-serialisable view of a VMRecord sent to API callers.
  34. type VMInfo struct {
  35. ExecID string `json:"execID"`
  36. ScriptFile string `json:"scriptFile"`
  37. Username string `json:"username"`
  38. StartTime int64 `json:"startTime"` // Unix seconds
  39. ElapsedSeconds int64 `json:"elapsedSeconds"` // seconds since StartTime
  40. }
  41. func toVMInfo(rec *VMRecord) VMInfo {
  42. return VMInfo{
  43. ExecID: rec.ExecID,
  44. ScriptFile: rec.ScriptFile,
  45. Username: rec.Username,
  46. StartTime: rec.StartTime.Unix(),
  47. ElapsedSeconds: int64(time.Since(rec.StartTime).Seconds()),
  48. }
  49. }
  50. // vmRegistry is a goroutine-safe map of execID → *VMRecord.
  51. type vmRegistry struct {
  52. mu sync.RWMutex
  53. records map[string]*VMRecord
  54. }
  55. func newVMRegistry() *vmRegistry {
  56. return &vmRegistry{records: make(map[string]*VMRecord)}
  57. }
  58. // register adds a record. Called just before vm.Run() in each Execute* path.
  59. func (r *vmRegistry) register(rec *VMRecord) {
  60. r.mu.Lock()
  61. r.records[rec.ExecID] = rec
  62. r.mu.Unlock()
  63. }
  64. // unregister removes a record. Always called via defer so it fires even on panic.
  65. func (r *vmRegistry) unregister(execID string) {
  66. r.mu.Lock()
  67. delete(r.records, execID)
  68. r.mu.Unlock()
  69. }
  70. // list returns VMInfo for every VM visible to the requester.
  71. // Admins see all; regular users see only their own records.
  72. func (r *vmRegistry) list(requesterUsername string, isAdmin bool) []VMInfo {
  73. r.mu.RLock()
  74. defer r.mu.RUnlock()
  75. result := make([]VMInfo, 0, len(r.records))
  76. for _, rec := range r.records {
  77. if isAdmin || rec.Username == requesterUsername {
  78. result = append(result, toVMInfo(rec))
  79. }
  80. }
  81. return result
  82. }
  83. // forceStop sends an interrupt to the VM with the given execID.
  84. // Regular users may only stop their own VMs; admins may stop any.
  85. func (r *vmRegistry) forceStop(execID, requesterUsername string, isAdmin bool) error {
  86. r.mu.RLock()
  87. rec, ok := r.records[execID]
  88. r.mu.RUnlock()
  89. if !ok {
  90. return errors.New("VM not found: " + execID)
  91. }
  92. if !isAdmin && rec.Username != requesterUsername {
  93. return errors.New("permission denied: you can only stop your own VMs")
  94. }
  95. select {
  96. case rec.interruptCh <- func() { panic(errForceStop) }:
  97. log.Printf("[AGI] VM %s (script: %s, user: %s) force-stopped by %s",
  98. execID, rec.ScriptFile, rec.Username, requesterUsername)
  99. return nil
  100. default:
  101. return errors.New("interrupt channel full — VM may already be stopping")
  102. }
  103. }
  104. // ── HTTP Handlers ──────────────────────────────────────────────────────────
  105. // HandleListRuntimes returns the list of running VMs visible to the caller.
  106. // GET /system/ajgi/runtime/list
  107. func (g *Gateway) HandleListRuntimes(w http.ResponseWriter, r *http.Request) {
  108. thisuser, err := g.Option.UserHandler.GetUserInfoFromRequest(w, r)
  109. if err != nil {
  110. http.Error(w, "401 Unauthorized", http.StatusUnauthorized)
  111. return
  112. }
  113. infos := g.vmReg.list(thisuser.Username, thisuser.IsAdmin())
  114. w.Header().Set("Content-Type", "application/json")
  115. json.NewEncoder(w).Encode(infos)
  116. }
  117. // HandleForceStopRuntime terminates a VM identified by the execid POST parameter.
  118. // POST /system/ajgi/runtime/stop
  119. func (g *Gateway) HandleForceStopRuntime(w http.ResponseWriter, r *http.Request) {
  120. thisuser, err := g.Option.UserHandler.GetUserInfoFromRequest(w, r)
  121. if err != nil {
  122. http.Error(w, "401 Unauthorized", http.StatusUnauthorized)
  123. return
  124. }
  125. execID, err := utils.PostPara(r, "execid")
  126. if err != nil {
  127. utils.SendErrorResponse(w, "missing execid parameter")
  128. return
  129. }
  130. if stopErr := g.vmReg.forceStop(execID, thisuser.Username, thisuser.IsAdmin()); stopErr != nil {
  131. utils.SendErrorResponse(w, stopErr.Error())
  132. return
  133. }
  134. w.Header().Set("Content-Type", "application/json")
  135. w.Write([]byte(`{"ok":true}`))
  136. }