function: picoclaw node, can link with gateway and execute system command

This commit is contained in:
陈波0668000637 2026-03-02 14:29:51 +08:00
parent f22f6006f8
commit 3c9dca7bef
18 changed files with 1774 additions and 1 deletions

6
.gitignore vendored
View file

@ -50,4 +50,8 @@ dist/
# cursor
.cursorindexingignore
.specstory/
.specstory/
# picoclaw-node
cmd/picoclaw-node/config.json

177
cmd/picoclaw-node/Makefile Normal file
View file

@ -0,0 +1,177 @@
.PHONY: all build install uninstall clean help test
# Build variables
BINARY_NAME=picoclaw-node
BUILD_DIR=build
# Version
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS=-ldflags "-s -w"
# Go variables
GO?=CGO_ENABLED=0 go
GOFLAGS?=-v
# Golangci-lint
GOLANGCI_LINT?=golangci-lint
# Installation
INSTALL_PREFIX?=$(HOME)/.local
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
INSTALL_TMP_SUFFIX=.new
# Data dir (config, identity, etc.)
OPENCLAW_HOME?=$(HOME)/.openclaw
# OS detection
UNAME_S:=Linux
UNAME_M:=aarch64
# Platform-specific settings
ifeq ($(UNAME_S),Linux)
PLATFORM=linux
ifeq ($(UNAME_M),x86_64)
ARCH=amd64
else ifeq ($(UNAME_M),aarch64)
ARCH=arm64
else ifeq ($(UNAME_M),loongarch64)
ARCH=loong64
else ifeq ($(UNAME_M),riscv64)
ARCH=riscv64
else ifeq ($(UNAME_M),armv7l)
ARCH=arm
else
ARCH=$(UNAME_M)
endif
else ifeq ($(UNAME_S),Darwin)
PLATFORM=darwin
ifeq ($(UNAME_M),x86_64)
ARCH=amd64
else ifeq ($(UNAME_M),arm64)
ARCH=arm64
else
ARCH=$(UNAME_M)
endif
else
PLATFORM=$(UNAME_S)
ARCH=$(UNAME_M)
endif
BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
# Default target
all: build
## build: Build the go_node binary for current platform
build:
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
GOOS=$(PLATFORM) GOARCH=$(ARCH) $(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) .
@echo "Build complete: $(BINARY_PATH)"
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
## build-all: Build go_node for all platforms
build-all:
@echo "Building for multiple platforms..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 .
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 .
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 .
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 .
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 .
GOOS=darwin GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 .
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe .
@echo "All builds complete"
## install: Install go_node to system
install: build
@echo "Installing $(BINARY_NAME)..."
@mkdir -p $(INSTALL_BIN_DIR)
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
@mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
@echo "Installation complete!"
## uninstall: Remove go_node from system
uninstall:
@echo "Uninstalling $(BINARY_NAME)..."
@rm -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@echo "Removed binary from $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
@echo "Note: Only the executable has been deleted."
@echo "To remove config and identity, run 'make uninstall-all'"
## uninstall-all: Remove go_node and all data (~/.openclaw)
uninstall-all:
@echo "Removing $(OPENCLAW_HOME)..."
@rm -rf $(OPENCLAW_HOME)
@make uninstall
@echo "Complete uninstallation done!"
## clean: Remove build artifacts
clean:
@echo "Cleaning build artifacts..."
@rm -rf $(BUILD_DIR)
@echo "Clean complete"
## vet: Run go vet for static analysis
vet:
@$(GO) vet ./...
## test: Test Go code
test:
@$(GO) test ./...
## fmt: Format Go code
fmt:
@go fmt ./...
## lint: Run linters
lint:
@$(GOLANGCI_LINT) run
## fix: Fix linting issues
fix:
@$(GOLANGCI_LINT) run --fix
## deps: Download dependencies
deps:
@$(GO) mod download
@$(GO) mod verify
## update-deps: Update dependencies
update-deps:
@$(GO) get -u ./...
@$(GO) mod tidy
## check: Run vet, fmt, and test
check: deps fmt vet test
## run: Build and run go_node
run: build
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
## help: Show this help message
help:
@echo "go_node Makefile"
@echo ""
@echo "Usage:"
@echo " make [target]"
@echo ""
@echo "Targets:"
@grep -E '^## ' $(MAKEFILE_LIST) | sort | awk -F': ' '{printf " %-16s %s\n", substr($$1, 4), $$2}'
@echo ""
@echo "Examples:"
@echo " make build # Build for current platform"
@echo " make install # Install to ~/.local/bin"
@echo " make uninstall # Remove binary"
@echo ""
@echo "Environment Variables:"
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"
@echo " OPENCLAW_HOME # Data dir (default: ~/.openclaw)"
@echo " VERSION # Version string (default: git describe)"
@echo ""
@echo "Current Configuration:"
@echo " Platform: $(PLATFORM)/$(ARCH)"
@echo " Binary: $(BINARY_PATH)"
@echo " Install Prefix: $(INSTALL_PREFIX)"

107
cmd/picoclaw-node/README.md Normal file
View file

