mirror of
https://github.com/BishopFox/joro.git
synced 2026-11-07 10:25:57 -10:00
Added installed JavaScript automations, event triggers, and a script editor
This commit is contained in:
1 parent
3ea047f4a3
commit
c0cb7dc9c6
26 files changed
+3922
-131
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -123,7 +124,15 @@ func (s *APIServer) newScriptManager() *jsautomation.Manager {
|
||||
"script.run is disabled this run", err)
|
||||
return nil
|
||||
}
|
||||
// Installed automations live beside the plugins, not under ~/.joro/configs/: a
|
||||
// project config is published to teammates, and executable code that runs against
|
||||
// the full SDK bundle must not travel with it. Their key/value state does ride in
|
||||
// the project config, because that is engagement data rather than code.
|
||||
s.automationStorage = jsautomation.NewStorage()
|
||||
|
||||
return jsautomation.New(jsautomation.Deps{
|
||||
Store: jsautomation.NewStore(filepath.Join(s.cfg.DataDir, "automations")),
|
||||
Storage: s.automationStorage,
|
||||
// A getter, for the same reason Deps.BgCtx is one: the capability that starts
|
||||
// a run needs this manager, and this manager needs the sealed registry, so one
|
||||
// of the two edges has to be deferred past construction.
|
||||
@@ -138,6 +147,21 @@ func (s *APIServer) newScriptManager() *jsautomation.Manager {
|
||||
})
|
||||
}
|
||||
|
||||
// startScriptTriggers brings up the trigger dispatcher.
|
||||
//
|
||||
// Started from here rather than from main.go because this runs inside Start, which is
|
||||
// already gated on proxy mode and on automation being configured — and because the
|
||||
// dispatcher needs the server-lifetime context, which is the same reason StartDetectLoop
|
||||
// takes one. The hub subscription is made here too: jsautomation cannot import this
|
||||
// package (api imports it to build the registry), so the wiring belongs on this side.
|
||||
func (s *APIServer) startScriptTriggers(ctx context.Context) {
|
||||
if s.scriptManager == nil || s.scriptManager.Packages() == nil {
|
||||
return
|
||||
}
|
||||
s.scriptTriggers = jsautomation.NewDispatcher(s.scriptManager, s.store, s.hub.Broadcast())
|
||||
go s.scriptTriggers.Run(ctx, s.hub.Subscribe(0))
|
||||
}
|
||||
|
||||
// scriptRunnerDep returns the runner as the interface capreg expects, or a nil
|
||||
// interface when scripting is off. Returning the typed nil pointer directly would give
|
||||
// capreg a non-nil interface holding nil, and the handler's own nil check would not
|
||||
@@ -162,6 +186,7 @@ func (s *APIServer) startAutomation(ctx context.Context) {
|
||||
}
|
||||
s.autoStore.StartFlushLoop(ctx)
|
||||
s.mcpListener.CloseOnContext(ctx)
|
||||
s.startScriptTriggers(ctx)
|
||||
|
||||
state := s.autoStore.MCP()
|
||||
if !state.Enabled {
|
||||
|
||||
@@ -117,7 +117,7 @@ func (s *APIServer) handleCreateAutomationToken(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
var body createTokenReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func (s *APIServer) handleUpdateAutomationToken(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
var body updateTokenReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
@@ -242,7 +242,7 @@ func (s *APIServer) handleSetAutomationTokenEnabled(w http.ResponseWriter, r *ht
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
@@ -392,7 +392,7 @@ func (s *APIServer) handleSetMCPState(w http.ResponseWriter, r *http.Request) {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Port *int `json:"port"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -200,6 +200,11 @@ type projectConfigFile struct {
|
||||
Highlights map[string]string `json:"highlights,omitempty"`
|
||||
RequestHistory []projectCapturedRequest `json:"requestHistory,omitempty"`
|
||||
PluginStates map[string]string `json:"pluginStates,omitempty"` // plugin name -> base64(opaque bytes)
|
||||
// AutomationStates is joro.storage: automation id -> base64(opaque bytes), the
|
||||
// same shape and the same machinery as PluginStates. Engagement state, so it
|
||||
// belongs here; the automations themselves are code and live on disk, because a
|
||||
// project config is published to teammates. Added in schema v7.
|
||||
AutomationStates map[string]string `json:"automationStates,omitempty"`
|
||||
}
|
||||
|
||||
// encodePluginStates base64-encodes each blob for transport inside a JSON
|
||||
@@ -301,6 +306,7 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
|
||||
pHighlights[k] = v
|
||||
}
|
||||
projectGhost := s.pendingProjectPluginStates
|
||||
automationGhost := s.pendingProjectAutomationStates
|
||||
s.mu.RUnlock()
|
||||
|
||||
var pReqs []projectCapturedRequest
|
||||
@@ -322,10 +328,18 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
|
||||
projectFresh = s.pluginManager.ExportProjectStates()
|
||||
}
|
||||
|
||||
var automationFresh map[string][]byte
|
||||
if s.automationStorage != nil {
|
||||
automationFresh = s.automationStorage.Export()
|
||||
}
|
||||
|
||||
dEnabled, dCfg, dDisabled, dOverrides, dRules, dFindings := s.detectStateForProject()
|
||||
|
||||
return projectConfigFile{
|
||||
Version: 6,
|
||||
// v7 added AutomationStates. No normalizeProjectConfig gate: an absent map
|
||||
// decodes to nil, which is the correct default — a backfill exists only where
|
||||
// the zero value would be wrong, as it was for autoSave and detectEnabled.
|
||||
Version: 7,
|
||||
AutoSave: autoSave,
|
||||
SaveHistory: saveHistory,
|
||||
ListenerURL: listenerURL,
|
||||
@@ -351,6 +365,11 @@ func (s *APIServer) buildProjectConfig(autoSave, saveHistory bool) projectConfig
|
||||
Highlights: pHighlights,
|
||||
RequestHistory: pReqs,
|
||||
PluginStates: encodePluginStates(mergePluginStates(projectGhost, projectFresh)),
|
||||
// Same ghost-then-fresh merge as plugin state, and for the same reason: a
|
||||
// teammate's project may reference an automation this machine does not have,
|
||||
// and dropping its bookkeeping would lose the operator who does have it their
|
||||
// state the first time anyone else saves.
|
||||
AutomationStates: encodePluginStates(mergePluginStates(automationGhost, automationFresh)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,7 +549,8 @@ func (s *APIServer) liveStateSignature() string {
|
||||
return fmt.Sprintf("r%d/s%d/n%d/u%d/h%d/sc%s/rp%d/cd%d/no%d",
|
||||
reqCount, lastSeq, noteCount, maxNoteUpdate, hlCount,
|
||||
scopeSignature(s.scope.Rules()), len(s.replace.Rules()),
|
||||
len(s.customData.Items()), len(s.noise.Patterns())) + s.detectSignature()
|
||||
len(s.customData.Items()), len(s.noise.Patterns())) +
|
||||
s.detectSignature() + s.automationSignature()
|
||||
}
|
||||
|
||||
// saveProject snapshots live state to the named project's .joro (respecting its
|
||||
@@ -593,9 +613,15 @@ func (s *APIServer) resetLiveProjectState() {
|
||||
// An automation session belongs to the engagement it authenticated against.
|
||||
s.capContexts.ResetAll()
|
||||
|
||||
// An automation's key/value state describes the engagement it was gathered in.
|
||||
if s.automationStorage != nil {
|
||||
s.automationStorage.Reset()
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.highlights = make(map[string]string)
|
||||
s.pendingProjectPluginStates = nil
|
||||
s.pendingProjectAutomationStates = nil
|
||||
teamChanged := s.settings.ListenerURL != "" || s.settings.TeamToken != "" || s.settings.TeamNickname != ""
|
||||
s.settings.ListenerURL = ""
|
||||
s.settings.TeamToken = ""
|
||||
@@ -661,6 +687,7 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
|
||||
}
|
||||
|
||||
decodedPluginStates := decodePluginStates(cfg.PluginStates)
|
||||
decodedAutomationStates := decodePluginStates(cfg.AutomationStates)
|
||||
|
||||
// An automation session belongs to the engagement it authenticated against, so
|
||||
// it does not survive into a different project.
|
||||
@@ -679,6 +706,7 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
|
||||
s.settings.TeamNickname = nickname
|
||||
s.activeProjectConfig = name
|
||||
s.pendingProjectPluginStates = decodedPluginStates
|
||||
s.pendingProjectAutomationStates = decodedAutomationStates
|
||||
settings := s.settings
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -774,6 +802,14 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
|
||||
unknownPluginStates = s.pluginManager.ApplyProjectStates(decodedPluginStates)
|
||||
}
|
||||
|
||||
// Automation storage, same shape. "Installed" is decided by asking the store, so a
|
||||
// blob belonging to an automation this machine lacks is reported rather than loaded
|
||||
// into a namespace nothing can read.
|
||||
var unknownAutomationStates []string
|
||||
if s.automationStorage != nil {
|
||||
unknownAutomationStates = s.automationStorage.Apply(decodedAutomationStates, s.automationInstalled)
|
||||
}
|
||||
|
||||
// Refresh the sidecar so the loaded project's counts show without decompressing
|
||||
// the .joro again. An existing sidecar's prefs win over the .joro's copy (the
|
||||
// sidecar is authoritative locally); the .joro seeds them only on first load.
|
||||
@@ -822,9 +858,34 @@ func (s *APIServer) applyProjectConfig(cfg *projectConfigFile, name string, pres
|
||||
if len(unknownPluginStates) > 0 {
|
||||
resp["unknownPluginStates"] = unknownPluginStates
|
||||
}
|
||||
if len(unknownAutomationStates) > 0 {
|
||||
resp["unknownAutomationStates"] = unknownAutomationStates
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// automationSignature folds joro.storage into the autosave dirty check.
|
||||
//
|
||||
// A revision counter rather than a count of anything: the state is an opaque blob per
|
||||
// automation, so its size can change while the number of automations does not — the same
|
||||
// reason detectSignature carries the findings store's revision.
|
||||
func (s *APIServer) automationSignature() string {
|
||||
if s.automationStorage == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("/as%d", s.automationStorage.Revision())
|
||||
}
|
||||
|
||||
// automationInstalled reports whether an automation id exists on this machine, so a
|
||||
// project's storage blob for something uninstalled is preserved rather than loaded.
|
||||
func (s *APIServer) automationInstalled(id string) bool {
|
||||
if s.scriptManager == nil || s.scriptManager.Packages() == nil {
|
||||
return false
|
||||
}
|
||||
_, err := s.scriptManager.Packages().Load(id)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ---- Handlers ----
|
||||
|
||||
func (s *APIServer) handleListUserConfigs(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/capability"
|
||||
"github.com/BishopFox/joro/internal/jsautomation"
|
||||
"github.com/BishopFox/joro/internal/jsruntime"
|
||||
)
|
||||
|
||||
// maxAutomationBody bounds a request on the automation control plane. Sized for the
|
||||
// largest of them — one carrying automation source — comfortably over
|
||||
// jsruntime.MaxSourceBytes so the source limit is the one that reports, with room for a
|
||||
// manifest beside it.
|
||||
const maxAutomationBody = 2 << 20
|
||||
|
||||
// decodeJSON reads a bounded JSON body, and requires the JSON content type.
|
||||
//
|
||||
// Requiring it is deliberate rather than pedantic. `application/json` is not a
|
||||
// CORS-safelisted content type, so a browser cannot send one cross-origin without a
|
||||
// preflight, and a preflight against this API fails — it sets no CORS headers. That holds
|
||||
// independently of originGuard, which is the primary control here but reasons from
|
||||
// headers the browser volunteers: a client that sends none at all is treated as local
|
||||
// tooling and allowed through. This check still applies to it, at the cost of one
|
||||
// `-H 'Content-Type: application/json'`.
|
||||
func decodeJSON(r *http.Request, dst any) error {
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || ct != "application/json" {
|
||||
return errors.New("expected Content-Type: application/json")
|
||||
}
|
||||
return json.NewDecoder(http.MaxBytesReader(nil, r.Body, maxAutomationBody)).Decode(dst)
|
||||
}
|
||||
|
||||
// The script run log, over REST.
|
||||
//
|
||||
// UI-only, like the rest of the automation control plane: an automation client reaches
|
||||
@@ -73,3 +108,326 @@ func (s *APIServer) handleClearScriptRuns(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"deleted": s.scriptManager.Runs().Clear()})
|
||||
}
|
||||
|
||||
// ---- Installed automations ----
|
||||
|
||||
// packages returns the installed-automation store, writing the 404 if unavailable.
|
||||
func (s *APIServer) packages(w http.ResponseWriter) *jsautomation.Store {
|
||||
if !s.requireScripting(w) {
|
||||
return nil
|
||||
}
|
||||
st := s.scriptManager.Packages()
|
||||
if st == nil {
|
||||
writeError(w, http.StatusNotFound, "installed automations are unavailable on this instance")
|
||||
return nil
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// writeStoreError maps the store's sentinels onto status codes. A hash mismatch is 409:
|
||||
// the request was well-formed and the caller's view of the world was simply stale.
|
||||
func writeStoreError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, jsautomation.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "no such automation")
|
||||
case errors.Is(err, jsautomation.ErrExists):
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, jsautomation.ErrHashMismatch):
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *APIServer) handleListScripts(w http.ResponseWriter, r *http.Request) {
|
||||
if s.packages(w) == nil {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"scripts": s.scriptManager.List(),
|
||||
"triggers": jsautomation.Triggers,
|
||||
"bundle": jsautomation.BundleVersion,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleGetScript(w http.ResponseWriter, r *http.Request) {
|
||||
store := s.packages(w)
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
a, err := store.Load(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
// With source: this is the operator reading their own automation, which is the
|
||||
// point of keeping it as a real file in the first place.
|
||||
writeJSON(w, http.StatusOK, a)
|
||||
}
|
||||
|
||||
type scriptWriteBody struct {
|
||||
Manifest jsautomation.Manifest `json:"manifest"`
|
||||
Source string `json:"source"`
|
||||
// ExpectedHash is required to replace the source of an armed automation.
|
||||
ExpectedHash string `json:"expectedHash,omitempty"`
|
||||
}
|
||||
|
||||
func (s *APIServer) handleInstallScript(w http.ResponseWriter, r *http.Request) {
|
||||
store := s.packages(w)
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
var body scriptWriteBody
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
a, err := store.Install(body.Manifest, body.Source)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[automation] installed %s v%s (disabled)", a.Manifest.ID, a.Manifest.Version)
|
||||
writeJSON(w, http.StatusCreated, a)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleUpdateScript(w http.ResponseWriter, r *http.Request) {
|
||||
store := s.packages(w)
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
var body scriptWriteBody
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if body.Manifest.ID == "" {
|
||||
body.Manifest.ID = id
|
||||
}
|
||||
a, err := store.Update(id, body.Manifest, body.Source, body.ExpectedHash)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, a)
|
||||
}
|
||||
|
||||
func (s *APIServer) handleDeleteScript(w http.ResponseWriter, r *http.Request) {
|
||||
store := s.packages(w)
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := store.Delete(id); err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
// Its stored state has no meaning without the code that wrote it.
|
||||
if s.automationStorage != nil {
|
||||
s.automationStorage.Drop(id)
|
||||
}
|
||||
if s.scriptTriggers != nil {
|
||||
s.scriptTriggers.Wake()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSetScriptEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
store := s.packages(w)
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
a, err := store.SetState(r.PathValue("id"), func(st *jsautomation.State) {
|
||||
st.Enabled = body.Enabled
|
||||
// Enabling clears a breaker pause. The operator is answering the question the
|
||||
// pause asked, and leaving it set would make Enable appear to do nothing.
|
||||
if body.Enabled {
|
||||
st.Paused = false
|
||||
st.PausedReason = ""
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if s.scriptTriggers != nil {
|
||||
s.scriptTriggers.Wake()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, a.Summarize())
|
||||
}
|
||||
|
||||
func (s *APIServer) handleSetScriptPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
store := s.packages(w)
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
// Pointers so an absent field is left alone rather than zeroed, the same reason
|
||||
// handleSetProjectPrefs takes *bool.
|
||||
var body struct {
|
||||
Limits *jsautomation.ManifestLimits `json:"limits"`
|
||||
TriggersDisabled map[string]bool `json:"triggersDisabled"`
|
||||
HostAllow *[]string `json:"hostAllow"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// Reject an unknown trigger name rather than storing it. A typo would otherwise be
|
||||
// accepted with a 200 and silently mean "armed", which is the wrong direction for the
|
||||
// control an operator reaches for to switch a trigger off.
|
||||
for name := range body.TriggersDisabled {
|
||||
if !slices.Contains(jsautomation.Triggers, name) {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf(
|
||||
"unknown trigger %q (known: %s)", name, strings.Join(jsautomation.Triggers, ", ")))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
a, err := store.SetState(r.PathValue("id"), func(st *jsautomation.State) {
|
||||
if body.Limits != nil {
|
||||
st.Limits = body.Limits
|
||||
}
|
||||
if body.TriggersDisabled != nil {
|
||||
st.TriggersDisabled = body.TriggersDisabled
|
||||
}
|
||||
if body.HostAllow != nil {
|
||||
st.HostAllow = *body.HostAllow
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if s.scriptTriggers != nil {
|
||||
s.scriptTriggers.Wake()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, a.Summarize())
|
||||
}
|
||||
|
||||
// handleRunScript runs an automation or a piece of inline source, on the operator's
|
||||
// behalf.
|
||||
//
|
||||
// Inline source is accepted here and nowhere else. This is the operator's own request
|
||||
// through the same-origin UI, not an agent's, and an authoring surface that cannot run a
|
||||
// draft before it is saved is not an authoring surface. It is also why a disabled
|
||||
// automation may be run from here: reviewing something means being able to run it first.
|
||||
func (s *APIServer) handleRunScript(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireScripting(w) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ScriptID string `json:"scriptId"`
|
||||
Source string `json:"source"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
TimeoutMs int `json:"timeoutMs"`
|
||||
MaxCalls int `json:"maxCalls"`
|
||||
MaxSendCalls int `json:"maxSendCalls"`
|
||||
}
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
run *jsautomation.Run
|
||||
err error
|
||||
)
|
||||
switch {
|
||||
case body.ScriptID != "":
|
||||
run, err = s.scriptManager.Invoke(r.Context(), jsautomation.InvokeRequest{
|
||||
ID: body.ScriptID,
|
||||
Input: body.Input,
|
||||
Caller: jsautomation.AutomationPrincipal(body.ScriptID),
|
||||
Trigger: triggerUI,
|
||||
OperatorRun: true,
|
||||
})
|
||||
case strings.TrimSpace(body.Source) != "":
|
||||
run, err = s.scriptManager.Run(r.Context(), jsautomation.RunRequest{
|
||||
Source: body.Source,
|
||||
Input: body.Input,
|
||||
Caller: capability.Principal{TokenName: "operator"},
|
||||
Trigger: triggerUI,
|
||||
Limits: jsruntime.Limits{
|
||||
Timeout: time.Duration(body.TimeoutMs) * time.Millisecond,
|
||||
MaxCalls: body.MaxCalls,
|
||||
MaxSendCalls: body.MaxSendCalls,
|
||||
},
|
||||
})
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "provide either scriptId or source")
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, jsautomation.ErrBusy):
|
||||
writeError(w, http.StatusTooManyRequests, err.Error())
|
||||
case errors.Is(err, jsautomation.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "no such automation")
|
||||
case err != nil:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
default:
|
||||
writeJSON(w, http.StatusOK, run)
|
||||
}
|
||||
}
|
||||
|
||||
// triggerUI labels a run the operator started by hand, so the run list can tell it from
|
||||
// an agent's ("mcp") and from a trigger firing.
|
||||
const triggerUI = "ui"
|
||||
|
||||
// handleScriptSDK is the authoring reference: every joro.* method, joined with the
|
||||
// registered title, description and argument schema of the capability behind it.
|
||||
//
|
||||
// Generated from the same binding table that builds the injected shim and the grant
|
||||
// bundle, so a method that exists is documented and a documented method exists. The
|
||||
// model-facing description doubles as author documentation, which is the whole reason
|
||||
// there is one source rather than three.
|
||||
func (s *APIServer) handleScriptSDK(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireScripting(w) {
|
||||
return
|
||||
}
|
||||
type entry struct {
|
||||
JS string `json:"js"`
|
||||
Capability string `json:"capability"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
InputSchema json.RawMessage `json:"inputSchema,omitempty"`
|
||||
ArgsExample json.RawMessage `json:"argsExample,omitempty"`
|
||||
SendsTraffic bool `json:"sendsTraffic,omitempty"`
|
||||
Mutating bool `json:"mutating,omitempty"`
|
||||
}
|
||||
|
||||
out := make([]entry, 0, len(jsruntime.Bindings))
|
||||
for _, b := range jsruntime.Bindings {
|
||||
e := entry{JS: "joro." + b.JS, Capability: b.Cap}
|
||||
if c, ok := s.capRegistry.Get(b.Cap); ok {
|
||||
e.Title, e.Description = c.Title, c.Description
|
||||
e.InputSchema, e.ArgsExample = c.InputSchema, c.ArgsExample
|
||||
e.SendsTraffic, e.Mutating = c.SendsTraffic, c.Mutating
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"bundle": jsautomation.BundleVersion,
|
||||
"methods": out,
|
||||
"storage": storageDocs,
|
||||
"triggers": jsautomation.Triggers,
|
||||
})
|
||||
}
|
||||
|
||||
// storageDocs describes joro.storage, which has no capability behind it to borrow a
|
||||
// description from. See jsruntime.StorageBridge for why it is not one.
|
||||
var storageDocs = []map[string]string{
|
||||
{"js": "joro.storage.get(key)", "description": "The value stored under key, or null. Installed automations only."},
|
||||
{"js": "joro.storage.set(key, value)", "description": "Store any JSON value under key."},
|
||||
{"js": "joro.storage.delete(key)", "description": "Remove key; returns whether it existed."},
|
||||
{"js": "joro.storage.keys()", "description": "Every key this automation has stored, sorted."},
|
||||
}
|
||||
@@ -275,7 +275,16 @@ func registerRoutes(s *APIServer, mux *http.ServeMux) {
|
||||
mux.HandleFunc("PUT /api/v1/automation/mcp", s.handleSetMCPState)
|
||||
mux.HandleFunc("GET /api/v1/automation/runs", s.handleListScriptRuns)
|
||||
mux.HandleFunc("GET /api/v1/automation/runs/{id}", s.handleGetScriptRun)
|
||||
mux.HandleFunc("POST /api/v1/automation/runs", s.handleRunScript)
|
||||
mux.HandleFunc("DELETE /api/v1/automation/runs", s.handleClearScriptRuns)
|
||||
mux.HandleFunc("GET /api/v1/automation/scripts", s.handleListScripts)
|
||||
mux.HandleFunc("POST /api/v1/automation/scripts", s.handleInstallScript)
|
||||
mux.HandleFunc("GET /api/v1/automation/scripts/{id}", s.handleGetScript)
|
||||
mux.HandleFunc("PUT /api/v1/automation/scripts/{id}", s.handleUpdateScript)
|
||||
mux.HandleFunc("DELETE /api/v1/automation/scripts/{id}", s.handleDeleteScript)
|
||||
mux.HandleFunc("PUT /api/v1/automation/scripts/{id}/enabled", s.handleSetScriptEnabled)
|
||||
mux.HandleFunc("PUT /api/v1/automation/scripts/{id}/prefs", s.handleSetScriptPrefs)
|
||||
mux.HandleFunc("GET /api/v1/automation/sdk", s.handleScriptSDK)
|
||||
}
|
||||
|
||||
// Plugin routes (dynamic, based on loaded plugins).
|
||||
|
||||
+11
-1
@@ -122,7 +122,14 @@ type APIServer struct {
|
||||
// scriptManager runs sandboxed JavaScript against the registry. Nil unless
|
||||
// --automation-scripting was given, which is what keeps script.run unregistered.
|
||||
scriptManager *jsautomation.Manager
|
||||
automationMu sync.Mutex // serializes MCP start/stop from HTTP handlers
|
||||
// automationStorage is joro.storage: per-automation key/value state that rides in
|
||||
// the project config like plugin state, because it describes one engagement.
|
||||
automationStorage *jsautomation.Storage
|
||||
// scriptTriggers watches Joro's events and runs armed automations. Nil unless
|
||||
// scripting is on; handlers ring its doorbell after a change so an enable takes
|
||||
// effect immediately rather than on the next 250ms tick.
|
||||
scriptTriggers *jsautomation.Dispatcher
|
||||
automationMu sync.Mutex // serializes MCP start/stop from HTTP handlers
|
||||
|
||||
buildInfo BuildInfo
|
||||
cancelFunc context.CancelFunc
|
||||
@@ -145,6 +152,9 @@ type APIServer struct {
|
||||
// so unknown-to-us plugins' data is never dropped.
|
||||
pendingUserPluginStates map[string][]byte
|
||||
pendingProjectPluginStates map[string][]byte
|
||||
// pendingProjectAutomationStates does the same for joro.storage blobs belonging to
|
||||
// automations not installed here.
|
||||
pendingProjectAutomationStates map[string][]byte
|
||||
|
||||
srv *http.Server
|
||||
}
|
||||
|
||||
@@ -73,6 +73,9 @@ type Hub struct {
|
||||
onConnect OnConnectFunc
|
||||
onDisconnect OnConnectFunc
|
||||
relayState *relayStateData // last-known team relay state (nil until first set)
|
||||
// subscribers are in-process consumers of the same event stream, for code that
|
||||
// needs to react to what the UI is being told. Guarded by mu; see Subscribe.
|
||||
subscribers []chan any
|
||||
}
|
||||
|
||||
// NewHub creates a Hub. Call Run() in a goroutine before accepting connections.
|
||||
@@ -94,6 +97,29 @@ func (h *Hub) SetOnDisconnect(fn OnConnectFunc) {
|
||||
h.onDisconnect = fn
|
||||
}
|
||||
|
||||
// Subscribe returns a channel carrying every broadcast event, for an in-process consumer
|
||||
// such as the automation trigger dispatcher.
|
||||
//
|
||||
// Deliveries are non-blocking and are dropped when the subscriber is behind, which is not
|
||||
// a corner worth softening: several producers send to the broadcast channel *blocking* —
|
||||
// every fuzzer result, every callback interaction, the team relay — so a subscriber that
|
||||
// could stall Run would stall every WebSocket client and back-pressure the proxy behind
|
||||
// them. A consumer that cannot afford a loss must treat its events as a hint and read the
|
||||
// authoritative state itself, the way the trigger dispatcher uses a store cursor.
|
||||
//
|
||||
// Subscriptions are permanent, which is fine because the only subscriber is created once
|
||||
// at startup and lives as long as the process.
|
||||
func (h *Hub) Subscribe(buf int) <-chan any {
|
||||
if buf <= 0 {
|
||||
buf = 256
|
||||
}
|
||||
ch := make(chan any, buf)
|
||||
h.mu.Lock()
|
||||
h.subscribers = append(h.subscribers, ch)
|
||||
h.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
// Run reads from the broadcast channel and fans out to all connected clients.
|
||||
// It blocks until the channel is closed.
|
||||
func (h *Hub) Run() {
|
||||
@@ -115,6 +141,13 @@ func (h *Hub) Run() {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
// In-process subscribers get the event, not the JSON: they type-switch on it.
|
||||
for _, sub := range h.subscribers {
|
||||
select {
|
||||
case sub <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ import (
|
||||
// capability body no reach it did not already have.
|
||||
type ScriptRunner interface {
|
||||
Run(ctx context.Context, req jsautomation.RunRequest) (*jsautomation.Run, error)
|
||||
// List reports the installed automations, without their source.
|
||||
List() []jsautomation.Summary
|
||||
// Invoke runs an installed automation. OperatorRun is never set from here: an
|
||||
// agent may run only what the operator has armed.
|
||||
Invoke(ctx context.Context, req jsautomation.InvokeRequest) (*jsautomation.Run, error)
|
||||
}
|
||||
|
||||
type scriptRunArgs struct {
|
||||
@@ -33,6 +38,11 @@ type scriptRunArgs struct {
|
||||
MaxSendCalls int `json:"maxSendCalls"`
|
||||
}
|
||||
|
||||
type scriptInvokeArgs struct {
|
||||
ID string `json:"id"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
}
|
||||
|
||||
// Output shaping for the tool result.
|
||||
//
|
||||
// The logs and the returned value both have to fit inside this capability's output cap,
|
||||
@@ -152,6 +162,128 @@ func registerScript(r *capability.Registry, d Deps) {
|
||||
return renderRun(run), nil
|
||||
}),
|
||||
})
|
||||
|
||||
r.MustRegister(capability.Capability{
|
||||
ID: "script.list",
|
||||
Class: capability.ClassScript,
|
||||
Title: "List installed automations",
|
||||
Privileged: true,
|
||||
Description: "The automations installed on this Joro, with their id, version, whether the " +
|
||||
"operator has enabled them, which events they are armed for, and how their last run ended. " +
|
||||
"Use an id with script_invoke. Source is never returned: read what an automation does by " +
|
||||
"asking the operator, not by reading their code through this tool.",
|
||||
InputSchema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`),
|
||||
ArgsExample: json.RawMessage(`{}`),
|
||||
MaxOutputBytes: 32 << 10,
|
||||
Handler: capability.Typed(func(ctx context.Context, _ capability.Principal, _ struct{}) (any, error) {
|
||||
if d.Script == nil {
|
||||
return nil, fmt.Errorf("the script runtime is unavailable")
|
||||
}
|
||||
return renderAutomations(d.Script.List()), nil
|
||||
}),
|
||||
})
|
||||
|
||||
r.MustRegister(capability.Capability{
|
||||
ID: "script.invoke",
|
||||
Class: capability.ClassScript,
|
||||
Title: "Run an installed automation",
|
||||
|
||||
// Privileged for the same reason script.run is: the code it runs holds the whole
|
||||
// SDK bundle. But it is a strictly narrower grant, and the more likely posture —
|
||||
// an operator can let an agent run automations they wrote and reviewed without
|
||||
// letting it submit code of its own.
|
||||
Privileged: true,
|
||||
Mutating: true,
|
||||
|
||||
Description: "Run an automation the operator has installed and enabled, by id, passing input " +
|
||||
"it is expecting. The code is theirs, not yours; you choose when to run it and what to feed " +
|
||||
"it. Returns the same run report as script_run: logs, the value it returned, and how it " +
|
||||
"ended. An automation that is not enabled cannot be invoked — ask the operator to enable it.",
|
||||
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"id": {"type":"string","description":"The automation id, from script_list."},
|
||||
"input": {"type":"object","description":"JSON handed to the automation as ctx.input. What it expects is up to that automation."}
|
||||
},
|
||||
"required":["id"],
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
ArgsExample: json.RawMessage(`{"id":"idor-check","input":{"ref":1842}}`),
|
||||
|
||||
MaxOutputBytes: scriptRunOutputCap,
|
||||
Timeout: scriptRunTimeout,
|
||||
|
||||
Handler: capability.Typed(func(ctx context.Context, p capability.Principal, args scriptInvokeArgs) (any, error) {
|
||||
if d.Script == nil {
|
||||
return nil, fmt.Errorf("the script runtime is unavailable")
|
||||
}
|
||||
if strings.TrimSpace(args.ID) == "" {
|
||||
return nil, &capability.Error{Code: capability.CodeInvalidArgs, Msg: "id is required"}
|
||||
}
|
||||
|
||||
run, err := d.Script.Invoke(ctx, jsautomation.InvokeRequest{
|
||||
ID: args.ID,
|
||||
Input: args.Input,
|
||||
Caller: p,
|
||||
Trigger: jsautomation.TriggerManual,
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, jsautomation.ErrBusy):
|
||||
return nil, &capability.Error{Code: capability.CodeBusy, Msg: err.Error()}
|
||||
case errors.Is(err, jsautomation.ErrNotFound):
|
||||
return nil, &capability.Error{
|
||||
Code: capability.CodeInvalidArgs,
|
||||
Msg: fmt.Sprintf("no automation with id %q is installed; use script_list", args.ID),
|
||||
}
|
||||
case errors.Is(err, jsautomation.ErrNotRunnable):
|
||||
// Forbidden rather than invalid_args: the automation exists and the
|
||||
// argument was right, the operator has simply not armed it.
|
||||
return nil, &capability.Error{Code: capability.CodeForbidden, Msg: err.Error()}
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
|
||||
capability.RecordChange(ctx, "invoke %s: %s", args.ID, run.Summary())
|
||||
return renderRun(run), nil
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// renderAutomations lists installed automations, one per line with aligned columns —
|
||||
// the shape the other list tools use, and cheap to scan for the id to invoke.
|
||||
func renderAutomations(items []jsautomation.Summary) string {
|
||||
if len(items) == 0 {
|
||||
return "(no automations installed)"
|
||||
}
|
||||
|
||||
rows := make([][3]string, 0, len(items))
|
||||
var idW, verW int
|
||||
for _, a := range items {
|
||||
state := "disabled"
|
||||
switch {
|
||||
case a.Paused:
|
||||
state = "paused"
|
||||
case a.Enabled:
|
||||
state = "enabled"
|
||||
}
|
||||
armed := joinOr(a.Armed, "-")
|
||||
last := "never"
|
||||
if a.LastRun != nil {
|
||||
last = a.LastRun.Reason
|
||||
}
|
||||
rows = append(rows, [3]string{a.ID, a.Version, fmt.Sprintf("%-8s armed=%s last=%s %s",
|
||||
state, armed, last, a.Name)})
|
||||
idW = max(idW, len(a.ID))
|
||||
verW = max(verW, len(a.Version))
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "n=%d\n", len(items))
|
||||
for _, r := range rows {
|
||||
fmt.Fprintf(&b, "%-*s %-*s %s\n", idW, r[0], verW, r[1], r[2])
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// renderRun formats a run for the caller: a header, then logs, then the value.
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/capability"
|
||||
@@ -61,6 +63,12 @@ type Deps struct {
|
||||
// same run and is dropped when the run ends — a session belongs to the run that
|
||||
// authenticated it.
|
||||
Contexts *httptools.Contexts
|
||||
|
||||
// Store is the installed-automation directory; Storage is their key/value state.
|
||||
// Both nil-tolerated: without a Store there are no installed automations and only
|
||||
// one-shot runs work, which is exactly phase B's behavior.
|
||||
Store *Store
|
||||
Storage *Storage
|
||||
}
|
||||
|
||||
// Manager runs scripts and remembers what they did.
|
||||
@@ -83,6 +91,13 @@ func New(d Deps) *Manager {
|
||||
// Runs exposes the run log for the control plane.
|
||||
func (m *Manager) Runs() *RunLog { return m.runs }
|
||||
|
||||
// Packages exposes the installed-automation store for the control plane. Nil when
|
||||
// scripting is configured without a data directory.
|
||||
func (m *Manager) Packages() *Store { return m.deps.Store }
|
||||
|
||||
// Storage exposes the automation key/value state, for project save and load.
|
||||
func (m *Manager) Storage() *Storage { return m.deps.Storage }
|
||||
|
||||
// RunRequest is one script to execute.
|
||||
type RunRequest struct {
|
||||
Source string
|
||||
@@ -94,6 +109,15 @@ type RunRequest struct {
|
||||
|
||||
Trigger string
|
||||
Limits jsruntime.Limits
|
||||
|
||||
// AutomationID names the installed automation this run belongs to, which is what
|
||||
// gives it a storage namespace. Empty for a one-shot: there is nowhere durable for
|
||||
// such a run to write, and saying so is better than a scratchpad that vanishes.
|
||||
AutomationID string
|
||||
AutomationVersion string
|
||||
|
||||
// TriggerData is the event payload, merged into ctx.trigger. References only.
|
||||
TriggerData json.RawMessage
|
||||
}
|
||||
|
||||
// ErrBusy means the concurrent-run limit is reached.
|
||||
@@ -144,14 +168,17 @@ func (m *Manager) Run(ctx context.Context, req RunRequest) (*Run, error) {
|
||||
Input: req.Input,
|
||||
Limits: req.Limits,
|
||||
Meta: jsruntime.Meta{
|
||||
RunID: runID,
|
||||
StartedAt: started.UTC().Format(time.RFC3339),
|
||||
TriggerType: orDefault(req.Trigger, "manual"),
|
||||
RunID: runID,
|
||||
StartedAt: started.UTC().Format(time.RFC3339),
|
||||
TriggerType: orDefault(req.Trigger, "manual"),
|
||||
TriggerData: req.TriggerData,
|
||||
AutomationID: req.AutomationID,
|
||||
AutomationVersion: req.AutomationVersion,
|
||||
},
|
||||
SendCaps: sendCapsFrom(reg),
|
||||
}
|
||||
|
||||
res, err := m.deps.Runtime.Run(ctx, rtReq, ®istryBridge{inv: reg, principal: principal})
|
||||
res, err := m.deps.Runtime.Run(ctx, rtReq, m.bridgeFor(reg, principal, req.AutomationID))
|
||||
if err != nil {
|
||||
// The runtime itself failed. Record it anyway: an operator investigating a
|
||||
// script that "did nothing" needs to find the attempt.
|
||||
@@ -163,21 +190,123 @@ func (m *Manager) Run(ctx context.Context, req RunRequest) (*Run, error) {
|
||||
}
|
||||
|
||||
run := &Run{
|
||||
ID: runID,
|
||||
StartedAt: started.UTC(),
|
||||
DurationMs: res.DurationMs,
|
||||
TokenID: req.Caller.TokenID,
|
||||
TokenName: req.Caller.TokenName,
|
||||
Trigger: rtReq.Meta.TriggerType,
|
||||
Bundle: BundleVersion,
|
||||
Source: req.Source,
|
||||
SourceHash: HashSource(req.Source),
|
||||
Result: res,
|
||||
ID: runID,
|
||||
StartedAt: started.UTC(),
|
||||
DurationMs: res.DurationMs,
|
||||
TokenID: req.Caller.TokenID,
|
||||
TokenName: req.Caller.TokenName,
|
||||
AutomationID: req.AutomationID,
|
||||
Trigger: rtReq.Meta.TriggerType,
|
||||
Bundle: BundleVersion,
|
||||
Source: req.Source,
|
||||
SourceHash: HashSource(req.Source),
|
||||
Result: res,
|
||||
}
|
||||
m.runs.Add(run)
|
||||
return run, nil
|
||||
}
|
||||
|
||||
// ErrNotRunnable means an installed automation exists but is not armed.
|
||||
var ErrNotRunnable = errors.New("this automation is not enabled")
|
||||
|
||||
// List returns every installed automation, without source. Used by the script.list
|
||||
// capability and by the control plane.
|
||||
func (m *Manager) List() []Summary {
|
||||
if m.deps.Store == nil {
|
||||
return nil
|
||||
}
|
||||
all := m.deps.Store.List()
|
||||
out := make([]Summary, 0, len(all))
|
||||
for _, a := range all {
|
||||
out = append(out, a.Summarize())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// InvokeRequest names one invocation of an installed automation. A struct rather than a
|
||||
// parameter list: it grew to six, and a trigger payload silently going missing is exactly
|
||||
// the mistake positional arguments invite.
|
||||
type InvokeRequest struct {
|
||||
ID string
|
||||
Input json.RawMessage
|
||||
Caller capability.Principal
|
||||
|
||||
Trigger string
|
||||
TriggerData json.RawMessage
|
||||
|
||||
// OperatorRun allows running an automation that is not armed. True only for the
|
||||
// operator's own request through the UI, because reviewing something means being
|
||||
// able to run it before enabling it. Never true for an agent.
|
||||
OperatorRun bool
|
||||
}
|
||||
|
||||
// Invoke runs an installed automation.
|
||||
func (m *Manager) Invoke(ctx context.Context, req InvokeRequest) (*Run, error) {
|
||||
id, input, caller := req.ID, req.Input, req.Caller
|
||||
trigger, operatorRun := req.Trigger, req.OperatorRun
|
||||
if m.deps.Store == nil {
|
||||
return nil, errors.New("installed automations are unavailable")
|
||||
}
|
||||
a, err := m.deps.Store.Load(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !operatorRun && !a.Runnable() {
|
||||
if a.State.Paused {
|
||||
return nil, fmt.Errorf("%w: it was paused automatically (%s)", ErrNotRunnable, a.State.PausedReason)
|
||||
}
|
||||
return nil, ErrNotRunnable
|
||||
}
|
||||
|
||||
// A trigger-fired run has no launching token, so nothing carries a host whitelist
|
||||
// into it and scope is its only bound. Where the operator has set one on the
|
||||
// automation, apply it. Only when the caller has none of its own: a token's leash is
|
||||
// already the stricter authority, and two whitelists in one field cannot be ANDed.
|
||||
if len(caller.HostAllow) == 0 && len(a.State.HostAllow) > 0 {
|
||||
caller.HostAllow = slices.Clone(a.State.HostAllow)
|
||||
}
|
||||
|
||||
run, err := m.Run(ctx, RunRequest{
|
||||
Source: a.Source,
|
||||
Input: input,
|
||||
Caller: caller,
|
||||
Trigger: orDefault(trigger, TriggerManual),
|
||||
TriggerData: req.TriggerData,
|
||||
Limits: a.Limits(),
|
||||
AutomationID: a.Manifest.ID,
|
||||
AutomationVersion: a.Manifest.Version,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.noteLastRun(a.Manifest.ID, run)
|
||||
return run, nil
|
||||
}
|
||||
|
||||
// noteLastRun records the outcome on the automation's sidecar, so the list view can show
|
||||
// what happened without holding the whole run log. Best-effort: a run that succeeded must
|
||||
// not be reported as failed because a sidecar write did.
|
||||
func (m *Manager) noteLastRun(id string, run *Run) {
|
||||
if m.deps.Store == nil || run == nil {
|
||||
return
|
||||
}
|
||||
if _, err := m.deps.Store.SetState(id, func(st *State) {
|
||||
st.LastRun = &LastRun{ID: run.ID, At: run.StartedAt, Reason: run.Result.Reason}
|
||||
}); err != nil {
|
||||
log.Printf("[automation] %s: recording last run: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// AutomationPrincipal is the caller for a run nothing launched — a trigger firing.
|
||||
//
|
||||
// It carries a name for the audit trail and no HostAllow, because there is no token to
|
||||
// inherit one from. Such a run is therefore bounded by scope alone, which is the
|
||||
// engagement boundary and the thing the operator was already relying on; runPrincipal
|
||||
// still pins RequireScope on and credentials off.
|
||||
func AutomationPrincipal(id string) capability.Principal {
|
||||
return capability.Principal{TokenName: "automation:" + id}
|
||||
}
|
||||
|
||||
// sendCapsFrom lists the granted capability IDs that put bytes on the wire, so the
|
||||
// runtime can charge them against the run's separate send budget. Read from the
|
||||
// registry rather than hardcoded, so a capability that gains SendsTraffic in a later
|
||||
@@ -192,6 +321,22 @@ func sendCapsFrom(reg Invoker) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// bridgeFor builds the host bridge for one run.
|
||||
//
|
||||
// Two types rather than one with a nil check: a one-shot run gets a bridge that does not
|
||||
// implement jsruntime.StorageBridge at all, so "this run has no namespace" is a fact the
|
||||
// type system carries rather than a condition someone has to remember to test.
|
||||
func (m *Manager) bridgeFor(reg Invoker, p capability.Principal, automationID string) jsruntime.HostBridge {
|
||||
base := registryBridge{inv: reg, principal: p}
|
||||
if automationID == "" || m.deps.Storage == nil {
|
||||
return &base
|
||||
}
|
||||
return &storageBridge{
|
||||
registryBridge: base,
|
||||
ns: namespaced{s: m.deps.Storage, id: automationID},
|
||||
}
|
||||
}
|
||||
|
||||
// registryBridge is the only thing a script can reach. Every call becomes a
|
||||
// Registry.Invoke under the run's principal, which is what makes a script's authority
|
||||
// identical to a token's: same guard, same scope check, same limits, same audit row.
|
||||
@@ -200,6 +345,16 @@ type registryBridge struct {
|
||||
principal capability.Principal
|
||||
}
|
||||
|
||||
// storageBridge adds joro.storage for a run that belongs to an installed automation.
|
||||
type storageBridge struct {
|
||||
registryBridge
|
||||
ns namespaced
|
||||
}
|
||||
|
||||
func (b *storageBridge) Storage(ctx context.Context, op, key string, value json.RawMessage) (json.RawMessage, error) {
|
||||
return b.ns.Storage(ctx, op, key, value)
|
||||
}
|
||||
|
||||
func (b *registryBridge) Invoke(ctx context.Context, id string, args json.RawMessage) (json.RawMessage, error) {
|
||||
res, err := b.inv.Invoke(ctx, b.principal, id, args)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
package jsautomation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/jsruntime"
|
||||
)
|
||||
|
||||
// SDKVersion1 binds to the automation-v1 bundle. A manifest declares which SDK
|
||||
// contract it expects; Joro decides what that contract grants. Adding a method to the
|
||||
// SDK follows ordinary compatibility rules, but a materially more dangerous class of
|
||||
// authority takes a new version rather than appearing inside this one.
|
||||
const SDKVersion1 = "1"
|
||||
|
||||
// Trigger names. These are Joro's own event type strings, not a parallel vocabulary —
|
||||
// an automation subscribes to the thing that already happens.
|
||||
//
|
||||
// callback.interaction is deliberately absent. Every callback listener is constructed
|
||||
// in listener mode, scripting exists only in proxy mode, and the team relay forwards
|
||||
// nothing but team.* — so a proxy-mode dispatcher would be waiting for an event that
|
||||
// structurally cannot arrive. Offering it would be a trigger that silently never fires.
|
||||
const (
|
||||
TriggerManual = "manual"
|
||||
TriggerRequestCaptured = "request.captured"
|
||||
TriggerDetectFinding = "detect.finding"
|
||||
TriggerFuzzerComplete = "fuzzer.complete"
|
||||
)
|
||||
|
||||
// Triggers lists every subscribable trigger, in the order the UI shows them: manual
|
||||
// first, then cheapest-to-reason-about to most consequential.
|
||||
var Triggers = []string{
|
||||
TriggerManual,
|
||||
TriggerDetectFinding,
|
||||
TriggerFuzzerComplete,
|
||||
TriggerRequestCaptured,
|
||||
}
|
||||
|
||||
// Limits on the shape of an installed package.
|
||||
const (
|
||||
MaxIDLen = 64
|
||||
MaxNameLen = 80
|
||||
MaxVersionLen = 32
|
||||
MaxDescriptionLen = 400
|
||||
MaxEntrypointLen = 64
|
||||
MaxRevisions = 50
|
||||
)
|
||||
|
||||
var (
|
||||
// idPattern is the plugin name pattern, reused so an operator learns one rule for
|
||||
// both kinds of extension. It excludes '/', '\' and '.', which is what makes an ID
|
||||
// safe to use as a path component — but see Store, which checks it again at the
|
||||
// join rather than trusting that this ran.
|
||||
idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
// entrypointPattern admits a bare filename only. A separator here would be a path
|
||||
// traversal, and a subdirectory would imply a module system that does not exist.
|
||||
entrypointPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*\.js$`)
|
||||
)
|
||||
|
||||
// DefaultEntrypoint is used when a manifest omits one.
|
||||
const DefaultEntrypoint = "index.js"
|
||||
|
||||
// ManifestLimits is the author's requested budget. Every field is optional; a zero
|
||||
// means "take the default". Values are requests, not guarantees: the operator may
|
||||
// lower any of them, and jsruntime.Limits.Normalize clamps the result to its ceilings.
|
||||
// Nothing here can raise a limit.
|
||||
type ManifestLimits struct {
|
||||
TimeoutMs int `json:"timeoutMs,omitempty"`
|
||||
MemoryMB int `json:"memoryMb,omitempty"`
|
||||
MaxCalls int `json:"maxCalls,omitempty"`
|
||||
MaxSendCalls int `json:"maxSendCalls,omitempty"`
|
||||
MaxLogBytes int `json:"maxLogBytes,omitempty"`
|
||||
MaxResultBytes int `json:"maxResultBytes,omitempty"`
|
||||
}
|
||||
|
||||
// Manifest is the author-owned half of a package: what this automation is and what it
|
||||
// expects. It cannot request a capability — the SDK version selects a Joro-owned bundle,
|
||||
// and that indirection is the whole point. An operator reviewing a package therefore
|
||||
// reviews code, triggers and limits, not a permission matrix.
|
||||
type Manifest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
SDKVersion string `json:"sdkVersion"`
|
||||
Entrypoint string `json:"entrypoint,omitempty"`
|
||||
|
||||
Triggers []string `json:"triggers,omitempty"`
|
||||
Limits *ManifestLimits `json:"limits,omitempty"`
|
||||
|
||||
// MinIntervalMs paces an event trigger: the shortest gap between two runs. Not in
|
||||
// Limits because it is the one value where the conservative choice is the larger
|
||||
// one, and Limits combines by taking the smaller of author and operator.
|
||||
MinIntervalMs int `json:"minIntervalMs,omitempty"`
|
||||
}
|
||||
|
||||
// Normalize fills defaults and trims. Called before Validate so a manifest that omits
|
||||
// optional fields is accepted rather than corrected by the operator.
|
||||
func (m *Manifest) Normalize() {
|
||||
m.ID = strings.ToLower(strings.TrimSpace(m.ID))
|
||||
m.Name = strings.TrimSpace(m.Name)
|
||||
m.Version = strings.TrimSpace(m.Version)
|
||||
m.Description = strings.TrimSpace(m.Description)
|
||||
m.SDKVersion = strings.TrimSpace(m.SDKVersion)
|
||||
m.Entrypoint = strings.TrimSpace(m.Entrypoint)
|
||||
|
||||
if m.SDKVersion == "" {
|
||||
m.SDKVersion = SDKVersion1
|
||||
}
|
||||
if m.Entrypoint == "" {
|
||||
m.Entrypoint = DefaultEntrypoint
|
||||
}
|
||||
if m.Name == "" {
|
||||
m.Name = m.ID
|
||||
}
|
||||
if m.Version == "" {
|
||||
m.Version = "0.0.0"
|
||||
}
|
||||
if len(m.Triggers) == 0 {
|
||||
m.Triggers = []string{TriggerManual}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(m.Triggers))
|
||||
kept := m.Triggers[:0]
|
||||
for _, t := range m.Triggers {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[t]; dup {
|
||||
continue
|
||||
}
|
||||
seen[t] = struct{}{}
|
||||
kept = append(kept, t)
|
||||
}
|
||||
m.Triggers = kept
|
||||
}
|
||||
|
||||
// Validate reports why a manifest cannot be installed. Messages name the field and the
|
||||
// rule, because the audience is as often a language model that generated the package as
|
||||
// a person who wrote it.
|
||||
func (m *Manifest) Validate() error {
|
||||
switch {
|
||||
case m.ID == "":
|
||||
return fmt.Errorf("id is required")
|
||||
case len(m.ID) > MaxIDLen:
|
||||
return fmt.Errorf("id is %d characters, over the %d limit", len(m.ID), MaxIDLen)
|
||||
case !idPattern.MatchString(m.ID):
|
||||
return fmt.Errorf("id %q is invalid: use lowercase letters, digits, hyphen and "+
|
||||
"underscore, starting with a letter or digit", m.ID)
|
||||
case len(m.Name) > MaxNameLen:
|
||||
return fmt.Errorf("name is %d characters, over the %d limit", len(m.Name), MaxNameLen)
|
||||
case len(m.Version) > MaxVersionLen:
|
||||
return fmt.Errorf("version is %d characters, over the %d limit", len(m.Version), MaxVersionLen)
|
||||
case len(m.Description) > MaxDescriptionLen:
|
||||
return fmt.Errorf("description is %d characters, over the %d limit",
|
||||
len(m.Description), MaxDescriptionLen)
|
||||
case m.SDKVersion != SDKVersion1:
|
||||
return fmt.Errorf("sdkVersion %q is not supported by this build of Joro (supported: %q)",
|
||||
m.SDKVersion, SDKVersion1)
|
||||
case len(m.Entrypoint) > MaxEntrypointLen:
|
||||
return fmt.Errorf("entrypoint is %d characters, over the %d limit",
|
||||
len(m.Entrypoint), MaxEntrypointLen)
|
||||
case !entrypointPattern.MatchString(m.Entrypoint):
|
||||
return fmt.Errorf("entrypoint %q must be a single .js filename with no directory "+
|
||||
"separator: there is no module loader, so a package is one bundled script",
|
||||
m.Entrypoint)
|
||||
}
|
||||
|
||||
for _, t := range m.Triggers {
|
||||
if !slices.Contains(Triggers, t) {
|
||||
return fmt.Errorf("unknown trigger %q (known: %s)", t, strings.Join(Triggers, ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Revision records one version of the source.
|
||||
//
|
||||
// Metadata only, by design. "Did the code change?" is answered here; "what exactly ran?"
|
||||
// is answered by the run log, which retains each run's source verbatim along with its
|
||||
// hash. Keeping full past source here too would be a third content store answering a
|
||||
// question the other two already cover between them.
|
||||
type Revision struct {
|
||||
Hash string `json:"hash"`
|
||||
At time.Time `json:"at"`
|
||||
Bytes int `json:"bytes"`
|
||||
}
|
||||
|
||||
// LastRun is a pointer to the most recent run, for the list view.
|
||||
type LastRun struct {
|
||||
ID string `json:"id"`
|
||||
At time.Time `json:"at"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// State is the operator-owned half of a package, kept in a separate file from the
|
||||
// manifest so that installing an update never silently reverts a limit the operator
|
||||
// lowered or a trigger they switched off.
|
||||
type State struct {
|
||||
// Enabled arms the automation: an agent may invoke it and the dispatcher may
|
||||
// trigger it. The operator can always run a disabled automation from the UI,
|
||||
// because testing has to come before arming.
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Paused is set by the runaway breaker, never by the operator. It is separate from
|
||||
// Enabled so that resuming restores the operator's original intent rather than
|
||||
// asking them to remember it, and it persists so a restart does not silently
|
||||
// re-arm something that was looping.
|
||||
Paused bool `json:"paused,omitempty"`
|
||||
PausedReason string `json:"pausedReason,omitempty"`
|
||||
|
||||
// TriggersDisabled switches off individual triggers the manifest declares: true
|
||||
// disables, and an absent key means armed — so a manifest that adds a trigger in an
|
||||
// update does not arm it behind an operator who had switched a different one off.
|
||||
//
|
||||
// Named for what a true means. The polarity is the whole content of this field, and
|
||||
// a reader who has to infer it from a call site will eventually infer it backwards.
|
||||
TriggersDisabled map[string]bool `json:"triggersDisabled,omitempty"`
|
||||
|
||||
// Limits narrows the manifest's request. Never widens: see Automation.Limits.
|
||||
Limits *ManifestLimits `json:"limits,omitempty"`
|
||||
|
||||
// HostAllow bounds where this automation's runs may send, as globs matched by the
|
||||
// capability guard. It exists for trigger-fired runs, which carry no launching token
|
||||
// and are otherwise bounded by scope alone. Empty means "scope only".
|
||||
HostAllow []string `json:"hostAllow,omitempty"`
|
||||
|
||||
// MinIntervalMs slows a trigger further than the manifest asked. Combined with
|
||||
// max, so the operator can only ever add space between runs.
|
||||
MinIntervalMs int `json:"minIntervalMs,omitempty"`
|
||||
|
||||
InstalledAt time.Time `json:"installedAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Revisions []Revision `json:"revisions,omitempty"`
|
||||
LastRun *LastRun `json:"lastRun,omitempty"`
|
||||
}
|
||||
|
||||
// Automation is a loaded package: what the author wrote, what the operator decided, and
|
||||
// the source itself.
|
||||
type Automation struct {
|
||||
Manifest Manifest `json:"manifest"`
|
||||
State State `json:"state"`
|
||||
Source string `json:"source,omitempty"`
|
||||
SourceHash string `json:"sourceHash"`
|
||||
}
|
||||
|
||||
// Summary is an Automation without its source, for lists. Source is withheld rather
|
||||
// than merely omitted: a capability that could read other automations' code would be a
|
||||
// lateral channel between tokens, which is the same reason the run log stays UI-only.
|
||||
type Summary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SDKVersion string `json:"sdkVersion"`
|
||||
Triggers []string `json:"triggers"`
|
||||
Armed []string `json:"armed"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Paused bool `json:"paused,omitempty"`
|
||||
PausedReason string `json:"pausedReason,omitempty"`
|
||||
SourceHash string `json:"sourceHash"`
|
||||
SourceBytes int `json:"sourceBytes"`
|
||||
InstalledAt time.Time `json:"installedAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Revisions int `json:"revisions"`
|
||||
LastRun *LastRun `json:"lastRun,omitempty"`
|
||||
}
|
||||
|
||||
// Summarize projects an Automation for a list view.
|
||||
func (a *Automation) Summarize() Summary {
|
||||
return Summary{
|
||||
ID: a.Manifest.ID,
|
||||
Name: a.Manifest.Name,
|
||||
Version: a.Manifest.Version,
|
||||
Description: a.Manifest.Description,
|
||||
SDKVersion: a.Manifest.SDKVersion,
|
||||
Triggers: slices.Clone(a.Manifest.Triggers),
|
||||
Armed: a.ArmedTriggers(),
|
||||
Enabled: a.State.Enabled,
|
||||
Paused: a.State.Paused,
|
||||
PausedReason: a.State.PausedReason,
|
||||
SourceHash: a.SourceHash,
|
||||
SourceBytes: len(a.Source),
|
||||
InstalledAt: a.State.InstalledAt,
|
||||
UpdatedAt: a.State.UpdatedAt,
|
||||
Revisions: len(a.State.Revisions),
|
||||
LastRun: a.State.LastRun,
|
||||
}
|
||||
}
|
||||
|
||||
// Runnable reports whether an agent or the dispatcher may run this. The operator's own
|
||||
// manual run deliberately does not consult it.
|
||||
func (a *Automation) Runnable() bool { return a.State.Enabled && !a.State.Paused }
|
||||
|
||||
// ArmedTriggers lists the event triggers currently live: declared by the manifest, not
|
||||
// switched off by the operator, and only while the automation is runnable. Manual is
|
||||
// excluded — it is not something the dispatcher watches for.
|
||||
func (a *Automation) ArmedTriggers() []string {
|
||||
if !a.Runnable() {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, t := range a.Manifest.Triggers {
|
||||
if t == TriggerManual {
|
||||
continue
|
||||
}
|
||||
if a.State.TriggersDisabled[t] {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Limits resolves the budget one run of this automation gets.
|
||||
//
|
||||
// The author asks, the operator narrows, the runtime clamps. Taking the smaller of each
|
||||
// pair rather than letting either win outright is what makes "an automation can never
|
||||
// raise its own limits" true no matter which side was edited last.
|
||||
func (a *Automation) Limits() jsruntime.Limits {
|
||||
return narrower(a.Manifest.Limits, a.State.Limits).toRuntime().Normalize()
|
||||
}
|
||||
|
||||
// narrower returns the field-wise minimum of two optional budgets, treating zero as
|
||||
// "unspecified" rather than as the smallest value.
|
||||
func narrower(a, b *ManifestLimits) ManifestLimits {
|
||||
var out ManifestLimits
|
||||
if a == nil && b == nil {
|
||||
return out
|
||||
}
|
||||
if a == nil {
|
||||
return *b
|
||||
}
|
||||
if b == nil {
|
||||
return *a
|
||||
}
|
||||
pick := func(x, y int) int {
|
||||
switch {
|
||||
case x <= 0:
|
||||
return y
|
||||
case y <= 0:
|
||||
return x
|
||||
default:
|
||||
return min(x, y)
|
||||
}
|
||||
}
|
||||
out.TimeoutMs = pick(a.TimeoutMs, b.TimeoutMs)
|
||||
out.MemoryMB = pick(a.MemoryMB, b.MemoryMB)
|
||||
out.MaxCalls = pick(a.MaxCalls, b.MaxCalls)
|
||||
out.MaxSendCalls = pick(a.MaxSendCalls, b.MaxSendCalls)
|
||||
out.MaxLogBytes = pick(a.MaxLogBytes, b.MaxLogBytes)
|
||||
out.MaxResultBytes = pick(a.MaxResultBytes, b.MaxResultBytes)
|
||||
return out
|
||||
}
|
||||
|
||||
func (l ManifestLimits) toRuntime() jsruntime.Limits {
|
||||
return jsruntime.Limits{
|
||||
Timeout: time.Duration(l.TimeoutMs) * time.Millisecond,
|
||||
MemoryBytes: int64(l.MemoryMB) << 20,
|
||||
MaxCalls: l.MaxCalls,
|
||||
MaxSendCalls: l.MaxSendCalls,
|
||||
MaxLogBytes: l.MaxLogBytes,
|
||||
MaxResultBytes: l.MaxResultBytes,
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,15 @@ type Run struct {
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
|
||||
// TokenID and TokenName identify the launching token, not the run's synthetic
|
||||
// principal: the operator wants to know which credential set this off.
|
||||
// principal: the operator wants to know which credential set this off. Both are
|
||||
// empty for a run a trigger started, where TokenName carries the automation name.
|
||||
TokenID string `json:"tokenId"`
|
||||
TokenName string `json:"tokenName"`
|
||||
|
||||
// AutomationID is set when this run came from an installed automation, which is
|
||||
// also what gave it a storage namespace.
|
||||
AutomationID string `json:"automationId,omitempty"`
|
||||
|
||||
Trigger string `json:"trigger"`
|
||||
Bundle string `json:"bundle"`
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package jsautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/BishopFox/joro/internal/jsruntime"
|
||||
)
|
||||
|
||||
// joro.storage: a small key/value store per installed automation, so a triggered script
|
||||
// can remember what it has already seen.
|
||||
//
|
||||
// In memory, serialized into the project config on save, and reset when the project
|
||||
// changes — the same lifecycle as plugin project state, for the same reason. "IDs I have
|
||||
// already tested" is a fact about one engagement, and an automation that carried it into
|
||||
// the next one would confidently skip a client it has never touched.
|
||||
//
|
||||
// Not a capability. There is no argument to authorize: the namespace is bound from the
|
||||
// automation's own identity by namespaced(), never taken from the script, and storage
|
||||
// reaches nothing outside itself. See jsruntime.StorageBridge.
|
||||
|
||||
// Bounds. Fixed rather than operator-configurable: this is a scratchpad for bookkeeping,
|
||||
// and an automation that needs more than this wants a finding or a note, which are
|
||||
// capabilities with their own budgets and which the operator can actually see.
|
||||
const (
|
||||
maxStorageKeys = 200
|
||||
maxStorageKeyLen = 128
|
||||
maxStorageValueBytes = 64 << 10
|
||||
maxStorageTotalBytes = 256 << 10
|
||||
)
|
||||
|
||||
// Storage holds every automation's namespace.
|
||||
type Storage struct {
|
||||
mu sync.Mutex
|
||||
ns map[string]map[string]json.RawMessage
|
||||
// rev increments on every mutation. The autosave loop compares a signature rather
|
||||
// than diffing state, and an opaque blob's byte count can change without its
|
||||
// length changing, so a counter is the only honest way to report "this is dirty".
|
||||
rev atomic.Uint64
|
||||
}
|
||||
|
||||
// NewStorage returns an empty store.
|
||||
func NewStorage() *Storage {
|
||||
return &Storage{ns: make(map[string]map[string]json.RawMessage)}
|
||||
}
|
||||
|
||||
// Revision reports the mutation counter, for the autosave dirty check.
|
||||
func (s *Storage) Revision() uint64 { return s.rev.Load() }
|
||||
|
||||
func storageErr(code, format string, args ...any) error {
|
||||
return &jsruntime.CallError{Code: code, Msg: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// Op applies one operation within a namespace. Errors are CallErrors so the script sees a
|
||||
// code it can branch on.
|
||||
func (s *Storage) Op(id, op, key string, value json.RawMessage) (json.RawMessage, error) {
|
||||
if id == "" {
|
||||
return nil, storageErr("storage_unavailable", "%s", "this run has no storage namespace")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
switch op {
|
||||
case "keys":
|
||||
bucket := s.ns[id]
|
||||
keys := make([]string, 0, len(bucket))
|
||||
for k := range bucket {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return json.Marshal(keys)
|
||||
|
||||
case "get":
|
||||
if err := checkKey(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v, ok := s.ns[id][key]; ok {
|
||||
return v, nil
|
||||
}
|
||||
// Absent reads as null rather than an error: "have I seen this?" is the
|
||||
// commonest thing a script asks, and making it throw would push every author
|
||||
// into a try/catch around a normal answer.
|
||||
return json.RawMessage("null"), nil
|
||||
|
||||
case "delete":
|
||||
if err := checkKey(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucket := s.ns[id]
|
||||
_, existed := bucket[key]
|
||||
if existed {
|
||||
delete(bucket, key)
|
||||
if len(bucket) == 0 {
|
||||
delete(s.ns, id)
|
||||
}
|
||||
s.rev.Add(1)
|
||||
}
|
||||
return json.Marshal(existed)
|
||||
|
||||
case "set":
|
||||
if err := checkKey(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(value) > maxStorageValueBytes {
|
||||
return nil, storageErr("storage_too_large",
|
||||
"value is %d bytes, over the %d byte limit for one key", len(value), maxStorageValueBytes)
|
||||
}
|
||||
bucket := s.ns[id]
|
||||
if bucket == nil {
|
||||
bucket = make(map[string]json.RawMessage)
|
||||
s.ns[id] = bucket
|
||||
}
|
||||
if _, replacing := bucket[key]; !replacing && len(bucket) >= maxStorageKeys {
|
||||
return nil, storageErr("storage_full",
|
||||
"this automation already holds %d keys, the limit. Delete one, or keep a single "+
|
||||
"aggregate value instead of one key per item", maxStorageKeys)
|
||||
}
|
||||
// Total is measured against what the namespace would become, not what it is,
|
||||
// so a single oversize write cannot slip through under the wire.
|
||||
if total := bucketBytes(bucket) - len(bucket[key]) + len(value) + len(key); total > maxStorageTotalBytes {
|
||||
return nil, storageErr("storage_full",
|
||||
"this write would take the automation to %d bytes, over its %d byte limit",
|
||||
total, maxStorageTotalBytes)
|
||||
}
|
||||
stored := make(json.RawMessage, len(value))
|
||||
copy(stored, value)
|
||||
bucket[key] = stored
|
||||
s.rev.Add(1)
|
||||
return json.RawMessage("null"), nil
|
||||
}
|
||||
|
||||
return nil, storageErr("invalid_args", "unknown storage operation %q", op)
|
||||
}
|
||||
|
||||
func checkKey(key string) error {
|
||||
switch {
|
||||
case key == "":
|
||||
return storageErr("invalid_args", "%s", "a storage key is required")
|
||||
case len(key) > maxStorageKeyLen:
|
||||
return storageErr("invalid_args", "key is %d characters, over the %d limit",
|
||||
len(key), maxStorageKeyLen)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bucketBytes(bucket map[string]json.RawMessage) int {
|
||||
n := 0
|
||||
for k, v := range bucket {
|
||||
n += len(k) + len(v)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Drop removes an automation's namespace, called when it is uninstalled. Its data has no
|
||||
// meaning without the code that wrote it.
|
||||
func (s *Storage) Drop(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.ns[id]; ok {
|
||||
delete(s.ns, id)
|
||||
s.rev.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset empties every namespace, on a project switch or a new project.
|
||||
func (s *Storage) Reset() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.ns) > 0 {
|
||||
clear(s.ns)
|
||||
s.rev.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Export serializes each namespace for the project config: automation id -> JSON object.
|
||||
//
|
||||
// Plain JSON rather than compressed, because the whole project config is gzipped on the
|
||||
// way to disk and compressing twice buys nothing. The shape matches plugin state
|
||||
// (name -> opaque bytes) so it can reuse encodePluginStates and its ghost-preservation
|
||||
// machinery verbatim.
|
||||
func (s *Storage) Export() map[string][]byte {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if len(s.ns) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string][]byte, len(s.ns))
|
||||
for id, bucket := range s.ns {
|
||||
if len(bucket) == 0 {
|
||||
continue
|
||||
}
|
||||
b, err := json.Marshal(bucket)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[id] = b
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Apply replaces every namespace from a project config. Unparseable entries are skipped
|
||||
// rather than failing the load, following decodePluginStates: one corrupt blob must not
|
||||
// stop a project from opening.
|
||||
//
|
||||
// It returns the ids it could not match to an installed automation, so the caller can
|
||||
// preserve those blobs across a load-then-save round trip. A teammate's project may
|
||||
// reference an automation this machine does not have, and dropping the data would mean
|
||||
// the operator who does have it loses their bookkeeping the first time someone else
|
||||
// saves the project.
|
||||
func (s *Storage) Apply(states map[string][]byte, installed func(id string) bool) []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
clear(s.ns)
|
||||
var unknown []string
|
||||
for id, raw := range states {
|
||||
if installed != nil && !installed(id) {
|
||||
unknown = append(unknown, id)
|
||||
continue
|
||||
}
|
||||
var bucket map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &bucket); err != nil || len(bucket) == 0 {
|
||||
continue
|
||||
}
|
||||
// Re-apply every bound Op enforces. A project config is an imported artifact —
|
||||
// a teammate publishes one and the operator loads it — so this is an untrusted
|
||||
// write path into state an automation trusts, and the ceilings that bound the
|
||||
// script's own writes have to bound this one too. Violators are dropped rather
|
||||
// than failing the load, matching the skip-and-continue above.
|
||||
clean := make(map[string]json.RawMessage, len(bucket))
|
||||
total := 0
|
||||
for k, v := range bucket {
|
||||
if checkKey(k) != nil || len(v) > maxStorageValueBytes || len(clean) >= maxStorageKeys {
|
||||
continue
|
||||
}
|
||||
if total+len(k)+len(v) > maxStorageTotalBytes {
|
||||
continue
|
||||
}
|
||||
total += len(k) + len(v)
|
||||
clean[k] = v
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
continue
|
||||
}
|
||||
s.ns[id] = clean
|
||||
}
|
||||
s.rev.Add(1)
|
||||
sort.Strings(unknown)
|
||||
return unknown
|
||||
}
|
||||
|
||||
// namespaced binds a storage handle to one automation, which is what makes the namespace
|
||||
// un-forgeable: the script never names it, so there is nothing for it to name wrongly.
|
||||
type namespaced struct {
|
||||
s *Storage
|
||||
id string
|
||||
}
|
||||
|
||||
func (n namespaced) Storage(_ context.Context, op, key string, value json.RawMessage) (json.RawMessage, error) {
|
||||
return n.s.Op(n.id, op, key, value)
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package jsautomation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/jsruntime"
|
||||
)
|
||||
|
||||
// Installed automations live in ~/.joro/automations/<id>/ as three files:
|
||||
//
|
||||
// joro.json the manifest, author-owned
|
||||
// <entrypoint>.js the source, a real file an operator can open in their own editor
|
||||
// joro.state.json enabled / limits / triggers / revisions, operator-owned
|
||||
//
|
||||
// On disk rather than in a project config, because a project config is published to
|
||||
// teammates: shipping executable code that runs against the full SDK bundle is exactly
|
||||
// the privilege transfer that keeps automation tokens out of project configs too. The
|
||||
// consequence is that automations are machine-global, which is also why "enabled" is.
|
||||
//
|
||||
// Three files rather than one, for two reasons. The source has to be a real .js file —
|
||||
// a pentester will open it in their own editor, and a JSON-escaped source string is
|
||||
// hostile to that. And operator state is separate from author state so installing an
|
||||
// update never silently reverts a lowered limit or a trigger someone switched off.
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("no such automation")
|
||||
ErrExists = errors.New("an automation with that id is already installed")
|
||||
ErrHashMismatch = errors.New("the automation changed since it was read")
|
||||
)
|
||||
|
||||
const (
|
||||
manifestFile = "joro.json"
|
||||
stateFile = "joro.state.json"
|
||||
)
|
||||
|
||||
// Store reads and writes installed automation packages.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
// rev increments on every mutation so a consumer that caches the installed set —
|
||||
// the trigger dispatcher — can reload only when something actually changed rather
|
||||
// than re-reading every package on a 250ms tick.
|
||||
rev atomic.Uint64
|
||||
}
|
||||
|
||||
// NewStore returns a store rooted at dir, which is created lazily on first write.
|
||||
func NewStore(dir string) *Store { return &Store{dir: dir} }
|
||||
|
||||
// Revision reports the mutation counter.
|
||||
func (s *Store) Revision() uint64 { return s.rev.Load() }
|
||||
|
||||
// path resolves an automation's directory, validating the id here rather than trusting
|
||||
// that it was validated upstream.
|
||||
//
|
||||
// This is deliberate duplication. The plugin loader validates a name in one file and
|
||||
// joins it into a path in another, which is safe today only because the order happens to
|
||||
// hold; a store whose only defense against traversal lives in a different package is one
|
||||
// refactor away from not having one.
|
||||
func (s *Store) path(id string) (string, error) {
|
||||
clean := strings.ToLower(strings.TrimSpace(id))
|
||||
if clean == "" || len(clean) > MaxIDLen || !idPattern.MatchString(clean) {
|
||||
return "", fmt.Errorf("invalid automation id %q", id)
|
||||
}
|
||||
return filepath.Join(s.dir, clean), nil
|
||||
}
|
||||
|
||||
// List loads every installed automation, newest-installed first.
|
||||
//
|
||||
// A package that fails to load is logged and skipped rather than failing the whole list,
|
||||
// following the plugin loader: one corrupt manifest must not hide every other
|
||||
// automation from the operator who needs to go fix it.
|
||||
func (s *Store) List() []*Automation {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Printf("[automation] reading %s: %v", s.dir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]*Automation, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
a, err := s.loadLocked(e.Name())
|
||||
if err != nil {
|
||||
log.Printf("[automation] skipping %q: %v", e.Name(), err)
|
||||
continue
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].State.InstalledAt.After(out[j].State.InstalledAt)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Load reads one automation, source included.
|
||||
func (s *Store) Load(id string) (*Automation, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.loadLocked(id)
|
||||
}
|
||||
|
||||
func (s *Store) loadLocked(id string) (*Automation, error) {
|
||||
dir, err := s.path(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(dir, manifestFile))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("reading %s: %w", manifestFile, err)
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", manifestFile, err)
|
||||
}
|
||||
m.Normalize()
|
||||
if err := m.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", manifestFile, err)
|
||||
}
|
||||
// The directory name is authoritative, the same rule readProjectMeta applies to a
|
||||
// sidecar: a hand-edited id inside the file must not point Joro at another package.
|
||||
if m.ID != strings.ToLower(strings.TrimSpace(id)) {
|
||||
return nil, fmt.Errorf("%s declares id %q but lives in %q", manifestFile, m.ID, id)
|
||||
}
|
||||
|
||||
src, err := os.ReadFile(filepath.Join(dir, m.Entrypoint))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", m.Entrypoint, err)
|
||||
}
|
||||
|
||||
st := State{}
|
||||
if b, err := os.ReadFile(filepath.Join(dir, stateFile)); err == nil {
|
||||
if err := json.Unmarshal(b, &st); err != nil {
|
||||
// A corrupt sidecar must not make the package unloadable, but it must not
|
||||
// silently read as "enabled" either: defaults are inert.
|
||||
log.Printf("[automation] %s: unreadable %s, using defaults: %v", id, stateFile, err)
|
||||
st = State{}
|
||||
}
|
||||
}
|
||||
|
||||
return &Automation{
|
||||
Manifest: m,
|
||||
State: st,
|
||||
Source: string(src),
|
||||
SourceHash: HashSource(string(src)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Install writes a new package. It refuses an id that already exists rather than
|
||||
// overwriting: replacing installed code is Update's job, and Update has the
|
||||
// hash precondition that makes a replacement deliberate.
|
||||
func (s *Store) Install(m Manifest, source string) (*Automation, error) {
|
||||
m.Normalize()
|
||||
if err := m.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateSource(source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
dir, err := s.path(m.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
return nil, ErrExists
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("checking %s: %w", dir, err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
st := State{
|
||||
// Installed disabled. An operator reviews code, triggers and limits, and then
|
||||
// arms it; nothing an install can do should start something running.
|
||||
Enabled: false,
|
||||
InstalledAt: now,
|
||||
UpdatedAt: now,
|
||||
Revisions: []Revision{{Hash: HashSource(source), At: now, Bytes: len(source)}},
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("creating %s: %w", dir, err)
|
||||
}
|
||||
if err := s.writeAllLocked(dir, m, source, st); err != nil {
|
||||
// Leave nothing half-installed: a directory with a manifest and no source
|
||||
// would show up in List as a broken package forever.
|
||||
_ = os.RemoveAll(dir)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.rev.Add(1)
|
||||
return &Automation{Manifest: m, State: st, Source: source, SourceHash: HashSource(source)}, nil
|
||||
}
|
||||
|
||||
// Update replaces an installed package's manifest and source.
|
||||
//
|
||||
// expectedHash is required while the automation is armed. Silently replacing code that
|
||||
// something is actively triggering is how an operator ends up supervising an automation
|
||||
// they have not read, so a concurrent edit has to lose rather than win.
|
||||
func (s *Store) Update(id string, m Manifest, source, expectedHash string) (*Automation, error) {
|
||||
m.Normalize()
|
||||
if err := m.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateSource(source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cur, err := s.loadLocked(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.ID != cur.Manifest.ID {
|
||||
return nil, fmt.Errorf("cannot change an automation's id (%q -> %q); install a new one instead",
|
||||
cur.Manifest.ID, m.ID)
|
||||
}
|
||||
if cur.Runnable() {
|
||||
switch {
|
||||
case expectedHash == "":
|
||||
return nil, fmt.Errorf("%w: this automation is enabled, so an update must state the "+
|
||||
"source hash it is replacing (expectedHash)", ErrHashMismatch)
|
||||
case expectedHash != cur.SourceHash:
|
||||
return nil, ErrHashMismatch
|
||||
}
|
||||
}
|
||||
|
||||
dir, err := s.path(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
st := cur.State
|
||||
st.UpdatedAt = now
|
||||
newHash := HashSource(source)
|
||||
if newHash != cur.SourceHash {
|
||||
st.Revisions = append(st.Revisions, Revision{Hash: newHash, At: now, Bytes: len(source)})
|
||||
if len(st.Revisions) > MaxRevisions {
|
||||
st.Revisions = st.Revisions[len(st.Revisions)-MaxRevisions:]
|
||||
}
|
||||
}
|
||||
// A trigger the operator switched off stays off; one the update newly declares is
|
||||
// not armed by that fact alone, because ArmedTriggers only reads the manifest for
|
||||
// triggers the operator has not overridden.
|
||||
if cur.Manifest.Entrypoint != m.Entrypoint {
|
||||
_ = os.Remove(filepath.Join(dir, cur.Manifest.Entrypoint))
|
||||
}
|
||||
if err := s.writeAllLocked(dir, m, source, st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.rev.Add(1)
|
||||
return &Automation{Manifest: m, State: st, Source: source, SourceHash: newHash}, nil
|
||||
}
|
||||
|
||||
// SetState applies a mutation to the operator-owned sidecar and nothing else. The
|
||||
// manifest and the source are not rewritten, which is what makes toggling a flag cheap
|
||||
// and keeps an author's artifact byte-identical across an operator's decisions.
|
||||
func (s *Store) SetState(id string, mutate func(*State)) (*Automation, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cur, err := s.loadLocked(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := s.path(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
st := cur.State
|
||||
mutate(&st)
|
||||
st.UpdatedAt = time.Now().UTC()
|
||||
if err := s.writeStateLocked(dir, st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cur.State = st
|
||||
s.rev.Add(1)
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
// Delete removes a package and everything under it.
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
dir, err := s.path(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return fmt.Errorf("removing %s: %w", dir, err)
|
||||
}
|
||||
s.rev.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) writeAllLocked(dir string, m Manifest, source string, st State) error {
|
||||
mjson, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding manifest: %w", err)
|
||||
}
|
||||
if err := writeFileAtomic(filepath.Join(dir, manifestFile), append(mjson, '\n'), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(filepath.Join(dir, m.Entrypoint), []byte(source), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeStateLocked(dir, st)
|
||||
}
|
||||
|
||||
func (s *Store) writeStateLocked(dir string, st State) error {
|
||||
b, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding state: %w", err)
|
||||
}
|
||||
return writeFileAtomic(filepath.Join(dir, stateFile), append(b, '\n'), 0o600)
|
||||
}
|
||||
|
||||
// writeFileAtomic writes via a temp file and a rename, so an interrupted write leaves the
|
||||
// previous content rather than a truncated file. configstore writes in place; the token
|
||||
// store does it this way, and installed code deserves the same treatment — a half-written
|
||||
// automation is a package that fails to load with no obvious cause.
|
||||
//
|
||||
// The temp file is created with os.CreateTemp, which opens O_EXCL under a name it
|
||||
// generates. Two properties follow, and both are wanted: the open fails outright rather
|
||||
// than writing through anything that already sits at that path, and the name is not
|
||||
// predictable, so it cannot be staked out in advance. A fixed ".tmp" suffix has neither —
|
||||
// it is guessable, and a plain write to it follows what it finds. The rename is safe
|
||||
// either way, since it replaces a path rather than resolving through it.
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
f, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating a temp file beside %s: %w", path, err)
|
||||
}
|
||||
tmp := f.Name()
|
||||
|
||||
// Every failure past this point removes the temp file: a leftover would otherwise
|
||||
// accumulate in the automation's directory on each failed write.
|
||||
fail := func(err error) error {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := f.Write(data); err != nil {
|
||||
return fail(fmt.Errorf("writing %s: %w", tmp, err))
|
||||
}
|
||||
// CreateTemp opens at 0600; set the requested mode explicitly so the file on disk
|
||||
// does not depend on that staying true.
|
||||
if err := f.Chmod(perm); err != nil {
|
||||
return fail(fmt.Errorf("setting mode on %s: %w", tmp, err))
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("closing %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("renaming onto %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSource rejects a package whose source could never run: too large, or not
|
||||
// parseable as JavaScript.
|
||||
//
|
||||
// Compiling at install time rather than at first run means a syntax error is reported to
|
||||
// whoever submitted the package, while they are still looking at it, instead of surfacing
|
||||
// hours later as a trigger that quietly fails. Compilation parses and does not execute.
|
||||
func ValidateSource(source string) error {
|
||||
if strings.TrimSpace(source) == "" {
|
||||
return errors.New("source is required")
|
||||
}
|
||||
return jsruntime.Validate(source)
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
package jsautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/joro/internal/event"
|
||||
"github.com/BishopFox/joro/internal/proxy"
|
||||
)
|
||||
|
||||
// The trigger dispatcher: one goroutine that decides when an armed automation runs.
|
||||
//
|
||||
// Shaped after detect.Scanner — a ticker plus a capacity-1 coalescing doorbell — because
|
||||
// the problem is the same one. Joro's event bus drops request.captured when it is busy
|
||||
// (the proxy emits with a non-blocking send), so an event cannot be the unit of work. For
|
||||
// traffic the doorbell only says "look now" and a sequence cursor over the capture store
|
||||
// is what is actually authoritative, which is why a dropped notification costs latency
|
||||
// rather than a missed request.
|
||||
//
|
||||
// Every trigger delivers a *batch*. That is what makes the highest-volume one affordable:
|
||||
// one run per interval handles up to maxTriggerBatch events instead of one run per event,
|
||||
// so there is no process spawn per request and no queue to overflow in the common case.
|
||||
|
||||
const (
|
||||
dispatchInterval = 250 * time.Millisecond
|
||||
|
||||
// maxTriggerBatch bounds one run's payload. Also the cursor advance, so a burst is
|
||||
// worked through over several runs rather than in one enormous ctx.trigger.
|
||||
maxTriggerBatch = 50
|
||||
|
||||
// pendingCap bounds the coalescing buffer for discrete events. Past it the oldest
|
||||
// are dropped and counted — and the count is handed to the script, so an automation
|
||||
// can say in its own findings that it did not see everything.
|
||||
pendingCap = 4 * maxTriggerBatch
|
||||
|
||||
// defaultMinInterval paces a trigger. One second is slow enough that a script doing
|
||||
// real work per batch cannot saturate a core, and fast enough to feel live.
|
||||
defaultMinInterval = time.Second
|
||||
|
||||
// The runaway breaker. A script that sends creates traffic, creates findings, and
|
||||
// can therefore feed its own trigger through a path the cursor rule below does not
|
||||
// cover. Rather than enumerate those paths, cap the rate and stop.
|
||||
breakerWindow = time.Minute
|
||||
breakerMaxRuns = 30
|
||||
breakerReasonFm = "paused automatically: %d runs in the last minute, which is the runaway limit. " +
|
||||
"An automation that sends traffic can trigger itself; check its trigger and its interval, " +
|
||||
"then re-enable it."
|
||||
)
|
||||
|
||||
// Dispatcher watches Joro's events and runs armed automations.
|
||||
type Dispatcher struct {
|
||||
mgr *Manager
|
||||
store *proxy.Store
|
||||
broadcast chan<- any
|
||||
|
||||
wake chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
armed map[string]*armed
|
||||
knownRev uint64
|
||||
loaded bool
|
||||
}
|
||||
|
||||
// armed is the dispatcher's per-automation state. None of it is persisted: a cursor
|
||||
// seeded from the store head means enabling an automation does not replay an
|
||||
// engagement's history at it, which is what an operator expects from arming something.
|
||||
type armed struct {
|
||||
id string
|
||||
version string
|
||||
triggers []string
|
||||
interval time.Duration
|
||||
|
||||
cursor int64
|
||||
|
||||
pending map[string][]json.RawMessage
|
||||
dropped map[string]int
|
||||
|
||||
lastRun time.Time
|
||||
running bool
|
||||
runTimes []time.Time
|
||||
}
|
||||
|
||||
// NewDispatcher returns a dispatcher. broadcast may be nil.
|
||||
func NewDispatcher(mgr *Manager, store *proxy.Store, broadcast chan<- any) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
mgr: mgr,
|
||||
store: store,
|
||||
broadcast: broadcast,
|
||||
wake: make(chan struct{}, 1),
|
||||
armed: make(map[string]*armed),
|
||||
}
|
||||
}
|
||||
|
||||
// Wake asks for an immediate pass. Non-blocking and coalescing: many callers between two
|
||||
// passes cost one pass, which is the point.
|
||||
func (d *Dispatcher) Wake() {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case d.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Run is the dispatcher loop. events is a subscription to Joro's event bus; it is read
|
||||
// here rather than in its own goroutine so that observing an event and acting on it are
|
||||
// serialized, and one automation's state needs no lock against the other path.
|
||||
func (d *Dispatcher) Run(ctx context.Context, events <-chan any) {
|
||||
if d == nil || d.mgr == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(dispatchInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ev := <-events:
|
||||
// Observe rings the doorbell, so the next iteration dispatches. Handling
|
||||
// the event and dispatching in the same pass would make a burst of events
|
||||
// a burst of passes.
|
||||
d.observe(ev)
|
||||
continue
|
||||
case <-ticker.C:
|
||||
case <-d.wake:
|
||||
}
|
||||
d.dispatch(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// observe classifies one bus event.
|
||||
func (d *Dispatcher) observe(ev any) {
|
||||
we, ok := ev.(event.WSEvent)
|
||||
if !ok {
|
||||
// Some producers send a bare map; all of those are team.* events, which are
|
||||
// not triggers.
|
||||
return
|
||||
}
|
||||
|
||||
switch we.Type {
|
||||
case "request.captured":
|
||||
// Deliberately not read: the payload is a pointer shared with the capture
|
||||
// store, and the cursor already knows what is new. This is only a doorbell.
|
||||
d.Wake()
|
||||
|
||||
case "detect.finding":
|
||||
if ref := findingRef(we.Data); ref != nil {
|
||||
d.enqueue(TriggerDetectFinding, ref)
|
||||
}
|
||||
|
||||
case "fuzzer.complete":
|
||||
if ref := compact(we.Data, &campaignFields{}); ref != nil {
|
||||
d.enqueue(TriggerFuzzerComplete, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// enqueue adds a reference to every automation armed for that trigger.
|
||||
func (d *Dispatcher) enqueue(trigger string, ref json.RawMessage) {
|
||||
d.mu.Lock()
|
||||
for _, a := range d.armed {
|
||||
if !slices.Contains(a.triggers, trigger) {
|
||||
continue
|
||||
}
|
||||
if len(a.pending[trigger]) >= pendingCap {
|
||||
// Drop the oldest: a stale event matters less than a fresh one, and the
|
||||
// count travels to the script so the loss is visible rather than silent.
|
||||
a.pending[trigger] = a.pending[trigger][1:]
|
||||
a.dropped[trigger]++
|
||||
}
|
||||
a.pending[trigger] = append(a.pending[trigger], ref)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
d.Wake()
|
||||
}
|
||||
|
||||
// dispatch is one pass: reload the armed set if it changed, then start at most one run
|
||||
// per automation.
|
||||
func (d *Dispatcher) dispatch(ctx context.Context) {
|
||||
d.reload()
|
||||
|
||||
now := time.Now()
|
||||
d.mu.Lock()
|
||||
type job struct {
|
||||
a *armed
|
||||
trigger string
|
||||
payload json.RawMessage
|
||||
// batchMax is the highest capture sequence this batch actually contained, so a
|
||||
// watcher advances to exactly what it was shown rather than to an estimate.
|
||||
batchMax int
|
||||
}
|
||||
var jobs []job
|
||||
for _, a := range d.armed {
|
||||
if a.running || now.Sub(a.lastRun) < a.interval {
|
||||
continue
|
||||
}
|
||||
trigger, payload, batchMax := d.nextBatchLocked(a)
|
||||
if payload == nil {
|
||||
continue
|
||||
}
|
||||
a.running = true
|
||||
jobs = append(jobs, job{a: a, trigger: trigger, payload: payload, batchMax: batchMax})
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
for _, j := range jobs {
|
||||
go d.runOne(ctx, j.a, j.trigger, j.payload, j.batchMax)
|
||||
}
|
||||
}
|
||||
|
||||
// nextBatchLocked picks the first armed trigger with work and builds its payload.
|
||||
//
|
||||
// Manifest order decides precedence, so an author who cares can put the trigger that
|
||||
// matters first. Called with d.mu held.
|
||||
func (d *Dispatcher) nextBatchLocked(a *armed) (string, json.RawMessage, int) {
|
||||
for _, t := range a.triggers {
|
||||
switch t {
|
||||
case TriggerRequestCaptured:
|
||||
if d.store == nil {
|
||||
continue
|
||||
}
|
||||
items := d.store.SinceSeq(int(a.cursor), maxTriggerBatch)
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
return t, requestBatch(items, a.takeDropped(t)), items[len(items)-1].Seq
|
||||
|
||||
default:
|
||||
refs := a.pending[t]
|
||||
if len(refs) == 0 {
|
||||
continue
|
||||
}
|
||||
n := min(len(refs), maxTriggerBatch)
|
||||
batch := refs[:n]
|
||||
a.pending[t] = refs[n:]
|
||||
return t, discreteBatch(t, batch, a.takeDropped(t)), 0
|
||||
}
|
||||
}
|
||||
return "", nil, 0
|
||||
}
|
||||
|
||||
func (a *armed) takeDropped(trigger string) int {
|
||||
n := a.dropped[trigger]
|
||||
delete(a.dropped, trigger)
|
||||
return n
|
||||
}
|
||||
|
||||
// runOne executes one batch and settles the automation's state afterwards.
|
||||
func (d *Dispatcher) runOne(ctx context.Context, a *armed, trigger string, payload json.RawMessage, batchMax int) {
|
||||
run, err := d.mgr.Invoke(ctx, InvokeRequest{
|
||||
ID: a.id,
|
||||
Caller: AutomationPrincipal(a.id),
|
||||
// Invoke applies the automation's own host whitelist, since a trigger-fired run
|
||||
// has no launching token to inherit one from.
|
||||
Trigger: trigger,
|
||||
TriggerData: payload,
|
||||
})
|
||||
if err != nil {
|
||||
// Busy is ordinary backpressure: leave the cursor unmoved so the next pass picks
|
||||
// the same traffic up. Discrete events were already consumed, which is the right
|
||||
// trade for something that has been coalesced anyway.
|
||||
if err != ErrBusy {
|
||||
log.Printf("[automation] %s: %v", a.id, err)
|
||||
}
|
||||
d.finish(a, nil, trigger, 0)
|
||||
return
|
||||
}
|
||||
d.finish(a, run, trigger, batchMax)
|
||||
}
|
||||
|
||||
// finish clears the running flag, records the run for the breaker, and moves the cursor.
|
||||
//
|
||||
// The cursor rule is the amplification fix, and which moment it reads matters:
|
||||
//
|
||||
// - A run that sent anything jumps to the store head *as of now*, which is after its
|
||||
// own traffic has been captured. That is precisely the window it caused, so it cannot
|
||||
// re-trigger itself. The cost is any browsing that happened during the run, which is
|
||||
// the honest price of sending from a traffic trigger.
|
||||
// - A pure watcher — the common case, and the one where completeness matters — advances
|
||||
// only to the highest sequence it was actually handed, so a long run swallows nothing
|
||||
// that arrived while it worked.
|
||||
func (d *Dispatcher) finish(a *armed, run *Run, trigger string, batchMax int) {
|
||||
now := time.Now()
|
||||
|
||||
head := 0
|
||||
if run != nil && run.Result.SendCalls > 0 && d.store != nil {
|
||||
head = d.store.LastSeq()
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
a.running = false
|
||||
a.lastRun = now
|
||||
|
||||
if run != nil && trigger == TriggerRequestCaptured {
|
||||
switch {
|
||||
case head > 0:
|
||||
a.cursor = int64(head)
|
||||
case batchMax > 0:
|
||||
a.cursor = int64(batchMax)
|
||||
}
|
||||
}
|
||||
|
||||
if run != nil {
|
||||
a.runTimes = append(a.runTimes, now)
|
||||
cut := now.Add(-breakerWindow)
|
||||
for len(a.runTimes) > 0 && a.runTimes[0].Before(cut) {
|
||||
a.runTimes = a.runTimes[1:]
|
||||
}
|
||||
}
|
||||
tripped := len(a.runTimes) >= breakerMaxRuns
|
||||
count := len(a.runTimes)
|
||||
id := a.id
|
||||
d.mu.Unlock()
|
||||
|
||||
if tripped {
|
||||
d.pause(id, fmt.Sprintf(breakerReasonFm, count))
|
||||
}
|
||||
}
|
||||
|
||||
// pause stops an automation the breaker caught, and tells the operator.
|
||||
//
|
||||
// Enabled is left alone: it records what the operator wanted, and resuming should restore
|
||||
// that rather than ask them to remember it. Paused persists to the sidecar, so a restart
|
||||
// does not quietly re-arm something that was looping.
|
||||
func (d *Dispatcher) pause(id, reason string) {
|
||||
store := d.mgr.Packages()
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
if _, err := store.SetState(id, func(st *State) {
|
||||
st.Paused = true
|
||||
st.PausedReason = reason
|
||||
}); err != nil {
|
||||
log.Printf("[automation] %s: recording pause: %v", id, err)
|
||||
return
|
||||
}
|
||||
log.Printf("[automation] %s %s", id, reason)
|
||||
|
||||
d.mu.Lock()
|
||||
delete(d.armed, id)
|
||||
d.mu.Unlock()
|
||||
|
||||
// The one automation event worth pushing. Per-run events would be a firehose an
|
||||
// agent controls; this fires only on a state change the operator did not make, which
|
||||
// is the case where waiting for the next poll is wrong.
|
||||
if d.broadcast != nil {
|
||||
select {
|
||||
case d.broadcast <- event.WSEvent{Type: "automation.script.state", Data: map[string]any{
|
||||
"id": id, "enabled": true, "paused": true, "pausedReason": reason,
|
||||
}}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reload syncs the armed set with the installed set, but only when the store says
|
||||
// something changed — otherwise every 250ms tick would re-read every package from disk.
|
||||
func (d *Dispatcher) reload() {
|
||||
store := d.mgr.Packages()
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
rev := store.Revision()
|
||||
|
||||
d.mu.Lock()
|
||||
if d.loaded && rev == d.knownRev {
|
||||
d.mu.Unlock()
|
||||
return
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
list := store.List()
|
||||
|
||||
head := 0
|
||||
if d.store != nil {
|
||||
head = d.store.LastSeq()
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
seen := make(map[string]struct{}, len(list))
|
||||
for _, a := range list {
|
||||
triggers := a.ArmedTriggers()
|
||||
if len(triggers) == 0 {
|
||||
continue
|
||||
}
|
||||
seen[a.Manifest.ID] = struct{}{}
|
||||
|
||||
cur, ok := d.armed[a.Manifest.ID]
|
||||
if !ok {
|
||||
// A freshly armed automation starts at the head. Replaying the engagement
|
||||
// so far at something the operator just switched on would be a surprise,
|
||||
// and for a sender an expensive one.
|
||||
cur = &armed{
|
||||
id: a.Manifest.ID,
|
||||
cursor: int64(head),
|
||||
pending: make(map[string][]json.RawMessage),
|
||||
dropped: make(map[string]int),
|
||||
}
|
||||
d.armed[a.Manifest.ID] = cur
|
||||
}
|
||||
cur.version = a.Manifest.Version
|
||||
cur.triggers = triggers
|
||||
cur.interval = effectiveInterval(a)
|
||||
}
|
||||
|
||||
for id, a := range d.armed {
|
||||
if _, still := seen[id]; !still && !a.running {
|
||||
delete(d.armed, id)
|
||||
}
|
||||
}
|
||||
|
||||
d.knownRev = rev
|
||||
d.loaded = true
|
||||
}
|
||||
|
||||
// effectiveInterval takes the longer of the author's and the operator's minimum, since
|
||||
// for an interval the more conservative value is the larger one — the opposite of every
|
||||
// other limit, which is why it is not in ManifestLimits.
|
||||
func effectiveInterval(a *Automation) time.Duration {
|
||||
ms := max(a.Manifest.MinIntervalMs, a.State.MinIntervalMs)
|
||||
if ms <= 0 {
|
||||
return defaultMinInterval
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
|
||||
// ---- trigger payloads ----
|
||||
|
||||
// requestRef is what a traffic-triggered run sees. References, not content: the script
|
||||
// resolves detail with joro.http.read, where its principal is enforced. Being handed an
|
||||
// event is not permission to read what it is about.
|
||||
type requestRef struct {
|
||||
Seq int `json:"seq"`
|
||||
Method string `json:"method"`
|
||||
Host string `json:"host"`
|
||||
URL string `json:"url"`
|
||||
Status int `json:"status"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
}
|
||||
|
||||
func requestBatch(items []*proxy.CapturedRequest, dropped int) json.RawMessage {
|
||||
refs := make([]requestRef, 0, len(items))
|
||||
for _, it := range items {
|
||||
refs = append(refs, requestRef{
|
||||
Seq: it.Seq, Method: it.Method, Host: it.Host,
|
||||
URL: it.URL, Status: it.StatusCode, ContentType: it.ContentType,
|
||||
})
|
||||
}
|
||||
b, err := json.Marshal(map[string]any{"requests": refs, "dropped": dropped})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func discreteBatch(trigger string, refs []json.RawMessage, dropped int) json.RawMessage {
|
||||
key := "events"
|
||||
switch trigger {
|
||||
case TriggerDetectFinding:
|
||||
key = "findings"
|
||||
case TriggerFuzzerComplete:
|
||||
key = "campaigns"
|
||||
}
|
||||
b, err := json.Marshal(map[string]any{key: refs, "dropped": dropped})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// agentReportedRuleID mirrors capreg's agentFindingRuleID, the reserved rule under which
|
||||
// findings.create files an agent's report. Duplicated rather than imported because capreg
|
||||
// imports this package, so the reverse direction is a cycle.
|
||||
const agentReportedRuleID = "agent-reported"
|
||||
|
||||
// findingFields is the intersection of the two shapes detect.finding arrives in.
|
||||
type findingFields struct {
|
||||
ID string `json:"id"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
RuleID string `json:"ruleId,omitempty"`
|
||||
}
|
||||
|
||||
type campaignFields struct {
|
||||
CampaignID string `json:"campaignId"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Completed int64 `json:"completed,omitempty"`
|
||||
Errors int64 `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// findingRef pulls the identifying fields out of a detect.finding payload.
|
||||
//
|
||||
// That event has two producers with different concrete types — the scanner sends a
|
||||
// summary, the findings.create capability sends the whole finding — so the payload is
|
||||
// re-encoded and read back through a struct holding only the fields both share. Slower
|
||||
// than a type assertion and immune to the difference, which matters more: findings are
|
||||
// low-volume, and a dispatcher that worked for one producer and not the other would be a
|
||||
// trigger that fires for the operator's scans and not for an agent's reports.
|
||||
func findingRef(data any) json.RawMessage {
|
||||
wrapper, ok := data.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
f, ok := wrapper["finding"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var fields findingFields
|
||||
raw, err := json.Marshal(f)
|
||||
if err != nil || json.Unmarshal(raw, &fields) != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A finding an agent reported is not an engagement event, and must not run the
|
||||
// operator's code.
|
||||
//
|
||||
// findings.create is non-privileged and sits in the stock tester and triage profiles,
|
||||
// and its host is a free string with no target extractor and no scope guard. Treating
|
||||
// its broadcast as a trigger would let any token holding it induce a run — and a
|
||||
// trigger-fired run carries no launching token, so it is bounded by scope alone and
|
||||
// can reach hosts the inducing token is itself denied. That is a confused deputy
|
||||
// around the one grant meant to gate exactly this: script.invoke, which is privileged,
|
||||
// flag-gated, refused to every profile, and audited.
|
||||
if fields.RuleID == agentReportedRuleID {
|
||||
return nil
|
||||
}
|
||||
|
||||
ref, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// isNew rides along so a script can ignore a finding it has already been told about
|
||||
// without keeping its own bookkeeping.
|
||||
isNew, _ := wrapper["isNew"].(bool)
|
||||
merged, err := json.Marshal(map[string]any{"finding": json.RawMessage(ref), "isNew": isNew})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// compact re-encodes a payload through dst, keeping only the fields dst declares.
|
||||
func compact[T any](data any, dst *T) json.RawMessage {
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, dst); err != nil {
|
||||
return nil
|
||||
}
|
||||
out, err := json.Marshal(dst)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -172,6 +172,13 @@ type Meta struct {
|
||||
AutomationVersion string `json:"automationVersion,omitempty"`
|
||||
|
||||
TriggerType string `json:"triggerType"`
|
||||
|
||||
// TriggerData is merged into ctx.trigger beside the type, so an event-driven run
|
||||
// can see what woke it. It carries references — a request seq, a finding id, a
|
||||
// campaign id — and never a resolved object: the script fetches detail through the
|
||||
// SDK, where its principal is enforced. Receiving an event is not permission to
|
||||
// read what the event is about.
|
||||
TriggerData json.RawMessage `json:"triggerData,omitempty"`
|
||||
}
|
||||
|
||||
// Request is one program to run.
|
||||
@@ -213,8 +220,12 @@ type Result struct {
|
||||
Logs []LogLine `json:"logs,omitempty"`
|
||||
LogsTruncated bool `json:"logsTruncated,omitempty"`
|
||||
|
||||
Calls int `json:"calls"`
|
||||
SendCalls int `json:"sendCalls"`
|
||||
Calls int `json:"calls"`
|
||||
SendCalls int `json:"sendCalls"`
|
||||
// StorageOps counts joro.storage calls. Tracked separately from Calls because
|
||||
// storage is not a capability invocation: it consumes no registry budget, engages
|
||||
// no scope guard, and writes no audit entry.
|
||||
StorageOps int `json:"storageOps,omitempty"`
|
||||
CallInputBytes int `json:"callInputBytes"`
|
||||
CallOutputBytes int `json:"callOutputBytes"`
|
||||
|
||||
@@ -237,6 +248,34 @@ type HostBridge interface {
|
||||
Invoke(ctx context.Context, id string, args json.RawMessage) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// StorageBridge is the per-automation key/value store, exposed to a script as
|
||||
// joro.storage. Optional: a bridge that does not implement it makes joro.storage report
|
||||
// that this run has no namespace, which is the honest answer for a one-shot script.
|
||||
//
|
||||
// This is the one part of the SDK that is not a capability, and the reason is that there
|
||||
// is nothing to authorize. The namespace is bound by the host from the automation's own
|
||||
// identity and is never an argument, so a script cannot name another automation's data;
|
||||
// and storage reaches nothing — not a target, not the operator's configuration, not
|
||||
// Joro's state. It is memory, not authority. Making it a capability would add a grant
|
||||
// checkbox whose only possible answer is the one already implied by installing the
|
||||
// automation.
|
||||
//
|
||||
// op is one of "get", "set", "delete", "keys".
|
||||
type StorageBridge interface {
|
||||
Storage(ctx context.Context, op, key string, value json.RawMessage) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// storageUnavailableMsg is reported by both the in-process VM and the worker's parent, so
|
||||
// an operator sees one wording however the run was executed.
|
||||
const storageUnavailableMsg = "joro.storage is available only to an installed automation: a " +
|
||||
"one-shot script has no namespace of its own. Return the value instead, or install this as " +
|
||||
"an automation"
|
||||
|
||||
// maxStorageOps bounds joro.storage calls per run. A fixed constant rather than a
|
||||
// configurable limit: it exists to stop a loop from hammering the pipe, not to express an
|
||||
// operator's policy, and the wall clock already bounds the run itself.
|
||||
const maxStorageOps = 1000
|
||||
|
||||
// CallError is a capability failure the script can inspect. Code becomes err.code in
|
||||
// JavaScript, so a script can retry on "busy" and give up on "forbidden".
|
||||
type CallError struct {
|
||||
|
||||
+153
-19
@@ -115,8 +115,10 @@ func shimSource() string {
|
||||
"use strict";
|
||||
var invoke = globalThis.__joro_invoke;
|
||||
var log = globalThis.__joro_log;
|
||||
var storage = globalThis.__joro_storage;
|
||||
delete globalThis.__joro_invoke;
|
||||
delete globalThis.__joro_log;
|
||||
delete globalThis.__joro_storage;
|
||||
|
||||
// GoError is goja's own constructor for errors carrying a Go value. Nothing here
|
||||
// ever puts a Go error into the VM — failures arrive as JSON and are thrown as
|
||||
@@ -186,6 +188,31 @@ func shimSource() string {
|
||||
fmt.Fprintf(&b, "\tns(%q)[%q] = call(%q);\n", nsName, method, bind.Cap)
|
||||
}
|
||||
|
||||
// storage is emitted unconditionally, and whether this run actually has a namespace
|
||||
// is the host's answer at call time. That keeps the shim a single compiled program
|
||||
// shared by every run rather than one generated per run.
|
||||
b.WriteString(`
|
||||
function storageOp(op) {
|
||||
return function (key, value) {
|
||||
var res = JSON.parse(storage(
|
||||
op,
|
||||
key === undefined || key === null ? "" : String(key),
|
||||
JSON.stringify(value === undefined ? null : value)
|
||||
));
|
||||
if (res.ok) { return res.data; }
|
||||
var e = new Error(res.message || res.code || "storage call failed");
|
||||
e.name = "JoroStorageError";
|
||||
e.code = res.code || "";
|
||||
throw e;
|
||||
};
|
||||
}
|
||||
var st = ns("storage");
|
||||
st.get = storageOp("get");
|
||||
st.set = storageOp("set");
|
||||
st["delete"] = storageOp("delete");
|
||||
st.keys = storageOp("keys");
|
||||
`)
|
||||
|
||||
b.WriteString(`
|
||||
Object.keys(joro).forEach(function (k) { Object.freeze(joro[k]); });
|
||||
Object.freeze(joro);
|
||||
@@ -222,17 +249,22 @@ var (
|
||||
reExportList = regexp.MustCompile(`(?m)^[ \t]*export[ \t]*\{[^}\n]*\}[ \t]*;?`)
|
||||
)
|
||||
|
||||
// Prepare validates a program and erases the module preamble the runtime does not
|
||||
// implement.
|
||||
// Prepare validates a program and erases the module syntax the runtime does not implement.
|
||||
//
|
||||
// Every erasure is replaced with spaces of equal length rather than deleted, so byte
|
||||
// offsets, line numbers and columns are untouched and a stack trace still points at
|
||||
// the line the author wrote.
|
||||
// offsets, line numbers and columns are untouched and a stack trace still points at the
|
||||
// line the author wrote.
|
||||
//
|
||||
// An import of anything other than the SDK is an error, never a silent rewrite. The
|
||||
// alternative — dropping it and letting the script fail later on an undefined symbol —
|
||||
// would report a missing variable when the real problem is that host module resolution
|
||||
// does not exist and third-party code has to be bundled before it gets here.
|
||||
//
|
||||
// Erasure applies only to module syntax in *code* position. Without that, a template
|
||||
// literal holding `export const x = 1` would be silently rewritten and a string holding
|
||||
// `import y from "lodash"` would reject the whole program — either way the source an
|
||||
// operator reads back would not be the source that ran, which is the one property the run
|
||||
// log exists to provide.
|
||||
func Prepare(source string) (string, error) {
|
||||
if strings.TrimSpace(source) == "" {
|
||||
return "", fmt.Errorf("script is empty: define an entry point, for example `async function run(ctx) { ... }`")
|
||||
@@ -242,9 +274,14 @@ func Prepare(source string) (string, error) {
|
||||
}
|
||||
|
||||
out := source
|
||||
// Computed once: blanking preserves length, so offsets stay valid across passes.
|
||||
mask := codeMask(out)
|
||||
|
||||
whole := func(m []int) (int, int) { return m[0], m[1] }
|
||||
|
||||
for _, re := range []*regexp.Regexp{reImportFrom, reImportBare} {
|
||||
var bad string
|
||||
out = replaceIndexed(out, re, func(groups []string) bool {
|
||||
out = blankMatches(out, mask, re, whole, func(groups []string) bool {
|
||||
if groups[1] != SDKModule {
|
||||
if bad == "" {
|
||||
bad = groups[1]
|
||||
@@ -261,29 +298,34 @@ func Prepare(source string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// `export` is meaningless without a module system, but generated code writes it,
|
||||
// and erasing the keyword leaves a declaration that behaves identically.
|
||||
out = replaceIndexed(out, reExportList, func([]string) bool { return true })
|
||||
out = reExportDecl.ReplaceAllStringFunc(out, func(m string) string {
|
||||
g := reExportDecl.FindStringSubmatch(m)
|
||||
// Keep the indentation and the declaration keyword; blank the rest so the
|
||||
// length is preserved.
|
||||
keep := g[1] + g[3]
|
||||
return keep + strings.Repeat(" ", len(m)-len(keep))
|
||||
})
|
||||
// `export` is meaningless without a module system, but generated code writes it, and
|
||||
// erasing the keyword leaves a declaration that behaves identically.
|
||||
out = blankMatches(out, mask, reExportList, whole, func([]string) bool { return true })
|
||||
out = blankMatches(out, mask, reExportDecl,
|
||||
// Keep the indentation and the declaration keyword; blank only "export" and an
|
||||
// optional "default" between them.
|
||||
func(m []int) (int, int) { return m[3], m[6] },
|
||||
func([]string) bool { return true })
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// replaceIndexed blanks every match for which keep reports true, preserving length.
|
||||
// A match the callback rejects is left in place, which lets the caller report it.
|
||||
func replaceIndexed(s string, re *regexp.Regexp, keep func(groups []string) bool) string {
|
||||
// blankMatches overwrites part of each match with spaces, skipping any match that does not
|
||||
// begin in code position and any the keep callback rejects. A rejected match is left in
|
||||
// place so the caller can report it.
|
||||
func blankMatches(
|
||||
s string, mask []bool, re *regexp.Regexp,
|
||||
span func(m []int) (from, to int), keep func(groups []string) bool,
|
||||
) string {
|
||||
matches := re.FindAllStringSubmatchIndex(s, -1)
|
||||
if len(matches) == 0 {
|
||||
return s
|
||||
}
|
||||
b := []byte(s)
|
||||
for _, m := range matches {
|
||||
if m[0] >= len(mask) || !mask[m[0]] {
|
||||
continue
|
||||
}
|
||||
groups := make([]string, len(m)/2)
|
||||
for i := range groups {
|
||||
if m[2*i] >= 0 {
|
||||
@@ -293,7 +335,8 @@ func replaceIndexed(s string, re *regexp.Regexp, keep func(groups []string) bool
|
||||
if !keep(groups) {
|
||||
continue
|
||||
}
|
||||
for i := m[0]; i < m[1]; i++ {
|
||||
from, to := span(m)
|
||||
for i := from; i < to && i < len(b); i++ {
|
||||
if b[i] != '\n' && b[i] != '\r' {
|
||||
b[i] = ' '
|
||||
}
|
||||
@@ -301,3 +344,94 @@ func replaceIndexed(s string, re *regexp.Regexp, keep func(groups []string) bool
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// codeMask marks the bytes of src that are program text rather than the inside of a
|
||||
// string, template literal or comment.
|
||||
//
|
||||
// One pass, tracking the nesting that matters: line and block comments, the three quote
|
||||
// forms with their escapes, and `${}` inside a template, which can hold arbitrary code
|
||||
// including another template.
|
||||
//
|
||||
// Regex literals are deliberately not tracked — telling `/` as division from `/` as the
|
||||
// start of a regex needs the parser. The consequence is bounded and in the safe direction:
|
||||
// a `//` sequence inside a regex literal reads as a comment, so the rest of that line is
|
||||
// marked non-code and simply escapes erasure. Less rewriting, never more.
|
||||
func codeMask(src string) []bool {
|
||||
mask := make([]bool, len(src))
|
||||
|
||||
// tplBraces records, for each enclosing ${} substitution, the brace depth it began at;
|
||||
// returning to that depth means the substitution closed and we are back in a template.
|
||||
var tplBraces []int
|
||||
braces, inTpl, i := 0, false, 0
|
||||
|
||||
for i < len(src) {
|
||||
c := src[i]
|
||||
|
||||
if inTpl {
|
||||
switch {
|
||||
case c == '\\' && i+1 < len(src):
|
||||
i += 2
|
||||
case c == '`':
|
||||
inTpl = false
|
||||
i++
|
||||
case c == '$' && i+1 < len(src) && src[i+1] == '{':
|
||||
tplBraces = append(tplBraces, braces)
|
||||
braces++
|
||||
inTpl = false
|
||||
i += 2
|
||||
default:
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch c {
|
||||
case '/':
|
||||
if i+1 < len(src) && src[i+1] == '/' {
|
||||
for i < len(src) && src[i] != '\n' {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if i+1 < len(src) && src[i+1] == '*' {
|
||||
i += 2
|
||||
for i+1 < len(src) && !(src[i] == '*' && src[i+1] == '/') {
|
||||
i++
|
||||
}
|
||||
i = min(i+2, len(src))
|
||||
continue
|
||||
}
|
||||
case '\'', '"':
|
||||
q := c
|
||||
i++
|
||||
for i < len(src) {
|
||||
if src[i] == '\\' {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if src[i] == q || src[i] == '\n' {
|
||||
i++
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
continue
|
||||
case '`':
|
||||
inTpl = true
|
||||
i++
|
||||
continue
|
||||
case '{':
|
||||
braces++
|
||||
case '}':
|
||||
braces--
|
||||
if n := len(tplBraces); n > 0 && braces == tplBraces[n-1] {
|
||||
tplBraces = tplBraces[:n-1]
|
||||
inTpl = true
|
||||
}
|
||||
}
|
||||
|
||||
mask[i] = true
|
||||
i++
|
||||
}
|
||||
return mask
|
||||
}
|
||||
+156
-67
@@ -60,6 +60,23 @@ type VM struct{}
|
||||
// New returns an in-process runtime.
|
||||
func New() *VM { return &VM{} }
|
||||
|
||||
// Validate reports whether a program could be loaded at all: the module preamble must be
|
||||
// erasable and the result must parse.
|
||||
//
|
||||
// It compiles without executing, which is what makes it safe to run in the parent process
|
||||
// at install time. Catching a syntax error while whoever submitted the package is still
|
||||
// looking at it beats surfacing it hours later as a trigger that quietly does nothing.
|
||||
func Validate(source string) error {
|
||||
prepared, err := Prepare(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := goja.Compile("automation.js", prepared, true); err != nil {
|
||||
return fmt.Errorf("syntax error: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
shimOnce sync.Once
|
||||
shimProg *goja.Program
|
||||
@@ -106,13 +123,37 @@ func (v *VM) Run(ctx context.Context, req Request, bridge HostBridge) (res Resul
|
||||
start := time.Now()
|
||||
sink := &logSink{maxBytes: lim.MaxLogBytes}
|
||||
|
||||
// Per-run counters. Declared before finish so that one place stamps them onto every
|
||||
// Result, success or not — on a failure they are most of what explains it, and
|
||||
// repeating them at each return is how one gets forgotten.
|
||||
var (
|
||||
calls, sendCalls int
|
||||
storageOps int
|
||||
callInBytes, callOutBytes int
|
||||
)
|
||||
|
||||
finish := func(r Result) (Result, error) {
|
||||
r.Logs = sink.lines
|
||||
r.LogsTruncated = sink.truncated
|
||||
r.Calls, r.SendCalls, r.StorageOps = calls, sendCalls, storageOps
|
||||
r.CallInputBytes, r.CallOutputBytes = callInBytes, callOutBytes
|
||||
r.DurationMs = time.Since(start).Milliseconds()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Last resort. Every known path that touches a script-controlled value is wrapped in
|
||||
// vm.Try, but this package's whole job is to survive whatever the guest does, and a
|
||||
// panic escaping into the caller would be the one failure mode it cannot report. In
|
||||
// the worker that means a signal and a lost run; in-process it would take the proxy.
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
res, err = finish(Result{
|
||||
Reason: ReasonRuntimeFailure,
|
||||
Err: fmt.Sprintf("the script runtime panicked: %v", rec),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
prepared, perr := Prepare(req.Source)
|
||||
if perr != nil {
|
||||
return finish(Result{Reason: ReasonRuntimeFailure, Err: perr.Error()})
|
||||
@@ -137,11 +178,9 @@ func (v *VM) Run(ctx context.Context, req Request, bridge HostBridge) (res Resul
|
||||
// Counters and the denial record, all touched only from the VM goroutine (host
|
||||
// functions run synchronously on it) except halted, which the watchdog writes.
|
||||
var (
|
||||
calls, sendCalls int
|
||||
callInBytes, callOutBytes int
|
||||
deniedCodes = map[string]bool{}
|
||||
halted halt
|
||||
haltedMu sync.Mutex
|
||||
deniedCodes = map[string]bool{}
|
||||
halted halt
|
||||
haltedMu sync.Mutex
|
||||
)
|
||||
setHalt := func(h halt) {
|
||||
haltedMu.Lock()
|
||||
@@ -230,9 +269,41 @@ func (v *VM) Run(ctx context.Context, req Request, bridge HostBridge) (res Resul
|
||||
return okEnvelope(out)
|
||||
}
|
||||
|
||||
// joro.storage. Present in the shim for every run; whether this run has a namespace
|
||||
// to store into is answered here, because only the host knows.
|
||||
storageFn := func(op, key, valueJSON string) string {
|
||||
sb, ok := bridge.(StorageBridge)
|
||||
if !ok {
|
||||
return errEnvelope("storage_unavailable", storageUnavailableMsg)
|
||||
}
|
||||
if h := readHalt(); h.reason != "" {
|
||||
return errEnvelope("halted", "the run has been stopped")
|
||||
}
|
||||
if storageOps >= maxStorageOps {
|
||||
setHalt(halt{reason: ReasonBudget, detail: fmt.Sprintf(
|
||||
"this run reached its limit of %d storage operations", maxStorageOps)})
|
||||
return errEnvelope("budget_exceeded", fmt.Sprintf(
|
||||
"this run reached its limit of %d storage operations", maxStorageOps))
|
||||
}
|
||||
storageOps++
|
||||
|
||||
out, serr := sb.Storage(ctx, op, key, json.RawMessage(valueJSON))
|
||||
if serr != nil {
|
||||
var ce *CallError
|
||||
if errors.As(serr, &ce) {
|
||||
return errEnvelope(ce.Code, ce.Msg)
|
||||
}
|
||||
return errEnvelope("storage_error", serr.Error())
|
||||
}
|
||||
return okEnvelope(out)
|
||||
}
|
||||
|
||||
if err := vm.Set("__joro_invoke", invoke); err != nil {
|
||||
return Result{}, fmt.Errorf("installing the host bridge: %w", err)
|
||||
}
|
||||
if err := vm.Set("__joro_storage", storageFn); err != nil {
|
||||
return Result{}, fmt.Errorf("installing the storage bridge: %w", err)
|
||||
}
|
||||
if err := vm.Set("__joro_log", func(level, text string) { sink.add(level, text) }); err != nil {
|
||||
return Result{}, fmt.Errorf("installing console capture: %w", err)
|
||||
}
|
||||
@@ -252,8 +323,11 @@ func (v *VM) Run(ctx context.Context, req Request, bridge HostBridge) (res Resul
|
||||
return Result{}, fmt.Errorf("compiling the entry hook: %w", eerr)
|
||||
}
|
||||
|
||||
// Watchdogs. Stopped before any post-run inspection so a late interrupt cannot
|
||||
// land on the VM while the result is being read out.
|
||||
// Watchdogs. Deliberately NOT stopped before the result is read out: rendering a
|
||||
// thrown value and exporting a returned one both run script code — a getter, a
|
||||
// toString, a toJSON — so those reads need the deadline and the memory sampler as
|
||||
// much as run() does. Every such read happens inside vm.Try, which turns a landing
|
||||
// interrupt into an error rather than a panic.
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() {
|
||||
@@ -291,95 +365,84 @@ func (v *VM) Run(ctx context.Context, req Request, bridge HostBridge) (res Resul
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
defer stopWatchdogs()
|
||||
|
||||
// Phase 1: run the script body, which defines the entry point.
|
||||
_, rerr := vm.RunProgram(userProg)
|
||||
if rerr == nil {
|
||||
_, rerr = vm.RunProgram(entryProg)
|
||||
}
|
||||
vm.GlobalObject().Delete("__joro_entry")
|
||||
_ = vm.Try(func() { vm.GlobalObject().Delete("__joro_entry") })
|
||||
|
||||
if rerr != nil {
|
||||
stopWatchdogs()
|
||||
return finish(v.failure(rerr, readHalt(), deniedCodes, calls, sendCalls, callInBytes, callOutBytes))
|
||||
return finish(failure(vm, rerr, readHalt(), deniedCodes))
|
||||
}
|
||||
|
||||
fn, ok := goja.AssertFunction(entry)
|
||||
if !ok {
|
||||
stopWatchdogs()
|
||||
return finish(Result{
|
||||
Reason: ReasonRuntimeFailure,
|
||||
Err: "no entry point: define `async function run(ctx) { ... }` at the top level. " +
|
||||
"The value returned from run() is the result of the automation.",
|
||||
Calls: calls, SendCalls: sendCalls,
|
||||
CallInputBytes: callInBytes, CallOutputBytes: callOutBytes,
|
||||
})
|
||||
}
|
||||
|
||||
// Phase 2: call it.
|
||||
ctxArg, cerr2 := buildRunContext(vm, req)
|
||||
if cerr2 != nil {
|
||||
stopWatchdogs()
|
||||
return finish(Result{Reason: ReasonRuntimeFailure, Err: cerr2.Error()})
|
||||
}
|
||||
|
||||
out, ferr := fn(goja.Undefined(), ctxArg)
|
||||
if ferr != nil {
|
||||
stopWatchdogs()
|
||||
return finish(v.failure(ferr, readHalt(), deniedCodes, calls, sendCalls, callInBytes, callOutBytes))
|
||||
return finish(failure(vm, ferr, readHalt(), deniedCodes))
|
||||
}
|
||||
|
||||
// An async function returns a promise. Every SDK call is synchronous, so a
|
||||
// promise whose awaits all resolve from SDK calls or settled values is already
|
||||
// settled by the time the call returns, and goja has drained its job queue.
|
||||
//
|
||||
// Export is inside Try because it is not a passive read: for a plain object it walks
|
||||
// own enumerable properties and invokes their accessors, which is script code.
|
||||
settled := out
|
||||
if p, isPromise := out.Export().(*goja.Promise); isPromise {
|
||||
switch p.State() {
|
||||
var promise *goja.Promise
|
||||
if ex := vm.Try(func() { promise, _ = out.Export().(*goja.Promise) }); ex != nil {
|
||||
return finish(failure(vm, ex, readHalt(), deniedCodes))
|
||||
}
|
||||
if promise != nil {
|
||||
switch promise.State() {
|
||||
case goja.PromiseStateFulfilled:
|
||||
settled = p.Result()
|
||||
settled = promise.Result()
|
||||
case goja.PromiseStateRejected:
|
||||
stopWatchdogs()
|
||||
// No Go error here: a rejected promise is not a goja error value, so the
|
||||
// rejection itself is what failure reports on.
|
||||
return finish(v.failure(
|
||||
nil, readHalt(), deniedCodes, calls, sendCalls, callInBytes, callOutBytes,
|
||||
withRejection(vm, p.Result())))
|
||||
return finish(failure(
|
||||
vm, nil, readHalt(), deniedCodes, withRejection(vm, promise.Result())))
|
||||
default:
|
||||
stopWatchdogs()
|
||||
return finish(Result{
|
||||
Reason: ReasonException,
|
||||
Err: "run() returned a promise that never settled. The sandbox has no timers, sockets " +
|
||||
"or filesystem, so every await must resolve from an SDK call or an already-settled " +
|
||||
"value — an await on `new Promise(...)` that nothing resolves will hang here.",
|
||||
Calls: calls, SendCalls: sendCalls,
|
||||
CallInputBytes: callInBytes, CallOutputBytes: callOutBytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
stopWatchdogs()
|
||||
|
||||
if h := readHalt(); h.reason != "" {
|
||||
return finish(Result{
|
||||
Reason: h.reason, Err: h.detail,
|
||||
Calls: calls, SendCalls: sendCalls,
|
||||
CallInputBytes: callInBytes, CallOutputBytes: callOutBytes,
|
||||
})
|
||||
}
|
||||
|
||||
value, verr := marshalResult(settled, lim.MaxResultBytes)
|
||||
value, verr := marshalResult(vm, settled, lim.MaxResultBytes)
|
||||
if verr != nil {
|
||||
return finish(Result{
|
||||
Reason: ReasonRuntimeFailure, Err: verr.Error(),
|
||||
Calls: calls, SendCalls: sendCalls,
|
||||
CallInputBytes: callInBytes, CallOutputBytes: callOutBytes,
|
||||
})
|
||||
}
|
||||
|
||||
return finish(Result{
|
||||
Reason: ReasonSuccess, Value: value,
|
||||
Calls: calls, SendCalls: sendCalls,
|
||||
CallInputBytes: callInBytes, CallOutputBytes: callOutBytes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -398,15 +461,10 @@ func withRejection(vm *goja.Runtime, v goja.Value) *rejection {
|
||||
// error: when a run is interrupted, goja surfaces an InterruptedError, but the script
|
||||
// may also have been mid-throw, and the operator needs to be told it was stopped
|
||||
// rather than that it failed.
|
||||
func (v *VM) failure(
|
||||
gerr error, h halt, deniedCodes map[string]bool,
|
||||
calls, sendCalls, inBytes, outBytes int,
|
||||
rej ...*rejection,
|
||||
) Result {
|
||||
res := Result{
|
||||
Calls: calls, SendCalls: sendCalls,
|
||||
CallInputBytes: inBytes, CallOutputBytes: outBytes,
|
||||
}
|
||||
// Counters are not passed in: finish stamps them onto whatever this returns. It takes the
|
||||
// runtime because rendering the thrown value runs script code and needs one.
|
||||
func failure(rt *goja.Runtime, gerr error, h halt, deniedCodes map[string]bool, rej ...*rejection) Result {
|
||||
var res Result
|
||||
|
||||
if h.reason != "" {
|
||||
res.Reason = h.reason
|
||||
@@ -443,7 +501,7 @@ func (v *VM) failure(
|
||||
return res
|
||||
}
|
||||
if exc, ok := gerr.(*goja.Exception); ok {
|
||||
text, code := describeThrown(nil, exc.Value())
|
||||
text, code := describeThrown(rt, exc.Value())
|
||||
if text == "" {
|
||||
text = trunc(exc.String(), maxErrDetail)
|
||||
}
|
||||
@@ -461,29 +519,42 @@ func (v *VM) failure(
|
||||
return res
|
||||
}
|
||||
|
||||
// describeThrown renders a thrown JavaScript value and extracts its code property, if
|
||||
// it has one. Reading named properties off an object is not the same as exporting it:
|
||||
// nothing goja-owned escapes into the host.
|
||||
func describeThrown(_ *goja.Runtime, v goja.Value) (text, code string) {
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
// describeThrown renders a thrown JavaScript value and extracts its code property, if it
|
||||
// has one.
|
||||
//
|
||||
// Every line of this touches script-controlled machinery, and none of it is a passive
|
||||
// read. ToObject on a primitive allocates a wrapper *through the runtime* — which is why
|
||||
// it must be given a real one rather than nil — and reading .name, .message, .code or
|
||||
// .stack invokes whatever accessor the script defined, as does String() on an object with
|
||||
// a toString. goja's own documentation says these panic with *Exception, so the whole body
|
||||
// runs inside Try and a failure to render reports a fixed string rather than propagating.
|
||||
func describeThrown(rt *goja.Runtime, v goja.Value) (text, code string) {
|
||||
if rt == nil || v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return "", ""
|
||||
}
|
||||
obj := v.ToObject(nil)
|
||||
if obj == nil {
|
||||
return trunc(v.String(), maxErrDetail), ""
|
||||
}
|
||||
get := func(k string) string {
|
||||
pv := obj.Get(k)
|
||||
if pv == nil || goja.IsUndefined(pv) || goja.IsNull(pv) {
|
||||
return ""
|
||||
|
||||
var name, msg, stack, raw string
|
||||
if ex := rt.Try(func() {
|
||||
raw = v.String()
|
||||
obj := v.ToObject(rt)
|
||||
if obj == nil {
|
||||
return
|
||||
}
|
||||
return pv.String()
|
||||
get := func(k string) string {
|
||||
pv := obj.Get(k)
|
||||
if pv == nil || goja.IsUndefined(pv) || goja.IsNull(pv) {
|
||||
return ""
|
||||
}
|
||||
return pv.String()
|
||||
}
|
||||
name, msg, code, stack = get("name"), get("message"), get("code"), get("stack")
|
||||
}); ex != nil {
|
||||
return "the thrown value could not be rendered: reading it threw", ""
|
||||
}
|
||||
name, msg := get("name"), get("message")
|
||||
code = get("code")
|
||||
|
||||
if name == "" && msg == "" && code == "" {
|
||||
// Not an Error at all — a thrown string, number or bare object.
|
||||
return trunc(v.String(), maxErrDetail), ""
|
||||
return trunc(raw, maxErrDetail), ""
|
||||
}
|
||||
|
||||
header := orDefault(name, "Error")
|
||||
@@ -498,7 +569,7 @@ func describeThrown(_ *goja.Runtime, v goja.Value) (text, code string) {
|
||||
// goja's stack property already begins with "Name: message", so splicing the code
|
||||
// into that first line reads as one error with a trace, where concatenating would
|
||||
// print the message twice.
|
||||
if st := get("stack"); st != "" {
|
||||
if st := stack; st != "" {
|
||||
if rest, ok := strings.CutPrefix(st, header); ok {
|
||||
return trunc(full+rest, maxErrDetail), code
|
||||
}
|
||||
@@ -520,10 +591,22 @@ func buildRunContext(vm *goja.Runtime, req Request) (goja.Value, error) {
|
||||
}
|
||||
|
||||
m := req.Meta
|
||||
|
||||
// The trigger payload is merged beside the type rather than nested under a "data"
|
||||
// key, so a script reads ctx.trigger.requests instead of ctx.trigger.data.requests.
|
||||
// "type" is written last so a payload cannot shadow it.
|
||||
trigger := map[string]any{}
|
||||
if len(m.TriggerData) > 0 {
|
||||
if err := json.Unmarshal(m.TriggerData, &trigger); err != nil {
|
||||
return nil, fmt.Errorf("trigger payload is not a JSON object: %v", err)
|
||||
}
|
||||
}
|
||||
trigger["type"] = orDefault(m.TriggerType, "manual")
|
||||
|
||||
run := map[string]any{"id": m.RunID, "startedAt": m.StartedAt}
|
||||
ctxObj := map[string]any{
|
||||
"run": run,
|
||||
"trigger": map[string]any{"type": orDefault(m.TriggerType, "manual")},
|
||||
"trigger": trigger,
|
||||
"input": input,
|
||||
}
|
||||
if m.AutomationID != "" {
|
||||
@@ -533,11 +616,17 @@ func buildRunContext(vm *goja.Runtime, req Request) (goja.Value, error) {
|
||||
}
|
||||
|
||||
// marshalResult serializes run()'s return value under the result ceiling.
|
||||
func marshalResult(v goja.Value, maxBytes int) (json.RawMessage, error) {
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
func marshalResult(rt *goja.Runtime, v goja.Value, maxBytes int) (json.RawMessage, error) {
|
||||
if rt == nil || v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return nil, nil
|
||||
}
|
||||
exported := v.Export()
|
||||
// Export walks own enumerable properties and invokes their accessors, so a returned
|
||||
// object with a throwing or looping getter runs script code here. Inside Try, that
|
||||
// surfaces as a reportable error instead of taking the process down.
|
||||
var exported any
|
||||
if ex := rt.Try(func() { exported = v.Export() }); ex != nil {
|
||||
return nil, fmt.Errorf("the value returned from run() could not be read: %s", trunc(ex.Error(), 512))
|
||||
}
|
||||
// A function, or an object holding one, has no JSON form. Reporting that
|
||||
// plainly beats the encoder's "unsupported type" against an internal type name.
|
||||
encoded, err := json.Marshal(exported)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"os/exec"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -55,6 +56,7 @@ const (
|
||||
type frame struct {
|
||||
Job *Request `json:"job,omitempty"`
|
||||
Call *callFrame `json:"call,omitempty"`
|
||||
Store *storeFrame `json:"store,omitempty"`
|
||||
Reply *replyFrame `json:"reply,omitempty"`
|
||||
Done *Result `json:"done,omitempty"`
|
||||
// Fatal reports that the worker could not run at all. Distinct from a Result
|
||||
@@ -68,6 +70,17 @@ type callFrame struct {
|
||||
Args json.RawMessage `json:"args"`
|
||||
}
|
||||
|
||||
// storeFrame is a joro.storage operation. A separate frame from callFrame because it is
|
||||
// not a capability invocation — no ID to authorize, no scope guard, no audit entry — and
|
||||
// collapsing the two would invite a handler that treats a storage key as a capability
|
||||
// name.
|
||||
type storeFrame struct {
|
||||
ID int `json:"id"`
|
||||
Op string `json:"op"`
|
||||
Key string `json:"key"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
type replyFrame struct {
|
||||
ID int `json:"id"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
@@ -242,6 +255,35 @@ func (w *WorkerRuntime) Run(ctx context.Context, req Request, bridge HostBridge)
|
||||
continue
|
||||
}
|
||||
|
||||
case f.Store != nil:
|
||||
// Storage availability is decided here, not in the child: the child's
|
||||
// bridge is the pipe and always implements the interface, so only the
|
||||
// parent knows whether this run actually has a namespace.
|
||||
rep := replyFrame{ID: f.Store.ID}
|
||||
sb, ok := bridge.(StorageBridge)
|
||||
switch {
|
||||
case !ok:
|
||||
rep.Failed = true
|
||||
rep.Code = "storage_unavailable"
|
||||
rep.Msg = storageUnavailableMsg
|
||||
default:
|
||||
data, serr := sb.Storage(runCtx, f.Store.Op, f.Store.Key, f.Store.Value)
|
||||
if serr != nil {
|
||||
rep.Failed = true
|
||||
var ce *CallError
|
||||
if errors.As(serr, &ce) {
|
||||
rep.Code, rep.Msg = ce.Code, ce.Msg
|
||||
} else {
|
||||
rep.Code, rep.Msg = "storage_error", serr.Error()
|
||||
}
|
||||
} else {
|
||||
rep.Data = data
|
||||
}
|
||||
}
|
||||
if err := enc.Encode(frame{Reply: &rep}); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
default:
|
||||
_ = wait()
|
||||
return Result{}, errors.New("script worker sent an empty frame")
|
||||
@@ -250,10 +292,21 @@ func (w *WorkerRuntime) Run(ctx context.Context, req Request, bridge HostBridge)
|
||||
}
|
||||
|
||||
func workerLostDetail(stderr string) string {
|
||||
s := string(bytes.TrimSpace([]byte(stderr)))
|
||||
|
||||
// Distinguish the two ways a worker vanishes, because they call for opposite
|
||||
// responses. Reclaimed-for-memory is the design working; a Go panic is a bug in the
|
||||
// runtime, and reporting it as containment would guarantee nobody ever looks at it.
|
||||
if strings.Contains(s, "panic:") || strings.Contains(s, "runtime error:") {
|
||||
return "the script worker crashed rather than reporting a result. This is a defect in " +
|
||||
"Joro's script runtime, not a limit being enforced — please report it.\nworker stderr: " +
|
||||
trunc(s, maxWorkerStderr)
|
||||
}
|
||||
|
||||
base := "the script worker exited without reporting a result. The usual cause is the " +
|
||||
"operating system reclaiming it for memory, which is the containment working"
|
||||
if s := bytes.TrimSpace([]byte(stderr)); len(s) > 0 {
|
||||
return base + "\nworker stderr: " + trunc(string(s), maxWorkerStderr)
|
||||
if len(s) > 0 {
|
||||
return base + "\nworker stderr: " + trunc(s, maxWorkerStderr)
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -287,10 +340,19 @@ func (b *lockedBuffer) String() string {
|
||||
//
|
||||
// The host's main should call this and exit. It never returns a Result — the result
|
||||
// goes on the wire — only an error that could not be reported that way.
|
||||
func RunWorker(ctx context.Context, in io.Reader, out io.Writer) error {
|
||||
func RunWorker(ctx context.Context, in io.Reader, out io.Writer) (err error) {
|
||||
dec := json.NewDecoder(io.LimitReader(in, maxWireBytes))
|
||||
enc := json.NewEncoder(out)
|
||||
|
||||
// A panic here would exit the worker on a signal, and the parent can only report
|
||||
// that as "the worker vanished" — losing the run's logs, counters and reason. Turn it
|
||||
// into a frame the parent can attribute instead.
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
err = enc.Encode(frame{Fatal: fmt.Sprintf("the script worker panicked: %v", rec)})
|
||||
}
|
||||
}()
|
||||
|
||||
var f frame
|
||||
if err := dec.Decode(&f); err != nil {
|
||||
return fmt.Errorf("reading the job: %w", err)
|
||||
@@ -335,23 +397,41 @@ type pipeBridge struct {
|
||||
}
|
||||
|
||||
func (b *pipeBridge) Invoke(_ context.Context, id string, args json.RawMessage) (json.RawMessage, error) {
|
||||
return b.roundTrip(func(reqID int) frame {
|
||||
return frame{Call: &callFrame{ID: reqID, Cap: id, Args: args}}
|
||||
})
|
||||
}
|
||||
|
||||
// Storage forwards a joro.storage operation. Same request/response discipline as a
|
||||
// capability call, and the same id sequence, so the two can never be confused for each
|
||||
// other on the wire.
|
||||
func (b *pipeBridge) Storage(_ context.Context, op, key string, value json.RawMessage) (json.RawMessage, error) {
|
||||
return b.roundTrip(func(reqID int) frame {
|
||||
return frame{Store: &storeFrame{ID: reqID, Op: op, Key: key, Value: value}}
|
||||
})
|
||||
}
|
||||
|
||||
// roundTrip sends one frame and waits for its reply. Strict alternation is safe because
|
||||
// every SDK call is synchronous inside the VM, so two requests are never in flight.
|
||||
func (b *pipeBridge) roundTrip(build func(reqID int) frame) (json.RawMessage, error) {
|
||||
unreachable := &CallError{Code: "handler_error", Msg: "the host is no longer reachable"}
|
||||
if b.broken != nil {
|
||||
return nil, &CallError{Code: "handler_error", Msg: "the host is no longer reachable"}
|
||||
return nil, unreachable
|
||||
}
|
||||
b.nextID++
|
||||
callID := b.nextID
|
||||
reqID := b.nextID
|
||||
|
||||
if err := b.enc.Encode(frame{Call: &callFrame{ID: callID, Cap: id, Args: args}}); err != nil {
|
||||
b.broken = fmt.Errorf("sending a capability call: %w", err)
|
||||
return nil, &CallError{Code: "handler_error", Msg: "the host is no longer reachable"}
|
||||
if err := b.enc.Encode(build(reqID)); err != nil {
|
||||
b.broken = fmt.Errorf("sending a host request: %w", err)
|
||||
return nil, unreachable
|
||||
}
|
||||
|
||||
var f frame
|
||||
if err := b.dec.Decode(&f); err != nil {
|
||||
b.broken = fmt.Errorf("reading a capability reply: %w", err)
|
||||
return nil, &CallError{Code: "handler_error", Msg: "the host is no longer reachable"}
|
||||
b.broken = fmt.Errorf("reading a host reply: %w", err)
|
||||
return nil, unreachable
|
||||
}
|
||||
if f.Reply == nil || f.Reply.ID != callID {
|
||||
if f.Reply == nil || f.Reply.ID != reqID {
|
||||
b.broken = errors.New("host replied out of sequence")
|
||||
return nil, &CallError{Code: "handler_error", Msg: "the host replied out of sequence"}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,16 @@ import AutomationTokenModal from './AutomationTokenModal'
|
||||
import AutomationSecretModal from './AutomationSecretModal'
|
||||
import AutomationActivity from './AutomationActivity'
|
||||
import ScriptRuns from './ScriptRuns'
|
||||
import ScriptsPanel from './automation/ScriptsPanel'
|
||||
import ConfirmModal from './ConfirmModal'
|
||||
|
||||
const inputCls = 'bg-surface-input text-xs px-2 py-1 rounded-sm border border-border'
|
||||
|
||||
// Three surfaces that share a subject and nothing else: what a client may do, what code
|
||||
// is installed, and what has happened. Sub-tabs rather than one long pane, mirroring
|
||||
// Settings -> Plugins — and the editor needs the full pane height regardless.
|
||||
type SubTab = 'tokens' | 'scripts' | 'activity'
|
||||
|
||||
export default function AutomationSettings() {
|
||||
const { tokens, capabilities, profiles, classes, mcp, available, refresh, create, update, rotate, setEnabled, review, revoke, setMcp } =
|
||||
useAutomationStore()
|
||||
@@ -30,6 +36,7 @@ export default function AutomationSettings() {
|
||||
const [secret, setSecret] = useState<{ value: string; name: string } | null>(null)
|
||||
const [confirm, setConfirm] = useState<{ message: string; action: () => Promise<void> } | null>(null)
|
||||
const [port, setPort] = useState(0)
|
||||
const [subTab, setSubTab] = useState<SubTab>('tokens')
|
||||
const [scopeReady, setScopeReady] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -57,7 +64,7 @@ export default function AutomationSettings() {
|
||||
|
||||
if (available === false) {
|
||||
return (
|
||||
<div>
|
||||
<div className="p-5">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide mb-3">Automation</h2>
|
||||
<p className="text-content-muted text-xs">
|
||||
Automation is disabled for this run (<code className="font-mono">--no-automation</code>). Restart Joro without
|
||||
@@ -83,10 +90,42 @@ export default function AutomationSettings() {
|
||||
setSecret({ value: s, name: body.name ?? 'token' })
|
||||
}
|
||||
|
||||
const tab = (id: SubTab, label: string) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setSubTab(id)}
|
||||
className={`px-3 py-1.5 text-xs font-semibold rounded-t-sm border-b-2 transition-colors ${
|
||||
subTab === id
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-content-secondary hover:text-content-primary'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex gap-1 px-3 pt-2 pb-0 bg-surface-card border-b border-border shrink-0">
|
||||
{tab('tokens', 'Tokens')}
|
||||
{tab('scripts', 'Automations')}
|
||||
{tab('activity', 'Activity')}
|
||||
</div>
|
||||
|
||||
{subTab === 'scripts' && <ScriptsPanel />}
|
||||
|
||||
{subTab === 'activity' && (
|
||||
<div className="flex-1 overflow-auto p-5 space-y-4">
|
||||
<AutomationActivity />
|
||||
<ScriptRuns />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The tokens tab. Modals live outside it so switching tabs cannot unmount one. */}
|
||||
{subTab === 'tokens' && (
|
||||
<div className="flex-1 overflow-auto p-5 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide">Automation</h2>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide">Tokens</h2>
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="bg-accent-tertiary text-black text-xs font-semibold px-2.5 py-1 rounded-sm inline-flex items-center gap-1.5"
|
||||
@@ -302,9 +341,8 @@ export default function AutomationSettings() {
|
||||
in History, where Match & Replace and Custom Data rules apply to them like any other request.
|
||||
</p>
|
||||
|
||||
<AutomationActivity />
|
||||
|
||||
<ScriptRuns />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creating && (
|
||||
<AutomationTokenModal
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { Download, Play, Save, X } from 'lucide-react'
|
||||
import { api, type AutomationLimits, type AutomationManifest, type ScriptRun } from '../../lib/api'
|
||||
import { downloadPackage } from '../../lib/automationPackage'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import SdkReference from './SdkReference'
|
||||
|
||||
const inputCls = 'bg-surface-input text-xs px-2 py-1 rounded-sm border border-border w-full'
|
||||
|
||||
const STARTER = `async function run(ctx) {
|
||||
// ctx.trigger tells you why this ran; ctx.input carries anything passed in.
|
||||
// joro.* is the whole SDK — see the reference on the right.
|
||||
const recent = await joro.history.list({ limit: 10 });
|
||||
console.log(recent);
|
||||
return { looked: true };
|
||||
}
|
||||
`
|
||||
|
||||
export interface EditorDraft {
|
||||
manifest: AutomationManifest
|
||||
source: string
|
||||
}
|
||||
|
||||
function blankManifest(): AutomationManifest {
|
||||
return { id: '', name: '', version: '1.0.0', sdkVersion: '1', triggers: ['manual'] }
|
||||
}
|
||||
|
||||
/**
|
||||
* The authoring surface for one automation.
|
||||
*
|
||||
* Inline in the sub-tab rather than in a modal: a code editor wants the full pane height,
|
||||
* and index.css sets `.cm-editor { height: 100% !important }` globally, so an editor only
|
||||
* works inside a parent whose height actually resolves.
|
||||
*/
|
||||
export default function ScriptEditor({
|
||||
id,
|
||||
draft,
|
||||
triggers,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
/** Editing an installed automation, or undefined for a new one. */
|
||||
id?: string
|
||||
/** Seed for a new automation, or one imported from a file. */
|
||||
draft?: EditorDraft
|
||||
triggers: string[]
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const addToast = useToastStore((s) => s.addToast)
|
||||
|
||||
const [manifest, setManifest] = useState<AutomationManifest>(draft?.manifest ?? blankManifest())
|
||||
const [source, setSource] = useState(draft?.source ?? STARTER)
|
||||
// The hash the source was loaded at. Sent back on update so replacing the code of an
|
||||
// armed automation cannot silently clobber a concurrent edit.
|
||||
const [baseHash, setBaseHash] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [run, setRun] = useState<ScriptRun | null>(null)
|
||||
const [input, setInput] = useState('{}')
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
let cancelled = false
|
||||
api
|
||||
.getScript(id)
|
||||
.then((pkg) => {
|
||||
if (cancelled) return
|
||||
setManifest(pkg.manifest)
|
||||
setSource(pkg.source ?? '')
|
||||
setBaseHash(pkg.sourceHash)
|
||||
})
|
||||
.catch((e) => addToast(String(e instanceof Error ? e.message : e), 'error'))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id, addToast])
|
||||
|
||||
const extensions = useMemo(() => [javascript(), EditorView.lineWrapping], [])
|
||||
|
||||
const patch = (p: Partial<AutomationManifest>) => setManifest((m) => ({ ...m, ...p }))
|
||||
const patchLimits = (p: Partial<AutomationLimits>) =>
|
||||
setManifest((m) => ({ ...m, limits: { ...m.limits, ...p } }))
|
||||
|
||||
const guard = useCallback(
|
||||
async (fn: () => Promise<unknown>, ok?: string) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await fn()
|
||||
if (ok) addToast(ok, 'info')
|
||||
return true
|
||||
} catch (e) {
|
||||
addToast(String(e instanceof Error ? e.message : e), 'error')
|
||||
return false
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[addToast]
|
||||
)
|
||||
|
||||
const save = async () => {
|
||||
const ok = await guard(async () => {
|
||||
if (id) {
|
||||
const pkg = await api.updateScript(id, manifest, source, baseHash)
|
||||
setBaseHash(pkg.sourceHash)
|
||||
} else {
|
||||
const pkg = await api.installScript(manifest, source)
|
||||
setBaseHash(pkg.sourceHash)
|
||||
}
|
||||
}, id ? 'Saved' : 'Installed (disabled until you enable it)')
|
||||
if (ok) onSaved()
|
||||
}
|
||||
|
||||
const runNow = async () => {
|
||||
let parsed: unknown = {}
|
||||
if (input.trim()) {
|
||||
try {
|
||||
parsed = JSON.parse(input)
|
||||
} catch {
|
||||
addToast('Test input is not valid JSON', 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
await guard(async () => {
|
||||
// Runs the buffer, not what is on disk: reviewing means running a draft before
|
||||
// committing to it.
|
||||
setRun(await api.runScript({ source, input: parsed }))
|
||||
})
|
||||
}
|
||||
|
||||
const toggleTrigger = (t: string) => {
|
||||
const cur = manifest.triggers ?? []
|
||||
patch({ triggers: cur.includes(t) ? cur.filter((x) => x !== t) : [...cur, t] })
|
||||
}
|
||||
|
||||
const canSave = manifest.id.trim() !== '' && source.trim() !== ''
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-xs font-semibold text-content-primary truncate">
|
||||
{id ? manifest.name || id : 'New automation'}
|
||||
</h3>
|
||||
{baseHash && (
|
||||
<p className="text-[10px] text-content-muted font-mono">sha256:{baseHash.slice(0, 16)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={runNow}
|
||||
disabled={busy || !source.trim()}
|
||||
className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-sm bg-accent-tertiary hover:bg-accent-tertiary-hover text-black font-semibold disabled:opacity-40"
|
||||
>
|
||||
<Play size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||
Run
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={busy || !canSave}
|
||||
className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-sm bg-accent-secondary hover:bg-accent-secondary-hover text-black font-semibold disabled:opacity-40"
|
||||
>
|
||||
<Save size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||
{id ? 'Save' : 'Install'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadPackage(manifest, source)}
|
||||
disabled={!canSave}
|
||||
className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary disabled:opacity-40"
|
||||
title="Export as a .jauto package"
|
||||
>
|
||||
<Download size={11} strokeWidth={2} aria-hidden="true" />
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-content-muted hover:text-content-primary"
|
||||
aria-label="Close editor"
|
||||
>
|
||||
<X size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Editor. The height chain is load-bearing: flex-1 relative min-h-0, then an
|
||||
absolutely positioned child, because .cm-editor is height:100% !important. */}
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<CodeMirror
|
||||
value={source}
|
||||
theme={oneDark}
|
||||
height="100%"
|
||||
onChange={setSource}
|
||||
extensions={extensions}
|
||||
basicSetup={{ lineNumbers: true, foldGutter: true }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-72 shrink-0 border-l border-border overflow-y-auto p-3 space-y-3">
|
||||
<Field label="Id">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={manifest.id}
|
||||
disabled={!!id}
|
||||
placeholder="idor-check"
|
||||
onChange={(e) => patch({ id: e.target.value })}
|
||||
/>
|
||||
{!id && (
|
||||
<p className="text-[10px] text-content-muted mt-0.5">
|
||||
Lowercase letters, digits, hyphen, underscore. Permanent.
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Name">
|
||||
<input className={inputCls} value={manifest.name} onChange={(e) => patch({ name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Version">
|
||||
<input
|
||||
className={inputCls}
|
||||
value={manifest.version}
|
||||
onChange={(e) => patch({ version: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Description">
|
||||
<textarea
|
||||
className={`${inputCls} h-14 resize-none`}
|
||||
value={manifest.description ?? ''}
|
||||
onChange={(e) => patch({ description: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Triggers">
|
||||
<div className="space-y-0.5">
|
||||
{triggers.map((t) => (
|
||||
<label key={t} className="flex items-center gap-1.5 text-[11px] text-content-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(manifest.triggers ?? []).includes(t)}
|
||||
onChange={() => toggleTrigger(t)}
|
||||
/>
|
||||
<code className="font-mono">{t}</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{(manifest.triggers ?? []).includes('request.captured') && (
|
||||
<p className="text-[10px] text-semantic-warning mt-1 leading-snug">
|
||||
A traffic-triggered automation that sends requests skips the traffic its own run
|
||||
produced, so it cannot trigger itself — but it will also miss whatever else was
|
||||
captured during that run.
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Minimum interval (ms)">
|
||||
<input
|
||||
type="number"
|
||||
className={inputCls}
|
||||
value={manifest.minIntervalMs ?? ''}
|
||||
placeholder="1000"
|
||||
onChange={(e) => patch({ minIntervalMs: Number(e.target.value) || undefined })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Limits">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<LimitBox label="timeout ms" value={manifest.limits?.timeoutMs} onChange={(v) => patchLimits({ timeoutMs: v })} />
|
||||
<LimitBox label="memory MB" value={manifest.limits?.memoryMb} onChange={(v) => patchLimits({ memoryMb: v })} />
|
||||
<LimitBox label="max calls" value={manifest.limits?.maxCalls} onChange={(v) => patchLimits({ maxCalls: v })} />
|
||||
<LimitBox label="max sends" value={manifest.limits?.maxSendCalls} onChange={(v) => patchLimits({ maxSendCalls: v })} />
|
||||
</div>
|
||||
<p className="text-[10px] text-content-muted mt-0.5">
|
||||
Blank takes the default. The operator can lower these further; nothing here can
|
||||
raise a limit.
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<Field label="Test input (JSON)">
|
||||
<textarea
|
||||
className={`${inputCls} h-14 resize-none font-mono`}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SdkReference />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{run && <RunOutput run={run} onClose={() => setRun(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[10px] font-semibold text-content-muted uppercase tracking-wide mb-1">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LimitBox({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value?: number
|
||||
onChange: (v: number | undefined) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-[10px] text-content-muted">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
className={inputCls}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(Number(e.target.value) || undefined)}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/** The run report: how it ended, what it logged, what it returned. */
|
||||
function RunOutput({ run, onClose }: { run: ScriptRun; onClose: () => void }) {
|
||||
const r = run.result
|
||||
const bad = r.reason !== 'success'
|
||||
return (
|
||||
<div className="shrink-0 border-t border-border max-h-56 overflow-y-auto">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 sticky top-0 bg-surface-card border-b border-border-subtle">
|
||||
<span className={`text-[11px] font-semibold ${bad ? 'text-semantic-warning' : 'text-semantic-success'}`}>
|
||||
{r.reason}
|
||||
</span>
|
||||
<span className="text-[10px] text-content-muted font-mono">
|
||||
{run.durationMs}ms · {r.calls} call{r.calls === 1 ? '' : 's'}
|
||||
{r.sendCalls > 0 ? ` (${r.sendCalls} sending)` : ''}
|
||||
{r.storageOps ? ` · ${r.storageOps} storage` : ''}
|
||||
</span>
|
||||
<button onClick={onClose} className="ml-auto text-content-muted hover:text-content-primary" aria-label="Dismiss">
|
||||
<X size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3 space-y-2 text-[11px]">
|
||||
{r.err && <pre className="font-mono whitespace-pre-wrap text-semantic-error">{r.err}</pre>}
|
||||
{r.logs && r.logs.length > 0 && (
|
||||
<div className="font-mono bg-surface-input rounded p-2">
|
||||
{r.logs.map((l, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={
|
||||
l.level === 'error'
|
||||
? 'text-semantic-error'
|
||||
: l.level === 'warn'
|
||||
? 'text-semantic-warning'
|
||||
: 'text-content-secondary'
|
||||
}
|
||||
>
|
||||
{l.text}
|
||||
</div>
|
||||
))}
|
||||
{r.logsTruncated && <div className="text-content-muted">… log budget reached</div>}
|
||||
</div>
|
||||
)}
|
||||
{r.value !== undefined && r.value !== null && (
|
||||
<pre className="font-mono whitespace-pre-wrap bg-surface-input rounded p-2 overflow-x-auto">
|
||||
{JSON.stringify(r.value, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Download, Pause, Play, Plus, Trash2, Upload } from 'lucide-react'
|
||||
import { api, type AutomationSummary } from '../../lib/api'
|
||||
import { downloadPackage, pickPackage } from '../../lib/automationPackage'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import ConfirmModal from '../ConfirmModal'
|
||||
import ScriptEditor, { type EditorDraft } from './ScriptEditor'
|
||||
|
||||
/**
|
||||
* Installed automations: the list, and the editor it opens.
|
||||
*
|
||||
* Two levels of availability, and they mean different things to whoever is looking:
|
||||
* automation can be off entirely (--no-automation) or on with scripting off (no
|
||||
* --automation-scripting). Saying which is missing is the difference between a fixable
|
||||
* situation and a mystery.
|
||||
*/
|
||||
export default function ScriptsPanel() {
|
||||
const addToast = useToastStore((s) => s.addToast)
|
||||
const [scripts, setScripts] = useState<AutomationSummary[]>([])
|
||||
const [triggers, setTriggers] = useState<string[]>([])
|
||||
const [unavailable, setUnavailable] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<{ id?: string; draft?: EditorDraft } | null>(null)
|
||||
const [confirm, setConfirm] = useState<{ id: string } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const d = await api.listScripts()
|
||||
setScripts(d.scripts ?? [])
|
||||
setTriggers(d.triggers ?? [])
|
||||
setUnavailable(null)
|
||||
} catch (e) {
|
||||
setUnavailable(String(e instanceof Error ? e.message : e))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const guard = async (fn: () => Promise<unknown>, ok?: string) => {
|
||||
try {
|
||||
await fn()
|
||||
if (ok) addToast(ok, 'info')
|
||||
await load()
|
||||
} catch (e) {
|
||||
addToast(String(e instanceof Error ? e.message : e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const importFile = async () => {
|
||||
try {
|
||||
const bundle = await pickPackage()
|
||||
if (!bundle) return
|
||||
// Opened in the editor rather than installed straight away: importing someone
|
||||
// else's automation is exactly when reading it first matters.
|
||||
setEditing({ draft: { manifest: bundle.manifest, source: bundle.source } })
|
||||
} catch (e) {
|
||||
addToast(String(e instanceof Error ? e.message : e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const exportOne = async (id: string) => {
|
||||
try {
|
||||
const pkg = await api.getScript(id)
|
||||
await downloadPackage(pkg.manifest, pkg.source ?? '')
|
||||
} catch (e) {
|
||||
addToast(String(e instanceof Error ? e.message : e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<ScriptEditor
|
||||
id={editing.id}
|
||||
draft={editing.draft}
|
||||
triggers={triggers}
|
||||
onClose={() => {
|
||||
setEditing(null)
|
||||
load()
|
||||
}}
|
||||
onSaved={load}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<div className="flex-1 overflow-auto p-5">
|
||||
<h3 className="text-sm font-semibold text-content-primary mb-2">Automations</h3>
|
||||
<p className="text-[11px] text-content-secondary leading-relaxed max-w-xl">
|
||||
{unavailable.includes('--automation-scripting') ? (
|
||||
<>
|
||||
Script automation is off. Start Joro with{' '}
|
||||
<code className="font-mono text-content-primary">--automation-scripting</code> to install
|
||||
JavaScript automations and expose <code className="font-mono">script_run</code>,{' '}
|
||||
<code className="font-mono">script_list</code> and{' '}
|
||||
<code className="font-mono">script_invoke</code> to a granted token. Automations run in a
|
||||
sandboxed worker process against Joro’s automation SDK.
|
||||
</>
|
||||
) : (
|
||||
<>Automation is disabled on this instance ({unavailable}).</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 overflow-auto p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content-primary">Automations</h3>
|
||||
<p className="text-[11px] text-content-muted">
|
||||
Reviewed JavaScript that runs against the standard automation SDK — by hand, on an
|
||||
event, or when an agent asks for it by id.
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={importFile}
|
||||
className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-sm bg-surface-input hover:bg-surface-hover text-content-secondary"
|
||||
>
|
||||
<Upload size={11} strokeWidth={2} aria-hidden="true" />
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditing({})}
|
||||
className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-sm bg-accent-secondary hover:bg-accent-secondary-hover text-black font-semibold"
|
||||
>
|
||||
<Plus size={11} strokeWidth={2.4} aria-hidden="true" />
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scripts.length === 0 ? (
|
||||
<p className="text-[11px] text-content-muted italic py-6 text-center">
|
||||
No automations installed. New writes one from a starter; Import reads a .jauto package.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="text-content-muted uppercase tracking-wide text-[10px] text-left">
|
||||
<th className="pb-1 font-semibold">Automation</th>
|
||||
<th className="pb-1 font-semibold">Armed for</th>
|
||||
<th className="pb-1 font-semibold">Last run</th>
|
||||
<th className="pb-1 font-semibold text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scripts.map((s) => (
|
||||
<tr key={s.id} className="border-t border-border-subtle align-top">
|
||||
<td className="py-1.5 pr-2">
|
||||
<button
|
||||
onClick={() => setEditing({ id: s.id })}
|
||||
className="text-content-primary hover:text-accent-secondary font-semibold text-left"
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
<div className="text-content-muted font-mono text-[10px]">
|
||||
{s.id} v{s.version} · sha256:{s.sourceHash.slice(0, 12)}
|
||||
{s.revisions > 1 ? ` · ${s.revisions} revisions` : ''}
|
||||
</div>
|
||||
{s.paused && (
|
||||
<div className="text-semantic-warning text-[10px] mt-0.5 max-w-md leading-snug">
|
||||
{s.pausedReason}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 font-mono text-[10px] text-content-secondary">
|
||||
{s.armed.length > 0 ? (
|
||||
s.armed.join(', ')
|
||||
) : (
|
||||
<span className="text-content-muted">
|
||||
{s.enabled ? 'manual only' : 'disabled'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-content-secondary">
|
||||
{s.lastRun ? (
|
||||
<>
|
||||
<span className={s.lastRun.reason === 'success' ? '' : 'text-semantic-warning'}>
|
||||
{s.lastRun.reason}
|
||||
</span>
|
||||
<div className="text-content-muted text-[10px]">
|
||||
{new Date(s.lastRun.at).toLocaleString()}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-content-muted">never</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => guard(() => api.runScript({ scriptId: s.id }), `Ran ${s.id}`)}
|
||||
className="text-content-muted hover:text-accent-tertiary px-1"
|
||||
title="Run once now (allowed even when disabled)"
|
||||
>
|
||||
<Play size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
guard(
|
||||
() => api.setScriptEnabled(s.id, !s.enabled),
|
||||
s.enabled ? `Disabled ${s.id}` : `Enabled ${s.id}`
|
||||
)
|
||||
}
|
||||
className={`px-1 ${s.enabled ? 'text-semantic-success' : 'text-content-muted'} hover:text-accent`}
|
||||
title={s.enabled ? 'Disable' : s.paused ? 'Enable (clears the pause)' : 'Enable'}
|
||||
>
|
||||
{s.enabled ? <Pause size={13} strokeWidth={2} /> : <Play size={13} strokeWidth={2} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportOne(s.id)}
|
||||
className="text-content-muted hover:text-content-primary px-1"
|
||||
title="Export as .jauto"
|
||||
>
|
||||
<Download size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirm({ id: s.id })}
|
||||
className="text-content-muted hover:text-semantic-error px-1"
|
||||
title="Uninstall"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-content-muted leading-relaxed max-w-2xl pt-1">
|
||||
An automation is installed disabled. Enabling it arms its event triggers and lets a token
|
||||
holding <code className="font-mono">script_invoke</code> run it; you can run a disabled one
|
||||
from here at any time. Code lives in{' '}
|
||||
<code className="font-mono">~/.joro/automations/</code> and never travels inside a project
|
||||
config — what an automation stores with{' '}
|
||||
<code className="font-mono">joro.storage</code> does, because that describes one engagement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{confirm && (
|
||||
<ConfirmModal
|
||||
title="Uninstall automation"
|
||||
message={`Remove ${confirm.id}? Its code and everything it has stored are deleted.`}
|
||||
confirmLabel="Uninstall"
|
||||
onConfirm={() => {
|
||||
const id = confirm.id
|
||||
setConfirm(null)
|
||||
guard(() => api.deleteScript(id), `Uninstalled ${id}`)
|
||||
}}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, AlertTriangle, Pencil } from 'lucide-react'
|
||||
import { api, type SdkMethod } from '../../lib/api'
|
||||
|
||||
/**
|
||||
* The joro.* surface, for whoever is writing a script.
|
||||
*
|
||||
* Served from GET /automation/sdk, which joins the binding table with each capability's
|
||||
* registered title and description — the same text a language model reads as its tool
|
||||
* contract. One source, so a method that exists is documented and a documented method
|
||||
* exists, and neither can drift from what the sandbox actually exposes.
|
||||
*
|
||||
* There is deliberately no editor autocomplete: @codemirror/autocomplete is not a declared
|
||||
* dependency and its popup has no theme CSS here, so it would arrive off-palette. A list
|
||||
* you can read beside the code covers discovery without that.
|
||||
*/
|
||||
export default function SdkReference() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [methods, setMethods] = useState<SdkMethod[]>([])
|
||||
const [storage, setStorage] = useState<{ js: string; description: string }[]>([])
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || methods.length > 0) return
|
||||
api
|
||||
.getScriptSdk()
|
||||
.then((d) => {
|
||||
setMethods(d.methods ?? [])
|
||||
setStorage(d.storage ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
/* the panel simply stays empty; the editor above still works */
|
||||
})
|
||||
}, [open, methods.length])
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-[10px] font-semibold text-content-muted uppercase tracking-wide"
|
||||
>
|
||||
{open ? <ChevronDown size={12} strokeWidth={2} /> : <ChevronRight size={12} strokeWidth={2} />}
|
||||
SDK reference
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-2 pb-2 space-y-1.5">
|
||||
<p className="text-[10px] text-content-muted leading-snug">
|
||||
Every method is <code className="font-mono">await</code>-able and throws on failure, with{' '}
|
||||
<code className="font-mono">err.code</code> carrying the reason. Arguments match the tool
|
||||
of the same name.
|
||||
</p>
|
||||
|
||||
{methods.map((m) => (
|
||||
<div key={m.js}>
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === m.js ? null : m.js)}
|
||||
className="w-full text-left flex items-start gap-1 hover:bg-surface-hover rounded px-1 py-0.5"
|
||||
>
|
||||
<code className="font-mono text-[10px] text-accent-secondary break-all">{m.js}</code>
|
||||
<span className="ml-auto shrink-0 inline-flex gap-0.5 pt-0.5">
|
||||
{m.sendsTraffic && (
|
||||
<span title="Sends traffic to a target">
|
||||
<AlertTriangle size={9} strokeWidth={2} className="text-semantic-warning" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
{m.mutating && (
|
||||
<span title="Changes Joro's own state">
|
||||
<Pencil size={9} strokeWidth={2} className="text-semantic-special" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{expanded === m.js && (
|
||||
<div className="px-1 pb-1 space-y-1">
|
||||
{m.description && (
|
||||
<p className="text-[10px] text-content-secondary leading-snug">{m.description}</p>
|
||||
)}
|
||||
{m.argsExample !== undefined && m.argsExample !== null && (
|
||||
<pre className="font-mono text-[10px] bg-surface-input rounded p-1 overflow-x-auto">
|
||||
{JSON.stringify(m.argsExample)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{storage.length > 0 && (
|
||||
<div className="pt-1 border-t border-border-subtle">
|
||||
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide mb-0.5">
|
||||
Storage
|
||||
</p>
|
||||
{storage.map((s) => (
|
||||
<div key={s.js} className="px-1 py-0.5">
|
||||
<code className="font-mono text-[10px] text-accent-secondary break-all">{s.js}</code>
|
||||
<p className="text-[10px] text-content-muted leading-snug">{s.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -256,6 +256,9 @@ export interface ScriptResult {
|
||||
logsTruncated?: boolean
|
||||
calls: number
|
||||
sendCalls: number
|
||||
/** joro.storage calls, counted apart from capability calls because they consume no
|
||||
* registry budget, engage no scope guard, and write no audit entry. */
|
||||
storageOps?: number
|
||||
callInputBytes: number
|
||||
callOutputBytes: number
|
||||
durationMs: number
|
||||
@@ -277,6 +280,104 @@ export interface ScriptRun {
|
||||
result: ScriptResult
|
||||
}
|
||||
|
||||
/** The author's requested budget. Every field optional; the operator may only lower. */
|
||||
export interface AutomationLimits {
|
||||
timeoutMs?: number
|
||||
memoryMb?: number
|
||||
maxCalls?: number
|
||||
maxSendCalls?: number
|
||||
maxLogBytes?: number
|
||||
maxResultBytes?: number
|
||||
}
|
||||
|
||||
/** The author-owned half of an installed automation. Cannot request capabilities: the
|
||||
* sdkVersion selects a Joro-owned bundle, which is the point of the indirection. */
|
||||
export interface AutomationManifest {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
description?: string
|
||||
sdkVersion: string
|
||||
entrypoint?: string
|
||||
triggers?: string[]
|
||||
limits?: AutomationLimits
|
||||
/** Shortest gap between two triggered runs. Combined with the operator's by taking
|
||||
* the longer, which is why it is not inside limits. */
|
||||
minIntervalMs?: number
|
||||
}
|
||||
|
||||
export interface AutomationRevision {
|
||||
hash: string
|
||||
at: string
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export interface AutomationLastRun {
|
||||
id: string
|
||||
at: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** The operator-owned half, in a separate file so an update never reverts a decision. */
|
||||
export interface AutomationState {
|
||||
enabled: boolean
|
||||
/** Set by the runaway breaker, never by the operator. Enabling clears it. */
|
||||
paused?: boolean
|
||||
pausedReason?: string
|
||||
/** Individual triggers switched off: true disables, absent means armed. */
|
||||
triggersDisabled?: Record<string, boolean>
|
||||
limits?: AutomationLimits
|
||||
minIntervalMs?: number
|
||||
/** Bounds where this automation's runs may send. Exists for trigger-fired runs, which
|
||||
* carry no launching token and are otherwise bounded by scope alone. */
|
||||
hostAllow?: string[]
|
||||
installedAt: string
|
||||
updatedAt: string
|
||||
revisions?: AutomationRevision[]
|
||||
lastRun?: AutomationLastRun
|
||||
}
|
||||
|
||||
/** One installed automation, source included. */
|
||||
export interface AutomationPackage {
|
||||
manifest: AutomationManifest
|
||||
state: AutomationState
|
||||
source?: string
|
||||
sourceHash: string
|
||||
}
|
||||
|
||||
/** List projection. Source is withheld, not merely omitted. */
|
||||
export interface AutomationSummary {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
description?: string
|
||||
sdkVersion: string
|
||||
triggers: string[]
|
||||
/** The triggers currently live: declared, not switched off, and runnable. */
|
||||
armed: string[]
|
||||
enabled: boolean
|
||||
paused?: boolean
|
||||
pausedReason?: string
|
||||
sourceHash: string
|
||||
sourceBytes: number
|
||||
installedAt: string
|
||||
updatedAt: string
|
||||
revisions: number
|
||||
lastRun?: AutomationLastRun
|
||||
}
|
||||
|
||||
/** One joro.* method, joined with the capability behind it. */
|
||||
export interface SdkMethod {
|
||||
js: string
|
||||
capability: string
|
||||
title?: string
|
||||
description?: string
|
||||
inputSchema?: unknown
|
||||
argsExample?: unknown
|
||||
sendsTraffic?: boolean
|
||||
mutating?: boolean
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
seq: number
|
||||
at: string
|
||||
@@ -998,6 +1099,34 @@ export const api = {
|
||||
),
|
||||
getScriptRun: (id: string) => req<ScriptRun>('GET', `/automation/runs/${id}`),
|
||||
clearScriptRuns: () => req<{ deleted: number }>('DELETE', '/automation/runs'),
|
||||
|
||||
listScripts: () =>
|
||||
req<{ scripts: AutomationSummary[]; triggers: string[]; bundle: string }>(
|
||||
'GET',
|
||||
'/automation/scripts'
|
||||
),
|
||||
getScript: (id: string) => req<AutomationPackage>('GET', `/automation/scripts/${id}`),
|
||||
installScript: (manifest: AutomationManifest, source: string) =>
|
||||
req<AutomationPackage>('POST', '/automation/scripts', { manifest, source }),
|
||||
updateScript: (id: string, manifest: AutomationManifest, source: string, expectedHash?: string) =>
|
||||
req<AutomationPackage>('PUT', `/automation/scripts/${id}`, { manifest, source, expectedHash }),
|
||||
deleteScript: (id: string) => req<{ status: string }>('DELETE', `/automation/scripts/${id}`),
|
||||
setScriptEnabled: (id: string, enabled: boolean) =>
|
||||
req<AutomationSummary>('PUT', `/automation/scripts/${id}/enabled`, { enabled }),
|
||||
setScriptPrefs: (
|
||||
id: string,
|
||||
prefs: { limits?: AutomationLimits; triggersDisabled?: Record<string, boolean>; hostAllow?: string[] }
|
||||
) =>
|
||||
req<AutomationSummary>('PUT', `/automation/scripts/${id}/prefs`, prefs),
|
||||
// No client timeout below the server's: a run may legitimately take a minute, and
|
||||
// aborting it here would leave the operator with no result and the run still going.
|
||||
runScript: (body: { scriptId?: string; source?: string; input?: unknown; timeoutMs?: number }) =>
|
||||
req<ScriptRun>('POST', '/automation/runs', body, 120000),
|
||||
getScriptSdk: () =>
|
||||
req<{ bundle: string; methods: SdkMethod[]; storage: { js: string; description: string }[]; triggers: string[] }>(
|
||||
'GET',
|
||||
'/automation/sdk'
|
||||
),
|
||||
getMcpState: () => req<McpState>('GET', '/automation/mcp'),
|
||||
setMcpState: (body: { enabled?: boolean; port?: number }) =>
|
||||
req<McpState>('PUT', '/automation/mcp', body),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Automation package file (.jauto) serialization.
|
||||
//
|
||||
// A package is a manifest plus one bundled script, which is small enough that this is a
|
||||
// client-side blob rather than a multipart upload: GET /automation/scripts/{id} and
|
||||
// POST /automation/scripts already carry both halves, so a download and an upload need no
|
||||
// new route. Mirrors lib/deaddrop.ts — gzip when CompressionStream exists, sniff the gzip
|
||||
// magic bytes on the way back in, and validate field by field so a partial file cannot
|
||||
// produce undefined at render time.
|
||||
|
||||
import type { AutomationManifest } from './api'
|
||||
|
||||
export const PACKAGE_TYPE = 'joro-automation'
|
||||
export const PACKAGE_VERSION = 1
|
||||
|
||||
export interface AutomationBundle {
|
||||
type: typeof PACKAGE_TYPE
|
||||
version: number
|
||||
exportedAt: string
|
||||
manifest: AutomationManifest
|
||||
source: string
|
||||
}
|
||||
|
||||
/** exportPackage serializes a package to a Blob for download as a .jauto file. */
|
||||
export async function exportPackage(bundle: AutomationBundle): Promise<Blob> {
|
||||
const json = JSON.stringify(bundle)
|
||||
if (typeof CompressionStream === 'undefined') {
|
||||
return new Blob([json], { type: 'application/json' })
|
||||
}
|
||||
const stream = new Blob([json]).stream().pipeThrough(new CompressionStream('gzip'))
|
||||
return new Blob([await new Response(stream).arrayBuffer()], { type: 'application/gzip' })
|
||||
}
|
||||
|
||||
/** importPackage reads a .jauto file and returns the validated bundle. */
|
||||
export async function importPackage(file: File): Promise<AutomationBundle> {
|
||||
const buf = new Uint8Array(await file.arrayBuffer())
|
||||
let json: string
|
||||
if (buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b) {
|
||||
if (typeof DecompressionStream === 'undefined') {
|
||||
throw new Error('This browser cannot decompress gzipped .jauto files')
|
||||
}
|
||||
const stream = new Blob([buf]).stream().pipeThrough(new DecompressionStream('gzip'))
|
||||
json = await new Response(stream).text()
|
||||
} else {
|
||||
json = new TextDecoder().decode(buf)
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(json)
|
||||
} catch {
|
||||
throw new Error('Not a valid .jauto file (invalid JSON)')
|
||||
}
|
||||
|
||||
const b = parsed as Partial<AutomationBundle>
|
||||
if (!b || b.type !== PACKAGE_TYPE || !b.manifest || typeof b.source !== 'string') {
|
||||
throw new Error('Not a valid Joro automation package')
|
||||
}
|
||||
if (!b.manifest.id) {
|
||||
throw new Error('The package has no automation id')
|
||||
}
|
||||
// The server validates the manifest properly; this only guarantees the shape the
|
||||
// editor is about to render, so a missing optional field is filled rather than fatal.
|
||||
return {
|
||||
type: PACKAGE_TYPE,
|
||||
version: typeof b.version === 'number' ? b.version : PACKAGE_VERSION,
|
||||
exportedAt: b.exportedAt ?? '',
|
||||
manifest: {
|
||||
...b.manifest,
|
||||
name: b.manifest.name || b.manifest.id,
|
||||
version: b.manifest.version || '0.0.0',
|
||||
sdkVersion: b.manifest.sdkVersion || '1',
|
||||
triggers: b.manifest.triggers ?? ['manual'],
|
||||
},
|
||||
source: b.source,
|
||||
}
|
||||
}
|
||||
|
||||
/** downloadPackage triggers a browser download of one automation. */
|
||||
export async function downloadPackage(manifest: AutomationManifest, source: string) {
|
||||
const blob = await exportPackage({
|
||||
type: PACKAGE_TYPE,
|
||||
version: PACKAGE_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
manifest,
|
||||
source,
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${manifest.id}-${manifest.version}.jauto`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/** pickPackage opens a file chooser and returns the parsed bundle, or null if cancelled. */
|
||||
export function pickPackage(): Promise<AutomationBundle | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.jauto,application/gzip,application/json'
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(await importPackage(file))
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
@@ -354,6 +354,18 @@ function handleMessage(msg: WSMessage) {
|
||||
useDetectStore.setState({ rulesLoaded: false })
|
||||
break
|
||||
}
|
||||
case 'automation.script.state': {
|
||||
// The only automation event that is not polled, and the only one an agent does not
|
||||
// cause: the runaway breaker pausing an automation the operator armed. Waiting for
|
||||
// the next poll to learn that something stopped itself is the wrong default.
|
||||
const d = msg.data as { id?: string; paused?: boolean; pausedReason?: string }
|
||||
if (d?.paused) {
|
||||
useToastStore
|
||||
.getState()
|
||||
.addToast(`Automation ${d.id ?? ''} was paused: ${d.pausedReason ?? 'runaway'}`, 'error')
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
if (msg.type.startsWith('plugin.')) {
|
||||
pluginEventListeners.forEach((fn) => fn(msg))
|
||||
|
||||
@@ -230,12 +230,15 @@ export default function SettingsPage() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Content pane. Plugins is full-bleed rather than padded-and-scrolling:
|
||||
its feature-plugin sub-tabs are iframes that need a real height, and a
|
||||
block parent gives a `flex-1` child none. */}
|
||||
{/* Content pane. Plugins and Automation are full-bleed rather than
|
||||
padded-and-scrolling: the former's feature-plugin sub-tabs are iframes and the
|
||||
latter's script editor is a CodeMirror instance, and both need a real height —
|
||||
a block parent gives a `flex-1` child none. Both supply their own padding. */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className={`h-full bg-surface-card rounded-lg shadow-sm ${
|
||||
category === 'plugins' ? 'flex flex-col overflow-hidden' : 'overflow-y-auto p-5'
|
||||
category === 'plugins' || category === 'automation'
|
||||
? 'flex flex-col overflow-hidden'
|
||||
: 'overflow-y-auto p-5'
|
||||
}`}>
|
||||
{category === 'project' && <ProjectBrowser />}
|
||||
|
||||
|
||||
Reference in new issue
Block a user