jsruntime

package
v0.0.0-...-fe79b6d Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 24, 2026 License: GPL-3.0 Imports: 16 Imported by: 0

Documentation

Overview

Package jsruntime executes one JavaScript program under hard limits.

It imports nothing from the rest of Joro, and that is the point. The runtime is handed a HostBridge whose only method takes a capability ID and a blob of JSON, so there is no field on any type in this package through which a script could reach a capture store, a token file, or an HTTP client. The bridge implementation lives elsewhere and is the only thing that knows what a capability is.

Two Runtime implementations ship here:

  • VM runs goja in the calling process. Fast, but a script sharing the Go heap can allocate until the operating system kills that process.
  • WorkerRuntime re-execs a host binary in worker mode and speaks to it over pipes. A run is then terminated by killing a process rather than by asking a VM to stop, and an allocation blowup takes the worker instead of the proxy.

Joro uses WorkerRuntime. VM exists because the worker needs it: the child process calls it with a bridge that forwards over the pipe. Keeping both behind one interface is also what lets the untrusted-execution tier be replaced later without touching the SDK surface or any script anyone has written.

What the sandbox is, and is not

A script can affect the outside world only by calling a capability. goja's default global object holds ECMAScript built-ins and nothing else — no process, no require, no fetch, no timers — so there is no ambient authority to take away, only a bridge to add. Combined with the worker boundary, a run cannot outlive its deadline, cannot exhaust the proxy's memory, and cannot reach the filesystem or a socket.

It is not a defense against a deliberate engine exploit, and it does not need to be: any process running as the operator can already drive Joro's whole API. What this contains is the realistic failure — a generated script that loops forever, allocates without bound, or calls one capability ten thousand times.

Index

Constants

View Source
const (
	ReasonSuccess = "success"
	// ReasonException covers a throw the script did not catch, including one
	// originating from a denied capability call that it chose not to handle.
	ReasonException = "script exception"
	ReasonTimeout   = "timeout"
	// ReasonMemoryLimit means the heap ceiling was hit and the VM was interrupted.
	// It is distinct from ReasonWorkerLost, which is what an actual out-of-memory
	// kill looks like from the parent.
	ReasonMemoryLimit = "memory limit"
	ReasonBudget      = "sdk budget exceeded"
	ReasonCancelled   = "cancelled"
	// ReasonDenied means the run ended on an uncaught capability denial. It is
	// reported separately from a plain exception because the fix is a grant or a
	// scope rule, not a change to the script.
	ReasonDenied = "capability denied"
	// ReasonRuntimeFailure is ours, not the script's: a compile failure, an
	// unusable entry point, a result that will not serialize.
	ReasonRuntimeFailure = "runtime failure"
	// ReasonWorkerLost means the worker process died without reporting. The usual
	// cause is the operating system reclaiming it for memory.
	ReasonWorkerLost = "worker lost"
)

Termination reasons. Every run ends with exactly one, and the operator sees it verbatim, so they are phrased as outcomes rather than error classes.

View Source
const (
	OutcomeSuccess        = "success"
	OutcomeException      = "exception"
	OutcomeTimeout        = "timeout"
	OutcomeMemoryLimit    = "memory_limit"
	OutcomeBudget         = "budget_exceeded"
	OutcomeCancelled      = "cancelled"
	OutcomeDenied         = "denied"
	OutcomeRuntimeFailure = "runtime_failure"
	OutcomeWorkerLost     = "worker_lost"

	// OutcomeUnknown is what an unmapped reason resolves to. It exists so the mapping
	// can fail safe: a reason added without a code here reports a run whose fate is
	// unrecognized, which a caller can handle, rather than a run that succeeded.
	OutcomeUnknown = "unknown"
)

Outcome codes pair one-to-one with the termination reasons above.

Two fields for one fact, because the fact has two audiences. A reason is prose the operator reads verbatim and is therefore free to be reworded; an outcome is an identifier a program branches on and is therefore not. Collapsing them means any improvement to the wording is a silent breaking change for every consumer that compared against the old string.