@ -0,0 +1,107 @@
# go_node
Go 实现的 OpenClaw node 节点,与 Android node 架构一致,通过 WebSocket 连接 Gateway仅实现 `system.run`(执行 shell 命令)。
## 架构
- **GatewaySession**WebSocket 连接、connect 握手、RPC 协议、`node.invoke.request` 事件处理
- **InvokeDispatcher**:仅支持 `system.run` 命令,执行 shell 并返回 stdout/stderr
- **DeviceIdentity**Ed25519 设备身份、connect 时 device 签名
## 配置文件
配置通过 JSON 文件管理,支持以下路径(按优先级):
- `-config` 参数指定的路径
- 当前目录 `config.json`
- `~/.openclaw/go_node.json`
### 配置示例
```json
{
"gateway": {
"host": "127.0.0.1",
"port": 18789,
"tls": false,
"token": "your-gateway-token",
"password": ""
},
"node": {
"displayName": "my-node",
"nodeId": "my-node"
},
"reconnect": {
"maxRetries": 0,
"retryIntervalMs": 5000
},
"exec": {
"workDir": "/tmp/go_node_work",
"allowedCommands": ["ls", "echo", "cat"],
"allowAllCommands": false
}
}
```
| 字段 | 说明 | 默认 |
|------|------|------|
| gateway.host | Gateway 地址 | 127.0.0.1 |
| gateway.port | Gateway 端口 | 18789 |
| gateway.tls | 是否使用 WSS | false |
| gateway.token | Gateway 令牌 | - |
| gateway.password | Gateway 密码 | - |
| node.displayName | 节点显示名 | hostname |
| node.nodeId | 节点 ID | 同 displayName |
| reconnect.maxRetries | 最大重连次数0=无限) | 0 |
| reconnect.retryIntervalMs | 重连间隔(毫秒) | 5000 |
| exec.workDir | 工作目录,命令仅在此目录及子目录下执行,空=不限制 | - |
| exec.allowedCommands | 可执行命令白名单(按 argv[0] 的 basename空=允许所有 | - |
| exec.allowAllCommands | 为 true 时忽略白名单,允许执行任意命令(含 rawCommand | false |
**安全说明**
- `workDir` 非空时:`params.cwd` 必须在 workDir 下(相对路径或绝对路径均校验)
- `allowedCommands` 非空时:仅白名单中的命令可执行,且禁用 rawCommand只支持 command 数组)
- `allowAllCommands: true` 时: bypass 白名单,允许任意命令和 rawCommand适用于完全信任的节点环境
> 注意go_node 使用 client ID `node-host`,与 openclaw node-host 相同,以便通过 gateway 的 client ID 校验。
## 构建
```bash
go build -o go_node .
```
## 运行
```bash
# 生成示例配置
./go_node -init-config
# 或指定路径
./go_node -init-config -config ~/.openclaw/go_node.json
# 使用默认 config.json 运行
./go_node
# 指定配置文件
./go_node -config /path/to/config.json
```
## system.run 参数
与 openclaw node-host 兼容paramsJSON 示例:
```json
{
"command": ["echo", "hello"],
"cwd": "/tmp",
"env": {"KEY": "value"},
"timeoutMs": 30000
}
```
或使用 rawCommandshell -c
```json
{
"rawCommand": "echo hello | wc -c"
}
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

View file

@ -0,0 +1,27 @@
{
"gateway": {
"host": "127.0.0.1",
"port": 18790,
"tls": false,
"token": "",
"password": ""
},
"node": {
"displayName": "my_device",
"nodeId": "my_device"
},
"reconnect": {
"maxRetries": 0,
"retryIntervalMs": 5000
},
"exec": {
"workDir": "",
"allowedCommands": null,
"allowAllCommands": true
},
"identity": {
"deviceId": "",
"publicKeyB64": "",
"privateKeyB64": ""
}
}

View file

@ -0,0 +1,174 @@
package config
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/openclaw/go_node/infra"
)
// Config holds gateway and node settings
type Config struct {
Gateway GatewayConfig `json:"gateway"`
Node NodeConfig `json:"node"`
Reconnect ReconnectConfig `json:"reconnect"`
Exec ExecConfig `json:"exec"`
Identity IdentityConfig `json:"identity,omitempty"`
}
// ExecConfig holds security settings for system.run and camera mock
type ExecConfig struct {
WorkDir string `json:"workDir"` // base dir, commands run under this only
AllowedCommands []string `json:"allowedCommands"` // allowed executable names, empty = allow all
AllowAllCommands bool `json:"allowAllCommands"` // when true, bypass allowedCommands check and allow any command (including rawCommand)
CameraMockPath string `json:"cameraMockPath"` // fixed path for camera.snap mock image (default: workDir/camera_mock.jpg)
}
// ReconnectConfig holds auto-reconnect settings
type ReconnectConfig struct {
MaxRetries int `json:"maxRetries"` // 0 = unlimited
RetryIntervalMs int `json:"retryIntervalMs"` // milliseconds between retries
}
// GatewayConfig holds gateway connection settings
type GatewayConfig struct {
Host string `json:"host"`
Port int `json:"port"`
TLS bool `json:"tls"`
Token string `json:"token"`
Password string `json:"password"`
}
// NodeConfig holds node identity settings
type NodeConfig struct {
DisplayName string `json:"displayName"`
NodeID string `json:"nodeId"`
}
// IdentityConfig holds device identity (Ed25519 keypair) persisted in config.json
type IdentityConfig struct {
DeviceID string `json:"deviceId,omitempty"`
PublicKeyB64 string `json:"publicKeyB64,omitempty"`
PrivateKeyB64 string `json:"privateKeyB64,omitempty"`
}
// Load loads config from JSON file. Path can be absolute or relative.
// If path is empty, tries: ./config.json, ~/.openclaw/go_node.json
func Load(path string) (*Config, error) {
if path == "" {
for _, p := range defaultConfigPaths() {
if _, err := os.Stat(p); err == nil {
path = p
break
}
}
}
if path == "" {
return defaultConfig(), nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
cfg.applyDefaults()
return &cfg, nil
}
func defaultConfigPaths() []string {
home, _ := os.UserHomeDir()
return []string{
"config.json",
filepath.Join(home, ".openclaw", "go_node.json"),
}
}
func defaultConfig() *Config {
displayName, _ := os.Hostname()
return &Config{
Gateway: GatewayConfig{
Host: "127.0.0.1",
Port: 18789,
TLS: false,
},
Node: NodeConfig{
DisplayName: displayName,
NodeID: displayName,
},
Reconnect: ReconnectConfig{
MaxRetries: 0,
RetryIntervalMs: 5000,
},
Exec: ExecConfig{
WorkDir: "",
AllowedCommands: nil,
},
}
}
func (c *Config) applyDefaults() {
g := &c.Gateway
if g.Host == "" {
g.Host = "127.0.0.1"
}
if g.Port <= 0 {
g.Port = 18789
}
if c.Node.DisplayName == "" {
c.Node.DisplayName, _ = os.Hostname()
}
if c.Node.NodeID == "" {
c.Node.NodeID = c.Node.DisplayName
}
r := &c.Reconnect
if r.RetryIntervalMs <= 0 {
r.RetryIntervalMs = 5000
}
e := &c.Exec
if e.WorkDir != "" {
abs, err := filepath.Abs(e.WorkDir)
if err == nil {
e.WorkDir = abs
}
}
}
// WebSocketURL returns the gateway WebSocket URL
func (c *Config) WebSocketURL() string {
scheme := "ws"
if c.Gateway.TLS {
scheme = "wss"
}
return fmt.Sprintf("%s://%s:%d", scheme, c.Gateway.Host, c.Gateway.Port)
}
// Example writes an example config to path
func Example(path string) error {
cfg := defaultConfig()
cfg.Gateway.Token = ""
if ident, err := infra.GenerateDeviceIdentity(); err == nil {
cfg.Identity = IdentityConfig{
DeviceID: ident.DeviceID,
PublicKeyB64: base64.StdEncoding.EncodeToString(ident.PublicKeyRaw),
PrivateKeyB64: base64.StdEncoding.EncodeToString(ident.PrivateKey),
}
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(path)
if dir != "." {
_ = os.MkdirAll(dir, 0755)
}
return os.WriteFile(path, data, 0600)
}

View file

@ -0,0 +1,93 @@
package gateway
import "encoding/json"
// Protocol version (see openclaw src/gateway/protocol/schema.ts)
const ProtocolVersion = 3
// Frame types
const (
FrameTypeReq = "req"
FrameTypeRes = "res"
FrameTypeEvent = "event"
)
// Events
const (
EventConnectChallenge = "connect.challenge"
EventNodeInvokeReq = "node.invoke.request"
)
// Methods
const (
MethodConnect = "connect"
MethodNodeEvent = "node.event"
MethodNodeInvokeRes = "node.invoke.result"
)
// ConnectParams for node role
type ConnectParams struct {
MinProtocol int `json:"minProtocol"`
MaxProtocol int `json:"maxProtocol"`
Client ClientInfo `json:"client"`
Role string `json:"role"`
Scopes []string `json:"scopes,omitempty"`
Caps []string `json:"caps,omitempty"`
Commands []string `json:"commands,omitempty"`
Permissions map[string]bool `json:"permissions,omitempty"`
Auth *AuthParams `json:"auth,omitempty"`
Device *DeviceAuth `json:"device,omitempty"`
Locale string `json:"locale"`
UserAgent string `json:"userAgent,omitempty"`
}
type ClientInfo struct {
ID string `json:"id"`
DisplayName *string `json:"displayName,omitempty"`
Version string `json:"version"`
Platform string `json:"platform"`
Mode string `json:"mode"`
InstanceID *string `json:"instanceId,omitempty"`
DeviceFamily *string `json:"deviceFamily,omitempty"`
ModelIdentifier *string `json:"modelIdentifier,omitempty"`
}
type AuthParams struct {
Token string `json:"token,omitempty"`
Password string `json:"password,omitempty"`
}
type DeviceAuth struct {
ID string `json:"id"`
PublicKey string `json:"publicKey"`
Signature string `json:"signature"`
SignedAt int64 `json:"signedAt"`
Nonce string `json:"nonce"`
}
type RequestFrame struct {
Type string `json:"type"`
ID string `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type ResponseFrame struct {
Type string `json:"type"`
ID string `json:"id"`
OK bool `json:"ok"`
Payload json.RawMessage `json:"payload,omitempty"`
Error *ErrorShape `json:"error,omitempty"`
}
type EventFrame struct {
Type string `json:"type"`
Event string `json:"event"`
Payload json.RawMessage `json:"payload,omitempty"`
Seq *int64 `json:"seq,omitempty"`
}
type ErrorShape struct {
Code string `json:"code"`
Message string `json:"message"`
}

View file

@ -0,0 +1,303 @@
package gateway
import (
"context"
crand "crypto/rand"
"encoding/hex"
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/openclaw/go_node/infra"
)
// InvokeRequest is the payload of node.invoke.request event
type InvokeRequest struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
Command string `json:"command"`
ParamsJSON string `json:"paramsJSON,omitempty"`
TimeoutMs *int64 `json:"timeoutMs,omitempty"`
}
// InvokeResult is sent via node.invoke.result
type InvokeResult struct {
OK bool `json:"ok"`
Payload interface{} `json:"payload,omitempty"`
PayloadJSON string `json:"payloadJSON,omitempty"`
Error *ErrorShape `json:"error,omitempty"`
}
// OnInvoke is called when node.invoke.request is received
type OnInvoke func(req InvokeRequest) InvokeResult
// Session connects to the gateway as a node role and handles node.invoke.request
type Session struct {
url string
token string
password string
opts ConnectOptions
identity *infra.DeviceIdentity
onInvoke OnInvoke
conn *websocket.Conn
writeMu sync.Mutex
pending map[string]chan json.RawMessage
pendingMu sync.Mutex
connectNonce string
}
// ConnectOptions for node role
type ConnectOptions struct {
Client ClientInfo
Role string // "node"
Scopes []string
Caps []string
Commands []string
Locale string
UserAgent string
}
// NewSession creates a new node session
func NewSession(url, token, password string, opts ConnectOptions, identity *infra.DeviceIdentity, onInvoke OnInvoke) *Session {
return &Session{
url: url,
token: token,
password: password,
opts: opts,
identity: identity,
onInvoke: onInvoke,
pending: make(map[string]chan json.RawMessage),
}
}
// Run connects to the gateway and processes messages until ctx is done
func (s *Session) Run(ctx context.Context) error {
headers := http.Header{}
headers.Set("Origin", s.url)
if s.url[:5] == "wss:" {
headers.Set("Origin", "https"+s.url[3:])
}
conn, _, err := websocket.DefaultDialer.Dial(s.url, headers)
if err != nil {
return err
}
defer conn.Close()
s.conn = conn
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
<-ctx.Done()
conn.Close()
}()
for {
_, data, err := conn.ReadMessage()
if err != nil {
return err
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
continue
}
typ, _ := raw["type"]
var typStr string
_ = json.Unmarshal(typ, &typStr)
switch typStr {
case FrameTypeEvent:
s.handleEvent(data)
case FrameTypeRes:
s.handleResponse(data)
}
}
}
func (s *Session) handleEvent(data []byte) {
var evt EventFrame
if err := json.Unmarshal(data, &evt); err != nil {
return
}
if evt.Event == EventConnectChallenge {
var payload struct {
Nonce string `json:"nonce"`
}
_ = json.Unmarshal(evt.Payload, &payload)
s.connectNonce = payload.Nonce
if s.connectNonce != "" {
s.sendConnect()
}
return
}
if evt.Event == EventNodeInvokeReq && s.onInvoke != nil {
var req struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
Command string `json:"command"`
ParamsJSON string `json:"paramsJSON"`
Params json.RawMessage `json:"params"`
TimeoutMs *int64 `json:"timeoutMs"`
}
_ = json.Unmarshal(evt.Payload, &req)
paramsJSON := req.ParamsJSON
if paramsJSON == "" && len(req.Params) > 0 {
paramsJSON = string(req.Params)
}
invReq := InvokeRequest{
ID: req.ID,
NodeID: req.NodeID,
Command: req.Command,
ParamsJSON: paramsJSON,
TimeoutMs: req.TimeoutMs,
}
result := s.onInvoke(invReq)
s.sendInvokeResult(invReq.ID, invReq.NodeID, result)
}
}
func (s *Session) handleResponse(data []byte) {
var res struct {
ID json.RawMessage `json:"id"`
}
_ = json.Unmarshal(data, &res)
var idStr string
if err := json.Unmarshal(res.ID, &idStr); err != nil {
return
}
s.pendingMu.Lock()
ch := s.pending[idStr]
delete(s.pending, idStr)
s.pendingMu.Unlock()
if ch != nil {
select {
case ch <- data:
default:
}
}
}
func (s *Session) sendConnect() {
auth := &AuthParams{}
if s.token != "" {
auth.Token = s.token
} else if s.password != "" {
auth.Password = s.password
}
device := (*DeviceAuth)(nil)
if s.connectNonce != "" && s.identity != nil {
signedAtMs := time.Now().UnixMilli()
payload := infra.BuildDeviceAuthPayload(struct {
DeviceID string
ClientID string
ClientMode string
Role string
Scopes []string
SignedAtMs int64
Token string
Nonce string
}{
s.identity.DeviceID,
s.opts.Client.ID,
s.opts.Client.Mode,
s.opts.Role,
s.opts.Scopes,
signedAtMs,
s.token,
s.connectNonce,
})
sig := infra.SignDevicePayload(s.identity, payload)
device = &DeviceAuth{
ID: s.identity.DeviceID,
PublicKey: infra.PublicKeyBase64URL(s.identity),
Signature: sig,
SignedAt: signedAtMs,
Nonce: s.connectNonce,
}
}
params := ConnectParams{
MinProtocol: ProtocolVersion,
MaxProtocol: ProtocolVersion,
Client: s.opts.Client,
Role: s.opts.Role,
Scopes: s.opts.Scopes,
Caps: s.opts.Caps,
Commands: s.opts.Commands,
Auth: auth,
Device: device,
Locale: s.opts.Locale,
UserAgent: s.opts.UserAgent,
}
paramsB, _ := json.Marshal(params)
req := map[string]interface{}{
"type": FrameTypeReq,
"id": genID(),
"method": MethodConnect,
"params": json.RawMessage(paramsB),
}
_ = s.request(req, 15*time.Second)
log.Printf("go_node: connect sent")
}
func (s *Session) sendInvokeResult(id, nodeID string, result InvokeResult) {
params := map[string]interface{}{
"id": id,
"nodeId": nodeID,
"ok": result.OK,
}
if result.Payload != nil {
params["payload"] = result.Payload
}
if result.PayloadJSON != "" {
params["payloadJSON"] = result.PayloadJSON
}
if result.Error != nil {
params["error"] = result.Error
}
req := map[string]interface{}{
"type": FrameTypeReq,
"id": genID(),
"method": MethodNodeInvokeRes,
"params": params,
}
if err := s.request(req, 15*time.Second); err != nil {
log.Printf("go_node: node.invoke.result failed: %v", err)
}
}
func (s *Session) request(req map[string]interface{}, timeout time.Duration) error {
id, _ := req["id"].(string)
ch := make(chan json.RawMessage, 1)
s.pendingMu.Lock()
s.pending[id] = ch
s.pendingMu.Unlock()
defer func() {
s.pendingMu.Lock()
delete(s.pending, id)
s.pendingMu.Unlock()
}()
s.writeMu.Lock()
err := s.conn.WriteJSON(req)
s.writeMu.Unlock()
if err != nil {
return err
}
select {
case <-time.After(timeout):
return nil
case <-ch:
return nil
}
}
func genID() string {
b := make([]byte, 8)
crand.Read(b)
return "go-node-" + hex.EncodeToString(b)
}

