Added installed JavaScript automations, event triggers, and a script editor

This commit is contained in:
Tony West committed 2026-08-18 01:38:34 -04:00
1 parent 3ea047f4a3
commit c0cb7dc9c6
26 files changed
+3922 -131

No files matched your search

+25
View File
@@ -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 {
+4 -4
View File
@@ -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
}
+63 -2
View File
@@ -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) {
+358
View File
@@ -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."},
}
+9
View File
@@ -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
View File
@@ -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
}
+33
View File
@@ -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()
}
}
+132
View File
@@ -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.
+169 -14
View File
@@ -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, &registryBridge{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 {
+371
View File
@@ -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,
}
}
+6 -1
View File
@@ -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"`
+270
View File
@@ -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)
}
+410
View File
@@ -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)
}
+566
View File
@@ -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
}
+41 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+91 -11
View File
@@ -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"}
}
+44 -6
View File
@@ -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 &amp; 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&rsquo;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>
)
}
+129
View File
@@ -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),
+115
View File
@@ -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()
})
}
+12
View File
@@ -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))
+7 -4
View File
@@ -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 />}