View Source
const (
	// DefaultTimeout has no stock maximum beside it because CapTimeout serves as one:
	// the operator may hold a run below the cap but cannot raise past it, so the two
	// figures would be the same number. See boundsLimits.
	DefaultTimeout = 25 * time.Second

	DefaultMemoryBytes  int64 = 64 << 20
	StockMaxMemoryBytes int64 = 256 << 20

	DefaultMaxCalls = 100
	StockMaxCalls   = 500

	DefaultMaxSendCalls = 25
	StockMaxSendCalls   = 100

	DefaultMaxLogBytes = 256 << 10
	StockMaxLogBytes   = 1 << 20

	DefaultMaxResultBytes = 128 << 10
	StockMaxResultBytes   = 1 << 20

	// The cumulative byte budgets are not offered to the operator at all: they bound
	// what the host holds in memory while forwarding, which is Joro's concern rather
	// than a policy an engagement varies. Default and hard bound, therefore.
	DefaultMaxCallInputBytes = 2 << 20
	CapMaxCallInputBytes     = 8 << 20

	DefaultMaxCallOutputBytes = 8 << 20
	CapMaxCallOutputBytes     = 32 << 20
)

The numbers Joro ships. Each field has two: the default a run gets when nobody said otherwise, and the maximum a run may ask for when the *operator* has not set one.

Stock maxima are not a bound on the operator. They are what applies in their absence, so an agent cannot ask for an unbounded run on a Joro nobody has configured. An operator may set any maximum they like; the only real ceilings are the structural ones below, each tied to a number somewhere else that cannot move at runtime.

View Source
const (
	// CapTimeout bounds a run because the capability that exposes one to an agent
	// registers its own deadline before the registry is sealed, and that cannot change
	// while Joro is running. capreg derives that deadline from this.
	CapTimeout = 10 * time.Minute

	// CapSourceBytes bounds a program because the automation control plane caps the
	// request that carries it. internal/api derives that body limit from this.
	CapSourceBytes = 1 << 20

	// CapConcurrentRuns bounds overlapping runs because each holds up to two of the
	// capability registry's eight global concurrency slots: a fourth could take every
	// slot and starve the operator's own automation calls.
	CapConcurrentRuns = 3

	// AgentOutputCap is what an agent's log and result figures share, because the tool
	// result they travel in has one size and fails whole rather than truncating.
	// capreg derives that tool result cap from this.
	AgentOutputCap = 240 << 10
)

The structural ceilings: the only figures here an operator cannot raise, each because something outside this budget is fixed against it. Every one of them is reported to the UI with its reason, so a field is never presented as free when it is not.

View Source
const (
	// DefaultStorageOps bounds joro.storage calls per run. It exists to stop a loop
	// hammering the host pipe, not to express a policy, which is why the default is
	// far above any real automation's use — and why the operator's number is final.
	DefaultStorageOps = 1000

	// DefaultSourceBytes: well above anything hand-written or generated, far below a
	// bundled dependency tree. Capped by CapSourceBytes.
	DefaultSourceBytes = 256 << 10

	// DefaultConcurrentRuns, capped by CapConcurrentRuns.
	DefaultConcurrentRuns = 2

	// What an agent gets back from a run. The pair shares AgentOutputCap, so each is
	// capped by it and the caller of SetScriptBudget checks their sum as well.
	DefaultAgentLogBytes    = 64 << 10
	DefaultAgentResultBytes = 96 << 10
)

Defaults and ceilings for the host half of the budget: limits an operator sets once for this Joro rather than per run, and which neither an author nor a caller may ask to change.

Two of these are enforced outside this package — concurrent runs in the run manager, the agent output caps in the capability that exposes a run to an agent. They are declared here anyway, so the operator sees one budget with one form and one validator instead of three, and each field's spec names where it bites.