7
cmd/picoclaw-node/go.mod Normal file
View file

@ -0,0 +1,7 @@
module github.com/openclaw/go_node
go 1.21
require github.com/gorilla/websocket v1.5.1
require golang.org/x/net v0.17.0 // indirect

4
cmd/picoclaw-node/go.sum Normal file
View file

@ -0,0 +1,4 @@
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=

View file

@ -0,0 +1,35 @@
package infra
import (
"fmt"
"strings"
)
// BuildDeviceAuthPayload builds the string that gets signed for connect device auth
// Format: v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
func BuildDeviceAuthPayload(params struct {
DeviceID string
ClientID string
ClientMode string
Role string
Scopes []string
SignedAtMs int64
Token string
Nonce string
}) string {
scopes := strings.Join(params.Scopes, ",")
if params.Token == "" && params.Nonce == "" {
return ""
}
return strings.Join([]string{
"v2",
params.DeviceID,
params.ClientID,
params.ClientMode,
params.Role,
scopes,
fmt.Sprintf("%d", params.SignedAtMs),
params.Token,
params.Nonce,
}, "|")
}

View file

@ -0,0 +1,57 @@
package infra
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
)
// DeviceIdentity holds Ed25519 keypair and derived device ID
type DeviceIdentity struct {
DeviceID string
PublicKeyRaw []byte // raw 32-byte Ed25519 public key
PrivateKey ed25519.PrivateKey
}
// GenerateDeviceIdentity generates a new device identity (no file I/O; suitable for embedded)
func GenerateDeviceIdentity() (*DeviceIdentity, error) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
return nil, err
}
return &DeviceIdentity{
DeviceID: fingerprintPublicKey(pub),
PublicKeyRaw: pub,
PrivateKey: priv,
}, nil
}
func fingerprintPublicKey(pub ed25519.PublicKey) string {
h := sha256.Sum256(pub)
return encodeHex(h[:])
}
func encodeHex(b []byte) string {
const hex = "0123456789abcdef"
out := make([]byte, len(b)*2)
for i, v := range b {
out[i*2] = hex[v>>4]
out[i*2+1] = hex[v&0xf]
}
return string(out)
}
// SignDevicePayload signs the auth payload string with Ed25519, returns base64url signature
func SignDevicePayload(ident *DeviceIdentity, payload string) string {
sig := ed25519.Sign(ident.PrivateKey, []byte(payload))
return base64URLEncode(sig)
}
// PublicKeyBase64URL returns raw public key as base64url
func PublicKeyBase64URL(ident *DeviceIdentity) string {
return base64URLEncode(ident.PublicKeyRaw)
}
func base64URLEncode(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}

