feat(extension): split the session capability, and bound one queued prompt

session.read and session.submit are separate capabilities, so a mod that only
reads usage cannot put words in front of the model. One prompt a mod queues is
bounded by [mods] submit_bytes (8 KiB, 0 for none) and refused with a typed
identity that does not spend the per-turn count. The docs say the turn a queued
prompt starts is held to this session's own approvals and sandbox.
This commit is contained in:
esengine committed 2026-10-18 04:01:29 -07:00
1 parent 96f261b398
commit 2a9743d790
17 files changed
+90 -28

No files matched your search

+2 -2
View File
@@ -104,9 +104,9 @@ Streams follow `extension/provider/stream/open` → `stream/chunk` →
## Structured UI
Extensions with the `session` capability read the session (`host/session/read`: `usage` or `messages`, redacted and bounded by the user's ceilings) and queue a prompt (`host/prompt/submit`).
Extensions with the `session.read` capability read the session (`host/session/read`: `usage` or `messages`, redacted and bounded by the user's ceilings) and those with `session.submit` queue a prompt (`host/prompt/submit`); neither implies the other.
A queued prompt is the host's message about the extension, labelled with its source, never the user's words; the host may refuse it with a `refused` identity in the result (`submit_per_turn`, `no_session`).
A queued prompt is the host's message about the extension, labelled with its source, never the user's words; the host may refuse it with a `refused` identity in the result (`submit_per_turn`, `submit_bytes`, `no_session`).
Extensions with the `ui` capability publish `status`, `card`, `form`, `notification`, `panel`, and `view` payloads (`host/ui/publish`) and ask questions (`host/ui/request`: confirm, input, select, multiselect).
+7 -3
View File
@@ -769,15 +769,19 @@ Events served:
response's token counts. A mod that calls them is listed in the install
preview as reading the session.
- `prompt.submit(text)`: queues a prompt as the mod's own words, under the host's
header, never as yours; `asUser` is not served. Over `[mods] submit_per_turn`
(3 between two prompts you write) it rejects with `mod.submit_rate`, and with
`mod.submit_refused` while the session has no saved file.
header, never as yours; `asUser` is not served.
- It rejects with `mod.submit_rate` over `[mods] submit_per_turn` (3 between two
prompts you write), with `mod.submit_too_long` over `[mods] submit_bytes`
(8 KiB, 0 for none), and with `mod.submit_refused` while the session has no saved file.
- A call that needs an answer made while `session.start` runs rejects with
`mod.not_ready`: that hook runs before the session is ready.
- `tool.register/call/list` and `command.register/run/list`. Both register
while `session.start` runs.
- `ui.toast`, `status`, `notice`, `log`, `ask`, `resolve`, `invalidate`.
The turn a queued prompt starts is held to this session's own approvals and
sandbox like any other turn; that, not the label, is what keeps it safe.
The ceilings above are in the user file only, and `0` removes one.
Anything else is registered and named in the mod's status as unserved; a call to
@@ -94,7 +94,7 @@ export function register(on) {
});
}`
// A mod that declared the session capability reads the transcript and queues a
// A mod that declared the session capabilities reads the transcript and queues a
// prompt; the model sees the queued text labelled as the mod's own, never as the
// user's, and the read reaches it only through the context the mod chose to add.
func TestEffectAModQueuesAPromptThatReachesTheModelLabelledAsTheMods(t *testing.T) {
@@ -42,6 +42,8 @@ func (s extensionSession) SubmitPrompt(_ context.Context, plugin string, p proto
switch err := c.QueueExtensionPrompt(plugin, p.Text); {
case errors.Is(err, control.ErrExtensionSubmitRate):
return protocol.PromptSubmitResult{Refused: protocol.PromptSubmitRefusedPerTurn}, nil
case errors.Is(err, control.ErrExtensionSubmitTooLong):
return protocol.PromptSubmitResult{Refused: protocol.PromptSubmitRefusedTooLong}, nil
case errors.Is(err, control.ErrInboxNeedsSession):
return protocol.PromptSubmitResult{Refused: protocol.PromptSubmitRefusedNoSession}, nil
case err != nil:
+9 -4
View File
@@ -27,6 +27,8 @@ type ModsConfig struct {
// write: it stops a mod feeding the session to itself. 0 removes it, a
// deliberate act. User file only.
SubmitPerTurn int `toml:"submit_per_turn"`
// SubmitBytes bounds one prompt a mod queues; 0 removes the ceiling. User file only.
SubmitBytes int `toml:"submit_bytes"`
}
// Defaults of the ceilings above.
@@ -35,15 +37,16 @@ const (
DefaultModsContextBlocks = 8
DefaultModsMessagesMax = 200
DefaultModsSubmitPerTurn = 3
DefaultModsSubmitBytes = 8192
)
func defaultMods() ModsConfig {
return ModsConfig{ContextBytes: DefaultModsContextBytes, ContextBlocks: DefaultModsContextBlocks, MessagesMax: DefaultModsMessagesMax, SubmitPerTurn: DefaultModsSubmitPerTurn}
return ModsConfig{ContextBytes: DefaultModsContextBytes, ContextBlocks: DefaultModsContextBlocks, MessagesMax: DefaultModsMessagesMax, SubmitPerTurn: DefaultModsSubmitPerTurn, SubmitBytes: DefaultModsSubmitBytes}
}
// ModsLimits are the user's ceilings on what a mod may add to a session.
type ModsLimits struct {
ContextBytes, ContextBlocks, MessagesMax, SubmitPerTurn int
ContextBytes, ContextBlocks, MessagesMax, SubmitPerTurn, SubmitBytes int
}
// ModsLimits reads the ceilings; a nil config answers the defaults.
@@ -52,7 +55,7 @@ func (c *Config) ModsLimits() ModsLimits {
if c != nil {
m = c.Mods
}
return ModsLimits{ContextBytes: max(m.ContextBytes, 0), ContextBlocks: max(m.ContextBlocks, 0), MessagesMax: max(m.MessagesMax, 0), SubmitPerTurn: max(m.SubmitPerTurn, 0)}
return ModsLimits{ContextBytes: max(m.ContextBytes, 0), ContextBlocks: max(m.ContextBlocks, 0), MessagesMax: max(m.MessagesMax, 0), SubmitPerTurn: max(m.SubmitPerTurn, 0), SubmitBytes: max(m.SubmitBytes, 0)}
}
// ModsEnabled reports whether mods run.
@@ -82,6 +85,7 @@ func (h heldScope) narrowMods(c *Config) {
{"mods.context_blocks", c.Mods.ContextBlocks, h.mods.ContextBlocks},
{"mods.messages_max", c.Mods.MessagesMax, h.mods.MessagesMax},
{"mods.submit_per_turn", c.Mods.SubmitPerTurn, h.mods.SubmitPerTurn},
{"mods.submit_bytes", c.Mods.SubmitBytes, h.mods.SubmitBytes},
} {
if f.have != f.was {
c.ignoreProject(f.key, fmt.Sprint(f.have), ProjectUserOnly)
@@ -92,7 +96,7 @@ func (h heldScope) narrowMods(c *Config) {
func renderModsSection(b *strings.Builder, c *Config) {
custom := c.Mods.ContextBytes != DefaultModsContextBytes || c.Mods.ContextBlocks != DefaultModsContextBlocks ||
c.Mods.MessagesMax != DefaultModsMessagesMax || c.Mods.SubmitPerTurn != DefaultModsSubmitPerTurn
c.Mods.MessagesMax != DefaultModsMessagesMax || c.Mods.SubmitPerTurn != DefaultModsSubmitPerTurn || c.Mods.SubmitBytes != DefaultModsSubmitBytes
if !c.Mods.Enabled && c.Mods.HookBudgetMS == 0 && !custom {
return
}
@@ -106,6 +110,7 @@ func renderModsSection(b *strings.Builder, c *Config) {
fmt.Fprintf(b, "context_blocks = %d # blocks a mod may add to one turn; 0 for no ceiling\n", c.Mods.ContextBlocks)
fmt.Fprintf(b, "messages_max = %d # transcript items one read by a mod may return; 0 for no ceiling\n", c.Mods.MessagesMax)
fmt.Fprintf(b, "submit_per_turn = %d # prompts mods may queue between two prompts you write; 0 for no ceiling\n", c.Mods.SubmitPerTurn)
fmt.Fprintf(b, "submit_bytes = %d # bytes of one prompt a mod queues; 0 for no ceiling\n", c.Mods.SubmitBytes)
}
b.WriteString("\n")
}
+2 -2
View File
@@ -8,7 +8,7 @@ import (
)
func TestTheModCeilingsHaveDefaultsAndAZeroIsAnExplicitChoice(t *testing.T) {
want := ModsLimits{ContextBytes: 8192, ContextBlocks: 8, MessagesMax: 200, SubmitPerTurn: 3}
want := ModsLimits{ContextBytes: 8192, ContextBlocks: 8, MessagesMax: 200, SubmitPerTurn: 3, SubmitBytes: 8192}
if got := Default().ModsLimits(); got != want {
t.Fatalf("defaults = %+v, want %+v", got, want)
}
@@ -21,7 +21,7 @@ func TestTheModCeilingsHaveDefaultsAndAZeroIsAnExplicitChoice(t *testing.T) {
// A repository could otherwise lift the ceiling on what a mod adds to a turn.
func TestAProjectFileCannotRaiseWhatAModMayAdd(t *testing.T) {
cfg, _ := loadScoped(t, "", "[mods]\ncontext_bytes = 0\ncontext_blocks = 0\nmessages_max = 0\nsubmit_per_turn = 0\n")
cfg, _ := loadScoped(t, "", "[mods]\ncontext_bytes = 0\ncontext_blocks = 0\nmessages_max = 0\nsubmit_per_turn = 0\nsubmit_bytes = 0\n")
if got, want := cfg.ModsLimits(), Default().ModsLimits(); got != want {
t.Fatalf("a project file moved the ceilings to %+v", got)
}
@@ -71,6 +71,7 @@ var constraints = map[string]Entry{
"mods.context_blocks": {Min: 0, Max: 1024, Bounded: true},
"mods.messages_max": {Min: 0, Max: 100000, Bounded: true},
"mods.submit_per_turn": {Min: 0, Max: 1000, Bounded: true},
"mods.submit_bytes": {Min: 0, Max: 1 << 24, Bounded: true},
"agent.checkpoint_ceiling_ratio": {Min: 0.05, Max: 1, Bounded: true},
"agent.compact_force_ratio": {Min: 0.05, Max: 1, Bounded: true},
"agent.soft_compact_ratio": {Min: 0.05, Max: 1, Bounded: true},
+1
View File
@@ -238,6 +238,7 @@ var table = []Entry{
{ID: "mods.context_blocks", Class: Guarded, Effect: NextTurn},
{ID: "mods.messages_max", Class: Guarded, Effect: NextTurn},
{ID: "mods.submit_per_turn", Class: Guarded, Effect: NextTurn},
{ID: "mods.submit_bytes", Class: Guarded, Effect: NextTurn},
{ID: "peers.enabled", Class: Elevated, Effect: NextTurn},
{ID: "peers.dispatch", Class: Elevated, Effect: NextTurn},
{ID: "peers.release_projects", Class: Guarded, Effect: NextTurn},
@@ -1,7 +1,7 @@
package protocol
// Session DTOs: a read-only view of the session, and a queued prompt, for an
// extension that declared the "session" capability. Both ride Extension → Host
// extension that declared the "session.read" or "session.submit" capability. Both ride Extension → Host
// requests; text an extension sends reaches the model only as data from it.
// SessionReadKind names what host/session/read returns.
@@ -50,6 +50,9 @@ type PromptSubmitParams struct {
// queued prompts is spent.
const PromptSubmitRefusedPerTurn = "submit_per_turn"
// PromptSubmitRefusedTooLong is Refused when one prompt is over the user's byte ceiling.
const PromptSubmitRefusedTooLong = "submit_bytes"
// PromptSubmitRefusedNoSession is Refused when the session has no saved path yet,
// so there is nowhere to queue.
const PromptSubmitRefusedNoSession = "no_session"
@@ -16,11 +16,12 @@ type SessionHandler interface {
SubmitPrompt(ctx context.Context, p protocol.PromptSubmitParams) (protocol.PromptSubmitResult, error)
}
// sessionHandler is the handler for a call that needs the "session" capability:
// the runtime must have declared it, and the host must serve it.
func (c *Client) sessionHandler(method protocol.Method) (SessionHandler, error) {
if c.rt == nil || !containsString(c.rt.Capabilities, "session") {
return nil, &protocol.ProtocolError{Reason: protocol.ErrCapabilityNotDeclared, Message: fmt.Sprintf("extension %s called %s without the session capability", c.pluginID, method)}
// sessionHandler is the handler for a call that needs a session capability:
// "session.read" for reads, "session.submit" for queued prompts. The runtime
// must have declared it, and the host must serve it.
func (c *Client) sessionHandler(method protocol.Method, capability string) (SessionHandler, error) {
if c.rt == nil || !containsString(c.rt.Capabilities, capability) {
return nil, &protocol.ProtocolError{Reason: protocol.ErrCapabilityNotDeclared, Message: fmt.Sprintf("extension %s called %s without the %s capability", c.pluginID, method, capability)}
}
h, ok := c.ui.(SessionHandler)
if !ok {
@@ -34,7 +35,7 @@ func (c *Client) handleSessionRead(ctx context.Context, raw json.RawMessage) (an
if err != nil {
return nil, protocol.MustProtocolError(protocol.ErrInvalidParams).RPCError()
}
h, err := c.sessionHandler(protocol.MethodHostSessionRead)
h, err := c.sessionHandler(protocol.MethodHostSessionRead, "session.read")
if err != nil {
return nil, mapHandlerError(err)
}
@@ -50,7 +51,7 @@ func (c *Client) handlePromptSubmit(ctx context.Context, raw json.RawMessage) (a
if err != nil {
return nil, protocol.MustProtocolError(protocol.ErrInvalidParams).RPCError()
}
h, err := c.sessionHandler(protocol.MethodHostPromptSubmit)
h, err := c.sessionHandler(protocol.MethodHostPromptSubmit, "session.submit")
if err != nil {
return nil, mapHandlerError(err)
}
@@ -29,7 +29,8 @@ func sessionClient(caps []string, ui UIHandler) *Client {
}
// Session reads and prompt submissions are served only to a runtime that
// declared the session capability, whatever the host could answer.
// declared the matching capability (session.read, session.submit), whatever the
// host could answer.
func TestSessionCallsNeedTheDeclaredCapability(t *testing.T) {
ui := &sessionUI{}
read := json.RawMessage(`{"what":"messages","limit":5}`)
@@ -46,7 +47,18 @@ func TestSessionCallsNeedTheDeclaredCapability(t *testing.T) {
t.Fatalf("the host was asked %d reads and %d submits before the capability check", ui.reads, ui.submits)
}
with := sessionClient([]string{"session"}, ui)
// Each capability opens only its own call: a runtime that may read the session
// cannot put words in front of the model, and the reverse.
readOnly := sessionClient([]string{"session.read"}, ui)
if _, err := readOnly.handlePromptSubmit(context.Background(), submit); err == nil {
t.Fatal("a submit was served to a runtime that declared only session.read")
}
submitOnly := sessionClient([]string{"session.submit"}, ui)
if _, err := submitOnly.handleSessionRead(context.Background(), read); err == nil {
t.Fatal("a read was served to a runtime that declared only session.submit")
}
with := sessionClient([]string{"session.read", "session.submit"}, ui)
got, err := with.handleSessionRead(context.Background(), read)
if err != nil || got.(protocol.SessionReadResult).Messages[0].Text != "messages" {
t.Fatalf("read = %v, %v", got, err)
@@ -58,7 +70,7 @@ func TestSessionCallsNeedTheDeclaredCapability(t *testing.T) {
// A host that serves UI but not sessions says so, the same way it does for UI.
func TestSessionCallsWithoutAHostHandlerAreUnknownMethods(t *testing.T) {
c := sessionClient([]string{"session"}, unavailableUIHandler{})
c := sessionClient([]string{"session.read", "session.submit"}, unavailableUIHandler{})
if _, err := c.handleSessionRead(context.Background(), json.RawMessage(`{"what":"usage"}`)); err == nil {
t.Fatal("a read was served with no host handler")
}
+1 -1
View File
@@ -124,7 +124,7 @@ func Synthesize(d Deps, m Module, rt Runtime, script string) pluginpkg.Installed
KillOnTimeout: true,
TimeoutMillis: timeout,
Intercepts: append([]string(nil), Intercepts...),
Capabilities: []string{"interceptors", "ui", "tools", "session"},
Capabilities: []string{"interceptors", "ui", "tools", "session.read", "session.submit"},
}
pkg := pluginpkg.Package{Root: m.Root, ManifestKind: m.Installed.ManifestKind, Manifest: pluginpkg.Manifest{Name: m.Plugin, Version: m.Installed.Version, Runtime: spec}}
installed := m.Installed
+1
View File
@@ -256,6 +256,7 @@ const $ = {
if (opts?.asUser) throw unsupported("$.prompt.submit with asUser");
const r = await callHost("host/prompt/submit", { text: String(text ?? "") });
if (r.refused === "submit_per_turn") throw new ModError("mod.submit_rate", "the ceiling on prompts mods may queue between two of yours is spent");
if (r.refused === "submit_bytes") throw new ModError("mod.submit_too_long", "the prompt is longer than the ceiling on one prompt a mod may queue");
if (r.refused) throw new ModError("mod.submit_refused", `the host refused the prompt: ${r.refused}`);
return { queued: r.queued === true };
},
+1 -1
View File
@@ -379,7 +379,7 @@ var runtimeNamedSlots = map[string]bool{
"frontend_events": true,
}
var runtimeCapabilities = []string{"interceptors", "strategies", "providers", "ui", "tools", "session"}
var runtimeCapabilities = []string{"interceptors", "strategies", "providers", "ui", "tools", "session.read", "session.submit"}
var runtimeToolName = regexp.MustCompile(`^[A-Za-z0-9_-]{1,48}$`)
+1 -1
View File
@@ -357,7 +357,7 @@ func TestManifestV2RuntimeValidation(t *testing.T) {
{"unknown replace slot", `{"command": "./run", "replaces": ["systemprompt"]}`, `runtime.replaces: unknown slot "systemprompt"`},
{"bad tool slot", `{"command": "./run", "replaces": ["tool:"]}`, `runtime.replaces: invalid tool slot "tool:"`},
{"bad provider slot", `{"command": "./run", "replaces": ["provider:openai"]}`, `runtime.replaces: invalid provider slot "provider:openai"`},
{"unknown capability", `{"command": "./run", "capabilities": ["filesystem"]}`, `runtime.capabilities: unknown capability "filesystem" (want one of: interceptors, strategies, providers, ui, tools, session)`},
{"unknown capability", `{"command": "./run", "capabilities": ["filesystem"]}`, `runtime.capabilities: unknown capability "filesystem" (want one of: interceptors, strategies, providers, ui, tools, session.read, session.submit)`},
{"tools without capability", `{"command": "./run", "tools": [{"name": "t", "description": "d", "inputSchema": {"type": "object"}}]}`, `runtime.tools needs "tools" in runtime.capabilities`},
{"bad tool name", `{"command": "./run", "capabilities": ["tools"], "tools": [{"name": "a b", "description": "d", "inputSchema": {"type": "object"}}]}`, `runtime.tools[0].name "a b"`},
{"duplicate tool", `{"command": "./run", "capabilities": ["tools"], "tools": [{"name": "t", "description": "d", "inputSchema": {"type": "object"}}, {"name": "t", "description": "d", "inputSchema": {"type": "object"}}]}`, `runtime.tools: "t" is declared twice`},
@@ -17,7 +17,9 @@ import (
// Typed identities of the refusals an extension's session calls can get.
var (
// ErrExtensionSubmitRate: the extension queued its share of prompts this turn.
ErrExtensionSubmitRate = errors.New("extensions have queued as many prompts since you last wrote as the submit_per_turn ceiling allows")
// ErrExtensionSubmitTooLong: one prompt over the user's submit_bytes ceiling.
ErrExtensionSubmitTooLong = errors.New("an extension prompt is longer than the submit_bytes ceiling allows")
ErrExtensionSubmitRate = errors.New("extensions have queued as many prompts since you last wrote as the submit_per_turn ceiling allows")
// ErrExtensionReadKind: a read of something the host does not serve.
ErrExtensionReadKind = errors.New("the host serves only usage and messages")
)
@@ -63,7 +65,11 @@ func (c *Controller) QueueExtensionPrompt(plugin, text string) error {
if strings.TrimSpace(text) == "" {
return errors.New("an extension prompt must have text")
}
if !c.extPrompts.take(config.LoadForEdit(config.UserConfigPath()).ModsLimits().SubmitPerTurn) {
limits := config.LoadForEdit(config.UserConfigPath()).ModsLimits()
if limits.SubmitBytes > 0 && len(text) > limits.SubmitBytes {
return ErrExtensionSubmitTooLong
}
if !c.extPrompts.take(limits.SubmitPerTurn) {
return ErrExtensionSubmitRate
}
source := tool.Provenance{Kind: tool.ProvenanceExtension, Source: plugin}
@@ -2,6 +2,7 @@ package control
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
@@ -9,6 +10,7 @@ import (
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/ext/extension/protocol"
@@ -100,6 +102,30 @@ func TestAnExtensionMayQueueOnlyTheUsersCountPerTurn(t *testing.T) {
}
}
// One prompt is bounded by the user's byte ceiling, and a refused one does not
// spend the per-turn count.
func TestAnExtensionPromptOverTheByteCeilingIsRefusedAndSpendsNoCount(t *testing.T) {
c, r := heldController(t)
startTurn(t, c, r)
long := strings.Repeat("x", config.DefaultModsSubmitBytes+1)
for range 5 {
if err := c.QueueExtensionPrompt("mod-x", long); !errors.Is(err, ErrExtensionSubmitTooLong) {
t.Fatalf("an over-long prompt: %v", err)
}
}
if err := c.QueueExtensionPrompt("mod-x", strings.Repeat("x", config.DefaultModsSubmitBytes)); err != nil {
t.Fatalf("a prompt at the ceiling after five refusals: %v", err)
}
path := filepath.Join(os.Getenv("REASONIX_HOME"), "config.toml")
if err := os.WriteFile(path, []byte("[mods]\nsubmit_bytes = 0\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := c.QueueExtensionPrompt("mod-x", long); err != nil {
t.Fatalf("submit_bytes = 0 still refused: %v", err)
}
}
func TestAnExtensionReadsTheLastMessagesRedactedAndBounded(t *testing.T) {
t.Setenv("REASONIX_HOME", t.TempDir())
dir := testenv.TempDir(t)