View Source
const MaxSourceBytes = DefaultSourceBytes

MaxSourceBytes is the shipped program-size limit, kept as the name callers already use for the default.

View Source
const SDKModule = "@joro/sdk"

The module specifier the SDK is published under. The runtime has no module system — joro is a global — but generated code idiomatically writes the import, and a TypeScript author needs a name to import types from, so a preamble naming exactly this specifier is accepted and erased.

Variables

View Source
var Bindings = []Binding{
	{JS: "instance.get", Cap: "instance.get"},

	{JS: "history.list", Cap: "history.list"},
	{JS: "history.stats", Cap: "history.stats"},
	{JS: "history.highlight", Cap: "history.highlight"},

	{JS: "sitemap.get", Cap: "sitemap.get"},
	{JS: "scope.get", Cap: "scope.get"},

	{JS: "http.fingerprint", Cap: "http.fingerprint"},
	{JS: "http.read", Cap: "http.read"},
	{JS: "http.search", Cap: "http.search"},
	{JS: "http.diff", Cap: "http.diff"},
	{JS: "http.resend", Cap: "http.resend"},
	{JS: "http.batch", Cap: "http.batch"},

	{JS: "websocket.list", Cap: "websocket.list"},

	{JS: "fuzzer.start", Cap: "fuzzer.start"},
	{JS: "fuzzer.status", Cap: "fuzzer.status"},
	{JS: "fuzzer.results", Cap: "fuzzer.results"},
	{JS: "fuzzer.stop", Cap: "fuzzer.stop"},

	{JS: "findings.list", Cap: "findings.list"},
	{JS: "findings.get", Cap: "findings.get"},
	{JS: "findings.create", Cap: "findings.create"},
	{JS: "findings.update", Cap: "findings.update"},
	{JS: "findings.delete", Cap: "findings.delete"},

	{JS: "notes.list", Cap: "notes.list"},
	{JS: "notes.hosts", Cap: "notes.hosts"},
	{JS: "notes.create", Cap: "notes.create"},
	{JS: "notes.delete", Cap: "notes.delete"},

	{JS: "context.get", Cap: "context.get"},
	{JS: "context.clear", Cap: "context.clear"},

	{JS: "intercept.get", Cap: "config.intercept.get"},
	{JS: "intercept.list", Cap: "config.intercept.list"},

	{JS: "detect.rules", Cap: "detect.rules.list"},
	{JS: "detect.config", Cap: "detect.config.get"},
}

Bindings is the Automation SDK v1 surface: the reads and writes ordinary web security automation needs, and nothing administrative.

Excluded on purpose, and each for its own reason:

  • config.*.edit — a script adding a Match & Replace rule silently rewrites the operator's own traffic. Engagement setup is a different job with its own token profile.
  • detect.* writes, including rescan — same argument: they change what the operator sees rather than what the script learns.
  • scope.addrule / scope.enable — these are UnrestrictedOnly, and scope is the control that bounds a run, so a run must not be able to edit it. Enforced by capreg.validateBundle, which panics at startup on a bundle naming one: a run whose policy resolves unrestricted would otherwise be permitted to invoke it.
  • exec.* and c2.* — command execution and an operator's C2 are granted one at a time by hand, never bundled.
  • script.* — a script that can start scripts launders its own budget.

Weigh an addition against how far it reaches, not against the grant that launched the run: a run's grants come from this table and are never intersected with the launching token's, so anything here is authority every triggered run and every armed lens holds, including those no token launched. notes.delete and findings.delete are in on the grounds that both are bounded to the run's own entries and to findings the operator has already dismissed, which is no further than findings.update already reaches — it can rewrite severity, notes and the false-positive flag on the same record. A write that could reach what the operator has not dismissed belongs behind a new BundleVersion instead.

Functions

func CapabilityIDs

func CapabilityIDs() []string

CapabilityIDs returns the capability IDs the SDK can reach, sorted and deduplicated. This is the grant list a run is authorized with.