153
cmd/picoclaw-node/main.go Normal file
View file

@ -0,0 +1,153 @@
package main
import (
"context"
"crypto/ed25519"
"encoding/base64"
"flag"
"log"
"os"
"os/signal"
"runtime"
"strings"
"time"
"github.com/openclaw/go_node/config"
"github.com/openclaw/go_node/gateway"
"github.com/openclaw/go_node/infra"
"github.com/openclaw/go_node/node"
)
const version = "0.1.0"
func main() {
configPath := flag.String("config", "", "path to config.json (default: config.json, ~/.openclaw/go_node.json)")
initConfig := flag.Bool("init-config", false, "write example config.json and exit")
flag.Parse()
if *initConfig {
path := strings.TrimSpace(*configPath)
if path == "" {
path = "config.json"
}
if err := config.Example(path); err != nil {
log.Fatalf("init config: %v", err)
}
log.Printf("wrote example config to %s", path)
return
}
cfg, err := config.Load(strings.TrimSpace(*configPath))
if err != nil {
log.Fatalf("load config: %v", err)
}
identity, err := identityFromConfig(cfg)
if err != nil {
log.Fatalf("load device identity: %v", err)
}
invokeDispatcher := node.NewInvokeDispatcher(cfg.Exec)
opts := gateway.ConnectOptions{
Client: gateway.ClientInfo{
ID: "node-host",
Version: version,
Platform: runtime.GOOS,
Mode: "node",
},
Role: "node",
Scopes: []string{},
Caps: []string{"system", "file", "camera"},
Commands: []string{"system.run", "file.save", "camera.snap"},
Locale: "en-US",
UserAgent: "OpenClawGoNode/" + version + " (" + runtime.GOOS + "; " + runtime.Version() + ")",
}
if cfg.Node.DisplayName != "" {
opts.Client.DisplayName = &cfg.Node.DisplayName
}
if cfg.Node.NodeID != "" {
opts.Client.InstanceID = &cfg.Node.NodeID
}
onInvoke := func(req gateway.InvokeRequest) gateway.InvokeResult {
return invokeDispatcher.HandleInvoke(req)
}
sess := gateway.NewSession(
cfg.WebSocketURL(),
cfg.Gateway.Token,
cfg.Gateway.Password,
opts,
identity,
onInvoke,
)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt)
go func() {
<-sigCh
log.Println("shutting down...")
cancel()
}()
interval := time.Duration(cfg.Reconnect.RetryIntervalMs) * time.Millisecond
if interval <= 0 {
interval = 5 * time.Second
}
var attempt int
reconnect:
for {
if ctx.Err() != nil {
break reconnect
}
if attempt > 0 {
log.Printf("go_node: reconnect attempt %d", attempt)
} else {
log.Printf("go_node: connecting to %s as node %q", cfg.WebSocketURL(), cfg.Node.DisplayName)
}
err := sess.Run(ctx)
if ctx.Err() != nil {
break reconnect
}
attempt++
if cfg.Reconnect.MaxRetries > 0 && attempt > cfg.Reconnect.MaxRetries {
log.Fatalf("go_node: max retries (%d) reached, last error: %v", cfg.Reconnect.MaxRetries, err)
}
log.Printf("go_node: connection lost: %v, retrying in %v", err, interval)
select {
case <-time.After(interval):
continue
case <-ctx.Done():
break reconnect
}
}
log.Println("go_node: exited")
}
// identityFromConfig builds a DeviceIdentity from cfg.Identity.
// If identity information is missing or invalid, it falls back to generating
// a fresh in-memory identity (not persisted).
func identityFromConfig(cfg *config.Config) (*infra.DeviceIdentity, error) {
idCfg := cfg.Identity
if idCfg.DeviceID == "" || idCfg.PublicKeyB64 == "" || idCfg.PrivateKeyB64 == "" {
return infra.GenerateDeviceIdentity()
}
pubRaw, err := base64.StdEncoding.DecodeString(idCfg.PublicKeyB64)
if err != nil || len(pubRaw) != ed25519.PublicKeySize {
return infra.GenerateDeviceIdentity()
}
privRaw, err := base64.StdEncoding.DecodeString(idCfg.PrivateKeyB64)
if err != nil || len(privRaw) != ed25519.PrivateKeySize {
return infra.GenerateDeviceIdentity()
}
return &infra.DeviceIdentity{
DeviceID: idCfg.DeviceID,
PublicKeyRaw: pubRaw,
PrivateKey: ed25519.PrivateKey(privRaw),
}, nil
}

