Add an extension process protocol and package manager

Why: Tools, hooks, and providers need a way in without a Go rebuild, and packages need install, update, and removal.
Evidence: go test -count=1 ./runtime/ext/
Impact: Extensions run as subprocesses with the caller's privileges. They are not a sandbox.
Refs: feature/pi-headless N5
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-01 10:13:48 +00:00
co-authored by Cursor
parent dfd0d23bf3
commit 23b5e3f336
3 changed files with 516 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
package pkg
import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Manifest is the agent-native.json discovered in a package.
type Manifest struct {
Name string `json:"name"`
Extensions []string `json:"extensions,omitempty"`
Config map[string]string `json:"config,omitempty"`
}
// Manager installs packages under Dir.
type Manager struct {
Dir string
}
func (m Manager) root() string {
if m.Dir != "" {
return m.Dir
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".agent-native", "packages")
}
// Install copies a local directory, clones a git URL, or runs npm pack
// when src starts with "npm:". The package name comes from its manifest.
func (m Manager) Install(src string) (Manifest, error) {
dir, err := os.MkdirTemp("", "agent-native-pkg-*")
if err != nil {
return Manifest{}, err
}
defer os.RemoveAll(dir)
switch {
case strings.HasPrefix(src, "npm:"):
if _, err := exec.LookPath("npm"); err != nil {
return Manifest{}, fmt.Errorf("pkg: npm is not installed")
}
cmd := exec.Command("npm", "pack", strings.TrimPrefix(src, "npm:"))
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
return Manifest{}, fmt.Errorf("pkg: npm pack: %s", out)
}
case strings.HasPrefix(src, "git+") || strings.HasSuffix(src, ".git") || strings.Contains(src, "://"):
cmd := exec.Command("git", "clone", "--depth", "1", src, filepath.Join(dir, "src"))
if out, err := cmd.CombinedOutput(); err != nil {
return Manifest{}, fmt.Errorf("pkg: git clone: %s", out)
}
dir = filepath.Join(dir, "src")
default:
if err := copyDir(src, filepath.Join(dir, "src")); err != nil {
return Manifest{}, err
}
dir = filepath.Join(dir, "src")
}
manifest, err := ReadManifest(dir)
if err != nil {
return Manifest{}, err
}
dest := filepath.Join(m.root(), manifest.Name)
if err := os.RemoveAll(dest); err != nil {
return Manifest{}, err
}
if err := copyDir(dir, dest); err != nil {
return Manifest{}, err
}
return manifest, nil
}
// Remove deletes an installed package.
func (m Manager) Remove(name string) error {
return os.RemoveAll(filepath.Join(m.root(), name))
}
// Update reinstalls from the recorded source when present, otherwise no-ops.
func (m Manager) Update(name string) (Manifest, error) {
manifest, err := ReadManifest(filepath.Join(m.root(), name))
if err != nil {
return Manifest{}, err
}
src := manifest.Config["source"]
if src == "" {
return manifest, nil
}
return m.Install(src)
}
// List returns installed manifests.
func (m Manager) List() ([]Manifest, error) {
entries, err := os.ReadDir(m.root())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []Manifest
for _, ent := range entries {
if !ent.IsDir() {
continue
}
manifest, err := ReadManifest(filepath.Join(m.root(), ent.Name()))
if err != nil {
continue
}
out = append(out, manifest)
}
return out, nil
}
// Config sets one package config key.
func (m Manager) Config(name, key, value string) error {
path := filepath.Join(m.root(), name)
manifest, err := ReadManifest(path)
if err != nil {
return err
}
if manifest.Config == nil {
manifest.Config = map[string]string{}
}
manifest.Config[key] = value
raw, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(path, "agent-native.json"), raw, 0o644)
}
// ReadManifest loads agent-native.json from a directory.
func ReadManifest(dir string) (Manifest, error) {
raw, err := os.ReadFile(filepath.Join(dir, "agent-native.json"))
if err != nil {
return Manifest{}, err
}
var manifest Manifest
if err := json.Unmarshal(raw, &manifest); err != nil {
return Manifest{}, err
}
if manifest.Name == "" {
return Manifest{}, fmt.Errorf("pkg: manifest name is empty")
}
return manifest, nil
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o755)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode())
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
})
}
+235
View File
@@ -0,0 +1,235 @@
package ext
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"sync"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
// Message is one JSONL frame on the extension pipe.
type Message struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"`
Content string `json:"content,omitempty"`
IsError bool `json:"isError,omitempty"`
Event string `json:"event,omitempty"`
Block bool `json:"block,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
Protocol string `json:"protocol,omitempty"`
BaseURL string `json:"baseURL,omitempty"`
CustomType string `json:"customType,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
Command string `json:"command,omitempty"`
}
// UIRequest is a question an extension wants a human to answer.
type UIRequest struct {
ID string
Message string
}
// Process is one extension subprocess.
type Process struct {
cmd *exec.Cmd
stdin io.WriteCloser
mu sync.Mutex
tools []registeredTool
commands []string
providers []llm.Provider
pending map[string]chan Message
UI chan UIRequest
OnCustom func(customType string, data json.RawMessage)
closed bool
}
type registeredTool struct {
name string
description string
parameters json.RawMessage
proc *Process
}
func (t registeredTool) Name() string { return t.name }
func (t registeredTool) Description() string { return t.description }
func (t registeredTool) Parameters() json.RawMessage { return t.parameters }
func (t registeredTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
return t.proc.callTool(ctx, t.name, args)
}
// Start launches command and reads its registration frames until it goes idle
// or the context ends. command is argv, not a shell line.
func Start(ctx context.Context, command []string) (*Process, error) {
if len(command) == 0 {
return nil, fmt.Errorf("ext: empty command")
}
cmd := exec.CommandContext(ctx, command[0], command[1:]...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
p := &Process{
cmd: cmd, stdin: stdin, pending: map[string]chan Message{},
UI: make(chan UIRequest, 4),
}
go p.read(stdout)
if err := p.send(Message{Type: "init"}); err != nil {
_ = p.Close()
return nil, err
}
return p, nil
}
// Tools returns the tools the extension registered.
func (p *Process) Tools() []tools.Tool {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]tools.Tool, len(p.tools))
for i := range p.tools {
out[i] = p.tools[i]
}
return out
}
// Commands returns slash-command names.
func (p *Process) Commands() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.commands...)
}
// Providers returns endpoints the extension registered.
func (p *Process) Providers() []llm.Provider {
p.mu.Lock()
defer p.mu.Unlock()
return append([]llm.Provider(nil), p.providers...)
}
// Hook tells the extension about an event. A block result stops the host action.
func (p *Process) Hook(ctx context.Context, event, name string, args json.RawMessage) (bool, string) {
id := name + ":" + event
ch := make(chan Message, 1)
p.mu.Lock()
p.pending[id] = ch
p.mu.Unlock()
if err := p.send(Message{Type: "event", ID: id, Event: event, Name: name, Arguments: args}); err != nil {
return false, ""
}
select {
case <-ctx.Done():
return false, ctx.Err().Error()
case msg := <-ch:
return msg.Block, msg.Reason
}
}
func (p *Process) callTool(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) {
id := name
ch := make(chan Message, 1)
p.mu.Lock()
p.pending[id] = ch
p.mu.Unlock()
if err := p.send(Message{Type: "tool_call", ID: id, Name: name, Arguments: args}); err != nil {
return tools.Result{IsError: true, Content: err.Error()}, err
}
select {
case <-ctx.Done():
return tools.Result{IsError: true, Content: ctx.Err().Error()}, ctx.Err()
case msg := <-ch:
return tools.Result{Content: msg.Content, IsError: msg.IsError}, nil
}
}
func (p *Process) send(msg Message) error {
raw, err := json.Marshal(msg)
if err != nil {
return err
}
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return fmt.Errorf("ext: closed")
}
_, err = p.stdin.Write(append(raw, '\n'))
return err
}
func (p *Process) read(r io.Reader) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
var msg Message
if err := json.Unmarshal(sc.Bytes(), &msg); err != nil {
continue
}
p.dispatch(msg)
}
}
func (p *Process) dispatch(msg Message) {
switch msg.Type {
case "register_tool":
p.mu.Lock()
p.tools = append(p.tools, registeredTool{
name: msg.Name, description: msg.Description, parameters: msg.Parameters, proc: p,
})
p.mu.Unlock()
case "register_command":
p.mu.Lock()
p.commands = append(p.commands, msg.Name)
p.mu.Unlock()
case "register_provider":
p.mu.Lock()
p.providers = append(p.providers, llm.Provider{
Name: msg.Name, Protocol: llm.Protocol(msg.Protocol), BaseURL: msg.BaseURL,
})
p.mu.Unlock()
case "tool_result", "hook_result":
p.mu.Lock()
ch := p.pending[msg.ID]
delete(p.pending, msg.ID)
p.mu.Unlock()
if ch != nil {
ch <- msg
}
case "ui_request":
select {
case p.UI <- UIRequest{ID: msg.ID, Message: msg.Message}:
default:
}
case "custom_entry":
if p.OnCustom != nil {
p.OnCustom(msg.CustomType, msg.Data)
}
}
}
// Close stops the subprocess.
func (p *Process) Close() error {
p.mu.Lock()
p.closed = true
_ = p.stdin.Close()
p.mu.Unlock()
if p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
}
return p.cmd.Wait()
}
+100
View File
@@ -0,0 +1,100 @@
package ext
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"agent-native/runtime/ext/pkg"
)
func TestExtensionRegistersAndHooks(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "ext.sh")
body := `#!/bin/sh
while IFS= read -r line; do
case "$line" in
*'"type":"init"'*)
echo '{"type":"register_tool","name":"ping","description":"p","parameters":{"type":"object"}}'
echo '{"type":"register_command","name":"hi"}'
echo '{"type":"register_provider","name":"local","protocol":"openai-chat","baseURL":"http://127.0.0.1:9"}'
echo '{"type":"ui_request","id":"u1","message":"choose"}'
;;
*'"type":"tool_call"'*)
echo '{"type":"tool_result","id":"ping","content":"pong"}'
;;
*'"type":"event"'*)
echo '{"type":"hook_result","id":"ping:tool_call","block":true,"reason":"no"}'
;;
esac
done
`
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
proc, err := Start(ctx, []string{"/bin/sh", script})
if err != nil {
t.Fatal(err)
}
defer proc.Close()
deadline := time.Now().Add(2 * time.Second)
for len(proc.Tools()) == 0 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
tools := proc.Tools()
if len(tools) != 1 || tools[0].Name() != "ping" {
t.Fatalf("tools %+v", tools)
}
res, err := tools[0].Execute(ctx, []byte(`{}`))
if err != nil || res.Content != "pong" {
t.Fatalf("tool: %+v %v", res, err)
}
block, reason := proc.Hook(ctx, "tool_call", "ping", []byte(`{}`))
if !block || reason != "no" {
t.Fatalf("hook %v %s", block, reason)
}
if len(proc.Commands()) != 1 || len(proc.Providers()) != 1 {
t.Fatalf("commands %v providers %v", proc.Commands(), proc.Providers())
}
select {
case ui := <-proc.UI:
if ui.Message != "choose" {
t.Fatal(ui)
}
case <-time.After(time.Second):
t.Fatal("missing ui request")
}
}
func TestPackageInstallListConfigRemove(t *testing.T) {
src := t.TempDir()
if err := os.WriteFile(filepath.Join(src, "agent-native.json"), []byte(`{"name":"demo","extensions":["./ext.sh"]}`), 0o644); err != nil {
t.Fatal(err)
}
mgr := pkg.Manager{Dir: t.TempDir()}
manifest, err := mgr.Install(src)
if err != nil || manifest.Name != "demo" {
t.Fatalf("%+v %v", manifest, err)
}
if err := mgr.Config("demo", "source", src); err != nil {
t.Fatal(err)
}
list, err := mgr.List()
if err != nil || len(list) != 1 || list[0].Config["source"] != src {
t.Fatalf("list %+v %v", list, err)
}
if _, err := mgr.Update("demo"); err != nil {
t.Fatal(err)
}
if err := mgr.Remove("demo"); err != nil {
t.Fatal(err)
}
list, _ = mgr.List()
if len(list) != 0 {
t.Fatalf("still installed: %+v", list)
}
}