func OutcomeFor

func OutcomeFor(reason string) string

OutcomeFor returns the stable code for a termination reason.

func Prepare

func Prepare(source string, maxBytes int) (string, error)

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.

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. maxBytes is the operator's program-size limit; zero or less takes the shipped default, so a caller with no policy in hand still gets a bound.

func RunWorker

func RunWorker(ctx context.Context, in io.Reader, out io.Writer) (err error)

RunWorker is the child half. It reads one job from in, runs it, and writes the result to out. Every capability call is forwarded to the parent and waited on, which is what makes the whole protocol strict request/response: the SDK is synchronous inside the VM, so two calls can never be in flight at once.

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 Validate

func Validate(source string, maxBytes int) error

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.

Types

type Binding

type Binding struct {
	JS  string
	Cap string
}

Binding maps one name in the JavaScript SDK to one capability ID.

This table is the contract, in both directions. The shim injected into the VM is generated from it, and the grant bundle the host authorizes a run with is generated from it too — so it is impossible to expose a JavaScript method that is not granted, or to grant a capability with no way to call it. Adding a method to the SDK and widening the bundle are the same edit, which is the property worth having.

JS is a two-segment path: joro.<namespace>.<method>. It is allowed to differ from Cap, and does where the capability ID carries a grouping the script has no reason to repeat — config.intercept.get reads better as joro.intercept.get, and a script asking for detect rules wants joro.detect.rules, not joro.detect.rules.list.

Keeping the generic bridge private, rather than exposing invoke(id, args) to script authors, is what leaves room to rename a capability, validate an argument, or deprecate a method without every raw capability ID becoming public API forever.

type Budget

type Budget 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"`
}

Budget is Limits in the units an operator reads and a config file stores: whole milliseconds and megabytes rather than a time.Duration and a byte count. A zero field means "unspecified" rather than zero of anything, which is what lets one shape serve an author's request, the operator's global, and the wire.

func DefaultBudget

func DefaultBudget() Budget

DefaultBudget and StockMaxima report this package's own numbers in operator units, so the UI can state them without a second copy of the table.

func StockMaxima

func StockMaxima() Budget

StockMaxima carries no TimeoutMs: the wall-clock maximum is CapTimeout rather than a figure that applies in the operator's absence, and BudgetSpecs reports it as a cap.

func (Budget) Limits

func (b Budget) Limits() Limits

Limits converts to the runtime's own units. Not normalized: a zero stays a zero, so a caller can still tell "unspecified" from a real value.

func (Budget) Value

func (b Budget) Value(key string) (int, bool)

Value reports one field by its BudgetSpec key, and whether the key is known.

Paired with BudgetSpecs so a caller can validate the whole budget without keeping a second list of fields: a seventh field added above without a case here fails loudly at its validator rather than reading as zero and passing unchecked.

type BudgetPolicy

type BudgetPolicy struct {
	Defaults Budget     `json:"defaults,omitzero"`
	Maxima   Budget     `json:"maxima,omitzero"`
	Host     HostBudget `json:"host,omitzero"`
}

BudgetPolicy is everything the operator sets about runs: what a run gets by default, the most it may ask for, and the host limits that are neither requestable nor declarable.

One struct because it is one form, one stored object and one validator. Persisted in ~/.joro/automation.json; see internal/automation.

func (BudgetPolicy) Bounds

func (p BudgetPolicy) Bounds() (defaults, maxima Budget)

Bounds reports what a run's default and maximum actually resolve to under this policy.

The UI shows both, so a field never displays Joro's stock figure as the maximum when the operator's own default has raised it past that.

type BudgetSpec