View file

@ -0,0 +1,85 @@
package handlers
import (
"encoding/base64"
"encoding/json"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"log"
"os"
"path/filepath"
"strings"
"github.com/openclaw/go_node/config"
"github.com/openclaw/go_node/gateway"
)
// CameraSnapHandler handles camera.snap command: mock by reading a fixed image file and returning base64
type CameraSnapHandler struct {
cfg config.ExecConfig
}
// NewCameraSnapHandler creates a mock handler that reads from a fixed path
func NewCameraSnapHandler(cfg config.ExecConfig) *CameraSnapHandler {
return &CameraSnapHandler{cfg: cfg}
}
// Handle reads the mock image from fixed path, base64-encodes, and returns camera.snap payload
func (h *CameraSnapHandler) Handle(req gateway.InvokeRequest) gateway.InvokeResult {
path := h.mockPath()
data, err := os.ReadFile(path)
if err != nil {
return gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "UNAVAILABLE",
Message: fmt.Sprintf("camera.snap mock: read file %s: %v", path, err),
},
}
}
width, height := 0, 0
if cfg, _, err := image.DecodeConfig(strings.NewReader(string(data))); err == nil {
width, height = cfg.Width, cfg.Height
}
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), "."))
if ext == "" {
ext = "jpg"
}
if ext == "jpeg" {
ext = "jpg"
}
b64 := base64.StdEncoding.EncodeToString(data)
log.Printf("go_node: camera.snap mock path=%s bytes=%d width=%d height=%d", path, len(data), width, height)
out := map[string]any{
"format": ext,
"base64": b64,
"width": width,
"height": height,
}
payload, _ := json.Marshal(out)
return gateway.InvokeResult{
OK: true,
PayloadJSON: string(payload),
}
}
func (h *CameraSnapHandler) mockPath() string {
if p := strings.TrimSpace(h.cfg.CameraMockPath); p != "" {
return p
}
workDir := strings.TrimSpace(h.cfg.WorkDir)
if workDir == "" {
workDir, _ = os.Getwd()
}
if workDir == "" {
return "camera_mock.jpg"
}
return filepath.Join(workDir, "camera_mock.jpg")
}

