diff --git a/.gitignore b/.gitignore index 379d4ae89..27cdf5741 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,8 @@ dist/ # cursor .cursorindexingignore -.specstory/ \ No newline at end of file +.specstory/ + + +# picoclaw-node +cmd/picoclaw-node/config.json \ No newline at end of file diff --git a/cmd/picoclaw-node/Makefile b/cmd/picoclaw-node/Makefile new file mode 100644 index 000000000..efe819ff6 --- /dev/null +++ b/cmd/picoclaw-node/Makefile @@ -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)" diff --git a/cmd/picoclaw-node/README.md b/cmd/picoclaw-node/README.md new file mode 100644 index 000000000..8e2b7a883 --- /dev/null +++ b/cmd/picoclaw-node/README.md @@ -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 +} +``` + +或使用 rawCommand(shell -c): + +```json +{ + "rawCommand": "echo hello | wc -c" +} +``` diff --git a/cmd/picoclaw-node/camera_mock.jpg b/cmd/picoclaw-node/camera_mock.jpg new file mode 100644 index 000000000..f20f05d15 Binary files /dev/null and b/cmd/picoclaw-node/camera_mock.jpg differ diff --git a/cmd/picoclaw-node/config.json.example b/cmd/picoclaw-node/config.json.example new file mode 100644 index 000000000..20cc3034c --- /dev/null +++ b/cmd/picoclaw-node/config.json.example @@ -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": "" + } +} \ No newline at end of file diff --git a/cmd/picoclaw-node/config/config.go b/cmd/picoclaw-node/config/config.go new file mode 100644 index 000000000..096626d6c --- /dev/null +++ b/cmd/picoclaw-node/config/config.go @@ -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) +} diff --git a/cmd/picoclaw-node/gateway/protocol.go b/cmd/picoclaw-node/gateway/protocol.go new file mode 100644 index 000000000..25d8c28b5 --- /dev/null +++ b/cmd/picoclaw-node/gateway/protocol.go @@ -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"` +} diff --git a/cmd/picoclaw-node/gateway/session.go b/cmd/picoclaw-node/gateway/session.go new file mode 100644 index 000000000..7892047a2 --- /dev/null +++ b/cmd/picoclaw-node/gateway/session.go @@ -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) +} diff --git a/cmd/picoclaw-node/go.mod b/cmd/picoclaw-node/go.mod new file mode 100644 index 000000000..0ca01c486 --- /dev/null +++ b/cmd/picoclaw-node/go.mod @@ -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 diff --git a/cmd/picoclaw-node/go.sum b/cmd/picoclaw-node/go.sum new file mode 100644 index 000000000..272772f06 --- /dev/null +++ b/cmd/picoclaw-node/go.sum @@ -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= diff --git a/cmd/picoclaw-node/infra/auth.go b/cmd/picoclaw-node/infra/auth.go new file mode 100644 index 000000000..37bcec316 --- /dev/null +++ b/cmd/picoclaw-node/infra/auth.go @@ -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, + }, "|") +} diff --git a/cmd/picoclaw-node/infra/device_identity.go b/cmd/picoclaw-node/infra/device_identity.go new file mode 100644 index 000000000..a27ee233f --- /dev/null +++ b/cmd/picoclaw-node/infra/device_identity.go @@ -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) +} diff --git a/cmd/picoclaw-node/main.go b/cmd/picoclaw-node/main.go new file mode 100644 index 000000000..dc04aa187 --- /dev/null +++ b/cmd/picoclaw-node/main.go @@ -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 +} diff --git a/cmd/picoclaw-node/node/handlers/camera_snap.go b/cmd/picoclaw-node/node/handlers/camera_snap.go new file mode 100644 index 000000000..ca3ef77ad --- /dev/null +++ b/cmd/picoclaw-node/node/handlers/camera_snap.go @@ -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") +} diff --git a/cmd/picoclaw-node/node/handlers/file_save.go b/cmd/picoclaw-node/node/handlers/file_save.go new file mode 100644 index 000000000..2324cbb72 --- /dev/null +++ b/cmd/picoclaw-node/node/handlers/file_save.go @@ -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), ¶ms); 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") +} diff --git a/cmd/picoclaw-node/node/handlers/handler.go b/cmd/picoclaw-node/node/handlers/handler.go new file mode 100644 index 000000000..02178b65c --- /dev/null +++ b/cmd/picoclaw-node/node/handlers/handler.go @@ -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 +} diff --git a/cmd/picoclaw-node/node/handlers/system_run.go b/cmd/picoclaw-node/node/handlers/system_run.go new file mode 100644 index 000000000..e5fc89e49 --- /dev/null +++ b/cmd/picoclaw-node/node/handlers/system_run.go @@ -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), ¶ms); 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 +} diff --git a/cmd/picoclaw-node/node/invoke_dispatcher.go b/cmd/picoclaw-node/node/invoke_dispatcher.go new file mode 100644 index 000000000..0f1f09bdc --- /dev/null +++ b/cmd/picoclaw-node/node/invoke_dispatcher.go @@ -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) +}