type BudgetSpec struct {
	Key   string `json:"key"`
	Label string `json:"label"`
	Unit  string `json:"unit"`
	// Factor converts the operator's unit to the stored one: a value entered as
	// seconds is stored as milliseconds with a factor of 1000. Every other figure on
	// this struct is already in the operator's unit; the field on Budget is not.
	Factor int `json:"factor"`
	// Default is what a run gets when nobody set one; DefaultMax is the most a run may
	// ask for while the operator has named no maximum. Both are Joro's own numbers, and
	// both are replaced outright by whatever the operator types.
	//
	// DefaultMax is absent only on a host spec, which has no requestable side and so no
	// maximum to speak of — one editable figure and, where it applies, a Cap.
	Default    int `json:"default"`
	DefaultMax int `json:"defaultMax,omitempty"`
	// Cap is the one figure the operator cannot exceed, and CapReason says what it is
	// fixed against. Zero means there is no cap and their number is final — which is
	// the case for most of these, so a field is never shown as free when it is not.
	Cap         int    `json:"cap,omitempty"`
	CapReason   string `json:"capReason,omitempty"`
	Description string `json:"description"`
}

BudgetSpec documents one configurable field for the operator.

The rationale lives here beside the constants rather than in the frontend, so what the UI explains cannot drift from what the runtime enforces.

func BudgetSpecs

func BudgetSpecs() []BudgetSpec

BudgetSpecs describes the six per-run fields, in the order they should be read: what a run may do, then how long it may take, then what it may return.

func HostSpecs

func HostSpecs() []BudgetSpec

HostSpecs describes the five fields that belong to this Joro rather than to one run. They have no DefaultMax, because nothing can ask for another value: the operator's number is the limit.

type CallError

type CallError struct {
	Code string
	Msg  string
	// Denied distinguishes an authorization refusal from a handler that ran and
	// failed. It changes the run's termination reason when the script does not
	// catch it, because a denial is fixed by a grant and a handler error is not.
	Denied bool
}

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".

func (*CallError) Error

func (e *CallError) Error() string

type HostBridge

type HostBridge interface {
	// Invoke runs one capability. args and the returned value are JSON. An error
	// becomes a JavaScript throw; a *CallError additionally carries a code the
	// script can branch on and tells the runtime whether the failure was a denial.
	Invoke(ctx context.Context, id string, args json.RawMessage) (json.RawMessage, error)
}

HostBridge is everything a script can reach. One method, two JSON blobs.

Deliberately not an interface with a method per subsystem: the whole authorization story is that a call is named by a capability ID and evaluated by the registry, and a bridge with a ReadHistory method would be a second place where that decision could be made differently.

type HostBudget

type HostBudget struct {
	StorageOps       int `json:"storageOps,omitempty"`
	SourceBytes      int `json:"sourceBytes,omitempty"`
	ConcurrentRuns   int `json:"concurrentRuns,omitempty"`
	AgentLogBytes    int `json:"agentLogBytes,omitempty"`
	AgentResultBytes int `json:"agentResultBytes,omitempty"`
}

HostBudget is the half of the policy that is a property of this Joro rather than of one run: how hard a script may hammer storage, how large a program may be, how many runs may overlap, and how much of a run's output an agent gets back.

func (HostBudget) Resolved

func (h HostBudget) Resolved() HostBudget

Resolved fills each unset field with its shipped default and holds every field to its ceiling, so a caller can use the result without checking anything.

func (HostBudget) Value

func (h HostBudget) Value(key string) (int, bool)

Value reports one field by its spec key, paired with HostSpecs for the same reason Budget.Value is paired with BudgetSpecs.

type Limits