View file

@ -0,0 +1,133 @@
package handlers
import (
"encoding/base64"
"encoding/json"
"log"
"os"
"path/filepath"
"strings"
"github.com/openclaw/go_node/config"
"github.com/openclaw/go_node/gateway"
)
// FileSaveParams holds params for file.save (from picoclaw nodes tool)
type FileSaveParams struct {
Base64 string `json:"base64"`
Format string `json:"format"`
Filename string `json:"filename"`
}
// FileSaveHandler handles file.save command: save base64-encoded image to local disk
type FileSaveHandler struct {
cfg config.ExecConfig
}
// NewFileSaveHandler creates a handler that saves files under exec.workDir/saved
func NewFileSaveHandler(cfg config.ExecConfig) *FileSaveHandler {
return &FileSaveHandler{cfg: cfg}
}
// Handle decodes base64 image and saves to local directory
func (h *FileSaveHandler) Handle(req gateway.InvokeRequest) gateway.InvokeResult {
var params FileSaveParams
paramsJSON := strings.TrimSpace(req.ParamsJSON)
if paramsJSON == "" {
return gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "INVALID_REQUEST",
Message: "paramsJSON required",
},
}
}
if err := json.Unmarshal([]byte(paramsJSON), &params); err != nil {
return gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "INVALID_REQUEST",
Message: "invalid paramsJSON: " + err.Error(),
},
}
}
if params.Base64 == "" {
return gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "INVALID_REQUEST",
Message: "base64 required",
},
}
}
data, err := base64.StdEncoding.DecodeString(params.Base64)
if err != nil {
return gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "INVALID_REQUEST",
Message: "base64 decode failed: " + err.Error(),
},
}
}
filename := strings.TrimSpace(params.Filename)
if filename == "" {
ext := strings.TrimSpace(strings.ToLower(params.Format))
if ext == "" {
ext = "bin"
}
filename = "file." + ext
}
// Sanitize filename: remove path traversal and dangerous chars
filename = filepath.Base(filename)
if filename == "" || filename == "." {
filename = "file.bin"
}
saveDir := h.saveDir()
if err := os.MkdirAll(saveDir, 0o755); err != nil {
return gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "UNAVAILABLE",
Message: "mkdir failed: " + err.Error(),
},
}
}
outPath := filepath.Join(saveDir, filename)
if err := os.WriteFile(outPath, data, 0o644); err != nil {
return gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "UNAVAILABLE",
Message: "write file failed: " + err.Error(),
},
}
}
absPath, _ := filepath.Abs(outPath)
log.Printf("go_node: file.save path=%s bytes=%d", absPath, len(data))
payload, _ := json.Marshal(map[string]any{
"path": absPath,
"bytes": len(data),
"format": params.Format,
})
return gateway.InvokeResult{
OK: true,
PayloadJSON: string(payload),
}
}
func (h *FileSaveHandler) saveDir() string {
workDir := strings.TrimSpace(h.cfg.WorkDir)
if workDir == "" {
return filepath.Join(os.TempDir(), "go_node_saved")
}
return filepath.Join(workDir, "saved")
}

View file

@ -0,0 +1,8 @@
package handlers
import "github.com/openclaw/go_node/gateway"
// Handler handles a specific invoke command
type Handler interface {
Handle(req gateway.InvokeRequest) gateway.InvokeResult
}

View file

@ -0,0 +1,360 @@
package handlers
import (
"bytes"
"encoding/json"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/openclaw/go_node/config"
"github.com/openclaw/go_node/gateway"
)
const outputCap = 200_000 // max combined stdout+stderr bytes
// SystemRunParams matches openclaw node-host invoke-types SystemRunParams
type SystemRunParams struct {
Command []string `json:"command"`
RawCommand *string `json:"rawCommand,omitempty"`
CWD *string `json:"cwd,omitempty"`
Env map[string]string `json:"env,omitempty"`
TimeoutMs *int64 `json:"timeoutMs,omitempty"`
}
// RunResult matches openclaw invoke-types RunResult
type RunResult struct {
ExitCode *int `json:"exitCode,omitempty"`
TimedOut bool `json:"timedOut"`
Success bool `json:"success"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Error string `json:"error,omitempty"`
Truncated bool `json:"truncated"`
}
// SystemRunHandler handles system.run command
type SystemRunHandler struct {
cfg config.ExecConfig
}
// NewSystemRunHandler creates a handler with exec security config
func NewSystemRunHandler(cfg config.ExecConfig) *SystemRunHandler {
return &SystemRunHandler{cfg: cfg}
}
// Handle executes shell command and returns result
func (h *SystemRunHandler) Handle(req gateway.InvokeRequest) gateway.InvokeResult {
var params SystemRunParams
paramsJSON := strings.TrimSpace(req.ParamsJSON)
if paramsJSON == "" {
return gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "INVALID_REQUEST",
Message: "paramsJSON required",
},
}
}
if err := json.Unmarshal([]byte(paramsJSON), &params); err != nil {
return gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "INVALID_REQUEST",
Message: "invalid paramsJSON: " + err.Error(),
},
}
}
argv := params.Command
if len(argv) == 0 && params.RawCommand != nil {
if !h.cfg.AllowAllCommands && len(h.cfg.AllowedCommands) > 0 {
return gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "RAW_COMMAND_NOT_ALLOWED",
Message: "RAW_COMMAND_NOT_ALLOWED: rawCommand disabled when allowedCommands is set (unless allowAllCommands=true)",
},
}
}
raw := strings.TrimSpace(*params.RawCommand)
if raw == "" {
return gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "INVALID_REQUEST",
Message: "command required",
},
}
}
shell, args := shellExec()
argv = append([]string{shell}, append(args, raw)...)
}
if len(argv) == 0 {
return gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "INVALID_REQUEST",
Message: "command required",
},
}
}
if errRes := h.checkAllowed(argv[0]); errRes != nil {
return *errRes
}
cwd, errRes := h.resolveCWD(params.CWD)
if errRes != nil {
return *errRes
}
log.Printf("go_node: system.run argv=%v cwd=%s", argv, cwd)
timeoutMs := int64(60_000)
if params.TimeoutMs != nil && *params.TimeoutMs > 0 {
timeoutMs = *params.TimeoutMs
}
result := runCommand(argv, &cwd, params.Env, timeoutMs)
payload, _ := json.Marshal(result)
return gateway.InvokeResult{
OK: result.Success,
PayloadJSON: string(payload),
}
}
func (h *SystemRunHandler) checkAllowed(cmd string) *gateway.InvokeResult {
if h.cfg.AllowAllCommands || len(h.cfg.AllowedCommands) == 0 {
return nil // ok, allow all
}
name := filepath.Base(strings.TrimSpace(cmd))
for _, allowed := range h.cfg.AllowedCommands {
if name == strings.TrimSpace(allowed) {
return nil // ok
}
}
return &gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "COMMAND_NOT_ALLOWED",
Message: "COMMAND_NOT_ALLOWED: " + name + " is not in allowedCommands",
},
}
}
func (h *SystemRunHandler) resolveCWD(paramCWD *string) (string, *gateway.InvokeResult) {
workDir := strings.TrimSpace(h.cfg.WorkDir)
if workDir == "" {
if paramCWD != nil && strings.TrimSpace(*paramCWD) != "" {
abs, err := filepath.Abs(*paramCWD)
if err != nil {
return "", &gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "INVALID_REQUEST",
Message: "invalid cwd: " + err.Error(),
},
}
}
return abs, nil
}
wd, _ := os.Getwd()
return wd, nil
}
if paramCWD == nil || strings.TrimSpace(*paramCWD) == "" {
if fi, err := os.Stat(workDir); err != nil || !fi.IsDir() {
return "", &gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "WORKDIR_INVALID",
Message: "WORKDIR_INVALID: exec.workDir must be an existing directory: " + workDir,
},
}
}
return workDir, nil
}
if fi, err := os.Stat(workDir); err != nil || !fi.IsDir() {
return "", &gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "WORKDIR_INVALID",
Message: "WORKDIR_INVALID: exec.workDir must be an existing directory: " + workDir,
},
}
}
req := strings.TrimSpace(*paramCWD)
var abs string
if filepath.IsAbs(req) {
abs = req
} else {
abs = filepath.Join(workDir, req)
}
abs, err := filepath.Abs(abs)
if err != nil {
return "", &gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "INVALID_REQUEST",
Message: "invalid cwd: " + err.Error(),
},
}
}
rel, err := filepath.Rel(workDir, abs)
if err != nil || strings.HasPrefix(rel, "..") {
return "", &gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "CWD_OUTSIDE_WORKDIR",
Message: "CWD_OUTSIDE_WORKDIR: cwd must be under workDir " + workDir,
},
}
}
return abs, nil
}
func shellExec() (shell string, args []string) {
if runtime.GOOS == "windows" {
return "cmd.exe", []string{"/c"}
}
return "/bin/sh", []string{"-c"}
}
func runCommand(argv []string, cwd *string, env map[string]string, timeoutMs int64) RunResult {
cmd := exec.Command(argv[0], argv[1:]...)
if cwd != nil && *cwd != "" {
cmd.Dir = *cwd
}
if len(env) > 0 {
cmd.Env = envToSlice(env)
}
var stdout, stderr bytes.Buffer
stdoutCap := &capWriter{w: &stdout, max: outputCap}
stderrCap := &capWriter{w: &stderr, max: outputCap}
cmd.Stdout = stdoutCap
cmd.Stderr = stderrCap
err := cmd.Start()
if err != nil {
return RunResult{
Success: false,
Stdout: stdout.String(),
Stderr: stderr.String(),
Error: err.Error(),
}
}
timeout := time.Duration(timeoutMs) * time.Millisecond
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case waitErr := <-done:
if waitErr != nil {
if exitErr, ok := waitErr.(*exec.ExitError); ok {
code := exitErr.ExitCode()
return RunResult{
ExitCode: &code,
TimedOut: false,
Success: false,
Stdout: truncateOutput(stdout.String(), outputCap),
Stderr: truncateOutput(stderr.String(), outputCap),
Truncated: stdoutCap.truncated || stderrCap.truncated,
}
}
return RunResult{
Success: false,
Stdout: truncateOutput(stdout.String(), outputCap),
Stderr: truncateOutput(stderr.String(), outputCap),
Error: waitErr.Error(),
Truncated: stdoutCap.truncated || stderrCap.truncated,
}
}
ec := 0
return RunResult{
ExitCode: &ec,
TimedOut: false,
Success: true,
Stdout: truncateOutput(stdout.String(), outputCap),
Stderr: truncateOutput(stderr.String(), outputCap),
Truncated: stdoutCap.truncated || stderrCap.truncated,
}
case <-time.After(timeout):
cmd.Process.Kill()
<-done
return RunResult{
TimedOut: true,
Success: false,
Stdout: truncateOutput(stdout.String(), outputCap),
Stderr: truncateOutput(stderr.String(), outputCap),
Error: "command timeout",
Truncated: stdoutCap.truncated || stderrCap.truncated,
}
}
}
func envToSlice(m map[string]string) []string {
base := os.Environ()
overrides := make(map[string]string)
for k, v := range m {
overrides[k] = v
}
if len(overrides) == 0 {
return base
}
seen := make(map[string]bool)
var out []string
for _, s := range base {
if idx := strings.IndexByte(s, '='); idx > 0 {
key := s[:idx]
if v, ok := overrides[key]; ok {
out = append(out, key+"="+v)
seen[key] = true
continue
}
}
out = append(out, s)
}
for k, v := range overrides {
if !seen[k] {
out = append(out, k+"="+v)
}
}
return out
}
func truncateOutput(s string, max int) string {
if len(s) <= max {
return s
}
return "... (truncated) " + s[len(s)-max:]
}
type capWriter struct {
w *bytes.Buffer
max int
written int
truncated bool
}
func (c *capWriter) Write(p []byte) (n int, err error) {
if c.written >= c.max {
c.truncated = true
return len(p), nil
}
rem := c.max - c.written
if len(p) > rem {
c.w.Write(p[:rem])
c.written += rem
c.truncated = true
return len(p), nil
}
c.w.Write(p)
c.written += len(p)
return len(p), nil
}

View file

@ -0,0 +1,46 @@
package node
import (
"github.com/openclaw/go_node/config"
"github.com/openclaw/go_node/gateway"
"github.com/openclaw/go_node/node/handlers"
)
// InvokeDispatcher routes node.invoke.request to registered handlers
type InvokeDispatcher struct {
handlers map[string]handlers.Handler
}
// NewInvokeDispatcher creates a dispatcher with default handlers using exec config
func NewInvokeDispatcher(execCfg config.ExecConfig) *InvokeDispatcher {
return &InvokeDispatcher{
handlers: map[string]handlers.Handler{
"system.run": handlers.NewSystemRunHandler(execCfg),
"file.save": handlers.NewFileSaveHandler(execCfg),
"camera.snap": handlers.NewCameraSnapHandler(execCfg),
},
}
}
// Register adds or overrides a handler for command
func (d *InvokeDispatcher) Register(command string, h handlers.Handler) {
if d.handlers == nil {
d.handlers = make(map[string]handlers.Handler)
}
d.handlers[command] = h
}
// HandleInvoke dispatches to the handler for the command, or returns error if unknown
func (d *InvokeDispatcher) HandleInvoke(req gateway.InvokeRequest) gateway.InvokeResult {
h, ok := d.handlers[req.Command]
if !ok {
return gateway.InvokeResult{
OK: false,
Error: &gateway.ErrorShape{
Code: "UNAVAILABLE",
Message: "command not supported",
},
}
}
return h.Handle(req)
}