type Limits struct {
	Timeout     time.Duration `json:"timeout"`
	MemoryBytes int64         `json:"memoryBytes"`

	// MaxCalls bounds SDK calls of any kind; MaxSendCalls separately bounds the
	// subset that puts bytes on the wire. Two counters because the cost of a read
	// is context and the cost of a send is traffic against someone's target.
	MaxCalls     int `json:"maxCalls"`
	MaxSendCalls int `json:"maxSendCalls"`

	MaxLogBytes    int `json:"maxLogBytes"`
	MaxResultBytes int `json:"maxResultBytes"`

	// Cumulative bytes across every SDK call, in and out. A script that stays
	// inside MaxCalls can still drag megabytes through the bridge one call at a
	// time, and the parent has to hold each result in memory to forward it.
	MaxCallInputBytes  int `json:"maxCallInputBytes"`
	MaxCallOutputBytes int `json:"maxCallOutputBytes"`

	// MaxStorageOps and MaxSourceBytes come from the host half of the budget rather
	// than from anything a run or an author may request: one bounds how hard a loop
	// can hammer the host pipe, the other how large a program may be at all.
	MaxStorageOps  int `json:"maxStorageOps"`
	MaxSourceBytes int `json:"maxSourceBytes"`
}

Limits bound one run. A zero field takes the operator's default, and a field over their maximum is clamped down to it. Clamping rather than rejecting is deliberate: these arrive from a caller that may be a language model, and the useful response to "maxCalls: 9000" is a run at the maximum, not an argument error that costs a turn.

func (Limits) Budget

func (l Limits) Budget() Budget

Budget projects Limits back into operator units. Memory rounds up, because reporting a ceiling lower than the one being enforced would read as a bug in the enforcement.

func (Limits) Fill

func (l Limits) Fill() Limits

Fill supplies a default for any field left at zero and enforces the absolute caps, and deliberately does *not* apply the stock maxima.

Those maxima bound what a caller may ask for, and that question is settled once, in the run manager, against the operator's policy. By the time Limits reaches this package the numbers are a resolved budget rather than a request — so re-applying a stock maximum here would silently lower a limit the operator raised, which is exactly what happened when the worker re-normalized a job the parent had already resolved. Every internal entry point therefore fills rather than normalizes.

func (Limits) Normalize

func (l Limits) Normalize() Limits

Normalize resolves a request against no policy at all: Joro's own defaults and stock maxima. It is what a caller with no operator policy in hand gets.

func (Limits) NormalizeWith

func (l Limits) NormalizeWith(p BudgetPolicy) Limits

NormalizeWith resolves a requested budget against the operator's policy.

Per field the operator sets two numbers, and they answer two different questions: the default is what a run that asks for nothing gets, and the maximum is the most a run may ask for. Either can be left unset, in which case this package's own number applies — so an unconfigured Joro behaves exactly as it did before there was a policy. A request over the maximum is clamped down rather than refused, for the reason Limits' own doc comment gives, and nothing here can exceed a shipped ceiling even from a hand-edited file.

type LogLine

type LogLine struct {
	At    string `json:"at"`
	Level string `json:"level"`
	Text  string `json:"text"`
}

LogLine is one captured console call.

type Meta

type Meta struct {
	RunID     string `json:"runId"`
	StartedAt string `json:"startedAt"`

	AutomationID      string `json:"automationId,omitempty"`
	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"`
}

Meta is the non-secret description of a run, handed to the script as ctx.run, ctx.automation and ctx.trigger.

Nothing here may be a bearer token, a cookie, a filesystem path, or an internal Go identifier. A script's authority comes from the principal the host invokes with, never from a value it can read out of its own context — so there is no reason for anything sensitive to be in reach, and a script that could read one would leak it into its own return value or logs.

type Request

type Request struct {
	Source string          `json:"source"`
	Input  json.RawMessage `json:"input,omitempty"`
	Meta   Meta            `json:"meta"`
	Limits Limits          `json:"limits"`

	// SendCaps names the capability IDs that put bytes on the wire, so the runtime
	// can charge them against MaxSendCalls. The host supplies it rather than this
	// package carrying a list of Joro capability IDs that could fall out of step
	// with the registry — and passing it as data means the in-process and worker
	// paths share one mechanism instead of the worker having to bridge an interface.
	SendCaps []string `json:"sendCaps,omitempty"`
}

Request is one program to run.

type Result

type Result struct {
	Reason string `json:"reason"`

	// Outcome is Reason as a stable identifier, for a caller that branches on how the
	// run ended rather than displaying it. Derived from Reason by OutcomeFor and stamped
	// once, by the host, where a Result is turned into a run record — a direct Runtime
	// caller therefore sees it empty, which is why nothing inside this package reads it.
	Outcome string `json:"outcome"`

	// Err carries the script's own error message when Reason is an exception, a
	// denial, or a runtime failure. Never a Go error string with a wrapped chain —
	// the audience is a person reading Activity or a model correcting its own code.
	Err string `json:"err,omitempty"`

	// Value is the JSON-marshalled return value of run(). Absent unless the run
	// succeeded.
	Value json.RawMessage `json:"value,omitempty"`

	Logs          []LogLine `json:"logs,omitempty"`
	LogsTruncated bool      `json:"logsTruncated,omitempty"`

	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"`

	// Budget is what this run was actually held to, after the operator's global was
	// applied. Reported rather than left implicit because a count with nothing to read
	// it against explains nothing — and because a model that asked for more than it got
	// learns the real number here, which the static tool schema cannot tell it.
	Budget Budget `json:"budget"`

	DurationMs int64 `json:"durationMs"`
}

Result is the outcome of a run. It is always returned, including on failure: a run that timed out still has logs, a call count, and whatever it managed to do, and those are exactly what the operator needs in order to understand it.

func (Result) OK

func (r Result) OK() bool

OK reports whether the run completed normally.

type Runtime

type Runtime interface {
	Run(ctx context.Context, req Request, bridge HostBridge) (Result, error)
}

Runtime executes a program. Implementations must honor ctx cancellation by ending the run, not merely by returning: a Runtime that leaves a script executing after Run returns would let cancelled work keep calling capabilities.

type StorageBridge

type StorageBridge interface {
	Storage(ctx context.Context, op, key string, value 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 VM

type VM struct{}

VM runs JavaScript with goja in the calling process.

Terminating a run means interrupting the VM, which goja honors at loop back-edges and calls — reliable for computation, and the reason a plain interrupt is enough for timeouts. It is not enough for memory: goja has no allocation ceiling of any kind, so the heap is sampled and the VM interrupted on breach. That check is a mitigation, not a guarantee, because a single expression can allocate past the ceiling between two samples.

Which is why Joro does not use this type directly for untrusted code. It is the engine WorkerRuntime drives inside a process whose death costs nothing.

func New

func New() *VM

New returns an in-process runtime.

func (*VM) Run

func (v *VM) Run(ctx context.Context, req Request, bridge HostBridge) (res Result, err error)

Run executes one program. The returned error is reserved for a failure of the runtime itself; everything the script does or fails to do — a syntax error, a throw, a timeout, an exhausted budget — comes back as a Result with a reason, so a caller has exactly one thing to report.

type WorkerRuntime

type WorkerRuntime struct {

	// Env is the child's environment. Nil inherits the parent's, which is the
	// default because the Go runtime reads a few variables and an empty environment
	// is not portable. It changes nothing about the sandbox either way: the script
	// has no way to read the process environment, since the global object holds no
	// accessor for one.
	Env []string
	// contains filtered or unexported fields
}

WorkerRuntime runs each program in a fresh child process.

func NewWorkerRuntime

func NewWorkerRuntime(exePath string, args ...string) *WorkerRuntime

NewWorkerRuntime returns a Runtime that spawns exePath with the given arguments. The caller supplies the argument that selects worker mode, so this package needs to know nothing about the host's command line.

func (*WorkerRuntime) Run

func (w *WorkerRuntime) Run(ctx context.Context, req Request, bridge HostBridge) (Result, error)

Run spawns a worker, forwards its capability calls to bridge, and returns its result.

The returned error means the worker could not be run or stopped talking mid-run. Everything the script did comes back in the Result, including when it was killed: a run terminated for memory still reports the calls it made and the logs it wrote, because those are what explain it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL