Merge branch 'sipeed:main' into main
This commit is contained in:
commit
d1df34a031
118 changed files with 7167 additions and 1451 deletions
8
.github/workflows/docker-build.yml
vendored
8
.github/workflows/docker-build.yml
vendored
|
|
@ -31,11 +31,11 @@ jobs:
|
||||||
|
|
||||||
# ── Docker Buildx ─────────────────────────
|
# ── Docker Buildx ─────────────────────────
|
||||||
- name: 🔧 Set up Docker Buildx
|
- name: 🔧 Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
# ── Login to GHCR ─────────────────────────
|
# ── Login to GHCR ─────────────────────────
|
||||||
- name: 🔑 Login to GitHub Container Registry
|
- name: 🔑 Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.GHCR_REGISTRY }}
|
registry: ${{ env.GHCR_REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|
@ -43,7 +43,7 @@ jobs:
|
||||||
|
|
||||||
# ── Login to Docker Hub ────────────────────
|
# ── Login to Docker Hub ────────────────────
|
||||||
- name: 🔑 Login to Docker Hub
|
- name: 🔑 Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
|
@ -62,7 +62,7 @@ jobs:
|
||||||
|
|
||||||
# ── Build & Push ──────────────────────────
|
# ── Build & Push ──────────────────────────
|
||||||
- name: 🚀 Build and push Docker image
|
- name: 🚀 Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|
|
||||||
8
.github/workflows/nightly.yml
vendored
8
.github/workflows/nightly.yml
vendored
|
|
@ -48,7 +48,7 @@ jobs:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
|
|
@ -59,17 +59,17 @@ jobs:
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
|
|
||||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
|
|
||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -66,7 +66,7 @@ jobs:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
|
|
@ -77,17 +77,17 @@ jobs:
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
|
|
||||||
61
Makefile
61
Makefile
|
|
@ -12,10 +12,11 @@ GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||||
BUILD_TIME=$(shell date +%FT%T%z)
|
BUILD_TIME=$(shell date +%FT%T%z)
|
||||||
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
|
||||||
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||||
LDFLAGS=-ldflags "-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w"
|
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
|
||||||
|
|
||||||
# Go variables
|
# Go variables
|
||||||
GO?=CGO_ENABLED=0 go
|
GO?=CGO_ENABLED=0 go
|
||||||
|
WEB_GO?=$(GO)
|
||||||
GOFLAGS?=-v -tags stdjson
|
GOFLAGS?=-v -tags stdjson
|
||||||
|
|
||||||
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
|
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
|
||||||
|
|
@ -79,6 +80,7 @@ ifeq ($(UNAME_S),Linux)
|
||||||
endif
|
endif
|
||||||
else ifeq ($(UNAME_S),Darwin)
|
else ifeq ($(UNAME_S),Darwin)
|
||||||
PLATFORM=darwin
|
PLATFORM=darwin
|
||||||
|
WEB_GO=CGO_ENABLED=1 go
|
||||||
ifeq ($(UNAME_M),x86_64)
|
ifeq ($(UNAME_M),x86_64)
|
||||||
ARCH=amd64
|
ARCH=amd64
|
||||||
else ifeq ($(UNAME_M),arm64)
|
else ifeq ($(UNAME_M),arm64)
|
||||||
|
|
@ -107,7 +109,7 @@ generate:
|
||||||
build: generate
|
build: generate
|
||||||
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
|
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
@$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||||
@echo "Build complete: $(BINARY_PATH)"
|
@echo "Build complete: $(BINARY_PATH)"
|
||||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
|
@ -119,7 +121,7 @@ build-launcher:
|
||||||
echo "Building frontend..."; \
|
echo "Building frontend..."; \
|
||||||
cd web/frontend && pnpm install && pnpm build:backend; \
|
cd web/frontend && pnpm install && pnpm build:backend; \
|
||||||
fi
|
fi
|
||||||
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
|
@$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
|
||||||
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
|
||||||
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
|
||||||
|
|
||||||
|
|
@ -128,16 +130,16 @@ build-whatsapp-native: generate
|
||||||
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||||
@echo "Building for multiple platforms..."
|
@echo "Building for multiple platforms..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||||
@echo "Build complete"
|
@echo "Build complete"
|
||||||
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
|
@ -145,21 +147,21 @@ build-whatsapp-native: generate
|
||||||
build-linux-arm: generate
|
build-linux-arm: generate
|
||||||
@echo "Building for linux/arm (GOARM=7)..."
|
@echo "Building for linux/arm (GOARM=7)..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
|
||||||
|
|
||||||
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
|
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
|
||||||
build-linux-arm64: generate
|
build-linux-arm64: generate
|
||||||
@echo "Building for linux/arm64..."
|
@echo "Building for linux/arm64..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
||||||
|
|
||||||
## build-linux-mipsle: Build for Linux MIPS32 LE
|
## build-linux-mipsle: Build for Linux MIPS32 LE
|
||||||
build-linux-mipsle: generate
|
build-linux-mipsle: generate
|
||||||
@echo "Building for linux/mipsle (softfloat)..."
|
@echo "Building for linux/mipsle (softfloat)..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
|
||||||
|
|
||||||
|
|
@ -171,18 +173,18 @@ build-pi-zero: build-linux-arm build-linux-arm64
|
||||||
build-all: generate
|
build-all: generate
|
||||||
@echo "Building for multiple platforms..."
|
@echo "Building for multiple platforms..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
|
||||||
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
|
||||||
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
GOOS=darwin GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
GOOS=windows GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
GOOS=netbsd GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
GOOS=netbsd GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
|
||||||
GOOS=netbsd GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
GOOS=netbsd GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
|
||||||
@echo "All builds complete"
|
@echo "All builds complete"
|
||||||
|
|
||||||
## install: Install picoclaw to system and copy builtin skills
|
## install: Install picoclaw to system and copy builtin skills
|
||||||
|
|
@ -219,11 +221,14 @@ clean:
|
||||||
|
|
||||||
## vet: Run go vet for static analysis
|
## vet: Run go vet for static analysis
|
||||||
vet: generate
|
vet: generate
|
||||||
@$(GO) vet ./...
|
@packages="$$(go list ./...)" && \
|
||||||
|
$(GO) vet $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/')
|
||||||
|
@cd web/backend && $(WEB_GO) vet ./...
|
||||||
|
|
||||||
## test: Test Go code
|
## test: Test Go code
|
||||||
test: generate
|
test: generate
|
||||||
@$(GO) test ./...
|
@$(GO) test $$(go list ./... | grep -v github.com/sipeed/picoclaw/web/)
|
||||||
|
@cd web && make test
|
||||||
|
|
||||||
## fmt: Format Go code
|
## fmt: Format Go code
|
||||||
fmt:
|
fmt:
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
🦐 **PicoClaw** est un assistant personnel IA ultra-léger inspiré de [nanobot](https://github.com/HKUDS/nanobot), entièrement réécrit en **Go** via un processus d'auto-amorçage (self-bootstrapping) — où l'agent IA lui-même a piloté l'intégralité de la migration architecturale et de l'optimisation du code.
|
> **PicoClaw** est un projet open-source indépendant initié par [Sipeed](https://sipeed.com). Il est entièrement écrit en **Go** — ce n'est pas un fork d'OpenClaw, de NanoBot ou de tout autre projet.
|
||||||
|
|
||||||
|
🦐 **PicoClaw** est un assistant personnel IA ultra-léger inspiré de [NanoBot](https://github.com/HKUDS/nanobot), entièrement réécrit en **Go** via un processus d'auto-amorçage (self-bootstrapping) — où l'agent IA lui-même a piloté l'intégralité de la migration architecturale et de l'optimisation du code.
|
||||||
|
|
||||||
⚡️ **Extrêmement léger :** Fonctionne sur du matériel à seulement **10$** avec **<10 Mo** de RAM. C'est 99% de mémoire en moins qu'OpenClaw et 98% moins cher qu'un Mac mini !
|
⚡️ **Extrêmement léger :** Fonctionne sur du matériel à seulement **10$** avec **<10 Mo** de RAM. C'est 99% de mémoire en moins qu'OpenClaw et 98% moins cher qu'un Mac mini !
|
||||||
|
|
||||||
|
|
@ -251,6 +253,9 @@ picoclaw onboard
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": {
|
"web": {
|
||||||
|
"enabled": true,
|
||||||
|
"fetch_limit_bytes": 10485760,
|
||||||
|
"format": "plaintext",
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "VOTRE_CLE_API_BRAVE",
|
"api_key": "VOTRE_CLE_API_BRAVE",
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,9 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
🦐 PicoClaw は [nanobot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。Go でゼロからリファクタリングされ、AI エージェント自身がアーキテクチャの移行とコード最適化を推進するセルフブートストラッピングプロセスで構築されました。
|
> **PicoClaw** は [Sipeed](https://sipeed.com) が立ち上げた独立したオープンソースプロジェクトです。完全に **Go 言語**で一から書かれており、OpenClaw、NanoBot、その他のプロジェクトのフォークではありません。
|
||||||
|
|
||||||
|
🦐 PicoClaw は [NanoBot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。Go でゼロからリファクタリングされ、AI エージェント自身がアーキテクチャの移行とコード最適化を推進するセルフブートストラッピングプロセスで構築されました。
|
||||||
|
|
||||||
⚡️ $10 のハードウェアで 10MB 未満の RAM で動作:OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い!
|
⚡️ $10 のハードウェアで 10MB 未満の RAM で動作:OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い!
|
||||||
|
|
||||||
|
|
@ -216,6 +218,9 @@ picoclaw onboard
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": {
|
"web": {
|
||||||
|
"enabled": true,
|
||||||
|
"fetch_limit_bytes": 10485760,
|
||||||
|
"format": "plaintext",
|
||||||
"search": {
|
"search": {
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
|
|
|
||||||
22
README.md
22
README.md
|
|
@ -24,7 +24,9 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [nanobot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization.
|
> **PicoClaw** is an independent open-source project initiated by [Sipeed](https://sipeed.com). It is written entirely in **Go** — not a fork of OpenClaw, NanoBot, or any other project.
|
||||||
|
|
||||||
|
🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [NanoBot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization.
|
||||||
|
|
||||||
⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini!
|
⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini!
|
||||||
|
|
||||||
|
|
@ -270,6 +272,9 @@ picoclaw onboard
|
||||||
],
|
],
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": {
|
"web": {
|
||||||
|
"enabled": true,
|
||||||
|
"fetch_limit_bytes": 10485760,
|
||||||
|
"format": "plaintext",
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
|
@ -861,6 +866,21 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous
|
||||||
* `shutdown`, `reboot`, `poweroff` — System shutdown
|
* `shutdown`, `reboot`, `poweroff` — System shutdown
|
||||||
* Fork bomb `:(){ :|:& };:`
|
* Fork bomb `:(){ :|:& };:`
|
||||||
|
|
||||||
|
#### Known Limitation: Child Processes From Build Tools
|
||||||
|
|
||||||
|
The exec safety guard only inspects the command line PicoClaw launches directly. It does not recursively inspect child
|
||||||
|
processes spawned by allowed developer tools such as `make`, `go run`, `cargo`, `npm run`, or custom build scripts.
|
||||||
|
|
||||||
|
That means a top-level command can still compile or launch other binaries after it passes the initial guard check. In
|
||||||
|
practice, treat build scripts, Makefiles, package scripts, and generated binaries as executable code that needs the same
|
||||||
|
level of review as a direct shell command.
|
||||||
|
|
||||||
|
For higher-risk environments:
|
||||||
|
|
||||||
|
* Review build scripts before execution.
|
||||||
|
* Prefer approval/manual review for compile-and-run workflows.
|
||||||
|
* Run PicoClaw inside a container or VM if you need stronger isolation than the built-in guard provides.
|
||||||
|
|
||||||
#### Error Examples
|
#### Error Examples
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
🦐 **PicoClaw** é um assistente pessoal de IA ultra-leve inspirado no [nanobot](https://github.com/HKUDS/nanobot), reescrito do zero em **Go** por meio de um processo de "auto-inicialização" (self-bootstrapping) — onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código.
|
> **PicoClaw** é um projeto open-source independente iniciado pela [Sipeed](https://sipeed.com). É escrito inteiramente em **Go** — não é um fork do OpenClaw, NanoBot ou qualquer outro projeto.
|
||||||
|
|
||||||
|
🦐 **PicoClaw** é um assistente pessoal de IA ultra-leve inspirado no [NanoBot](https://github.com/HKUDS/nanobot), reescrito do zero em **Go** por meio de um processo de "auto-inicialização" (self-bootstrapping) — onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código.
|
||||||
|
|
||||||
⚡️ **Extremamente leve:** Roda em hardware de apenas **$10** com **<10MB** de RAM. Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini!
|
⚡️ **Extremamente leve:** Roda em hardware de apenas **$10** com **<10MB** de RAM. Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini!
|
||||||
|
|
||||||
|
|
@ -245,6 +247,9 @@ picoclaw onboard
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": {
|
"web": {
|
||||||
|
"enabled": true,
|
||||||
|
"fetch_limit_bytes": 10485760,
|
||||||
|
"format": "plaintext",
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
🦐 **PicoClaw** là trợ lý AI cá nhân siêu nhẹ, lấy cảm hứng từ [nanobot](https://github.com/HKUDS/nanobot), được viết lại hoàn toàn bằng **Go** thông qua quá trình "tự khởi tạo" (self-bootstrapping) — nơi chính AI Agent đã tự dẫn dắt toàn bộ quá trình chuyển đổi kiến trúc và tối ưu hóa mã nguồn.
|
> **PicoClaw** là dự án mã nguồn mở độc lập được khởi xướng bởi [Sipeed](https://sipeed.com). Được viết hoàn toàn bằng **Go** — không phải là bản fork của OpenClaw, NanoBot hay bất kỳ dự án nào khác.
|
||||||
|
|
||||||
|
🦐 **PicoClaw** là trợ lý AI cá nhân siêu nhẹ, lấy cảm hứng từ [NanoBot](https://github.com/HKUDS/nanobot), được viết lại hoàn toàn bằng **Go** thông qua quá trình "tự khởi tạo" (self-bootstrapping) — nơi chính AI Agent đã tự dẫn dắt toàn bộ quá trình chuyển đổi kiến trúc và tối ưu hóa mã nguồn.
|
||||||
|
|
||||||
⚡️ **Cực kỳ nhẹ:** Chạy trên phần cứng chỉ **$10** với RAM **<10MB**. Tiết kiệm 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini!
|
⚡️ **Cực kỳ nhẹ:** Chạy trên phần cứng chỉ **$10** với RAM **<10MB**. Tiết kiệm 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini!
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
🦐 **PicoClaw** 是一个受 [nanobot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个“自举”过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。
|
> **PicoClaw** 是由 [矽速科技 (Sipeed)](https://sipeed.com) 发起的独立开源项目,完全使用 **Go 语言**从零编写——不是 OpenClaw、NanoBot 或其他项目的分支。
|
||||||
|
|
||||||
|
🦐 **PicoClaw** 是一个受 [NanoBot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个“自举”过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。
|
||||||
|
|
||||||
⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%!
|
⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%!
|
||||||
|
|
||||||
|
|
@ -255,6 +257,9 @@ picoclaw onboard
|
||||||
],
|
],
|
||||||
"tools": {
|
"tools": {
|
||||||
"web": {
|
"web": {
|
||||||
|
"enabled": true,
|
||||||
|
"fetch_limit_bytes": 10485760,
|
||||||
|
"format": "plaintext",
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import (
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/gateway"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
@ -12,6 +14,7 @@ import (
|
||||||
func NewGatewayCommand() *cobra.Command {
|
func NewGatewayCommand() *cobra.Command {
|
||||||
var debug bool
|
var debug bool
|
||||||
var noTruncate bool
|
var noTruncate bool
|
||||||
|
var allowEmpty bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "gateway",
|
Use: "gateway",
|
||||||
|
|
@ -31,12 +34,19 @@ func NewGatewayCommand() *cobra.Command {
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
RunE: func(_ *cobra.Command, _ []string) error {
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
return gatewayCmd(debug)
|
return gateway.Run(debug, internal.GetConfigPath(), allowEmpty)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||||
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
|
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
|
||||||
|
cmd.Flags().BoolVarP(
|
||||||
|
&allowEmpty,
|
||||||
|
"allow-empty",
|
||||||
|
"E",
|
||||||
|
false,
|
||||||
|
"Continue starting even when no default model is configured",
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,5 @@ func TestNewGatewayCommand(t *testing.T) {
|
||||||
|
|
||||||
assert.True(t, cmd.HasFlags())
|
assert.True(t, cmd.HasFlags())
|
||||||
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,19 @@ import (
|
||||||
var embeddedFiles embed.FS
|
var embeddedFiles embed.FS
|
||||||
|
|
||||||
func NewOnboardCommand() *cobra.Command {
|
func NewOnboardCommand() *cobra.Command {
|
||||||
|
var encrypt bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "onboard",
|
Use: "onboard",
|
||||||
Aliases: []string{"o"},
|
Aliases: []string{"o"},
|
||||||
Short: "Initialize picoclaw configuration and workspace",
|
Short: "Initialize picoclaw configuration and workspace",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
onboard()
|
onboard(encrypt)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&encrypt, "enc", false,
|
||||||
|
"Enable credential encryption (generates SSH key and prompts for passphrase)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ func TestNewOnboardCommand(t *testing.T) {
|
||||||
assert.Nil(t, cmd.PersistentPreRun)
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
assert.Nil(t, cmd.PersistentPostRun)
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
|
||||||
assert.False(t, cmd.HasFlags())
|
assert.True(t, cmd.HasFlags())
|
||||||
|
encFlag := cmd.Flags().Lookup("enc")
|
||||||
|
require.NotNil(t, encFlag, "expected --enc flag to be registered")
|
||||||
|
assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false")
|
||||||
assert.False(t, cmd.HasSubCommands())
|
assert.False(t, cmd.HasSubCommands())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,71 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
)
|
)
|
||||||
|
|
||||||
func onboard() {
|
func onboard(encrypt bool) {
|
||||||
configPath := internal.GetConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
|
configExists := false
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
|
configExists = true
|
||||||
|
if encrypt {
|
||||||
|
// Only ask for confirmation when *both* config and SSH key already exist,
|
||||||
|
// indicating a full re-onboard that would reset the config to defaults.
|
||||||
|
sshKeyPath, _ := credential.DefaultSSHKeyPath()
|
||||||
|
if _, err := os.Stat(sshKeyPath); err == nil {
|
||||||
|
// Both exist — confirm a full reset.
|
||||||
fmt.Printf("Config already exists at %s\n", configPath)
|
fmt.Printf("Config already exists at %s\n", configPath)
|
||||||
fmt.Print("Overwrite? (y/n): ")
|
fmt.Print("Overwrite config with defaults? (y/n): ")
|
||||||
var response string
|
var response string
|
||||||
fmt.Scanln(&response)
|
fmt.Scanln(&response)
|
||||||
if response != "y" {
|
if response != "y" {
|
||||||
fmt.Println("Aborted.")
|
fmt.Println("Aborted.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
configExists = false // user agreed to reset; treat as fresh
|
||||||
|
}
|
||||||
|
// Config exists but SSH key is missing — keep existing config, only add SSH key.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.DefaultConfig()
|
var err error
|
||||||
|
if encrypt {
|
||||||
|
fmt.Println("\nSet up credential encryption")
|
||||||
|
fmt.Println("-----------------------------")
|
||||||
|
passphrase, pErr := promptPassphrase()
|
||||||
|
if pErr != nil {
|
||||||
|
fmt.Printf("Error: %v\n", pErr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
// Expose the passphrase to credential.PassphraseProvider (which calls
|
||||||
|
// os.Getenv by default) so that SaveConfig can encrypt api_keys.
|
||||||
|
// This process is a one-shot CLI tool; the env var is never exposed outside
|
||||||
|
// the current process and disappears when it exits.
|
||||||
|
os.Setenv(credential.PassphraseEnvVar, passphrase)
|
||||||
|
|
||||||
|
if err = setupSSHKey(); err != nil {
|
||||||
|
fmt.Printf("Error generating SSH key: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg *config.Config
|
||||||
|
if configExists {
|
||||||
|
// Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase.
|
||||||
|
cfg, err = config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading existing config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cfg = config.DefaultConfig()
|
||||||
|
}
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
fmt.Printf("Error saving config: %v\n", err)
|
fmt.Printf("Error saving config: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
@ -33,9 +79,17 @@ func onboard() {
|
||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
createWorkspaceTemplates(workspace)
|
createWorkspaceTemplates(workspace)
|
||||||
|
|
||||||
fmt.Printf("%s picoclaw is ready!\n", internal.Logo)
|
fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo)
|
||||||
fmt.Println("\nNext steps:")
|
fmt.Println("\nNext steps:")
|
||||||
|
if encrypt {
|
||||||
|
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
|
||||||
|
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
|
||||||
|
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Println(" 2. Add your API key to", configPath)
|
||||||
|
} else {
|
||||||
fmt.Println(" 1. Add your API key to", configPath)
|
fmt.Println(" 1. Add your API key to", configPath)
|
||||||
|
}
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println(" Recommended:")
|
fmt.Println(" Recommended:")
|
||||||
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
|
||||||
|
|
@ -43,7 +97,62 @@ func onboard() {
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println(" See README.md for 17+ supported providers.")
|
fmt.Println(" See README.md for 17+ supported providers.")
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptPassphrase reads the encryption passphrase twice from the terminal
|
||||||
|
// (with echo disabled) and returns it. Returns an error if the passphrase is
|
||||||
|
// empty or if the two inputs do not match.
|
||||||
|
func promptPassphrase() (string, error) {
|
||||||
|
fmt.Print("Enter passphrase for credential encryption: ")
|
||||||
|
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading passphrase: %w", err)
|
||||||
|
}
|
||||||
|
if len(p1) == 0 {
|
||||||
|
return "", fmt.Errorf("passphrase must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Confirm passphrase: ")
|
||||||
|
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading passphrase confirmation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(p1) != string(p2) {
|
||||||
|
return "", fmt.Errorf("passphrases do not match")
|
||||||
|
}
|
||||||
|
return string(p1), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key.
|
||||||
|
// If the key already exists the user is warned and asked to confirm overwrite.
|
||||||
|
// Answering anything other than "y" keeps the existing key (not an error).
|
||||||
|
func setupSSHKey() error {
|
||||||
|
keyPath, err := credential.DefaultSSHKeyPath()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot determine SSH key path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(keyPath); err == nil {
|
||||||
|
fmt.Printf("\n⚠️ WARNING: %s already exists.\n", keyPath)
|
||||||
|
fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.")
|
||||||
|
fmt.Print(" Overwrite? (y/n): ")
|
||||||
|
var response string
|
||||||
|
fmt.Scanln(&response)
|
||||||
|
if response != "y" {
|
||||||
|
fmt.Println("Keeping existing SSH key.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := credential.GenerateSSHKey(keyPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("SSH key generated: %s\n", keyPath)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createWorkspaceTemplates(workspace string) {
|
func createWorkspaceTemplates(workspace string) {
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,8 @@
|
||||||
"allow_write_paths": null,
|
"allow_write_paths": null,
|
||||||
"web": {
|
"web": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"fetch_limit_bytes": 10485760,
|
||||||
|
"format": "plaintext",
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
|
@ -351,7 +353,8 @@
|
||||||
"search_engine": "search_std",
|
"search_engine": "search_std",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"fetch_limit_bytes": 10485760
|
"fetch_limit_bytes": 10485760,
|
||||||
|
"private_host_whitelist": []
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|
@ -518,6 +521,7 @@
|
||||||
},
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 18790
|
"port": 18790,
|
||||||
|
"hot_reload": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
168
docs/credential_encryption.md
Normal file
168
docs/credential_encryption.md
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
# Credential Encryption
|
||||||
|
|
||||||
|
PicoClaw supports encrypting `api_key` values in `model_list` configuration entries.
|
||||||
|
Encrypted keys are stored as `enc://<base64>` strings and decrypted automatically at startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
**1. Set your passphrase**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PICOCLAW_KEY_PASSPHRASE="your-passphrase"
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Encrypt an API key**
|
||||||
|
|
||||||
|
Run `picoclaw onboard` — it prompts for your passphrase and generates the SSH key,
|
||||||
|
then automatically re-encrypts any plaintext `api_key` entries in your config on
|
||||||
|
the next `SaveConfig` call. The resulting `enc://` value will look like:
|
||||||
|
|
||||||
|
```
|
||||||
|
enc://AAAA...base64...
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Paste the output into your config**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "gpt-4o",
|
||||||
|
"api_key": "enc://AAAA...base64...",
|
||||||
|
"base_url": "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported `api_key` Formats
|
||||||
|
|
||||||
|
| Format | Example | Behaviour |
|
||||||
|
|--------|---------|-----------|
|
||||||
|
| Plaintext | `sk-abc123` | Used as-is |
|
||||||
|
| File reference | `file://openai.key` | Content read from the same directory as the config file |
|
||||||
|
| Encrypted | `enc://<base64>` | Decrypted at startup using `PICOCLAW_KEY_PASSPHRASE` |
|
||||||
|
| Empty | `""` | Passed through unchanged (used with `auth_method: oauth`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cryptographic Design
|
||||||
|
|
||||||
|
### Key Derivation
|
||||||
|
|
||||||
|
Encryption uses **HKDF-SHA256** with an optional SSH private key as a second factor.
|
||||||
|
|
||||||
|
```
|
||||||
|
Without SSH key (passphrase only):
|
||||||
|
|
||||||
|
ikm = SHA256(passphrase)
|
||||||
|
aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
|
||||||
|
|
||||||
|
|
||||||
|
With SSH key (recommended):
|
||||||
|
|
||||||
|
sshHash = SHA256(ssh_private_key_file_bytes)
|
||||||
|
ikm = HMAC-SHA256(key=sshHash, message=passphrase)
|
||||||
|
aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Encryption
|
||||||
|
|
||||||
|
```
|
||||||
|
AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wire Format
|
||||||
|
|
||||||
|
```
|
||||||
|
enc://<base64( salt[16] + nonce[12] + ciphertext )>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Size | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `salt` | 16 bytes | Random per encryption; fed into HKDF |
|
||||||
|
| `nonce` | 12 bytes | Random per encryption; AES-GCM IV |
|
||||||
|
| `ciphertext` | variable | AES-256-GCM ciphertext + 16-byte authentication tag |
|
||||||
|
|
||||||
|
The GCM authentication tag is appended to the ciphertext automatically. Any tampering causes decryption to fail with an error rather than returning corrupt plaintext.
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
| Operation | Time (ARM Cortex-A) |
|
||||||
|
|-----------|---------------------|
|
||||||
|
| Key derivation (HKDF) | < 1 ms |
|
||||||
|
| AES-256-GCM decrypt | < 1 ms |
|
||||||
|
| **Total startup overhead** | **< 2 ms per key** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Two-Factor Security with SSH Key
|
||||||
|
|
||||||
|
When a SSH private key is provided, breaking the encryption requires **both**:
|
||||||
|
|
||||||
|
1. The **passphrase** (`PICOCLAW_KEY_PASSPHRASE`)
|
||||||
|
2. The **SSH private key file**
|
||||||
|
|
||||||
|
This means a leaked config file alone is not sufficient to recover the API key, even if the passphrase is weak. The SSH key contributes 256 bits of entropy (Ed25519) regardless of passphrase strength.
|
||||||
|
|
||||||
|
### Threat Model
|
||||||
|
|
||||||
|
| Attacker Has | Can Decrypt? |
|
||||||
|
|---|---|
|
||||||
|
| Config file only | No — needs passphrase + SSH key |
|
||||||
|
| SSH key only | No — needs passphrase |
|
||||||
|
| Passphrase only | No — needs SSH key |
|
||||||
|
| Config file + SSH key + passphrase | Yes — full compromise |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `PICOCLAW_KEY_PASSPHRASE` | Yes (for `enc://`) | Passphrase used for key derivation |
|
||||||
|
| `PICOCLAW_SSH_KEY_PATH` | No | Path to SSH private key. Set to `""` to disable auto-detection and use passphrase-only mode |
|
||||||
|
|
||||||
|
### SSH Key Auto-Detection
|
||||||
|
|
||||||
|
If `PICOCLAW_SSH_KEY_PATH` is not set, PicoClaw looks for the picoclaw-specific key:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.ssh/picoclaw_ed25519.key
|
||||||
|
```
|
||||||
|
|
||||||
|
This dedicated file avoids conflicts with the user's existing SSH keys.
|
||||||
|
Run `picoclaw onboard` to generate it automatically.
|
||||||
|
|
||||||
|
`os.UserHomeDir()` is used for cross-platform home directory resolution (reads `USERPROFILE` on Windows, `HOME` on Unix/macOS).
|
||||||
|
|
||||||
|
To explicitly disable SSH key usage and use passphrase-only mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PICOCLAW_SSH_KEY_PATH=""
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Because the only secret material is `PICOCLAW_KEY_PASSPHRASE` and the SSH private key file, migration is straightforward:
|
||||||
|
|
||||||
|
1. Copy the config file to the new machine.
|
||||||
|
2. Set `PICOCLAW_KEY_PASSPHRASE` to the same value.
|
||||||
|
3. Copy the SSH private key file to the same path (or set `PICOCLAW_SSH_KEY_PATH` to its new location).
|
||||||
|
|
||||||
|
No re-encryption is needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- **Passphrase strength matters in passphrase-only mode.** Without an SSH key, a weak passphrase can be brute-forced offline. Use `PICOCLAW_SSH_KEY_PATH=""` only in environments where no SSH key is available and the passphrase is sufficiently strong (≥ 32 random characters).
|
||||||
|
- **The SSH key is read-only at runtime.** PicoClaw never writes to or modifies the SSH key file.
|
||||||
|
- **Plaintext keys remain supported.** Existing configs without `enc://` are unaffected.
|
||||||
|
- **The `enc://` format is versioned** via the HKDF `info` field (`picoclaw-credential-v1`), allowing future algorithm upgrades without breaking existing encrypted values.
|
||||||
|
|
@ -30,6 +30,15 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
|
||||||
|
|
||||||
Web tools are used for web search and fetching.
|
Web tools are used for web search and fetching.
|
||||||
|
|
||||||
|
### Web Fetcher
|
||||||
|
General settings for fetching and processing webpage content.
|
||||||
|
|
||||||
|
| Config | Type | Default | Description |
|
||||||
|
|---------------------|--------|---------------|-----------------------------------------------------------------------------------------------|
|
||||||
|
| `enabled` | bool | true | Enable the webpage fetching capability. |
|
||||||
|
| `fetch_limit_bytes` | int | 10485760 | Maximum size of the webpage payload to fetch, in bytes (default is 10MB). |
|
||||||
|
| `format` | string | "plaintext" | Output format of the fetched content. Options: `plaintext` or `markdown` (recommended). |
|
||||||
|
|
||||||
### Brave
|
### Brave
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|
|
@ -84,6 +93,22 @@ By default, PicoClaw blocks the following dangerous commands:
|
||||||
- Git: `git push`, `git force`
|
- Git: `git push`, `git force`
|
||||||
- Other: `eval`, `source *.sh`
|
- Other: `eval`, `source *.sh`
|
||||||
|
|
||||||
|
### Known Architectural Limitation
|
||||||
|
|
||||||
|
The exec guard only validates the top-level command sent to PicoClaw. It does **not** recursively inspect child
|
||||||
|
processes spawned by build tools or scripts after that command starts running.
|
||||||
|
|
||||||
|
Examples of workflows that can bypass the direct command guard once the initial command is allowed:
|
||||||
|
|
||||||
|
- `make run`
|
||||||
|
- `go run ./cmd/...`
|
||||||
|
- `cargo run`
|
||||||
|
- `npm run build`
|
||||||
|
|
||||||
|
This means the guard is useful for blocking obviously dangerous direct commands, but it is **not** a full sandbox for
|
||||||
|
unreviewed build pipelines. If your threat model includes untrusted code in the workspace, use stronger isolation such
|
||||||
|
as containers, VMs, or an approval flow around build-and-run commands.
|
||||||
|
|
||||||
### Configuration Example
|
### Configuration Example
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
|
||||||
6
go.mod
6
go.mod
|
|
@ -3,6 +3,7 @@ module github.com/sipeed/picoclaw
|
||||||
go 1.25.7
|
go 1.25.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
fyne.io/systray v1.12.0
|
||||||
github.com/adhocore/gronx v1.19.6
|
github.com/adhocore/gronx v1.19.6
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
|
|
@ -28,6 +29,7 @@ require (
|
||||||
github.com/tencent-connect/botgo v0.2.1
|
github.com/tencent-connect/botgo v0.2.1
|
||||||
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
|
golang.org/x/term v0.40.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|
@ -43,6 +45,7 @@ require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
github.com/gdamore/encoding v1.0.1 // indirect
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
|
@ -59,7 +62,6 @@ require (
|
||||||
go.mau.fi/libsignal v0.2.1 // indirect
|
go.mau.fi/libsignal v0.2.1 // indirect
|
||||||
go.mau.fi/util v0.9.6 // indirect
|
go.mau.fi/util v0.9.6 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||||
golang.org/x/term v0.40.0 // indirect
|
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|
@ -90,7 +92,7 @@ require (
|
||||||
github.com/valyala/fastjson v1.6.10 // indirect
|
github.com/valyala/fastjson v1.6.10 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/arch v0.24.0 // indirect
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -1,6 +1,8 @@
|
||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
|
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
|
||||||
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
|
||||||
|
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
||||||
|
|
@ -64,6 +66,8 @@ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg78
|
||||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
|
|
||||||
|
|
@ -458,7 +458,23 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||||
//
|
//
|
||||||
// See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
|
// See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
|
||||||
// See: https://platform.openai.com/docs/guides/prompt-caching
|
// See: https://platform.openai.com/docs/guides/prompt-caching
|
||||||
func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string {
|
func formatCurrentSenderLine(senderID, senderDisplayName string) string {
|
||||||
|
senderID = strings.TrimSpace(senderID)
|
||||||
|
senderDisplayName = strings.TrimSpace(senderDisplayName)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case senderDisplayName != "" && senderID != "":
|
||||||
|
return fmt.Sprintf("Current sender: %s (ID: %s)", senderDisplayName, senderID)
|
||||||
|
case senderDisplayName != "":
|
||||||
|
return fmt.Sprintf("Current sender: %s", senderDisplayName)
|
||||||
|
case senderID != "":
|
||||||
|
return fmt.Sprintf("Current sender: %s", senderID)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *ContextBuilder) buildDynamicContext(channel, chatID, senderID, senderDisplayName string) string {
|
||||||
now := time.Now().Format("2006-01-02 15:04 (Monday)")
|
now := time.Now().Format("2006-01-02 15:04 (Monday)")
|
||||||
rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
|
rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
|
||||||
|
|
||||||
|
|
@ -468,6 +484,9 @@ func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string {
|
||||||
if channel != "" && chatID != "" {
|
if channel != "" && chatID != "" {
|
||||||
fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
|
fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
|
||||||
}
|
}
|
||||||
|
if senderLine := formatCurrentSenderLine(senderID, senderDisplayName); senderLine != "" {
|
||||||
|
fmt.Fprintf(&sb, "\n\n## Current Sender\n%s", senderLine)
|
||||||
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
@ -477,7 +496,7 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
summary string,
|
summary string,
|
||||||
currentMessage string,
|
currentMessage string,
|
||||||
media []string,
|
media []string,
|
||||||
channel, chatID string,
|
channel, chatID, senderID, senderDisplayName string,
|
||||||
) []providers.Message {
|
) []providers.Message {
|
||||||
messages := []providers.Message{}
|
messages := []providers.Message{}
|
||||||
|
|
||||||
|
|
@ -493,7 +512,7 @@ func (cb *ContextBuilder) BuildMessages(
|
||||||
staticPrompt := cb.BuildSystemPromptWithCache()
|
staticPrompt := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
// Build short dynamic context (time, runtime, session) — changes per request
|
// Build short dynamic context (time, runtime, session) — changes per request
|
||||||
dynamicCtx := cb.buildDynamicContext(channel, chatID)
|
dynamicCtx := cb.buildDynamicContext(channel, chatID, senderID, senderDisplayName)
|
||||||
|
|
||||||
// Compose a single system message: static (cached) + dynamic + optional summary.
|
// Compose a single system message: static (cached) + dynamic + optional summary.
|
||||||
// Keeping all system content in one message ensures every provider adapter can
|
// Keeping all system content in one message ensures every provider adapter can
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ func TestSingleSystemMessage(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1")
|
msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1", "", "")
|
||||||
|
|
||||||
systemCount := 0
|
systemCount := 0
|
||||||
for _, m := range msgs {
|
for _, m := range msgs {
|
||||||
|
|
@ -126,6 +126,68 @@ func TestSingleSystemMessage(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildMessages_CurrentSenderDynamicContext(t *testing.T) {
|
||||||
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
|
"IDENTITY.md": "# Identity\nTest agent.",
|
||||||
|
})
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
senderID string
|
||||||
|
senderDisplayName string
|
||||||
|
wantLine string
|
||||||
|
wantSection bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "both id and display name",
|
||||||
|
senderID: "feishu:ou_xxx",
|
||||||
|
senderDisplayName: "Zhang San",
|
||||||
|
wantLine: "Current sender: Zhang San (ID: feishu:ou_xxx)",
|
||||||
|
wantSection: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "display name only",
|
||||||
|
senderDisplayName: "Alice",
|
||||||
|
wantLine: "Current sender: Alice",
|
||||||
|
wantSection: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "id only",
|
||||||
|
senderID: "discord:123",
|
||||||
|
wantLine: "Current sender: discord:123",
|
||||||
|
wantSection: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no sender info",
|
||||||
|
wantSection: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
msgs := cb.BuildMessages(nil, "", "hello", nil, "discord", "chat1", tt.senderID, tt.senderDisplayName)
|
||||||
|
sys := msgs[0].Content
|
||||||
|
|
||||||
|
if tt.wantSection {
|
||||||
|
if !strings.Contains(sys, "## Current Sender") {
|
||||||
|
t.Fatalf("system prompt missing Current Sender section:\n%s", sys)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sys, tt.wantLine) {
|
||||||
|
t.Fatalf("system prompt missing sender line %q:\n%s", tt.wantLine, sys)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(sys, "## Current Sender") {
|
||||||
|
t.Fatalf("system prompt should omit Current Sender section:\n%s", sys)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestMtimeAutoInvalidation verifies that the cache detects source file changes
|
// TestMtimeAutoInvalidation verifies that the cache detects source file changes
|
||||||
// via mtime without requiring explicit InvalidateCache().
|
// via mtime without requiring explicit InvalidateCache().
|
||||||
// Fix: original implementation had no auto-invalidation — edits to bootstrap files,
|
// Fix: original implementation had no auto-invalidation — edits to bootstrap files,
|
||||||
|
|
@ -576,7 +638,7 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also exercise BuildMessages concurrently
|
// Also exercise BuildMessages concurrently
|
||||||
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat")
|
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat", "", "")
|
||||||
if len(msgs) < 2 {
|
if len(msgs) < 2 {
|
||||||
errs <- "BuildMessages returned fewer than 2 messages"
|
errs <- "BuildMessages returned fewer than 2 messages"
|
||||||
return
|
return
|
||||||
|
|
@ -664,6 +726,6 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) {
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
_ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test")
|
_ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test", "", "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/memory"
|
"github.com/sipeed/picoclaw/pkg/memory"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/routing"
|
"github.com/sipeed/picoclaw/pkg/routing"
|
||||||
|
|
@ -66,7 +67,7 @@ func NewAgentInstance(
|
||||||
readRestrict := restrict && !defaults.AllowReadOutsideWorkspace
|
readRestrict := restrict && !defaults.AllowReadOutsideWorkspace
|
||||||
|
|
||||||
// Compile path whitelist patterns from config.
|
// Compile path whitelist patterns from config.
|
||||||
allowReadPaths := compilePatterns(cfg.Tools.AllowReadPaths)
|
allowReadPaths := buildAllowReadPatterns(cfg)
|
||||||
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
||||||
|
|
||||||
toolsRegistry := tools.NewToolRegistry()
|
toolsRegistry := tools.NewToolRegistry()
|
||||||
|
|
@ -82,7 +83,7 @@ func NewAgentInstance(
|
||||||
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
|
||||||
}
|
}
|
||||||
if cfg.Tools.IsToolEnabled("exec") {
|
if cfg.Tools.IsToolEnabled("exec") {
|
||||||
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
|
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg, allowReadPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -282,6 +283,28 @@ func compilePatterns(patterns []string) []*regexp.Regexp {
|
||||||
return compiled
|
return compiled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildAllowReadPatterns(cfg *config.Config) []*regexp.Regexp {
|
||||||
|
var configured []string
|
||||||
|
if cfg != nil {
|
||||||
|
configured = cfg.Tools.AllowReadPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
compiled := compilePatterns(configured)
|
||||||
|
mediaDirPattern := regexp.MustCompile(mediaTempDirPattern())
|
||||||
|
for _, pattern := range compiled {
|
||||||
|
if pattern.String() == mediaDirPattern.String() {
|
||||||
|
return compiled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(compiled, mediaDirPattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaTempDirPattern() string {
|
||||||
|
sep := regexp.QuoteMeta(string(os.PathSeparator))
|
||||||
|
return "^" + regexp.QuoteMeta(filepath.Clean(media.TempDir())) + "(?:" + sep + "|$)"
|
||||||
|
}
|
||||||
|
|
||||||
// Close releases resources held by the agent's session store.
|
// Close releases resources held by the agent's session store.
|
||||||
func (a *AgentInstance) Close() error {
|
func (a *AgentInstance) Close() error {
|
||||||
if a.Sessions != nil {
|
if a.Sessions != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
|
func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
|
||||||
|
|
@ -160,3 +164,85 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
mediaDir := media.TempDir()
|
||||||
|
if err := os.MkdirAll(mediaDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(mediaDir) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaFile, err := os.CreateTemp(mediaDir, "instance-tool-*.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTemp(mediaDir) error = %v", err)
|
||||||
|
}
|
||||||
|
mediaPath := mediaFile.Name()
|
||||||
|
if _, err := mediaFile.WriteString("attachment content"); err != nil {
|
||||||
|
mediaFile.Close()
|
||||||
|
t.Fatalf("WriteString(mediaFile) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := mediaFile.Close(); err != nil {
|
||||||
|
t.Fatalf("Close(mediaFile) error = %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Remove(mediaPath) })
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: workspace,
|
||||||
|
ModelName: "test-model",
|
||||||
|
RestrictToWorkspace: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tools: config.ToolsConfig{
|
||||||
|
ReadFile: config.ReadFileToolConfig{Enabled: true},
|
||||||
|
ListDir: config.ToolConfig{Enabled: true},
|
||||||
|
Exec: config.ExecConfig{
|
||||||
|
ToolConfig: config.ToolConfig{Enabled: true},
|
||||||
|
EnableDenyPatterns: true,
|
||||||
|
AllowRemote: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})
|
||||||
|
|
||||||
|
readTool, ok := agent.Tools.Get("read_file")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("read_file tool not registered")
|
||||||
|
}
|
||||||
|
readResult := readTool.Execute(context.Background(), map[string]any{"path": mediaPath})
|
||||||
|
if readResult.IsError {
|
||||||
|
t.Fatalf("read_file should allow media temp dir, got: %s", readResult.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(readResult.ForLLM, "attachment content") {
|
||||||
|
t.Fatalf("read_file output missing media content: %s", readResult.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
listTool, ok := agent.Tools.Get("list_dir")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("list_dir tool not registered")
|
||||||
|
}
|
||||||
|
listResult := listTool.Execute(context.Background(), map[string]any{"path": mediaDir})
|
||||||
|
if listResult.IsError {
|
||||||
|
t.Fatalf("list_dir should allow media temp dir, got: %s", listResult.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(listResult.ForLLM, filepath.Base(mediaPath)) {
|
||||||
|
t.Fatalf("list_dir output missing media file: %s", listResult.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
execTool, ok := agent.Tools.Get("exec")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("exec tool not registered")
|
||||||
|
}
|
||||||
|
execResult := execTool.Execute(context.Background(), map[string]any{
|
||||||
|
"command": "cat " + filepath.Base(mediaPath),
|
||||||
|
"working_dir": mediaDir,
|
||||||
|
})
|
||||||
|
if execResult.IsError {
|
||||||
|
t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(execResult.ForLLM, "attachment content") {
|
||||||
|
t.Fatalf("exec output missing media content: %s", execResult.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ type processOptions struct {
|
||||||
SessionKey string // Session identifier for history/context
|
SessionKey string // Session identifier for history/context
|
||||||
Channel string // Target channel for tool execution
|
Channel string // Target channel for tool execution
|
||||||
ChatID string // Target chat ID for tool execution
|
ChatID string // Target chat ID for tool execution
|
||||||
|
SenderID string // Current sender ID for dynamic context
|
||||||
|
SenderDisplayName string // Current sender display name for dynamic context
|
||||||
UserMessage string // User message content (may include prefix)
|
UserMessage string // User message content (may include prefix)
|
||||||
Media []string // media:// refs from inbound message
|
Media []string // media:// refs from inbound message
|
||||||
DefaultResponse string // Response when LLM returns empty
|
DefaultResponse string // Response when LLM returns empty
|
||||||
|
|
@ -117,6 +119,8 @@ func registerSharedTools(
|
||||||
registry *AgentRegistry,
|
registry *AgentRegistry,
|
||||||
provider providers.LLMProvider,
|
provider providers.LLMProvider,
|
||||||
) {
|
) {
|
||||||
|
allowReadPaths := buildAllowReadPatterns(cfg)
|
||||||
|
|
||||||
for _, agentID := range registry.ListAgentIDs() {
|
for _, agentID := range registry.ListAgentIDs() {
|
||||||
agent, ok := registry.GetAgent(agentID)
|
agent, ok := registry.GetAgent(agentID)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -157,7 +161,12 @@ func registerSharedTools(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg.Tools.IsToolEnabled("web_fetch") {
|
if cfg.Tools.IsToolEnabled("web_fetch") {
|
||||||
fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes)
|
fetchTool, err := tools.NewWebFetchToolWithProxy(
|
||||||
|
50000,
|
||||||
|
cfg.Tools.Web.Proxy,
|
||||||
|
cfg.Tools.Web.Format,
|
||||||
|
cfg.Tools.Web.FetchLimitBytes,
|
||||||
|
cfg.Tools.Web.PrivateHostWhitelist)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -195,6 +204,7 @@ func registerSharedTools(
|
||||||
cfg.Agents.Defaults.RestrictToWorkspace,
|
cfg.Agents.Defaults.RestrictToWorkspace,
|
||||||
cfg.Agents.Defaults.GetMaxMediaSize(),
|
cfg.Agents.Defaults.GetMaxMediaSize(),
|
||||||
nil,
|
nil,
|
||||||
|
allowReadPaths,
|
||||||
)
|
)
|
||||||
agent.Tools.Register(sendFileTool)
|
agent.Tools.Register(sendFileTool)
|
||||||
}
|
}
|
||||||
|
|
@ -222,20 +232,26 @@ func registerSharedTools(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn tool with allowlist checker
|
// Spawn and spawn_status tools share a SubagentManager.
|
||||||
if cfg.Tools.IsToolEnabled("spawn") {
|
// Construct it when either tool is enabled (both require subagent).
|
||||||
if cfg.Tools.IsToolEnabled("subagent") {
|
spawnEnabled := cfg.Tools.IsToolEnabled("spawn")
|
||||||
|
spawnStatusEnabled := cfg.Tools.IsToolEnabled("spawn_status")
|
||||||
|
if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") {
|
||||||
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
|
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
|
||||||
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
|
||||||
|
if spawnEnabled {
|
||||||
spawnTool := tools.NewSpawnTool(subagentManager)
|
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||||
currentAgentID := agentID
|
currentAgentID := agentID
|
||||||
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
|
||||||
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
||||||
})
|
})
|
||||||
agent.Tools.Register(spawnTool)
|
agent.Tools.Register(spawnTool)
|
||||||
} else {
|
|
||||||
logger.WarnCF("agent", "spawn tool requires subagent to be enabled", nil)
|
|
||||||
}
|
}
|
||||||
|
if spawnStatusEnabled {
|
||||||
|
agent.Tools.Register(tools.NewSpawnStatusTool(subagentManager))
|
||||||
|
}
|
||||||
|
} else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") {
|
||||||
|
logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -251,14 +267,11 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
default:
|
case msg, ok := <-al.bus.InboundChan():
|
||||||
msg, ok := al.bus.ConsumeInbound(ctx)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process message
|
// Process message
|
||||||
func() {
|
|
||||||
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
|
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
|
||||||
// Currently disabled because files are deleted before the LLM can access their content.
|
// Currently disabled because files are deleted before the LLM can access their content.
|
||||||
// defer func() {
|
// defer func() {
|
||||||
|
|
@ -311,7 +324,8 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
default:
|
||||||
|
time.Sleep(time.Microsecond * 200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -735,6 +749,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
|
SenderID: msg.SenderID,
|
||||||
|
SenderDisplayName: msg.Sender.DisplayName,
|
||||||
UserMessage: msg.Content,
|
UserMessage: msg.Content,
|
||||||
Media: msg.Media,
|
Media: msg.Media,
|
||||||
DefaultResponse: defaultResponse,
|
DefaultResponse: defaultResponse,
|
||||||
|
|
@ -879,6 +895,8 @@ func (al *AgentLoop) runAgentLoop(
|
||||||
opts.Media,
|
opts.Media,
|
||||||
opts.Channel,
|
opts.Channel,
|
||||||
opts.ChatID,
|
opts.ChatID,
|
||||||
|
opts.SenderID,
|
||||||
|
opts.SenderDisplayName,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Resolve media:// refs: images→base64 data URLs, non-images→local paths in content
|
// Resolve media:// refs: images→base64 data URLs, non-images→local paths in content
|
||||||
|
|
@ -1150,7 +1168,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
newSummary := agent.Sessions.GetSummary(opts.SessionKey)
|
newSummary := agent.Sessions.GetSummary(opts.SessionKey)
|
||||||
messages = agent.ContextBuilder.BuildMessages(
|
messages = agent.ContextBuilder.BuildMessages(
|
||||||
newHistory, newSummary, "",
|
newHistory, newSummary, "",
|
||||||
nil, opts.Channel, opts.ChatID,
|
nil, opts.Channel, opts.ChatID, opts.SenderID, opts.SenderDisplayName,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,28 @@ func (f *fakeChannel) IsAllowed(string) bool {
|
||||||
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
|
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
|
||||||
func (f *fakeChannel) ReasoningChannelID() string { return f.id }
|
func (f *fakeChannel) ReasoningChannelID() string { return f.id }
|
||||||
|
|
||||||
|
type recordingProvider struct {
|
||||||
|
lastMessages []providers.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []providers.Message,
|
||||||
|
tools []providers.ToolDefinition,
|
||||||
|
model string,
|
||||||
|
opts map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
r.lastMessages = append([]providers.Message(nil), messages...)
|
||||||
|
return &providers.LLMResponse{
|
||||||
|
Content: "Mock response",
|
||||||
|
ToolCalls: []providers.ToolCall{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingProvider) GetDefaultModel() string {
|
||||||
|
return "mock-model"
|
||||||
|
}
|
||||||
|
|
||||||
func newTestAgentLoop(
|
func newTestAgentLoop(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) {
|
) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) {
|
||||||
|
|
@ -54,6 +76,59 @@ func newTestAgentLoop(
|
||||||
return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) }
|
return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
provider := &recordingProvider{}
|
||||||
|
al := NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
|
response, err := al.processMessage(context.Background(), bus.InboundMessage{
|
||||||
|
Channel: "discord",
|
||||||
|
SenderID: "discord:123",
|
||||||
|
Sender: bus.SenderInfo{
|
||||||
|
DisplayName: "Alice",
|
||||||
|
},
|
||||||
|
ChatID: "group-1",
|
||||||
|
Content: "hello",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
if response != "Mock response" {
|
||||||
|
t.Fatalf("processMessage() response = %q, want %q", response, "Mock response")
|
||||||
|
}
|
||||||
|
if len(provider.lastMessages) == 0 {
|
||||||
|
t.Fatal("provider did not receive any messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPrompt := provider.lastMessages[0].Content
|
||||||
|
wantSender := "## Current Sender\nCurrent sender: Alice (ID: discord:123)"
|
||||||
|
if !strings.Contains(systemPrompt, wantSender) {
|
||||||
|
t.Fatalf("system prompt missing sender context %q:\n%s", wantSender, systemPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastMessage := provider.lastMessages[len(provider.lastMessages)-1]
|
||||||
|
if lastMessage.Role != "user" || lastMessage.Content != "hello" {
|
||||||
|
t.Fatalf("last provider message = %+v, want unchanged user message", lastMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRecordLastChannel(t *testing.T) {
|
func TestRecordLastChannel(t *testing.T) {
|
||||||
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
|
al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
@ -922,20 +997,33 @@ func TestHandleReasoning(t *testing.T) {
|
||||||
al, msgBus := newLoop(t)
|
al, msgBus := newLoop(t)
|
||||||
al.handleReasoning(context.Background(), "reasoning", "telegram", "")
|
al.handleReasoning(context.Background(), "reasoning", "telegram", "")
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if msg, ok := msgBus.SubscribeOutbound(ctx); ok {
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-msgBus.OutboundChan():
|
||||||
|
if !ok {
|
||||||
t.Fatalf("expected no outbound message, got %+v", msg)
|
t.Fatalf("expected no outbound message, got %+v", msg)
|
||||||
}
|
}
|
||||||
|
if msg.Content == "reasoning" {
|
||||||
|
t.Fatalf("expected no message for empty chatID, got %+v", msg)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Log("expected an outbound message, got none within timeout")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// Continue to check for message
|
||||||
|
time.Sleep(5 * time.Millisecond) // Avoid busy loop
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("publishes one message for non telegram", func(t *testing.T) {
|
t.Run("publishes one message for non telegram", func(t *testing.T) {
|
||||||
al, msgBus := newLoop(t)
|
al, msgBus := newLoop(t)
|
||||||
al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1")
|
al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1")
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
msg, ok := <-msgBus.OutboundChan()
|
||||||
defer cancel()
|
|
||||||
msg, ok := msgBus.SubscribeOutbound(ctx)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected an outbound message")
|
t.Fatal("expected an outbound message")
|
||||||
}
|
}
|
||||||
|
|
@ -949,9 +1037,14 @@ func TestHandleReasoning(t *testing.T) {
|
||||||
reasoning := "hello telegram reasoning"
|
reasoning := "hello telegram reasoning"
|
||||||
al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat")
|
al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat")
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
msg, ok := msgBus.SubscribeOutbound(ctx)
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("expected an outbound message, got none within timeout")
|
||||||
|
return
|
||||||
|
case msg, ok := <-msgBus.OutboundChan():
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected outbound message")
|
t.Fatal("expected outbound message")
|
||||||
}
|
}
|
||||||
|
|
@ -965,19 +1058,31 @@ func TestHandleReasoning(t *testing.T) {
|
||||||
if msg.Content != reasoning {
|
if msg.Content != reasoning {
|
||||||
t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning)
|
t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning)
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
t.Run("expired ctx", func(t *testing.T) {
|
t.Run("expired ctx", func(t *testing.T) {
|
||||||
al, msgBus := newLoop(t)
|
al, msgBus := newLoop(t)
|
||||||
reasoning := "hello telegram reasoning"
|
reasoning := "hello telegram reasoning"
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
al.handleReasoning(ctx, reasoning, "telegram", "tg-chat")
|
|
||||||
|
|
||||||
ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond)
|
al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat")
|
||||||
defer cancel()
|
|
||||||
msg, ok := msgBus.SubscribeOutbound(ctx)
|
consumeCtx, consumeCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
if ok {
|
defer consumeCancel()
|
||||||
t.Fatalf("expected no outbound message, got %+v", msg)
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-msgBus.OutboundChan():
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected no outbound message, but received: %+v", msg)
|
||||||
|
}
|
||||||
|
t.Logf("Received unexpected outbound message: %+v", msg)
|
||||||
|
return
|
||||||
|
case <-consumeCtx.Done():
|
||||||
|
t.Fatalf("failed: no message received within timeout")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1017,21 +1122,24 @@ func TestHandleReasoning(t *testing.T) {
|
||||||
|
|
||||||
// Drain the bus and verify the reasoning message was NOT published
|
// Drain the bus and verify the reasoning message was NOT published
|
||||||
// (it should have been dropped due to timeout).
|
// (it should have been dropped due to timeout).
|
||||||
drainCtx, drainCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
timeer := time.After(1 * time.Second)
|
||||||
defer drainCancel()
|
|
||||||
foundReasoning := false
|
|
||||||
for {
|
for {
|
||||||
msg, ok := msgBus.SubscribeOutbound(drainCtx)
|
select {
|
||||||
|
case <-timeer:
|
||||||
|
t.Logf(
|
||||||
|
"no reasoning message received after draining bus for 1s, as expected,length=%d",
|
||||||
|
len(msgBus.OutboundChan()),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
case msg, ok := <-msgBus.OutboundChan():
|
||||||
if !ok {
|
if !ok {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if msg.Content == "should timeout" {
|
if msg.Content == "should timeout" {
|
||||||
foundReasoning = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if foundReasoning {
|
|
||||||
t.Fatal("expected reasoning message to be dropped when bus is full, but it was published")
|
t.Fatal("expected reasoning message to be dropped when bus is full, but it was published")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
141
pkg/bus/bus.go
141
pkg/bus/bus.go
|
|
@ -3,6 +3,7 @@ package bus
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -17,8 +18,11 @@ type MessageBus struct {
|
||||||
inbound chan InboundMessage
|
inbound chan InboundMessage
|
||||||
outbound chan OutboundMessage
|
outbound chan OutboundMessage
|
||||||
outboundMedia chan OutboundMediaMessage
|
outboundMedia chan OutboundMediaMessage
|
||||||
|
|
||||||
|
closeOnce sync.Once
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMessageBus() *MessageBus {
|
func NewMessageBus() *MessageBus {
|
||||||
|
|
@ -30,128 +34,91 @@ func NewMessageBus() *MessageBus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
|
func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error {
|
||||||
|
// check bus closed before acquiring wg, to avoid unnecessary wg.Add and potential deadlock
|
||||||
if mb.closed.Load() {
|
if mb.closed.Load() {
|
||||||
return ErrBusClosed
|
return ErrBusClosed
|
||||||
}
|
}
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
// check again,before sending message, to avoid sending to closed channel
|
||||||
}
|
|
||||||
select {
|
select {
|
||||||
case mb.inbound <- msg:
|
|
||||||
return nil
|
|
||||||
case <-mb.done:
|
|
||||||
return ErrBusClosed
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
mb.wg.Add(1)
|
||||||
|
defer mb.wg.Done()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ch <- msg:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
|
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
|
||||||
select {
|
return publish(ctx, mb, mb.inbound, msg)
|
||||||
case msg, ok := <-mb.inbound:
|
|
||||||
return msg, ok
|
|
||||||
case <-mb.done:
|
|
||||||
return InboundMessage{}, false
|
|
||||||
case <-ctx.Done():
|
|
||||||
return InboundMessage{}, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mb *MessageBus) InboundChan() <-chan InboundMessage {
|
||||||
|
return mb.inbound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
|
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
|
||||||
if mb.closed.Load() {
|
return publish(ctx, mb, mb.outbound, msg)
|
||||||
return ErrBusClosed
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case mb.outbound <- msg:
|
|
||||||
return nil
|
|
||||||
case <-mb.done:
|
|
||||||
return ErrBusClosed
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
|
func (mb *MessageBus) OutboundChan() <-chan OutboundMessage {
|
||||||
select {
|
return mb.outbound
|
||||||
case msg, ok := <-mb.outbound:
|
|
||||||
return msg, ok
|
|
||||||
case <-mb.done:
|
|
||||||
return OutboundMessage{}, false
|
|
||||||
case <-ctx.Done():
|
|
||||||
return OutboundMessage{}, false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
|
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
|
||||||
if mb.closed.Load() {
|
return publish(ctx, mb, mb.outboundMedia, msg)
|
||||||
return ErrBusClosed
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case mb.outboundMedia <- msg:
|
|
||||||
return nil
|
|
||||||
case <-mb.done:
|
|
||||||
return ErrBusClosed
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMediaMessage, bool) {
|
func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage {
|
||||||
select {
|
return mb.outboundMedia
|
||||||
case msg, ok := <-mb.outboundMedia:
|
|
||||||
return msg, ok
|
|
||||||
case <-mb.done:
|
|
||||||
return OutboundMediaMessage{}, false
|
|
||||||
case <-ctx.Done():
|
|
||||||
return OutboundMediaMessage{}, false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) Close() {
|
func (mb *MessageBus) Close() {
|
||||||
if mb.closed.CompareAndSwap(false, true) {
|
mb.closeOnce.Do(func() {
|
||||||
|
// notify all blocked publishers to exit
|
||||||
close(mb.done)
|
close(mb.done)
|
||||||
|
|
||||||
// Drain buffered channels so messages aren't silently lost.
|
// because every publisher will check mb.closed before acquiring wg
|
||||||
// Channels are NOT closed to avoid send-on-closed panics from concurrent publishers.
|
// so we can be sure that new publishers will not be added new messages after this point
|
||||||
|
mb.closed.Store(true)
|
||||||
|
|
||||||
|
// wait for all ongoing Publish calls to finish, ensuring all messages have been sent to channels or exited
|
||||||
|
mb.wg.Wait()
|
||||||
|
|
||||||
|
// close channels safely
|
||||||
|
close(mb.inbound)
|
||||||
|
close(mb.outbound)
|
||||||
|
close(mb.outboundMedia)
|
||||||
|
|
||||||
|
// clean up any remaining messages in channels
|
||||||
drained := 0
|
drained := 0
|
||||||
for {
|
for range mb.inbound {
|
||||||
select {
|
|
||||||
case <-mb.inbound:
|
|
||||||
drained++
|
drained++
|
||||||
default:
|
|
||||||
goto doneInbound
|
|
||||||
}
|
}
|
||||||
}
|
for range mb.outbound {
|
||||||
doneInbound:
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-mb.outbound:
|
|
||||||
drained++
|
drained++
|
||||||
default:
|
|
||||||
goto doneOutbound
|
|
||||||
}
|
}
|
||||||
}
|
for range mb.outboundMedia {
|
||||||
doneOutbound:
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-mb.outboundMedia:
|
|
||||||
drained++
|
drained++
|
||||||
default:
|
|
||||||
goto doneMedia
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
doneMedia:
|
|
||||||
if drained > 0 {
|
if drained > 0 {
|
||||||
logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{
|
logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{
|
||||||
"count": drained,
|
"count": drained,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ func TestPublishConsume(t *testing.T) {
|
||||||
t.Fatalf("PublishInbound failed: %v", err)
|
t.Fatalf("PublishInbound failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, ok := mb.ConsumeInbound(ctx)
|
got, ok := <-mb.InboundChan()
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("ConsumeInbound returned ok=false")
|
t.Fatal("ConsumeInbound returned ok=false")
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +52,7 @@ func TestPublishOutboundSubscribe(t *testing.T) {
|
||||||
t.Fatalf("PublishOutbound failed: %v", err)
|
t.Fatalf("PublishOutbound failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, ok := mb.SubscribeOutbound(ctx)
|
got, ok := <-mb.OutboundChan()
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("SubscribeOutbound returned ok=false")
|
t.Fatal("SubscribeOutbound returned ok=false")
|
||||||
}
|
}
|
||||||
|
|
@ -108,27 +108,48 @@ func TestPublishOutbound_BusClosed(t *testing.T) {
|
||||||
|
|
||||||
func TestConsumeInbound_ContextCancel(t *testing.T) {
|
func TestConsumeInbound_ContextCancel(t *testing.T) {
|
||||||
mb := NewMessageBus()
|
mb := NewMessageBus()
|
||||||
|
|
||||||
defer mb.Close()
|
defer mb.Close()
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
for i := range defaultBusBufferSize {
|
||||||
cancel()
|
if err := mb.PublishInbound(context.Background(), InboundMessage{Content: "fill"}); err != nil {
|
||||||
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, ok := mb.ConsumeInbound(ctx)
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
if ok {
|
defer cancel()
|
||||||
|
mb.PublishInbound(ctx, InboundMessage{Content: "ContextCancel"})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Log("context canceled, as expected")
|
||||||
|
|
||||||
|
case msg, ok := <-mb.InboundChan():
|
||||||
|
if !ok {
|
||||||
t.Fatal("expected ok=false when context is canceled")
|
t.Fatal("expected ok=false when context is canceled")
|
||||||
}
|
}
|
||||||
|
if msg.Content == "ContextCancel" {
|
||||||
|
t.Fatalf("expected content 'ContextCancel', got %q", msg.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConsumeInbound_BusClosed(t *testing.T) {
|
func TestConsumeInbound_BusClosed(t *testing.T) {
|
||||||
mb := NewMessageBus()
|
mb := NewMessageBus()
|
||||||
|
|
||||||
|
timer := time.AfterFunc(100*time.Millisecond, func() {
|
||||||
mb.Close()
|
mb.Close()
|
||||||
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
select {
|
||||||
defer cancel()
|
case <-timer.C:
|
||||||
|
t.Log("context canceled, as expected")
|
||||||
|
|
||||||
_, ok := mb.ConsumeInbound(ctx)
|
case _, ok := <-mb.InboundChan():
|
||||||
if ok {
|
if ok {
|
||||||
t.Fatal("expected ok=false when bus is closed")
|
t.Fatal("expected ok=false when context is canceled")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,10 +157,7 @@ func TestSubscribeOutbound_BusClosed(t *testing.T) {
|
||||||
mb := NewMessageBus()
|
mb := NewMessageBus()
|
||||||
mb.Close()
|
mb.Close()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
_, ok := <-mb.OutboundChan()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, ok := mb.SubscribeOutbound(ctx)
|
|
||||||
if ok {
|
if ok {
|
||||||
t.Fatal("expected ok=false when bus is closed")
|
t.Fatal("expected ok=false when bus is closed")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -618,7 +618,7 @@ func (c *FeishuChannel) downloadResource(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to the shared picoclaw_media directory using a unique name to avoid collisions.
|
// Write to the shared picoclaw_media directory using a unique name to avoid collisions.
|
||||||
mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
|
mediaDir := media.TempDir()
|
||||||
if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil {
|
if mkdirErr := os.MkdirAll(mediaDir, 0o700); mkdirErr != nil {
|
||||||
logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{
|
logger.ErrorCF("feishu", "Failed to create media directory", map[string]any{
|
||||||
"error": mkdirErr.Error(),
|
"error": mkdirErr.Error(),
|
||||||
|
|
|
||||||
|
|
@ -361,7 +361,6 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
||||||
|
|
||||||
if len(m.channels) == 0 {
|
if len(m.channels) == 0 {
|
||||||
logger.WarnC("channels", "No channels enabled")
|
logger.WarnC("channels", "No channels enabled")
|
||||||
return errors.New("no channels enabled")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoC("channels", "Starting all channels")
|
logger.InfoC("channels", "Starting all channels")
|
||||||
|
|
@ -401,7 +400,7 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
||||||
"addr": m.httpServer.Addr,
|
"addr": m.httpServer.Addr,
|
||||||
})
|
})
|
||||||
if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
logger.ErrorCF("channels", "Shared HTTP server error", map[string]any{
|
logger.FatalCF("channels", "Shared HTTP server error", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -590,7 +589,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork
|
||||||
func dispatchLoop[M any](
|
func dispatchLoop[M any](
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
m *Manager,
|
m *Manager,
|
||||||
subscribe func(context.Context) (M, bool),
|
ch <-chan M,
|
||||||
getChannel func(M) string,
|
getChannel func(M) string,
|
||||||
enqueue func(context.Context, *channelWorker, M) bool,
|
enqueue func(context.Context, *channelWorker, M) bool,
|
||||||
startMsg, stopMsg, unknownMsg, noWorkerMsg string,
|
startMsg, stopMsg, unknownMsg, noWorkerMsg string,
|
||||||
|
|
@ -598,7 +597,12 @@ func dispatchLoop[M any](
|
||||||
logger.InfoC("channels", startMsg)
|
logger.InfoC("channels", startMsg)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
msg, ok := subscribe(ctx)
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.InfoC("channels", stopMsg)
|
||||||
|
return
|
||||||
|
|
||||||
|
case msg, ok := <-ch:
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.InfoC("channels", stopMsg)
|
logger.InfoC("channels", stopMsg)
|
||||||
return
|
return
|
||||||
|
|
@ -630,11 +634,12 @@ func dispatchLoop[M any](
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) dispatchOutbound(ctx context.Context) {
|
func (m *Manager) dispatchOutbound(ctx context.Context) {
|
||||||
dispatchLoop(
|
dispatchLoop(
|
||||||
ctx, m,
|
ctx, m,
|
||||||
m.bus.SubscribeOutbound,
|
m.bus.OutboundChan(),
|
||||||
func(msg bus.OutboundMessage) string { return msg.Channel },
|
func(msg bus.OutboundMessage) string { return msg.Channel },
|
||||||
func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool {
|
func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool {
|
||||||
select {
|
select {
|
||||||
|
|
@ -654,7 +659,7 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
|
||||||
func (m *Manager) dispatchOutboundMedia(ctx context.Context) {
|
func (m *Manager) dispatchOutboundMedia(ctx context.Context) {
|
||||||
dispatchLoop(
|
dispatchLoop(
|
||||||
ctx, m,
|
ctx, m,
|
||||||
m.bus.SubscribeOutboundMedia,
|
m.bus.OutboundMediaChan(),
|
||||||
func(msg bus.OutboundMediaMessage) string { return msg.Channel },
|
func(msg bus.OutboundMediaMessage) string { return msg.Channel },
|
||||||
func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool {
|
func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool {
|
||||||
select {
|
select {
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,6 @@ const (
|
||||||
roomKindCacheTTL = 5 * time.Minute
|
roomKindCacheTTL = 5 * time.Minute
|
||||||
roomKindCacheCleanupPeriod = 1 * time.Minute
|
roomKindCacheCleanupPeriod = 1 * time.Minute
|
||||||
roomKindCacheMaxEntries = 2048
|
roomKindCacheMaxEntries = 2048
|
||||||
|
|
||||||
matrixMediaTempDirName = "picoclaw_media"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var matrixMentionHrefRegexp = regexp.MustCompile(`(?i)<a[^>]+href=["']([^"']+)["']`)
|
var matrixMentionHrefRegexp = regexp.MustCompile(`(?i)<a[^>]+href=["']([^"']+)["']`)
|
||||||
|
|
@ -1105,7 +1103,7 @@ func (c *MatrixChannel) stripSelfMention(text string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func matrixMediaTempDir() (string, error) {
|
func matrixMediaTempDir() (string, error) {
|
||||||
mediaDir := filepath.Join(os.TempDir(), matrixMediaTempDirName)
|
mediaDir := media.TempDir()
|
||||||
if err := os.MkdirAll(mediaDir, 0o700); err != nil {
|
if err := os.MkdirAll(mediaDir, 0o700); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"maunium.net/go/mautrix/id"
|
"maunium.net/go/mautrix/id"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMatrixLocalpartMentionRegexp(t *testing.T) {
|
func TestMatrixLocalpartMentionRegexp(t *testing.T) {
|
||||||
|
|
@ -165,7 +166,7 @@ func TestMatrixMediaTempDir(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("matrixMediaTempDir failed: %v", err)
|
t.Fatalf("matrixMediaTempDir failed: %v", err)
|
||||||
}
|
}
|
||||||
if filepath.Base(dir) != matrixMediaTempDirName {
|
if filepath.Base(dir) != media.TempDirName {
|
||||||
t.Fatalf("unexpected media dir base: %q", filepath.Base(dir))
|
t.Fatalf("unexpected media dir base: %q", filepath.Base(dir))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,19 @@ func TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("timeout waiting for inbound message")
|
||||||
|
return
|
||||||
|
case inbound, ok := <-messageBus.InboundChan():
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected inbound message")
|
t.Fatal("expected inbound message")
|
||||||
}
|
}
|
||||||
if inbound.Metadata["account_id"] != "7750283E123456" {
|
if inbound.Metadata["account_id"] != "7750283E123456" {
|
||||||
t.Fatalf("account_id metadata = %q, want %q", inbound.Metadata["account_id"], "7750283E123456")
|
t.Fatalf("account_id metadata = %q, want %q", inbound.Metadata["account_id"], "7750283E123456")
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package telegram
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
|
|
||||||
|
|
@ -36,10 +35,7 @@ func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
t.Fatalf("handleMessage error: %v", err)
|
t.Fatalf("handleMessage error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
inbound, ok := <-messageBus.InboundChan()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected inbound message to be forwarded")
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,10 +108,15 @@ func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) {
|
||||||
t.Fatalf("handleMessage error: %v", err)
|
t.Fatalf("handleMessage error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Microsecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
select {
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
case <-ctx.Done():
|
||||||
|
if tc.wantForwarded {
|
||||||
|
t.Fatal("timeout waiting for message to be forwarded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case inbound, ok := <-messageBus.InboundChan():
|
||||||
if tc.wantForwarded {
|
if tc.wantForwarded {
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected inbound message to be forwarded")
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
|
|
@ -121,9 +126,6 @@ func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok {
|
|
||||||
t.Fatalf("expected message to be filtered, got content=%q", inbound.Content)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
ta "github.com/mymmrac/telego/telegoapi"
|
ta "github.com/mymmrac/telego/telegoapi"
|
||||||
|
|
@ -355,10 +354,7 @@ func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) {
|
||||||
err := ch.handleMessage(context.Background(), msg)
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
inbound, ok := <-messageBus.InboundChan()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
|
||||||
require.True(t, ok, "expected inbound message")
|
require.True(t, ok, "expected inbound message")
|
||||||
|
|
||||||
// Composite chatID should include thread ID
|
// Composite chatID should include thread ID
|
||||||
|
|
@ -397,10 +393,7 @@ func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) {
|
||||||
err := ch.handleMessage(context.Background(), msg)
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
inbound, ok := <-messageBus.InboundChan()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
|
|
||||||
// Plain chatID without thread suffix
|
// Plain chatID without thread suffix
|
||||||
|
|
@ -443,10 +436,7 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
|
||||||
err := ch.handleMessage(context.Background(), msg)
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
inbound, ok := <-messageBus.InboundChan()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
|
|
||||||
// chatID should NOT include thread suffix for non-forum groups
|
// chatID should NOT include thread suffix for non-forum groups
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package whatsapp
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
|
@ -25,10 +24,7 @@ func TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T
|
||||||
"content": "/help",
|
"content": "/help",
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
inbound, ok := <-messageBus.InboundChan()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected inbound message to be forwarded")
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,11 @@ func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("timeout waiting for message to be forwarded")
|
||||||
|
return
|
||||||
|
case inbound, ok := <-messageBus.InboundChan():
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected inbound message to be forwarded")
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
}
|
}
|
||||||
|
|
@ -54,3 +58,4 @@ func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
t.Fatalf("content=%q", inbound.Content)
|
t.Fatalf("content=%q", inbound.Content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -652,6 +654,7 @@ func (c *ModelConfig) Validate() error {
|
||||||
type GatewayConfig struct {
|
type GatewayConfig struct {
|
||||||
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
|
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
|
||||||
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
||||||
|
HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ToolDiscoveryConfig struct {
|
type ToolDiscoveryConfig struct {
|
||||||
|
|
@ -721,11 +724,14 @@ type WebToolsConfig struct {
|
||||||
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
|
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
|
||||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||||
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
|
FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"`
|
||||||
|
Format string `json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"`
|
||||||
|
PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CronToolsConfig struct {
|
type CronToolsConfig struct {
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
|
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
|
||||||
ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
|
ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
|
||||||
|
AllowCommand bool ` env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND" json:"allow_command"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecConfig struct {
|
type ExecConfig struct {
|
||||||
|
|
@ -775,6 +781,7 @@ type ToolsConfig struct {
|
||||||
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
|
||||||
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
|
||||||
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
|
||||||
|
SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"`
|
||||||
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
|
||||||
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
||||||
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
||||||
|
|
@ -864,10 +871,24 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if passphrase := credential.PassphraseProvider(); passphrase != "" {
|
||||||
|
for _, m := range cfg.ModelList {
|
||||||
|
if m.APIKey != "" && !strings.HasPrefix(m.APIKey, "enc://") && !strings.HasPrefix(m.APIKey, "file://") {
|
||||||
|
fmt.Fprintf(os.Stderr,
|
||||||
|
"picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n",
|
||||||
|
m.ModelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := env.Parse(cfg); err != nil {
|
if err := env.Parse(cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := resolveAPIKeys(cfg.ModelList, filepath.Dir(path)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Migrate legacy channel config fields to new unified structures
|
// Migrate legacy channel config fields to new unified structures
|
||||||
cfg.migrateChannelConfigs()
|
cfg.migrateChannelConfigs()
|
||||||
|
|
||||||
|
|
@ -884,6 +905,48 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encryptPlaintextAPIKeys returns a copy of models with plaintext api_key values
|
||||||
|
// encrypted. Returns (nil, nil) when nothing changed (all keys already sealed or
|
||||||
|
// empty). Returns (nil, error) if any key fails to encrypt — callers must treat
|
||||||
|
// this as a hard failure to prevent a mixed plaintext/ciphertext state on disk.
|
||||||
|
// Symmetric counterpart of resolveAPIKeys: both operate purely on []ModelConfig
|
||||||
|
// and leave JSON marshaling to the caller.
|
||||||
|
func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelConfig, error) {
|
||||||
|
sealed := make([]ModelConfig, len(models))
|
||||||
|
copy(sealed, models)
|
||||||
|
changed := false
|
||||||
|
for i := range sealed {
|
||||||
|
m := &sealed[i]
|
||||||
|
if m.APIKey == "" || strings.HasPrefix(m.APIKey, "enc://") || strings.HasPrefix(m.APIKey, "file://") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
encrypted, err := credential.Encrypt(passphrase, "", m.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot seal api_key for model %q: %w", m.ModelName, err)
|
||||||
|
}
|
||||||
|
m.APIKey = encrypted
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return sealed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAPIKeys decrypts or dereferences each api_key in models in-place.
|
||||||
|
// Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt).
|
||||||
|
func resolveAPIKeys(models []ModelConfig, configDir string) error {
|
||||||
|
cr := credential.NewResolver(configDir)
|
||||||
|
for i := range models {
|
||||||
|
resolved, err := cr.Resolve(models[i].APIKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("model_list[%d] (%s): %w", i, models[i].ModelName, err)
|
||||||
|
}
|
||||||
|
models[i].APIKey = resolved
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Config) migrateChannelConfigs() {
|
func (c *Config) migrateChannelConfigs() {
|
||||||
// Discord: mention_only -> group_trigger.mention_only
|
// Discord: mention_only -> group_trigger.mention_only
|
||||||
if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
|
if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
|
||||||
|
|
@ -898,12 +961,22 @@ func (c *Config) migrateChannelConfigs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveConfig(path string, cfg *Config) error {
|
func SaveConfig(path string, cfg *Config) error {
|
||||||
|
if passphrase := credential.PassphraseProvider(); passphrase != "" {
|
||||||
|
sealed, err := encryptPlaintextAPIKeys(cfg.ModelList, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sealed != nil {
|
||||||
|
tmp := *cfg
|
||||||
|
tmp.ModelList = sealed
|
||||||
|
cfg = &tmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data, err := json.MarshalIndent(cfg, "", " ")
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
|
||||||
return fileutil.WriteFileAtomic(path, data, 0o600)
|
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -985,7 +1058,7 @@ func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple configs - use round-robin for load balancing
|
// Multiple configs - use round-robin for load balancing
|
||||||
idx := rrCounter.Add(1) % uint64(len(matches))
|
idx := (rrCounter.Add(1) - 1) % uint64(len(matches))
|
||||||
return &matches[idx], nil
|
return &matches[idx], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1070,6 +1143,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
return t.ReadFile.Enabled
|
return t.ReadFile.Enabled
|
||||||
case "spawn":
|
case "spawn":
|
||||||
return t.Spawn.Enabled
|
return t.Spawn.Enabled
|
||||||
|
case "spawn_status":
|
||||||
|
return t.SpawnStatus.Enabled
|
||||||
case "spi":
|
case "spi":
|
||||||
return t.SPI.Enabled
|
return t.SPI.Enabled
|
||||||
case "subagent":
|
case "subagent":
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,22 @@ import (
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// mustSetupSSHKey generates a temporary Ed25519 SSH key in t.TempDir() and sets
|
||||||
|
// PICOCLAW_SSH_KEY_PATH to its path for the duration of the test. This is required
|
||||||
|
// whenever a test exercises encryption/decryption via credential.Encrypt or SaveConfig.
|
||||||
|
func mustSetupSSHKey(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
keyPath := filepath.Join(t.TempDir(), "picoclaw_ed25519.key")
|
||||||
|
if err := credential.GenerateSSHKey(keyPath); err != nil {
|
||||||
|
t.Fatalf("mustSetupSSHKey: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", keyPath)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAgentModelConfig_UnmarshalString(t *testing.T) {
|
func TestAgentModelConfig_UnmarshalString(t *testing.T) {
|
||||||
var m AgentModelConfig
|
var m AgentModelConfig
|
||||||
if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil {
|
if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil {
|
||||||
|
|
@ -253,6 +267,9 @@ func TestDefaultConfig_Gateway(t *testing.T) {
|
||||||
if cfg.Gateway.Port == 0 {
|
if cfg.Gateway.Port == 0 {
|
||||||
t.Error("Gateway port should have default value")
|
t.Error("Gateway port should have default value")
|
||||||
}
|
}
|
||||||
|
if cfg.Gateway.HotReload {
|
||||||
|
t.Error("Gateway hot reload should be disabled by default")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultConfig_Providers verifies provider structure
|
// TestDefaultConfig_Providers verifies provider structure
|
||||||
|
|
@ -391,6 +408,13 @@ func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if !cfg.Tools.Cron.AllowCommand {
|
||||||
|
t.Fatal("DefaultConfig().Tools.Cron.AllowCommand should be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
|
func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
configPath := filepath.Join(dir, "config.json")
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
|
@ -423,6 +447,22 @@ func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
if err := os.WriteFile(configPath, []byte(`{"tools":{"cron":{"exec_timeout_minutes":5}}}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error: %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.Tools.Cron.AllowCommand {
|
||||||
|
t.Fatal("tools.cron.allow_command should remain true when unset in config file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
|
func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
configPath := filepath.Join(dir, "config.json")
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
|
@ -482,13 +522,19 @@ func TestDefaultConfig_DMScope(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultConfig_WorkspacePath_Default(t *testing.T) {
|
func TestDefaultConfig_WorkspacePath_Default(t *testing.T) {
|
||||||
// Unset to ensure we test the default
|
|
||||||
t.Setenv("PICOCLAW_HOME", "")
|
t.Setenv("PICOCLAW_HOME", "")
|
||||||
// Set a known home for consistent test results
|
|
||||||
t.Setenv("HOME", "/tmp/home")
|
var fakeHome string
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
fakeHome = `C:\tmp\home`
|
||||||
|
t.Setenv("USERPROFILE", fakeHome)
|
||||||
|
} else {
|
||||||
|
fakeHome = "/tmp/home"
|
||||||
|
t.Setenv("HOME", fakeHome)
|
||||||
|
}
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
want := filepath.Join("/tmp/home", ".picoclaw", "workspace")
|
want := filepath.Join(fakeHome, ".picoclaw", "workspace")
|
||||||
|
|
||||||
if cfg.Agents.Defaults.Workspace != want {
|
if cfg.Agents.Defaults.Workspace != want {
|
||||||
t.Errorf("Default workspace path = %q, want %q", cfg.Agents.Defaults.Workspace, want)
|
t.Errorf("Default workspace path = %q, want %q", cfg.Agents.Defaults.Workspace, want)
|
||||||
|
|
@ -499,7 +545,7 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
|
||||||
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw/home")
|
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw/home")
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
want := "/custom/picoclaw/home/workspace"
|
want := filepath.Join("/custom/picoclaw/home", "workspace")
|
||||||
|
|
||||||
if cfg.Agents.Defaults.Workspace != want {
|
if cfg.Agents.Defaults.Workspace != want {
|
||||||
t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want)
|
t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want)
|
||||||
|
|
@ -621,3 +667,338 @@ func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoadConfig_WarnsForPlaintextAPIKey verifies that LoadConfig resolves a plaintext
|
||||||
|
// api_key into memory but does NOT rewrite the config file. File writes are the sole
|
||||||
|
// responsibility of SaveConfig.
|
||||||
|
func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
const original = `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}`
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
||||||
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
|
}
|
||||||
|
// In-memory value must be the resolved plaintext.
|
||||||
|
if cfg.ModelList[0].APIKey != "sk-plaintext" {
|
||||||
|
t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey, "sk-plaintext")
|
||||||
|
}
|
||||||
|
// The file on disk must remain unchanged — LoadConfig must not write anything.
|
||||||
|
raw, _ := os.ReadFile(cfgPath)
|
||||||
|
if string(raw) != original {
|
||||||
|
t.Errorf("LoadConfig must not modify the config file; got:\n%s", string(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSaveConfig_EncryptsPlaintextAPIKey verifies that SaveConfig writes enc:// ciphertext
|
||||||
|
// to disk and that a subsequent LoadConfig decrypts it back to the original plaintext.
|
||||||
|
func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
||||||
|
mustSetupSSHKey(t)
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.ModelList = []ModelConfig{
|
||||||
|
{ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"},
|
||||||
|
}
|
||||||
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk must contain enc://, not the raw key.
|
||||||
|
raw, _ := os.ReadFile(cfgPath)
|
||||||
|
if !strings.Contains(string(raw), "enc://") {
|
||||||
|
t.Errorf("saved file should contain enc://, got:\n%s", string(raw))
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), "sk-plaintext") {
|
||||||
|
t.Errorf("saved file must not contain the plaintext key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh load must decrypt back to the original plaintext.
|
||||||
|
cfg2, err := LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig after SaveConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg2.ModelList[0].APIKey != "sk-plaintext" {
|
||||||
|
t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey, "sk-plaintext")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadConfig_NoSealWithoutPassphrase verifies that api_key values are left
|
||||||
|
// unchanged when PICOCLAW_KEY_PASSPHRASE is not set.
|
||||||
|
func TestLoadConfig_NoSealWithoutPassphrase(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}`
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
||||||
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
||||||
|
|
||||||
|
if _, err := LoadConfig(cfgPath); err != nil {
|
||||||
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ := os.ReadFile(cfgPath)
|
||||||
|
if strings.Contains(string(raw), "enc://") {
|
||||||
|
t.Error("config file must not be modified when no passphrase is set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadConfig_FileRefNotSealed verifies that file:// api_key references are not
|
||||||
|
// converted to enc:// values (they are resolved at runtime by the Resolver).
|
||||||
|
func TestLoadConfig_FileRefNotSealed(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
keyFile := filepath.Join(dir, "openai.key")
|
||||||
|
if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"file://openai.key"}]}`
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
||||||
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
||||||
|
|
||||||
|
if _, err := LoadConfig(cfgPath); err != nil {
|
||||||
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ := os.ReadFile(cfgPath)
|
||||||
|
if !strings.Contains(string(raw), "file://openai.key") {
|
||||||
|
t.Error("file:// reference should be preserved unchanged in the config file")
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), "enc://") {
|
||||||
|
t.Error("file:// reference must not be converted to enc://")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSaveConfig_MixedKeys verifies that SaveConfig encrypts only plaintext api_keys
|
||||||
|
// and leaves already-encrypted (enc://) and file:// entries unchanged.
|
||||||
|
func TestSaveConfig_MixedKeys(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
||||||
|
mustSetupSSHKey(t)
|
||||||
|
|
||||||
|
// Pre-encrypt one key so we have a genuine enc:// value to put in the config.
|
||||||
|
if err := SaveConfig(cfgPath, &Config{
|
||||||
|
ModelList: []ModelConfig{
|
||||||
|
{ModelName: "pre", Model: "openai/gpt-4", APIKey: "sk-already-plain"},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("setup SaveConfig: %v", err)
|
||||||
|
}
|
||||||
|
raw, _ := os.ReadFile(cfgPath)
|
||||||
|
// Extract the enc:// value from the saved file.
|
||||||
|
var tmp struct {
|
||||||
|
ModelList []struct {
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
} `json:"model_list"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 {
|
||||||
|
t.Fatalf("setup: could not parse saved config: %v", err)
|
||||||
|
}
|
||||||
|
alreadyEncrypted := tmp.ModelList[0].APIKey
|
||||||
|
if !strings.HasPrefix(alreadyEncrypted, "enc://") {
|
||||||
|
t.Fatalf("setup: expected enc:// key, got %q", alreadyEncrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a config with three models:
|
||||||
|
// 1. plaintext → must be encrypted by SaveConfig
|
||||||
|
// 2. enc:// → must be left unchanged (already encrypted)
|
||||||
|
// 3. file:// → must be left unchanged (file reference)
|
||||||
|
keyFile := filepath.Join(dir, "api.key")
|
||||||
|
if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
cfg := &Config{
|
||||||
|
ModelList: []ModelConfig{
|
||||||
|
{ModelName: "plain", Model: "openai/gpt-4", APIKey: "sk-new-plaintext"},
|
||||||
|
{ModelName: "enc", Model: "openai/gpt-4", APIKey: alreadyEncrypted},
|
||||||
|
{ModelName: "file", Model: "openai/gpt-4", APIKey: "file://api.key"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ = os.ReadFile(cfgPath)
|
||||||
|
s := string(raw)
|
||||||
|
|
||||||
|
// 1. Plaintext must be encrypted.
|
||||||
|
if strings.Contains(s, "sk-new-plaintext") {
|
||||||
|
t.Error("plaintext key must not appear in saved file")
|
||||||
|
}
|
||||||
|
// 2. The pre-existing enc:// value must still be present (byte-for-byte unchanged).
|
||||||
|
if !strings.Contains(s, alreadyEncrypted) {
|
||||||
|
t.Error("pre-existing enc:// entry must be preserved unchanged")
|
||||||
|
}
|
||||||
|
// 3. file:// must be preserved.
|
||||||
|
if !strings.Contains(s, "file://api.key") {
|
||||||
|
t.Error("file:// reference must be preserved unchanged")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now load and verify all three decrypt/resolve correctly.
|
||||||
|
cfg2, err := LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig after SaveConfig: %v", err)
|
||||||
|
}
|
||||||
|
byName := make(map[string]string)
|
||||||
|
for _, m := range cfg2.ModelList {
|
||||||
|
byName[m.ModelName] = m.APIKey
|
||||||
|
}
|
||||||
|
if byName["plain"] != "sk-new-plaintext" {
|
||||||
|
t.Errorf("plain model api_key = %q, want %q", byName["plain"], "sk-new-plaintext")
|
||||||
|
}
|
||||||
|
if byName["enc"] != "sk-already-plain" {
|
||||||
|
t.Errorf("enc model api_key = %q, want %q", byName["enc"], "sk-already-plain")
|
||||||
|
}
|
||||||
|
if byName["file"] != "sk-from-file" {
|
||||||
|
t.Errorf("file model api_key = %q, want %q", byName["file"], "sk-from-file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadConfig_MixedKeys_NoPassphrase verifies that when PICOCLAW_KEY_PASSPHRASE
|
||||||
|
// is not set, enc:// entries cause LoadConfig to return an error, while plaintext
|
||||||
|
// and file:// entries in the same config are not affected.
|
||||||
|
func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
|
||||||
|
// First encrypt a key so we have a real enc:// value.
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
||||||
|
mustSetupSSHKey(t)
|
||||||
|
if err := SaveConfig(cfgPath, &Config{
|
||||||
|
ModelList: []ModelConfig{
|
||||||
|
{ModelName: "m", Model: "openai/gpt-4", APIKey: "sk-secret"},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("setup SaveConfig: %v", err)
|
||||||
|
}
|
||||||
|
raw, _ := os.ReadFile(cfgPath)
|
||||||
|
var tmp struct {
|
||||||
|
ModelList []struct {
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
} `json:"model_list"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &tmp); err != nil {
|
||||||
|
t.Fatalf("setup parse: %v", err)
|
||||||
|
}
|
||||||
|
encValue := tmp.ModelList[0].APIKey
|
||||||
|
|
||||||
|
// Write a mixed config: enc:// + plaintext + file://
|
||||||
|
keyFile := filepath.Join(dir, "api.key")
|
||||||
|
if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
mixed, _ := json.Marshal(map[string]any{
|
||||||
|
"model_list": []map[string]any{
|
||||||
|
{"model_name": "enc", "model": "openai/gpt-4", "api_key": encValue},
|
||||||
|
{"model_name": "plain", "model": "openai/gpt-4", "api_key": "sk-plain"},
|
||||||
|
{"model_name": "file", "model": "openai/gpt-4", "api_key": "file://api.key"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err := os.WriteFile(cfgPath, mixed, 0o600); err != nil {
|
||||||
|
t.Fatalf("setup write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted.
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
||||||
|
|
||||||
|
_, err := LoadConfig(cfgPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "passphrase required") {
|
||||||
|
t.Errorf("error should mention passphrase required, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSaveConfig_UsesPassphraseProvider verifies that SaveConfig encrypts plaintext
|
||||||
|
// api_keys using credential.PassphraseProvider() rather than os.Getenv directly.
|
||||||
|
// This matters for the launcher, which clears the environment variable and redirects
|
||||||
|
// PassphraseProvider to an in-memory SecureStore.
|
||||||
|
func TestSaveConfig_UsesPassphraseProvider(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
|
||||||
|
// Ensure the env var is empty — passphrase must come from PassphraseProvider only.
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
||||||
|
mustSetupSSHKey(t)
|
||||||
|
|
||||||
|
// Replace PassphraseProvider with an in-memory function (simulating SecureStore).
|
||||||
|
const testPassphrase = "provider-passphrase"
|
||||||
|
orig := credential.PassphraseProvider
|
||||||
|
credential.PassphraseProvider = func() string { return testPassphrase }
|
||||||
|
t.Cleanup(func() { credential.PassphraseProvider = orig })
|
||||||
|
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.ModelList = []ModelConfig{
|
||||||
|
{ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"},
|
||||||
|
}
|
||||||
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ := os.ReadFile(cfgPath)
|
||||||
|
if !strings.Contains(string(raw), "enc://") {
|
||||||
|
t.Errorf("SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadConfig_UsesPassphraseProvider verifies that LoadConfig decrypts enc:// keys
|
||||||
|
// using credential.PassphraseProvider() rather than os.Getenv directly.
|
||||||
|
func TestLoadConfig_UsesPassphraseProvider(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
|
||||||
|
// Ensure the env var is empty throughout.
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
||||||
|
mustSetupSSHKey(t)
|
||||||
|
|
||||||
|
const testPassphrase = "provider-passphrase"
|
||||||
|
const plainKey = "sk-secret"
|
||||||
|
|
||||||
|
// First, encrypt the key using the same passphrase.
|
||||||
|
encrypted, err := credential.Encrypt(testPassphrase, "", plainKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ := json.Marshal(map[string]any{
|
||||||
|
"model_list": []map[string]any{
|
||||||
|
{"model_name": "test", "model": "openai/gpt-4", "api_key": encrypted},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err = os.WriteFile(cfgPath, raw, 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect PassphraseProvider — env var is empty, so without this the load would fail.
|
||||||
|
orig := credential.PassphraseProvider
|
||||||
|
credential.PassphraseProvider = func() string { return testPassphrase }
|
||||||
|
t.Cleanup(func() { credential.PassphraseProvider = orig })
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.ModelList[0].APIKey != plainKey {
|
||||||
|
t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey, plainKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -397,6 +397,7 @@ func DefaultConfig() *Config {
|
||||||
Gateway: GatewayConfig{
|
Gateway: GatewayConfig{
|
||||||
Host: "127.0.0.1",
|
Host: "127.0.0.1",
|
||||||
Port: 18790,
|
Port: 18790,
|
||||||
|
HotReload: false,
|
||||||
},
|
},
|
||||||
Tools: ToolsConfig{
|
Tools: ToolsConfig{
|
||||||
MediaCleanup: MediaCleanupConfig{
|
MediaCleanup: MediaCleanupConfig{
|
||||||
|
|
@ -412,6 +413,7 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
Proxy: "",
|
Proxy: "",
|
||||||
FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default
|
FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default
|
||||||
|
Format: "plaintext",
|
||||||
Brave: BraveConfig{
|
Brave: BraveConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
|
|
@ -452,6 +454,7 @@ func DefaultConfig() *Config {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
ExecTimeoutMinutes: 5,
|
ExecTimeoutMinutes: 5,
|
||||||
|
AllowCommand: true,
|
||||||
},
|
},
|
||||||
Exec: ExecConfig{
|
Exec: ExecConfig{
|
||||||
ToolConfig: ToolConfig{
|
ToolConfig: ToolConfig{
|
||||||
|
|
@ -521,6 +524,9 @@ func DefaultConfig() *Config {
|
||||||
Spawn: ToolConfig{
|
Spawn: ToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
|
SpawnStatus: ToolConfig{
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
SPI: ToolConfig{
|
SPI: ToolConfig{
|
||||||
Enabled: false, // Hardware tool - Linux only
|
Enabled: false, // Hardware tool - Linux only
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,36 @@ func TestGetModelConfig_RoundRobin(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) {
|
||||||
|
rrCounter.Store(0)
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
ModelList: []ModelConfig{
|
||||||
|
{ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
|
||||||
|
{ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
|
||||||
|
{ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
wantOrder := []string{
|
||||||
|
"openai/gpt-4o-1",
|
||||||
|
"openai/gpt-4o-2",
|
||||||
|
"openai/gpt-4o-3",
|
||||||
|
"openai/gpt-4o-1",
|
||||||
|
"openai/gpt-4o-2",
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, want := range wantOrder {
|
||||||
|
result, err := cfg.GetModelConfig("lb-model")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetModelConfig() call %d error = %v", i, err)
|
||||||
|
}
|
||||||
|
if result.Model != want {
|
||||||
|
t.Fatalf("GetModelConfig() call %d model = %q, want %q", i, result.Model, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetModelConfig_Concurrent(t *testing.T) {
|
func TestGetModelConfig_Concurrent(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ModelList: []ModelConfig{
|
ModelList: []ModelConfig{
|
||||||
|
|
|
||||||
335
pkg/credential/credential.go
Normal file
335
pkg/credential/credential.go
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
// Package credential resolves API credential values for model_list entries.
|
||||||
|
//
|
||||||
|
// An API key is a form of authorization credential. This package centralizes
|
||||||
|
// how raw credential strings—plaintext or file references—are resolved into
|
||||||
|
// their actual values, keeping that logic out of the config loader.
|
||||||
|
//
|
||||||
|
// Supported formats for the api_key field:
|
||||||
|
//
|
||||||
|
// - Plaintext: "sk-abc123" → returned as-is
|
||||||
|
// - File ref: "file://filename.key" → content read from configDir/filename.key
|
||||||
|
// - Encrypted: "enc://<base64>" → AES-256-GCM decrypt via PICOCLAW_KEY_PASSPHRASE
|
||||||
|
// - Empty: "" → returned as-is (auth_method=oauth etc.)
|
||||||
|
//
|
||||||
|
// Encryption uses AES-256-GCM with HKDF-SHA256 key derivation (< 1ms, safe for embedded Linux).
|
||||||
|
// An SSH private key is required for both encryption and decryption.
|
||||||
|
// Key derivation:
|
||||||
|
//
|
||||||
|
// HKDF-SHA256(ikm=HMAC-SHA256(SHA256(sshKeyBytes), passphrase), salt, info)
|
||||||
|
//
|
||||||
|
// SSH key path resolution priority:
|
||||||
|
//
|
||||||
|
// 1. sshKeyPath argument to Encrypt (explicit)
|
||||||
|
// 2. PICOCLAW_SSH_KEY_PATH env var
|
||||||
|
// 3. ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform)
|
||||||
|
package credential
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/hkdf"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PassphraseEnvVar is the environment variable that holds the encryption passphrase.
|
||||||
|
// Other packages (e.g. config) reference this constant to avoid duplicating the string.
|
||||||
|
const PassphraseEnvVar = "PICOCLAW_KEY_PASSPHRASE"
|
||||||
|
|
||||||
|
// PassphraseProvider is the function used to retrieve the passphrase for enc://
|
||||||
|
// credential decryption. It defaults to reading PICOCLAW_KEY_PASSPHRASE from the
|
||||||
|
// process environment. Replace it at startup to use a different source, such as
|
||||||
|
// an in-memory SecureStore, so that all LoadConfig() calls everywhere share the
|
||||||
|
// same passphrase source without needing os.Environ.
|
||||||
|
//
|
||||||
|
// Example (launcher main.go):
|
||||||
|
//
|
||||||
|
// credential.PassphraseProvider = apiHandler.passphraseStore.Get
|
||||||
|
var PassphraseProvider func() string = func() string {
|
||||||
|
return os.Getenv(PassphraseEnvVar)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrPassphraseRequired is returned when an enc:// credential is encountered but
|
||||||
|
// no passphrase is available from PassphraseProvider. Callers can detect this
|
||||||
|
// with errors.Is to distinguish a missing-passphrase condition from other errors.
|
||||||
|
var ErrPassphraseRequired = errors.New("credential: enc:// passphrase required")
|
||||||
|
|
||||||
|
// ErrDecryptionFailed is returned when an enc:// credential cannot be decrypted,
|
||||||
|
// indicating a wrong passphrase or SSH key. Callers can detect this with errors.Is.
|
||||||
|
var ErrDecryptionFailed = errors.New("credential: enc:// decryption failed (wrong passphrase or SSH key?)")
|
||||||
|
|
||||||
|
const (
|
||||||
|
fileScheme = "file://"
|
||||||
|
encScheme = "enc://"
|
||||||
|
hkdfInfo = "picoclaw-credential-v1"
|
||||||
|
saltLen = 16
|
||||||
|
nonceLen = 12
|
||||||
|
keyLen = 32
|
||||||
|
sshKeyEnv = "PICOCLAW_SSH_KEY_PATH"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolver resolves raw credential strings for model_list api_key fields.
|
||||||
|
// File references are resolved relative to the directory of the config file.
|
||||||
|
type Resolver struct {
|
||||||
|
configDir string
|
||||||
|
resolvedConfigDir string // symlink-resolved form of configDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewResolver returns a Resolver that resolves file:// references relative to
|
||||||
|
// configDir (typically filepath.Dir of the config file path).
|
||||||
|
func NewResolver(configDir string) *Resolver {
|
||||||
|
resolved := configDir
|
||||||
|
if configDir != "" {
|
||||||
|
if linkedPath, err := filepath.EvalSymlinks(configDir); err == nil {
|
||||||
|
resolved = linkedPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Resolver{configDir: configDir, resolvedConfigDir: resolved}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns the actual credential value for raw:
|
||||||
|
//
|
||||||
|
// - "" → "" (no error; auth_method=oauth needs no key)
|
||||||
|
// - "file://name.key" → trimmed content of configDir/name.key
|
||||||
|
// - anything else → raw unchanged (plaintext credential)
|
||||||
|
func (r *Resolver) Resolve(raw string) (string, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(raw, fileScheme) {
|
||||||
|
fileName := strings.TrimSpace(strings.TrimPrefix(raw, fileScheme))
|
||||||
|
if fileName == "" {
|
||||||
|
return "", fmt.Errorf("credential: file:// reference has no filename")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDir := r.resolvedConfigDir
|
||||||
|
if baseDir == "" {
|
||||||
|
baseDir = r.configDir
|
||||||
|
}
|
||||||
|
keyPath := filepath.Join(baseDir, fileName)
|
||||||
|
// Resolve symlinks before enforcing containment to prevent escaping via symlinks.
|
||||||
|
realKeyPath, err := filepath.EvalSymlinks(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("credential: failed to resolve credential file path %q: %w", keyPath, err)
|
||||||
|
}
|
||||||
|
if !isWithinDir(realKeyPath, baseDir) {
|
||||||
|
return "", fmt.Errorf("credential: file:// path escapes config directory")
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(realKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("credential: failed to read credential file %q: %w", realKeyPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
value := strings.TrimSpace(string(data))
|
||||||
|
if value == "" {
|
||||||
|
return "", fmt.Errorf("credential: credential file %q is empty", realKeyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(raw, encScheme) {
|
||||||
|
return resolveEncrypted(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plaintext credential — return unchanged.
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveEncrypted decrypts an enc:// credential using PassphraseProvider.
|
||||||
|
func resolveEncrypted(raw string) (string, error) {
|
||||||
|
passphrase := PassphraseProvider()
|
||||||
|
if passphrase == "" {
|
||||||
|
return "", ErrPassphraseRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
sshKeyPath := pickSSHKeyPath("") // override="": consult env then auto-detect
|
||||||
|
|
||||||
|
b64 := strings.TrimPrefix(raw, encScheme)
|
||||||
|
blob, err := base64.StdEncoding.DecodeString(b64)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("credential: enc:// invalid base64: %w", err)
|
||||||
|
}
|
||||||
|
if len(blob) < saltLen+nonceLen+1 {
|
||||||
|
return "", fmt.Errorf("credential: enc:// payload too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
salt := blob[:saltLen]
|
||||||
|
nonce := blob[saltLen : saltLen+nonceLen]
|
||||||
|
ciphertext := blob[saltLen+nonceLen:]
|
||||||
|
|
||||||
|
key, err := deriveKey(passphrase, sshKeyPath, salt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("credential: enc:// cipher init: %w", err)
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("credential: enc:// gcm init: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %w", ErrDecryptionFailed, err)
|
||||||
|
}
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt encrypts plaintext and returns an enc:// credential string.
|
||||||
|
//
|
||||||
|
// passphrase is required (PICOCLAW_KEY_PASSPHRASE value).
|
||||||
|
// sshKeyPath is the SSH private key file to use; pass "" to auto-detect via
|
||||||
|
// PICOCLAW_SSH_KEY_PATH env var or ~/.ssh/picoclaw_ed25519.key.
|
||||||
|
// An SSH private key must be resolvable or Encrypt returns an error.
|
||||||
|
func Encrypt(passphrase, sshKeyPath, plaintext string) (string, error) {
|
||||||
|
if passphrase == "" {
|
||||||
|
return "", fmt.Errorf("credential: passphrase must not be empty")
|
||||||
|
}
|
||||||
|
sshKeyPath = pickSSHKeyPath(sshKeyPath)
|
||||||
|
|
||||||
|
salt := make([]byte, saltLen)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||||
|
return "", fmt.Errorf("credential: failed to generate salt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := deriveKey(passphrase, sshKeyPath, salt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("credential: cipher init: %w", err)
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("credential: gcm init: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, nonceLen)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", fmt.Errorf("credential: failed to generate nonce: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nil, nonce, []byte(plaintext), nil)
|
||||||
|
blob := make([]byte, 0, saltLen+nonceLen+len(ciphertext))
|
||||||
|
blob = append(blob, salt...)
|
||||||
|
blob = append(blob, nonce...)
|
||||||
|
blob = append(blob, ciphertext...)
|
||||||
|
return encScheme + base64.StdEncoding.EncodeToString(blob), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWithinDir reports whether path is contained within (or equal to) dir.
|
||||||
|
// Uses filepath.IsLocal on the relative path for robust cross-platform traversal detection.
|
||||||
|
func isWithinDir(path, dir string) bool {
|
||||||
|
rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(path))
|
||||||
|
return err == nil && filepath.IsLocal(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowedSSHKeyPath reports whether path is in a permitted location for SSH key files:
|
||||||
|
// - exact match with PICOCLAW_SSH_KEY_PATH env var
|
||||||
|
// - within the PICOCLAW_HOME env var directory
|
||||||
|
// - within ~/.ssh/
|
||||||
|
func allowedSSHKeyPath(path string) bool {
|
||||||
|
if path == "" {
|
||||||
|
return true // passphrase-only mode; no file will be read
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(path)
|
||||||
|
|
||||||
|
// Exact match with PICOCLAW_SSH_KEY_PATH.
|
||||||
|
if envPath, ok := os.LookupEnv(sshKeyEnv); ok && envPath != "" {
|
||||||
|
if clean == filepath.Clean(envPath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within PICOCLAW_HOME.
|
||||||
|
if picoHome := os.Getenv("PICOCLAW_HOME"); picoHome != "" {
|
||||||
|
if isWithinDir(clean, picoHome) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within ~/.ssh/.
|
||||||
|
if userHome, err := os.UserHomeDir(); err == nil {
|
||||||
|
if isWithinDir(clean, filepath.Join(userHome, ".ssh")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveKey derives a 32-byte AES-256 key from passphrase and SSH private key.
|
||||||
|
//
|
||||||
|
// ikm = HMAC-SHA256(key=SHA256(sshKeyBytes), msg=passphrase)
|
||||||
|
// Final key: HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
|
||||||
|
// sshKeyPath must be non-empty; returns an error otherwise.
|
||||||
|
func deriveKey(passphrase, sshKeyPath string, salt []byte) ([]byte, error) {
|
||||||
|
if sshKeyPath == "" {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"credential: SSH private key is required but not found" +
|
||||||
|
" (set PICOCLAW_SSH_KEY_PATH or place key at ~/.ssh/picoclaw_ed25519.key)")
|
||||||
|
}
|
||||||
|
if !allowedSSHKeyPath(sshKeyPath) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"credential: SSH key path %q is not in an allowed location (PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/)",
|
||||||
|
sshKeyPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sshBytes, err := os.ReadFile(sshKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("credential: cannot read SSH key %q: %w", sshKeyPath, err)
|
||||||
|
}
|
||||||
|
sshHash := sha256.Sum256(sshBytes)
|
||||||
|
mac := hmac.New(sha256.New, sshHash[:])
|
||||||
|
mac.Write([]byte(passphrase))
|
||||||
|
ikm := mac.Sum(nil)
|
||||||
|
|
||||||
|
key, err := hkdf.Key(sha256.New, ikm, salt, hkdfInfo, keyLen)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("credential: HKDF expand failed: %w", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickSSHKeyPath returns the SSH private key path to use for encryption/decryption.
|
||||||
|
//
|
||||||
|
// Priority:
|
||||||
|
// 1. override (non-empty explicit argument)
|
||||||
|
// 2. PICOCLAW_SSH_KEY_PATH env var
|
||||||
|
// 3. ~/.ssh/picoclaw_ed25519.key (auto-detection)
|
||||||
|
//
|
||||||
|
// Returns "" when no key is found; deriveKey will return an error in that case.
|
||||||
|
func pickSSHKeyPath(override string) string {
|
||||||
|
if override != "" {
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
if p, ok := os.LookupEnv(sshKeyEnv); ok {
|
||||||
|
return p // respect explicit setting, even if ""
|
||||||
|
}
|
||||||
|
return findDefaultSSHKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
// findDefaultSSHKey returns the picoclaw-specific SSH key path if it exists.
|
||||||
|
func findDefaultSSHKey() string {
|
||||||
|
p, err := DefaultSSHKeyPath()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
283
pkg/credential/credential_test.go
Normal file
283
pkg/credential/credential_test.go
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
package credential_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolve_PlainKey(t *testing.T) {
|
||||||
|
r := credential.NewResolver(t.TempDir())
|
||||||
|
got, err := r.Resolve("sk-plaintext-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "sk-plaintext-key" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "sk-plaintext-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_FileKey_Success(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
keyFile := "openai_plain.key"
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, keyFile), []byte("sk-from-file\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := credential.NewResolver(dir)
|
||||||
|
got, err := r.Resolve("file://" + keyFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "sk-from-file" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "sk-from-file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_FileKey_NotFound(t *testing.T) {
|
||||||
|
r := credential.NewResolver(t.TempDir())
|
||||||
|
_, err := r.Resolve("file://missing.key")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_FileKey_Empty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
keyFile := "empty.key"
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, keyFile), []byte(" \n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := credential.NewResolver(dir)
|
||||||
|
_, err := r.Resolve("file://" + keyFile)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty credential file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolve_EncKey_RoundTrip tests basic encryption/decryption round-trip with an SSH key.
|
||||||
|
func TestResolve_EncKey_RoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
|
||||||
|
if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const passphrase = "test-passphrase-32bytes-long-ok!"
|
||||||
|
const plaintext = "sk-encrypted-secret"
|
||||||
|
|
||||||
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath)
|
||||||
|
|
||||||
|
enc, err := credential.Encrypt(passphrase, "", plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase)
|
||||||
|
|
||||||
|
r := credential.NewResolver(t.TempDir())
|
||||||
|
got, err := r.Resolve(enc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve: %v", err)
|
||||||
|
}
|
||||||
|
if got != plaintext {
|
||||||
|
t.Fatalf("got %q, want %q", got, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolve_EncKey_WithSSHKey tests that the SSH key file is incorporated into key derivation.
|
||||||
|
func TestResolve_EncKey_WithSSHKey(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
|
||||||
|
if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-private-key-material\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const passphrase = "test-passphrase"
|
||||||
|
const plaintext = "sk-ssh-protected-secret"
|
||||||
|
|
||||||
|
// Set PICOCLAW_SSH_KEY_PATH before Encrypt so the path passes allowedSSHKeyPath validation.
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase)
|
||||||
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath)
|
||||||
|
|
||||||
|
enc, err := credential.Encrypt(passphrase, sshKeyPath, plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := credential.NewResolver(t.TempDir())
|
||||||
|
got, err := r.Resolve(enc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve: %v", err)
|
||||||
|
}
|
||||||
|
if got != plaintext {
|
||||||
|
t.Fatalf("got %q, want %q", got, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_EncKey_NoPassphrase(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
|
||||||
|
if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath)
|
||||||
|
|
||||||
|
enc, err := credential.Encrypt("some-passphrase", "", "sk-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
||||||
|
|
||||||
|
r := credential.NewResolver(t.TempDir())
|
||||||
|
_, err = r.Resolve(enc)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when PICOCLAW_KEY_PASSPHRASE is unset, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_EncKey_BadCiphertext(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase")
|
||||||
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
||||||
|
|
||||||
|
r := credential.NewResolver(t.TempDir())
|
||||||
|
_, err := r.Resolve("enc://!!not-valid-base64!!")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid enc:// payload, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_EncKey_PayloadTooShort(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase")
|
||||||
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
||||||
|
|
||||||
|
// Valid base64 but fewer bytes than salt(16)+nonce(12)+1 minimum.
|
||||||
|
import64 := "dG9vc2hvcnQ=" // "tooshort" = 8 bytes
|
||||||
|
r := credential.NewResolver(t.TempDir())
|
||||||
|
_, err := r.Resolve("enc://" + import64)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for too-short enc:// payload, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_EncKey_WrongPassphrase(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
|
||||||
|
if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath)
|
||||||
|
|
||||||
|
enc, err := credential.Encrypt("correct-passphrase", "", "sk-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "wrong-passphrase")
|
||||||
|
|
||||||
|
r := credential.NewResolver(t.TempDir())
|
||||||
|
_, err = r.Resolve(enc)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected decryption error for wrong passphrase, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncrypt_EmptyPassphrase(t *testing.T) {
|
||||||
|
_, err := credential.Encrypt("", "", "sk-secret")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty passphrase, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveKey_SSHKeyNotFound(t *testing.T) {
|
||||||
|
// Encrypt with a real SSH key path, then try to decrypt with a missing path.
|
||||||
|
dir := t.TempDir()
|
||||||
|
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
|
||||||
|
if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the real key path so allowedSSHKeyPath validation passes for Encrypt.
|
||||||
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath)
|
||||||
|
|
||||||
|
enc, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point to a non-existent SSH key so deriveKey's ReadFile fails.
|
||||||
|
// The path is still under the same dir, so allowedSSHKeyPath passes (exact env match).
|
||||||
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "passphrase")
|
||||||
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", filepath.Join(dir, "nonexistent_key"))
|
||||||
|
|
||||||
|
r := credential.NewResolver(t.TempDir())
|
||||||
|
_, err = r.Resolve(enc)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when SSH key file is missing, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolve_FileRef_PathTraversal verifies that file:// references cannot escape configDir
|
||||||
|
// via relative traversal ("../../etc/passwd") or absolute paths ("/abs/path").
|
||||||
|
func TestResolve_FileRef_PathTraversal(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
// Create a file outside configDir that the traversal would point to.
|
||||||
|
outsideFile := filepath.Join(t.TempDir(), "secret.key")
|
||||||
|
if err := os.WriteFile(outsideFile, []byte("stolen"), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := credential.NewResolver(filepath.Dir(cfgPath))
|
||||||
|
|
||||||
|
cases := []string{
|
||||||
|
"file://../../secret.key",
|
||||||
|
"file://../secret.key",
|
||||||
|
"file://" + outsideFile, // absolute path
|
||||||
|
}
|
||||||
|
for _, raw := range cases {
|
||||||
|
_, err := r.Resolve(raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Resolve(%q): expected path traversal error, got nil", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolve_FileRef_withinConfigDir verifies that a legitimate relative file:// ref works.
|
||||||
|
func TestResolve_FileRef_withinConfigDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "my.key"), []byte("sk-valid\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
r := credential.NewResolver(dir)
|
||||||
|
got, err := r.Resolve("file://my.key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "sk-valid" {
|
||||||
|
t.Fatalf("got %q, want %q", got, "sk-valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEncrypt_SSHKeyOutsideAllowedDirs verifies that Encrypt rejects SSH key paths
|
||||||
|
// that are not under PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/.
|
||||||
|
func TestEncrypt_SSHKeyOutsideAllowedDirs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
|
||||||
|
if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("setup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure none of the allowed env vars point here.
|
||||||
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
||||||
|
t.Setenv("PICOCLAW_HOME", "")
|
||||||
|
|
||||||
|
_, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for SSH key outside allowed directories, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
62
pkg/credential/keygen.go
Normal file
62
pkg/credential/keygen.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
package credential
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultSSHKeyPath returns the canonical path for the picoclaw-specific SSH key.
|
||||||
|
// The path is always ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform).
|
||||||
|
func DefaultSSHKeyPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("credential: cannot determine home directory: %w", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".ssh", "picoclaw_ed25519.key"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateSSHKey generates an Ed25519 SSH key pair and writes the private key
|
||||||
|
// to path (permissions 0600) and the public key to path+".pub" (permissions 0644).
|
||||||
|
// The ~/.ssh/ directory is created with 0700 if it does not exist.
|
||||||
|
// If the files already exist they are overwritten.
|
||||||
|
func GenerateSSHKey(path string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return fmt.Errorf("credential: keygen: cannot create directory %q: %w", filepath.Dir(path), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pubRaw, privRaw, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("credential: keygen: ed25519 key generation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal private key as OpenSSH PEM.
|
||||||
|
block, err := ssh.MarshalPrivateKey(privRaw, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("credential: keygen: marshal private key: %w", err)
|
||||||
|
}
|
||||||
|
privPEM := pem.EncodeToMemory(block)
|
||||||
|
|
||||||
|
if err = os.WriteFile(path, privPEM, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("credential: keygen: write private key %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal public key as authorized_keys line.
|
||||||
|
sshPub, err := ssh.NewPublicKey(pubRaw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("credential: keygen: marshal public key: %w", err)
|
||||||
|
}
|
||||||
|
pubLine := ssh.MarshalAuthorizedKey(sshPub)
|
||||||
|
|
||||||
|
pubPath := path + ".pub"
|
||||||
|
if err := os.WriteFile(pubPath, pubLine, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("credential: keygen: write public key %q: %w", pubPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
115
pkg/credential/keygen_test.go
Normal file
115
pkg/credential/keygen_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
package credential
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateSSHKey_CreatesFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
keyPath := filepath.Join(dir, "test_ed25519.key")
|
||||||
|
|
||||||
|
if err := GenerateSSHKey(keyPath); err != nil {
|
||||||
|
t.Fatalf("GenerateSSHKey() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private key must exist.
|
||||||
|
privInfo, err := os.Stat(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("private key file missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions on non-Windows (Windows does not support Unix permission bits).
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
if got := privInfo.Mode().Perm(); got != 0o600 {
|
||||||
|
t.Errorf("private key permissions = %04o, want 0600", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public key must exist.
|
||||||
|
pubPath := keyPath + ".pub"
|
||||||
|
pubInfo, err := os.Stat(pubPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("public key file missing: %v", err)
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
if got := pubInfo.Mode().Perm(); got != 0o644 {
|
||||||
|
t.Errorf("public key permissions = %04o, want 0644", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private key must be parseable as an OpenSSH ed25519 key.
|
||||||
|
privPEM, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read private key: %v", err)
|
||||||
|
}
|
||||||
|
privKey, err := ssh.ParseRawPrivateKey(privPEM)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse private key: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := privKey.(*ed25519.PrivateKey); !ok {
|
||||||
|
t.Errorf("private key type = %T, want *ed25519.PrivateKey", privKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public key must be parseable as authorized_keys line.
|
||||||
|
pubBytes, err := os.ReadFile(pubPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read public key: %v", err)
|
||||||
|
}
|
||||||
|
pubKey, _, _, rest, err := ssh.ParseAuthorizedKey(pubBytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse public key: %v", err)
|
||||||
|
}
|
||||||
|
if pubKey == nil {
|
||||||
|
t.Fatal("expected non-nil public key")
|
||||||
|
}
|
||||||
|
if len(rest) > 0 {
|
||||||
|
t.Errorf("unexpected trailing bytes after public key: %d bytes", len(rest))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSSHKey_OverwritesExisting(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
keyPath := filepath.Join(dir, "test_ed25519.key")
|
||||||
|
|
||||||
|
// Generate twice; second call must not error and must produce a different key.
|
||||||
|
if err := GenerateSSHKey(keyPath); err != nil {
|
||||||
|
t.Fatalf("first GenerateSSHKey() error = %v", err)
|
||||||
|
}
|
||||||
|
first, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read first key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = GenerateSSHKey(keyPath); err != nil {
|
||||||
|
t.Fatalf("second GenerateSSHKey() error = %v", err)
|
||||||
|
}
|
||||||
|
second, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read second key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two independently generated Ed25519 keys must differ.
|
||||||
|
if string(first) == string(second) {
|
||||||
|
t.Error("expected overwritten key to differ from original")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSSHKey_CreatesDirectory(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Nested directory that does not yet exist.
|
||||||
|
keyPath := filepath.Join(dir, "subdir", ".ssh", "picoclaw_ed25519.key")
|
||||||
|
|
||||||
|
if err := GenerateSSHKey(keyPath); err != nil {
|
||||||
|
t.Fatalf("GenerateSSHKey() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(keyPath); err != nil {
|
||||||
|
t.Fatalf("private key not created: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
pkg/credential/store.go
Normal file
44
pkg/credential/store.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package credential
|
||||||
|
|
||||||
|
import "sync/atomic"
|
||||||
|
|
||||||
|
// SecureStore holds a passphrase in memory.
|
||||||
|
//
|
||||||
|
// Uses atomic.Pointer so reads and writes are lock-free.
|
||||||
|
// The passphrase is never written to disk; callers decide how to
|
||||||
|
// transport it outside this store (e.g., via cmd.Env or os.Environ).
|
||||||
|
type SecureStore struct {
|
||||||
|
val atomic.Pointer[string]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSecureStore creates an empty SecureStore.
|
||||||
|
func NewSecureStore() *SecureStore {
|
||||||
|
return &SecureStore{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetString stores the passphrase. An empty string clears the store.
|
||||||
|
func (s *SecureStore) SetString(passphrase string) {
|
||||||
|
if passphrase == "" {
|
||||||
|
s.val.Store(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.val.Store(&passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the stored passphrase, or "" if not set.
|
||||||
|
func (s *SecureStore) Get() string {
|
||||||
|
if p := s.val.Load(); p != nil {
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSet reports whether a passphrase is currently stored.
|
||||||
|
func (s *SecureStore) IsSet() bool {
|
||||||
|
return s.val.Load() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes the stored passphrase.
|
||||||
|
func (s *SecureStore) Clear() {
|
||||||
|
s.val.Store(nil)
|
||||||
|
}
|
||||||
81
pkg/credential/store_test.go
Normal file
81
pkg/credential/store_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package credential
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSecureStore_SetGet(t *testing.T) {
|
||||||
|
s := NewSecureStore()
|
||||||
|
if s.IsSet() {
|
||||||
|
t.Error("expected empty store")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.SetString("hunter2")
|
||||||
|
if !s.IsSet() {
|
||||||
|
t.Error("expected store to be set")
|
||||||
|
}
|
||||||
|
if got := s.Get(); got != "hunter2" {
|
||||||
|
t.Errorf("Get() = %q, want %q", got, "hunter2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecureStore_Clear(t *testing.T) {
|
||||||
|
s := NewSecureStore()
|
||||||
|
s.SetString("secret")
|
||||||
|
s.Clear()
|
||||||
|
|
||||||
|
if s.IsSet() {
|
||||||
|
t.Error("expected store to be empty after Clear()")
|
||||||
|
}
|
||||||
|
if got := s.Get(); got != "" {
|
||||||
|
t.Errorf("Get() after Clear() = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecureStore_SetOverwrites(t *testing.T) {
|
||||||
|
s := NewSecureStore()
|
||||||
|
s.SetString("first")
|
||||||
|
s.SetString("second")
|
||||||
|
|
||||||
|
if got := s.Get(); got != "second" {
|
||||||
|
t.Errorf("Get() = %q, want %q", got, "second")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecureStore_EmptyPassphrase(t *testing.T) {
|
||||||
|
s := NewSecureStore()
|
||||||
|
s.SetString("") // empty → should not mark as set
|
||||||
|
|
||||||
|
if s.IsSet() {
|
||||||
|
t.Error("empty passphrase should not mark store as set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecureStore_ConcurrentSetGet(t *testing.T) {
|
||||||
|
s := NewSecureStore()
|
||||||
|
const goroutines = 10
|
||||||
|
const iterations = 1000
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(goroutines)
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < iterations; j++ {
|
||||||
|
if id%2 == 0 {
|
||||||
|
s.SetString("even")
|
||||||
|
} else {
|
||||||
|
s.SetString("odd")
|
||||||
|
}
|
||||||
|
_ = s.Get()
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
final := s.Get()
|
||||||
|
if final != "" && final != "even" && final != "odd" {
|
||||||
|
t.Errorf("Get() returned unexpected value %q after concurrent Set/Get", final)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,7 @@ type CronService struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
running bool
|
running bool
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
|
wakeChan chan struct{}
|
||||||
gronx *gronx.Gronx
|
gronx *gronx.Gronx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +74,7 @@ func NewCronService(storePath string, onJob JobHandler) *CronService {
|
||||||
storePath: storePath,
|
storePath: storePath,
|
||||||
onJob: onJob,
|
onJob: onJob,
|
||||||
gronx: gronx.New(),
|
gronx: gronx.New(),
|
||||||
|
wakeChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
// Initialize and load store on creation
|
// Initialize and load store on creation
|
||||||
cs.loadStore()
|
cs.loadStore()
|
||||||
|
|
@ -97,6 +99,9 @@ func (cs *CronService) Start() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
cs.stopChan = make(chan struct{})
|
cs.stopChan = make(chan struct{})
|
||||||
|
if cs.wakeChan == nil {
|
||||||
|
cs.wakeChan = make(chan struct{})
|
||||||
|
}
|
||||||
cs.running = true
|
cs.running = true
|
||||||
go cs.runLoop(cs.stopChan)
|
go cs.runLoop(cs.stopChan)
|
||||||
|
|
||||||
|
|
@ -119,14 +124,47 @@ func (cs *CronService) Stop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CronService) runLoop(stopChan chan struct{}) {
|
func (cs *CronService) runLoop(stopChan chan struct{}) {
|
||||||
ticker := time.NewTicker(1 * time.Second)
|
timer := time.NewTimer(time.Hour)
|
||||||
defer ticker.Stop()
|
if !timer.Stop() {
|
||||||
|
<-timer.C
|
||||||
|
}
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
// every loop, recalculate the next wake time
|
||||||
|
cs.mu.RLock()
|
||||||
|
nextWake := cs.getNextWakeMS()
|
||||||
|
cs.mu.RUnlock()
|
||||||
|
|
||||||
|
var delay time.Duration
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
if nextWake == nil {
|
||||||
|
// no jobs, sleep for a long time (or until a new job is added)
|
||||||
|
delay = time.Hour
|
||||||
|
} else {
|
||||||
|
diff := *nextWake - now
|
||||||
|
if diff <= 0 {
|
||||||
|
delay = 0
|
||||||
|
} else {
|
||||||
|
delay = time.Duration(diff) * time.Millisecond
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timer.Reset(delay)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-stopChan:
|
case <-stopChan:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-cs.wakeChan: // wake on new job or update
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case <-timer.C:
|
||||||
cs.checkJobs()
|
cs.checkJobs()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -264,22 +302,19 @@ func (cs *CronService) executeJobByID(jobID string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int64 {
|
func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int64 {
|
||||||
if schedule.Kind == "at" {
|
switch schedule.Kind {
|
||||||
|
case "at":
|
||||||
if schedule.AtMS != nil && *schedule.AtMS > nowMS {
|
if schedule.AtMS != nil && *schedule.AtMS > nowMS {
|
||||||
return schedule.AtMS
|
return schedule.AtMS
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
case "every":
|
||||||
|
|
||||||
if schedule.Kind == "every" {
|
|
||||||
if schedule.EveryMS == nil || *schedule.EveryMS <= 0 {
|
if schedule.EveryMS == nil || *schedule.EveryMS <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
next := nowMS + *schedule.EveryMS
|
next := nowMS + *schedule.EveryMS
|
||||||
return &next
|
return &next
|
||||||
}
|
case "cron":
|
||||||
|
|
||||||
if schedule.Kind == "cron" {
|
|
||||||
if schedule.Expr == "" {
|
if schedule.Expr == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -294,9 +329,19 @@ func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int6
|
||||||
|
|
||||||
nextMS := nextTime.UnixMilli()
|
nextMS := nextTime.UnixMilli()
|
||||||
return &nextMS
|
return &nextMS
|
||||||
|
default:
|
||||||
|
log.Printf("[cron] unknown schedule kind '%s'", schedule.Kind)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
// wake up the loop to re-evaluate next wake time immediately (e.g. after add/update/remove jobs)
|
||||||
|
func (cs *CronService) notify() {
|
||||||
|
select {
|
||||||
|
case cs.wakeChan <- struct{}{}:
|
||||||
|
default:
|
||||||
|
// if the channel is full, it means the loop will wake up soon anyway, so we can skip sending
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CronService) recomputeNextRuns() {
|
func (cs *CronService) recomputeNextRuns() {
|
||||||
|
|
@ -400,6 +445,8 @@ func (cs *CronService) AddJob(
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cs.notify()
|
||||||
|
|
||||||
return &job, nil
|
return &job, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,6 +458,9 @@ func (cs *CronService) UpdateJob(job *CronJob) error {
|
||||||
if cs.store.Jobs[i].ID == job.ID {
|
if cs.store.Jobs[i].ID == job.ID {
|
||||||
cs.store.Jobs[i] = *job
|
cs.store.Jobs[i] = *job
|
||||||
cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli()
|
cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli()
|
||||||
|
|
||||||
|
cs.notify()
|
||||||
|
|
||||||
return cs.saveStoreUnsafe()
|
return cs.saveStoreUnsafe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -441,6 +491,8 @@ func (cs *CronService) removeJobUnsafe(jobID string) bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cs.notify()
|
||||||
|
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -463,6 +515,9 @@ func (cs *CronService) EnableJob(jobID string, enabled bool) *CronJob {
|
||||||
if err := cs.saveStoreUnsafe(); err != nil {
|
if err := cs.saveStoreUnsafe(); err != nil {
|
||||||
log.Printf("[cron] failed to save store after enable: %v", err)
|
log.Printf("[cron] failed to save store after enable: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cs.notify()
|
||||||
|
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package cron
|
package cron
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSaveStore_FilePermissions(t *testing.T) {
|
func TestSaveStore_FilePermissions(t *testing.T) {
|
||||||
|
|
@ -36,3 +39,199 @@ func TestSaveStore_FilePermissions(t *testing.T) {
|
||||||
func int64Ptr(v int64) *int64 {
|
func int64Ptr(v int64) *int64 {
|
||||||
return &v
|
return &v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setupService(handler JobHandler) (*CronService, string) {
|
||||||
|
tmpFile := fmt.Sprintf("test_cron_%d.json", time.Now().UnixNano())
|
||||||
|
cs := NewCronService(tmpFile, handler)
|
||||||
|
return cs, tmpFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronService_CRUD(t *testing.T) {
|
||||||
|
cs, path := setupService(nil)
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
// Test AddJob
|
||||||
|
at := time.Now().Add(time.Hour).UnixMilli()
|
||||||
|
job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", true, "ch", "to")
|
||||||
|
if err != nil || job.ID == "" {
|
||||||
|
t.Fatalf("AddJob failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test ListJobs
|
||||||
|
if len(cs.ListJobs(true)) != 1 {
|
||||||
|
t.Error("ListJobs should return 1 job")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test UpdateJob
|
||||||
|
job.Name = "UpdatedName"
|
||||||
|
err = cs.UpdateJob(job)
|
||||||
|
if err != nil || cs.store.Jobs[0].Name != "UpdatedName" {
|
||||||
|
t.Error("UpdateJob failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test EnableJob
|
||||||
|
cs.EnableJob(job.ID, false)
|
||||||
|
if cs.store.Jobs[0].Enabled != false || cs.store.Jobs[0].State.NextRunAtMS != nil {
|
||||||
|
t.Error("EnableJob(false) failed to clear state")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test RemoveJob
|
||||||
|
removed := cs.RemoveJob(job.ID)
|
||||||
|
if !removed || len(cs.store.Jobs) != 0 {
|
||||||
|
t.Error("RemoveJob failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Test Cron Expression Calculation Logic
|
||||||
|
func TestCronService_ComputeNextRun(t *testing.T) {
|
||||||
|
cs, path := setupService(nil)
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
schedule CronSchedule
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{"Valid Cron", CronSchedule{Kind: "cron", Expr: "0 * * * *"}, false},
|
||||||
|
{"Invalid Cron", CronSchedule{Kind: "cron", Expr: "invalid"}, true},
|
||||||
|
{"Every MS", CronSchedule{Kind: "every", EveryMS: int64Ptr(5000)}, false},
|
||||||
|
{"At Future", CronSchedule{Kind: "at", AtMS: int64Ptr(now + 1000)}, false},
|
||||||
|
{"At Past", CronSchedule{Kind: "at", AtMS: int64Ptr(now - 1000)}, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := cs.computeNextRun(&tt.schedule, now)
|
||||||
|
if (got == nil) != tt.wantNil {
|
||||||
|
t.Errorf("%s: got %v, wantNil %v", tt.name, got, tt.wantNil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Test Execution Flow
|
||||||
|
func TestCronService_ExecutionFlow(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
executedJobs := make(map[string]bool)
|
||||||
|
|
||||||
|
handler := func(job *CronJob) (string, error) {
|
||||||
|
mu.Lock()
|
||||||
|
executedJobs[job.ID] = true
|
||||||
|
mu.Unlock()
|
||||||
|
return "ok", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cs, path := setupService(handler)
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
// Start the service
|
||||||
|
if err := cs.Start(); err != nil {
|
||||||
|
t.Fatalf("Start failed: %v", err)
|
||||||
|
}
|
||||||
|
defer cs.Stop()
|
||||||
|
|
||||||
|
// Add a job then runs 100ms from now
|
||||||
|
target := time.Now().Add(100 * time.Millisecond).UnixMilli()
|
||||||
|
job, _ := cs.AddJob("FastJob", CronSchedule{Kind: "at", AtMS: &target}, "", false, "", "")
|
||||||
|
|
||||||
|
// Check for job execution with a timeout
|
||||||
|
success := false
|
||||||
|
for range 20 {
|
||||||
|
mu.Lock()
|
||||||
|
if executedJobs[job.ID] {
|
||||||
|
success = true
|
||||||
|
mu.Unlock()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
t.Error("Job was not executed in time")
|
||||||
|
}
|
||||||
|
|
||||||
|
// check that the job is removed after execution (DeleteAfterRun = true)
|
||||||
|
status := cs.Status()
|
||||||
|
if status["jobs"].(int) != 0 {
|
||||||
|
t.Errorf("Job should be deleted after run, got count: %v", status["jobs"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronService_PersistenceIntegrity(t *testing.T) {
|
||||||
|
tmpFile := "persist_test.json"
|
||||||
|
defer os.Remove(tmpFile)
|
||||||
|
|
||||||
|
// write a job and persist
|
||||||
|
cs1 := NewCronService(tmpFile, nil)
|
||||||
|
at := int64(2000000000000)
|
||||||
|
cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", true, "ch1", "")
|
||||||
|
|
||||||
|
// check file exists
|
||||||
|
if _, err := os.Stat(tmpFile); os.IsNotExist(err) {
|
||||||
|
t.Fatal("Store file was not created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// reload and check data integrity
|
||||||
|
cs2 := NewCronService(tmpFile, nil)
|
||||||
|
if err := cs2.Load(); err != nil {
|
||||||
|
t.Fatalf("Failed to load store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs := cs2.ListJobs(true)
|
||||||
|
if len(jobs) != 1 || jobs[0].Name != "PersistMe" {
|
||||||
|
t.Errorf("Data corruption after reload. Got: %+v", jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// test loading invalid JSON
|
||||||
|
os.WriteFile(tmpFile, []byte("{invalid json}"), 0o644)
|
||||||
|
cs3 := NewCronService(tmpFile, nil)
|
||||||
|
err := cs3.loadStore()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Should return error when loading invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronService_ConcurrentAccess(t *testing.T) {
|
||||||
|
cs, path := setupService(nil)
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
cs.Start()
|
||||||
|
defer cs.Stop()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
workers := 10
|
||||||
|
iterations := 50
|
||||||
|
|
||||||
|
wg.Add(workers * 2)
|
||||||
|
|
||||||
|
// add jobs concurrently
|
||||||
|
for i := range workers {
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := range iterations {
|
||||||
|
at := time.Now().Add(time.Hour).UnixMilli()
|
||||||
|
cs.AddJob(fmt.Sprintf("Job-%d-%d", id, j), CronSchedule{Kind: "at", AtMS: &at}, "", false, "", "")
|
||||||
|
time.Sleep(100 * time.Microsecond)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// read and update jobs concurrently
|
||||||
|
for range workers {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := range iterations {
|
||||||
|
jobs := cs.ListJobs(true)
|
||||||
|
if len(jobs) > 0 {
|
||||||
|
cs.EnableJob(jobs[0].ID, j%2 == 0)
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Microsecond)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ import (
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
|
||||||
"github.com/sipeed/picoclaw/pkg/agent"
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
|
@ -41,16 +41,13 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/voice"
|
"github.com/sipeed/picoclaw/pkg/voice"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Timeout constants for service operations
|
|
||||||
const (
|
const (
|
||||||
serviceRestartTimeout = 30 * time.Second
|
|
||||||
serviceShutdownTimeout = 30 * time.Second
|
serviceShutdownTimeout = 30 * time.Second
|
||||||
providerReloadTimeout = 30 * time.Second
|
providerReloadTimeout = 30 * time.Second
|
||||||
gracefulShutdownTimeout = 15 * time.Second
|
gracefulShutdownTimeout = 15 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// gatewayServices holds references to all running services
|
type services struct {
|
||||||
type gatewayServices struct {
|
|
||||||
CronService *cron.CronService
|
CronService *cron.CronService
|
||||||
HeartbeatService *heartbeat.HeartbeatService
|
HeartbeatService *heartbeat.HeartbeatService
|
||||||
MediaStore media.MediaStore
|
MediaStore media.MediaStore
|
||||||
|
|
@ -59,24 +56,41 @@ type gatewayServices struct {
|
||||||
HealthServer *health.Server
|
HealthServer *health.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
func gatewayCmd(debug bool) error {
|
type startupBlockedProvider struct {
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *startupBlockedProvider) Chat(
|
||||||
|
_ context.Context,
|
||||||
|
_ []providers.Message,
|
||||||
|
_ []providers.ToolDefinition,
|
||||||
|
_ string,
|
||||||
|
_ map[string]any,
|
||||||
|
) (*providers.LLMResponse, error) {
|
||||||
|
return nil, fmt.Errorf("%s", p.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *startupBlockedProvider) GetDefaultModel() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the gateway runtime using the configuration loaded from configPath.
|
||||||
|
func Run(debug bool, configPath string, allowEmptyStartup bool) error {
|
||||||
if debug {
|
if debug {
|
||||||
logger.SetLevel(logger.DEBUG)
|
logger.SetLevel(logger.DEBUG)
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath := internal.GetConfigPath()
|
cfg, err := config.LoadConfig(configPath)
|
||||||
cfg, err := internal.LoadConfig()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error loading config: %w", err)
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
provider, modelID, err := providers.CreateProvider(cfg)
|
provider, modelID, err := createStartupProvider(cfg, allowEmptyStartup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating provider: %w", err)
|
return fmt.Errorf("error creating provider: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the resolved model ID from provider creation
|
|
||||||
if modelID != "" {
|
if modelID != "" {
|
||||||
cfg.Agents.Defaults.ModelName = modelID
|
cfg.Agents.Defaults.ModelName = modelID
|
||||||
}
|
}
|
||||||
|
|
@ -84,17 +98,13 @@ func gatewayCmd(debug bool) error {
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
// Print agent startup info
|
|
||||||
fmt.Println("\n📦 Agent Status:")
|
fmt.Println("\n📦 Agent Status:")
|
||||||
startupInfo := agentLoop.GetStartupInfo()
|
startupInfo := agentLoop.GetStartupInfo()
|
||||||
toolsInfo := startupInfo["tools"].(map[string]any)
|
toolsInfo := startupInfo["tools"].(map[string]any)
|
||||||
skillsInfo := startupInfo["skills"].(map[string]any)
|
skillsInfo := startupInfo["skills"].(map[string]any)
|
||||||
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
|
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
|
||||||
fmt.Printf(" • Skills: %d/%d available\n",
|
fmt.Printf(" • Skills: %d/%d available\n", skillsInfo["available"], skillsInfo["total"])
|
||||||
skillsInfo["available"],
|
|
||||||
skillsInfo["total"])
|
|
||||||
|
|
||||||
// Log to file as well
|
|
||||||
logger.InfoCF("agent", "Agent initialized",
|
logger.InfoCF("agent", "Agent initialized",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tools_count": toolsInfo["count"],
|
"tools_count": toolsInfo["count"],
|
||||||
|
|
@ -102,8 +112,7 @@ func gatewayCmd(debug bool) error {
|
||||||
"skills_available": skillsInfo["available"],
|
"skills_available": skillsInfo["available"],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Setup and start all services
|
runningServices, err := setupAndStartServices(cfg, agentLoop, msgBus)
|
||||||
services, err := setupAndStartServices(cfg, agentLoop, msgBus)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -116,23 +125,25 @@ func gatewayCmd(debug bool) error {
|
||||||
|
|
||||||
go agentLoop.Run(ctx)
|
go agentLoop.Run(ctx)
|
||||||
|
|
||||||
// Setup config file watcher for hot reload
|
var configReloadChan <-chan *config.Config
|
||||||
configReloadChan, stopWatch := setupConfigWatcherPolling(configPath, debug)
|
stopWatch := func() {}
|
||||||
|
if cfg.Gateway.HotReload {
|
||||||
|
configReloadChan, stopWatch = setupConfigWatcherPolling(configPath, debug)
|
||||||
|
logger.Info("Config hot reload enabled")
|
||||||
|
}
|
||||||
defer stopWatch()
|
defer stopWatch()
|
||||||
|
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, os.Interrupt)
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
|
|
||||||
// Main event loop - wait for signals or config changes
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-sigChan:
|
case <-sigChan:
|
||||||
logger.Info("Shutting down...")
|
logger.Info("Shutting down...")
|
||||||
shutdownGateway(services, agentLoop, provider, true)
|
shutdownGateway(runningServices, agentLoop, provider, true)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case newCfg := <-configReloadChan:
|
case newCfg := <-configReloadChan:
|
||||||
err := handleConfigReload(ctx, agentLoop, newCfg, &provider, services, msgBus)
|
err := handleConfigReload(ctx, agentLoop, newCfg, &provider, runningServices, msgBus, allowEmptyStartup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Config reload failed: %v", err)
|
logger.Errorf("Config reload failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -140,17 +151,33 @@ func gatewayCmd(debug bool) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupAndStartServices initializes and starts all services
|
func createStartupProvider(
|
||||||
|
cfg *config.Config,
|
||||||
|
allowEmptyStartup bool,
|
||||||
|
) (providers.LLMProvider, string, error) {
|
||||||
|
modelName := cfg.Agents.Defaults.GetModelName()
|
||||||
|
if modelName == "" && allowEmptyStartup {
|
||||||
|
reason := "no default model configured; gateway started in limited mode"
|
||||||
|
fmt.Printf("⚠ Warning: %s\n", reason)
|
||||||
|
logger.WarnCF("gateway", "Gateway started without default model", map[string]any{
|
||||||
|
"limited_mode": true,
|
||||||
|
})
|
||||||
|
return &startupBlockedProvider{reason: reason}, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers.CreateProvider(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
func setupAndStartServices(
|
func setupAndStartServices(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
agentLoop *agent.AgentLoop,
|
agentLoop *agent.AgentLoop,
|
||||||
msgBus *bus.MessageBus,
|
msgBus *bus.MessageBus,
|
||||||
) (*gatewayServices, error) {
|
) (*services, error) {
|
||||||
services := &gatewayServices{}
|
runningServices := &services{}
|
||||||
|
|
||||||
// Setup cron tool and service
|
|
||||||
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
||||||
services.CronService = setupCronTool(
|
var err error
|
||||||
|
runningServices.CronService, err = setupCronTool(
|
||||||
agentLoop,
|
agentLoop,
|
||||||
msgBus,
|
msgBus,
|
||||||
cfg.WorkspacePath(),
|
cfg.WorkspacePath(),
|
||||||
|
|
@ -158,139 +185,108 @@ func setupAndStartServices(
|
||||||
execTimeout,
|
execTimeout,
|
||||||
cfg,
|
cfg,
|
||||||
)
|
)
|
||||||
if err := services.CronService.Start(); err != nil {
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error setting up cron service: %w", err)
|
||||||
|
}
|
||||||
|
if err = runningServices.CronService.Start(); err != nil {
|
||||||
return nil, fmt.Errorf("error starting cron service: %w", err)
|
return nil, fmt.Errorf("error starting cron service: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Println("✓ Cron service started")
|
fmt.Println("✓ Cron service started")
|
||||||
|
|
||||||
// Setup heartbeat service
|
runningServices.HeartbeatService = heartbeat.NewHeartbeatService(
|
||||||
services.HeartbeatService = heartbeat.NewHeartbeatService(
|
|
||||||
cfg.WorkspacePath(),
|
cfg.WorkspacePath(),
|
||||||
cfg.Heartbeat.Interval,
|
cfg.Heartbeat.Interval,
|
||||||
cfg.Heartbeat.Enabled,
|
cfg.Heartbeat.Enabled,
|
||||||
)
|
)
|
||||||
services.HeartbeatService.SetBus(msgBus)
|
runningServices.HeartbeatService.SetBus(msgBus)
|
||||||
services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(agentLoop))
|
||||||
// Use cli:direct as fallback if no valid channel
|
if err = runningServices.HeartbeatService.Start(); err != nil {
|
||||||
if channel == "" || chatID == "" {
|
|
||||||
channel, chatID = "cli", "direct"
|
|
||||||
}
|
|
||||||
// Use ProcessHeartbeat - no session history, each heartbeat is independent
|
|
||||||
var response string
|
|
||||||
var err error
|
|
||||||
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
|
||||||
if err != nil {
|
|
||||||
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
|
||||||
}
|
|
||||||
if response == "HEARTBEAT_OK" {
|
|
||||||
return tools.SilentResult("Heartbeat OK")
|
|
||||||
}
|
|
||||||
// For heartbeat, always return silent - the subagent result will be
|
|
||||||
// sent to user via processSystemMessage when the async task completes
|
|
||||||
return tools.SilentResult(response)
|
|
||||||
})
|
|
||||||
if err := services.HeartbeatService.Start(); err != nil {
|
|
||||||
return nil, fmt.Errorf("error starting heartbeat service: %w", err)
|
return nil, fmt.Errorf("error starting heartbeat service: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Println("✓ Heartbeat service started")
|
fmt.Println("✓ Heartbeat service started")
|
||||||
|
|
||||||
// Create media store for file lifecycle management with TTL cleanup
|
runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
|
||||||
services.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
|
|
||||||
Enabled: cfg.Tools.MediaCleanup.Enabled,
|
Enabled: cfg.Tools.MediaCleanup.Enabled,
|
||||||
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
||||||
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
|
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
|
||||||
})
|
})
|
||||||
// Start the media store if it's a FileMediaStore with cleanup
|
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
|
||||||
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
|
||||||
fms.Start()
|
fms.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create channel manager
|
runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore)
|
||||||
var err error
|
|
||||||
services.ChannelManager, err = channels.NewManager(cfg, msgBus, services.MediaStore)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Stop the media store if it's a FileMediaStore with cleanup
|
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
|
||||||
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
|
||||||
fms.Stop()
|
fms.Stop()
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("error creating channel manager: %w", err)
|
return nil, fmt.Errorf("error creating channel manager: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject channel manager and media store into agent loop
|
agentLoop.SetChannelManager(runningServices.ChannelManager)
|
||||||
agentLoop.SetChannelManager(services.ChannelManager)
|
agentLoop.SetMediaStore(runningServices.MediaStore)
|
||||||
agentLoop.SetMediaStore(services.MediaStore)
|
|
||||||
|
|
||||||
// Wire up voice transcription if a supported provider is configured.
|
|
||||||
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
|
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
|
||||||
agentLoop.SetTranscriber(transcriber)
|
agentLoop.SetTranscriber(transcriber)
|
||||||
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
|
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
|
||||||
}
|
}
|
||||||
|
|
||||||
enabledChannels := services.ChannelManager.GetEnabledChannels()
|
enabledChannels := runningServices.ChannelManager.GetEnabledChannels()
|
||||||
if len(enabledChannels) > 0 {
|
if len(enabledChannels) > 0 {
|
||||||
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("⚠ Warning: No channels enabled")
|
fmt.Println("⚠ Warning: No channels enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup shared HTTP server with health endpoints and webhook handlers
|
|
||||||
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
services.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
services.ChannelManager.SetupHTTPServer(addr, services.HealthServer)
|
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
|
||||||
|
|
||||||
if err := services.ChannelManager.StartAll(context.Background()); err != nil {
|
if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil {
|
||||||
return nil, fmt.Errorf("error starting channels: %w", err)
|
return nil, fmt.Errorf("error starting channels: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
|
|
||||||
// Setup state manager and device service
|
|
||||||
stateManager := state.NewManager(cfg.WorkspacePath())
|
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||||
services.DeviceService = devices.NewService(devices.Config{
|
runningServices.DeviceService = devices.NewService(devices.Config{
|
||||||
Enabled: cfg.Devices.Enabled,
|
Enabled: cfg.Devices.Enabled,
|
||||||
MonitorUSB: cfg.Devices.MonitorUSB,
|
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||||
}, stateManager)
|
}, stateManager)
|
||||||
services.DeviceService.SetBus(msgBus)
|
runningServices.DeviceService.SetBus(msgBus)
|
||||||
if err := services.DeviceService.Start(context.Background()); err != nil {
|
if err = runningServices.DeviceService.Start(context.Background()); err != nil {
|
||||||
logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()})
|
logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()})
|
||||||
} else if cfg.Devices.Enabled {
|
} else if cfg.Devices.Enabled {
|
||||||
fmt.Println("✓ Device event service started")
|
fmt.Println("✓ Device event service started")
|
||||||
}
|
}
|
||||||
|
|
||||||
return services, nil
|
return runningServices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopAndCleanupServices stops all services and cleans up resources
|
func stopAndCleanupServices(runningServices *services, shutdownTimeout time.Duration) {
|
||||||
func stopAndCleanupServices(
|
|
||||||
services *gatewayServices,
|
|
||||||
shutdownTimeout time.Duration,
|
|
||||||
) {
|
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
defer shutdownCancel()
|
defer shutdownCancel()
|
||||||
|
|
||||||
if services.ChannelManager != nil {
|
if runningServices.ChannelManager != nil {
|
||||||
services.ChannelManager.StopAll(shutdownCtx)
|
runningServices.ChannelManager.StopAll(shutdownCtx)
|
||||||
}
|
}
|
||||||
if services.DeviceService != nil {
|
if runningServices.DeviceService != nil {
|
||||||
services.DeviceService.Stop()
|
runningServices.DeviceService.Stop()
|
||||||
}
|
}
|
||||||
if services.HeartbeatService != nil {
|
if runningServices.HeartbeatService != nil {
|
||||||
services.HeartbeatService.Stop()
|
runningServices.HeartbeatService.Stop()
|
||||||
}
|
}
|
||||||
if services.CronService != nil {
|
if runningServices.CronService != nil {
|
||||||
services.CronService.Stop()
|
runningServices.CronService.Stop()
|
||||||
}
|
}
|
||||||
if services.MediaStore != nil {
|
if runningServices.MediaStore != nil {
|
||||||
// Stop the media store if it's a FileMediaStore with cleanup
|
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
|
||||||
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
|
||||||
fms.Stop()
|
fms.Stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// shutdownGateway performs a complete gateway shutdown
|
|
||||||
func shutdownGateway(
|
func shutdownGateway(
|
||||||
services *gatewayServices,
|
runningServices *services,
|
||||||
agentLoop *agent.AgentLoop,
|
agentLoop *agent.AgentLoop,
|
||||||
provider providers.LLMProvider,
|
provider providers.LLMProvider,
|
||||||
fullShutdown bool,
|
fullShutdown bool,
|
||||||
|
|
@ -299,7 +295,7 @@ func shutdownGateway(
|
||||||
cp.Close()
|
cp.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
stopAndCleanupServices(services, gracefulShutdownTimeout)
|
stopAndCleanupServices(runningServices, gracefulShutdownTimeout)
|
||||||
|
|
||||||
agentLoop.Stop()
|
agentLoop.Stop()
|
||||||
agentLoop.Close()
|
agentLoop.Close()
|
||||||
|
|
@ -307,15 +303,14 @@ func shutdownGateway(
|
||||||
logger.Info("✓ Gateway stopped")
|
logger.Info("✓ Gateway stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConfigReload handles config file reload by stopping all services,
|
|
||||||
// reloading the provider and config, and restarting services with the new config.
|
|
||||||
func handleConfigReload(
|
func handleConfigReload(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
al *agent.AgentLoop,
|
al *agent.AgentLoop,
|
||||||
newCfg *config.Config,
|
newCfg *config.Config,
|
||||||
providerRef *providers.LLMProvider,
|
providerRef *providers.LLMProvider,
|
||||||
services *gatewayServices,
|
runningServices *services,
|
||||||
msgBus *bus.MessageBus,
|
msgBus *bus.MessageBus,
|
||||||
|
allowEmptyStartup bool,
|
||||||
) error {
|
) error {
|
||||||
logger.Info("🔄 Config file changed, reloading...")
|
logger.Info("🔄 Config file changed, reloading...")
|
||||||
|
|
||||||
|
|
@ -326,18 +321,14 @@ func handleConfigReload(
|
||||||
|
|
||||||
logger.Infof(" New model is '%s', recreating provider...", newModel)
|
logger.Infof(" New model is '%s', recreating provider...", newModel)
|
||||||
|
|
||||||
// Stop all services before reloading
|
|
||||||
logger.Info(" Stopping all services...")
|
logger.Info(" Stopping all services...")
|
||||||
stopAndCleanupServices(services, serviceShutdownTimeout)
|
stopAndCleanupServices(runningServices, serviceShutdownTimeout)
|
||||||
|
|
||||||
// Create new provider from updated config first to ensure validity
|
newProvider, newModelID, err := createStartupProvider(newCfg, allowEmptyStartup)
|
||||||
// This will use the correct API key and settings from newCfg.ModelList
|
|
||||||
newProvider, newModelID, err := providers.CreateProvider(newCfg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf(" ⚠ Error creating new provider: %v", err)
|
logger.Errorf(" ⚠ Error creating new provider: %v", err)
|
||||||
logger.Warn(" Attempting to restart services with old provider and config...")
|
logger.Warn(" Attempting to restart services with old provider and config...")
|
||||||
// Try to restart services with old configuration
|
if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil {
|
||||||
if restartErr := restartServices(al, services, msgBus); restartErr != nil {
|
|
||||||
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
|
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("error creating new provider: %w", err)
|
return fmt.Errorf("error creating new provider: %w", err)
|
||||||
|
|
@ -347,31 +338,25 @@ func handleConfigReload(
|
||||||
newCfg.Agents.Defaults.ModelName = newModelID
|
newCfg.Agents.Defaults.ModelName = newModelID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the atomic reload method on AgentLoop to safely swap provider and config.
|
|
||||||
// This handles locking internally to prevent races with in-flight LLM calls
|
|
||||||
// and concurrent reads of registry/config while the swap occurs.
|
|
||||||
reloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout)
|
reloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout)
|
||||||
defer reloadCancel()
|
defer reloadCancel()
|
||||||
|
|
||||||
if err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil {
|
if err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil {
|
||||||
logger.Errorf(" ⚠ Error reloading agent loop: %v", err)
|
logger.Errorf(" ⚠ Error reloading agent loop: %v", err)
|
||||||
// Close the newly created provider since it wasn't adopted
|
|
||||||
if cp, ok := newProvider.(providers.StatefulProvider); ok {
|
if cp, ok := newProvider.(providers.StatefulProvider); ok {
|
||||||
cp.Close()
|
cp.Close()
|
||||||
}
|
}
|
||||||
logger.Warn(" Attempting to restart services with old provider and config...")
|
logger.Warn(" Attempting to restart services with old provider and config...")
|
||||||
if restartErr := restartServices(al, services, msgBus); restartErr != nil {
|
if restartErr := restartServices(al, runningServices, msgBus); restartErr != nil {
|
||||||
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
|
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("error reloading agent loop: %w", err)
|
return fmt.Errorf("error reloading agent loop: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local provider reference only after successful atomic reload
|
|
||||||
*providerRef = newProvider
|
*providerRef = newProvider
|
||||||
|
|
||||||
// Restart all services with new config
|
|
||||||
logger.Info(" Restarting all services with new configuration...")
|
logger.Info(" Restarting all services with new configuration...")
|
||||||
if err := restartServices(al, services, msgBus); err != nil {
|
if err := restartServices(al, runningServices, msgBus); err != nil {
|
||||||
logger.Errorf(" ⚠ Error restarting services: %v", err)
|
logger.Errorf(" ⚠ Error restarting services: %v", err)
|
||||||
return fmt.Errorf("error restarting services: %w", err)
|
return fmt.Errorf("error restarting services: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -380,23 +365,16 @@ func handleConfigReload(
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// restartServices restarts all services after a config reload
|
|
||||||
func restartServices(
|
func restartServices(
|
||||||
al *agent.AgentLoop,
|
al *agent.AgentLoop,
|
||||||
services *gatewayServices,
|
runningServices *services,
|
||||||
msgBus *bus.MessageBus,
|
msgBus *bus.MessageBus,
|
||||||
) error {
|
) error {
|
||||||
// Create an independent context with timeout for service restart
|
|
||||||
// This prevents cancellation from the main loop context during reload
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), serviceRestartTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Get current config from agent loop (which has been updated if this is a reload)
|
|
||||||
cfg := al.GetConfig()
|
cfg := al.GetConfig()
|
||||||
|
|
||||||
// Re-create and start cron service with new config
|
|
||||||
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
|
||||||
services.CronService = setupCronTool(
|
var err error
|
||||||
|
runningServices.CronService, err = setupCronTool(
|
||||||
al,
|
al,
|
||||||
msgBus,
|
msgBus,
|
||||||
cfg.WorkspacePath(),
|
cfg.WorkspacePath(),
|
||||||
|
|
@ -404,80 +382,54 @@ func restartServices(
|
||||||
execTimeout,
|
execTimeout,
|
||||||
cfg,
|
cfg,
|
||||||
)
|
)
|
||||||
if err := services.CronService.Start(); err != nil {
|
if err != nil {
|
||||||
|
return fmt.Errorf("error restarting cron service: %w", err)
|
||||||
|
}
|
||||||
|
if err = runningServices.CronService.Start(); err != nil {
|
||||||
return fmt.Errorf("error restarting cron service: %w", err)
|
return fmt.Errorf("error restarting cron service: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Println(" ✓ Cron service restarted")
|
fmt.Println(" ✓ Cron service restarted")
|
||||||
|
|
||||||
// Re-create and start heartbeat service with new config
|
runningServices.HeartbeatService = heartbeat.NewHeartbeatService(
|
||||||
services.HeartbeatService = heartbeat.NewHeartbeatService(
|
|
||||||
cfg.WorkspacePath(),
|
cfg.WorkspacePath(),
|
||||||
cfg.Heartbeat.Interval,
|
cfg.Heartbeat.Interval,
|
||||||
cfg.Heartbeat.Enabled,
|
cfg.Heartbeat.Enabled,
|
||||||
)
|
)
|
||||||
services.HeartbeatService.SetBus(msgBus)
|
runningServices.HeartbeatService.SetBus(msgBus)
|
||||||
services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
runningServices.HeartbeatService.SetHandler(createHeartbeatHandler(al))
|
||||||
if channel == "" || chatID == "" {
|
if err = runningServices.HeartbeatService.Start(); err != nil {
|
||||||
channel, chatID = "cli", "direct"
|
|
||||||
}
|
|
||||||
var response string
|
|
||||||
var err error
|
|
||||||
response, err = al.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
|
||||||
if err != nil {
|
|
||||||
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
|
||||||
}
|
|
||||||
if response == "HEARTBEAT_OK" {
|
|
||||||
return tools.SilentResult("Heartbeat OK")
|
|
||||||
}
|
|
||||||
return tools.SilentResult(response)
|
|
||||||
})
|
|
||||||
if err := services.HeartbeatService.Start(); err != nil {
|
|
||||||
return fmt.Errorf("error restarting heartbeat service: %w", err)
|
return fmt.Errorf("error restarting heartbeat service: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Println(" ✓ Heartbeat service restarted")
|
fmt.Println(" ✓ Heartbeat service restarted")
|
||||||
|
|
||||||
// Stop the old media store before creating a new one
|
runningServices.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
|
||||||
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
|
||||||
fms.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-create media store with new config
|
|
||||||
services.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
|
|
||||||
Enabled: cfg.Tools.MediaCleanup.Enabled,
|
Enabled: cfg.Tools.MediaCleanup.Enabled,
|
||||||
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
|
||||||
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
|
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
|
||||||
})
|
})
|
||||||
// Start the media store if it's a FileMediaStore with cleanup
|
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
|
||||||
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
|
||||||
fms.Start()
|
fms.Start()
|
||||||
}
|
}
|
||||||
al.SetMediaStore(services.MediaStore)
|
al.SetMediaStore(runningServices.MediaStore)
|
||||||
|
|
||||||
// Re-create channel manager with new config
|
runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore)
|
||||||
var err error
|
|
||||||
services.ChannelManager, err = channels.NewManager(cfg, msgBus, services.MediaStore)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Stop the media store if it's a FileMediaStore with cleanup
|
|
||||||
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
|
|
||||||
fms.Stop()
|
|
||||||
}
|
|
||||||
return fmt.Errorf("error recreating channel manager: %w", err)
|
return fmt.Errorf("error recreating channel manager: %w", err)
|
||||||
}
|
}
|
||||||
al.SetChannelManager(services.ChannelManager)
|
al.SetChannelManager(runningServices.ChannelManager)
|
||||||
|
|
||||||
enabledChannels := services.ChannelManager.GetEnabledChannels()
|
enabledChannels := runningServices.ChannelManager.GetEnabledChannels()
|
||||||
if len(enabledChannels) > 0 {
|
if len(enabledChannels) > 0 {
|
||||||
fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels)
|
fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println(" ⚠ Warning: No channels enabled")
|
fmt.Println(" ⚠ Warning: No channels enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup HTTP server with new config
|
|
||||||
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
services.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
runningServices.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
||||||
services.ChannelManager.SetupHTTPServer(addr, services.HealthServer)
|
runningServices.ChannelManager.SetupHTTPServer(addr, runningServices.HealthServer)
|
||||||
|
|
||||||
if err := services.ChannelManager.StartAll(ctx); err != nil {
|
if err = runningServices.ChannelManager.StartAll(context.Background()); err != nil {
|
||||||
return fmt.Errorf("error restarting channels: %w", err)
|
return fmt.Errorf("error restarting channels: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf(
|
fmt.Printf(
|
||||||
|
|
@ -486,22 +438,20 @@ func restartServices(
|
||||||
cfg.Gateway.Port,
|
cfg.Gateway.Port,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Re-create device service with new config
|
|
||||||
stateManager := state.NewManager(cfg.WorkspacePath())
|
stateManager := state.NewManager(cfg.WorkspacePath())
|
||||||
services.DeviceService = devices.NewService(devices.Config{
|
runningServices.DeviceService = devices.NewService(devices.Config{
|
||||||
Enabled: cfg.Devices.Enabled,
|
Enabled: cfg.Devices.Enabled,
|
||||||
MonitorUSB: cfg.Devices.MonitorUSB,
|
MonitorUSB: cfg.Devices.MonitorUSB,
|
||||||
}, stateManager)
|
}, stateManager)
|
||||||
services.DeviceService.SetBus(msgBus)
|
runningServices.DeviceService.SetBus(msgBus)
|
||||||
if err := services.DeviceService.Start(ctx); err != nil {
|
if err := runningServices.DeviceService.Start(context.Background()); err != nil {
|
||||||
logger.WarnCF("device", "Failed to restart device service", map[string]any{"error": err.Error()})
|
logger.WarnCF("device", "Failed to restart device service", map[string]any{"error": err.Error()})
|
||||||
} else if cfg.Devices.Enabled {
|
} else if cfg.Devices.Enabled {
|
||||||
fmt.Println(" ✓ Device event service restarted")
|
fmt.Println(" ✓ Device event service restarted")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire up voice transcription with new config
|
|
||||||
transcriber := voice.DetectTranscriber(cfg)
|
transcriber := voice.DetectTranscriber(cfg)
|
||||||
al.SetTranscriber(transcriber) // This will set it to nil if disabled
|
al.SetTranscriber(transcriber)
|
||||||
if transcriber != nil {
|
if transcriber != nil {
|
||||||
logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
|
logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -511,8 +461,6 @@ func restartServices(
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupConfigWatcherPolling sets up a simple polling-based config file watcher
|
|
||||||
// Returns a channel for config updates and a stop function
|
|
||||||
func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) {
|
func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) {
|
||||||
configChan := make(chan *config.Config, 1)
|
configChan := make(chan *config.Config, 1)
|
||||||
stop := make(chan struct{})
|
stop := make(chan struct{})
|
||||||
|
|
@ -522,11 +470,10 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
// Get initial file info
|
|
||||||
lastModTime := getFileModTime(configPath)
|
lastModTime := getFileModTime(configPath)
|
||||||
lastSize := getFileSize(configPath)
|
lastSize := getFileSize(configPath)
|
||||||
|
|
||||||
ticker := time.NewTicker(2 * time.Second) // Check every 2 seconds
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|
@ -535,16 +482,16 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
|
||||||
currentModTime := getFileModTime(configPath)
|
currentModTime := getFileModTime(configPath)
|
||||||
currentSize := getFileSize(configPath)
|
currentSize := getFileSize(configPath)
|
||||||
|
|
||||||
// Check if file changed (modification time or size changed)
|
|
||||||
if currentModTime.After(lastModTime) || currentSize != lastSize {
|
if currentModTime.After(lastModTime) || currentSize != lastSize {
|
||||||
if debug {
|
if debug {
|
||||||
logger.Debugf("🔍 Config file change detected")
|
logger.Debugf("🔍 Config file change detected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounce - wait a bit to ensure file write is complete
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
// Validate and load new config
|
lastModTime = currentModTime
|
||||||
|
lastSize = currentSize
|
||||||
|
|
||||||
newCfg, err := config.LoadConfig(configPath)
|
newCfg, err := config.LoadConfig(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("⚠ Error loading new config: %v", err)
|
logger.Errorf("⚠ Error loading new config: %v", err)
|
||||||
|
|
@ -552,7 +499,6 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the new config
|
|
||||||
if err := newCfg.ValidateModelList(); err != nil {
|
if err := newCfg.ValidateModelList(); err != nil {
|
||||||
logger.Errorf(" ⚠ New config validation failed: %v", err)
|
logger.Errorf(" ⚠ New config validation failed: %v", err)
|
||||||
logger.Warn(" Using previous valid config")
|
logger.Warn(" Using previous valid config")
|
||||||
|
|
@ -561,19 +507,12 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
|
||||||
|
|
||||||
logger.Info("✓ Config file validated and loaded")
|
logger.Info("✓ Config file validated and loaded")
|
||||||
|
|
||||||
// Update last known state
|
|
||||||
lastModTime = currentModTime
|
|
||||||
lastSize = currentSize
|
|
||||||
|
|
||||||
// Send new config to main loop (non-blocking)
|
|
||||||
select {
|
select {
|
||||||
case configChan <- newCfg:
|
case configChan <- newCfg:
|
||||||
default:
|
default:
|
||||||
// Channel full, skip this update
|
|
||||||
logger.Warn("⚠ Previous config reload still in progress, skipping")
|
logger.Warn("⚠ Previous config reload still in progress, skipping")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case <-stop:
|
case <-stop:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -588,7 +527,6 @@ func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Conf
|
||||||
return configChan, stopFunc
|
return configChan, stopFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// getFileModTime returns the modification time of a file, or zero time if file doesn't exist
|
|
||||||
func getFileModTime(path string) time.Time {
|
func getFileModTime(path string) time.Time {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -597,7 +535,6 @@ func getFileModTime(path string) time.Time {
|
||||||
return info.ModTime()
|
return info.ModTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
// getFileSize returns the size of a file, or 0 if file doesn't exist
|
|
||||||
func getFileSize(path string) int64 {
|
func getFileSize(path string) int64 {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -613,25 +550,22 @@ func setupCronTool(
|
||||||
restrict bool,
|
restrict bool,
|
||||||
execTimeout time.Duration,
|
execTimeout time.Duration,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
) *cron.CronService {
|
) (*cron.CronService, error) {
|
||||||
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
|
||||||
|
|
||||||
// Create cron service
|
|
||||||
cronService := cron.NewCronService(cronStorePath, nil)
|
cronService := cron.NewCronService(cronStorePath, nil)
|
||||||
|
|
||||||
// Create and register CronTool if enabled
|
|
||||||
var cronTool *tools.CronTool
|
var cronTool *tools.CronTool
|
||||||
if cfg.Tools.IsToolEnabled("cron") {
|
if cfg.Tools.IsToolEnabled("cron") {
|
||||||
var err error
|
var err error
|
||||||
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatalf("Critical error during CronTool initialization: %v", err)
|
return nil, fmt.Errorf("critical error during CronTool initialization: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
agentLoop.RegisterTool(cronTool)
|
agentLoop.RegisterTool(cronTool)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set onJob handler
|
|
||||||
if cronTool != nil {
|
if cronTool != nil {
|
||||||
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
|
||||||
result := cronTool.ExecuteJob(context.Background(), job)
|
result := cronTool.ExecuteJob(context.Background(), job)
|
||||||
|
|
@ -639,5 +573,22 @@ func setupCronTool(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return cronService
|
return cronService, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult {
|
||||||
|
return func(prompt, channel, chatID string) *tools.ToolResult {
|
||||||
|
if channel == "" || chatID == "" {
|
||||||
|
channel, chatID = "cli", "direct"
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
|
||||||
|
if err != nil {
|
||||||
|
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
|
||||||
|
}
|
||||||
|
if response == "HEARTBEAT_OK" {
|
||||||
|
return tools.SilentResult("Heartbeat OK")
|
||||||
|
}
|
||||||
|
return tools.SilentResult(response)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -29,6 +30,7 @@ type StatusResponse struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Uptime string `json:"uptime"`
|
Uptime string `json:"uptime"`
|
||||||
Checks map[string]Check `json:"checks,omitempty"`
|
Checks map[string]Check `json:"checks,omitempty"`
|
||||||
|
Pid int `json:"pid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(host string, port int) *Server {
|
func NewServer(host string, port int) *Server {
|
||||||
|
|
@ -112,6 +114,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
resp := StatusResponse{
|
resp := StatusResponse{
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
Uptime: uptime.String(),
|
Uptime: uptime.String(),
|
||||||
|
Pid: os.Getpid(),
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
||||||
fileEvent.Str("component", component)
|
fileEvent.Str("component", component)
|
||||||
}
|
}
|
||||||
|
|
||||||
appendFields(event, fields)
|
appendFields(fileEvent, fields)
|
||||||
fileEvent.Msg(message)
|
fileEvent.Msg(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,20 @@
|
||||||
|
|
||||||
package logger
|
package logger
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// botTokenRe matches the bot ID prefix and the secret part of a Telegram bot token.
|
||||||
|
// Groups: 1 = "bot<id>:", 2 = first 4 chars of secret, 3 = middle, 4 = last 4 chars.
|
||||||
|
var botTokenRe = regexp.MustCompile(`(bot\d+:)([A-Za-z0-9_-]{4})[A-Za-z0-9_-]{12,}([A-Za-z0-9_-]{4})`)
|
||||||
|
|
||||||
|
// maskSecrets replaces any embedded bot tokens in s with a redacted placeholder
|
||||||
|
// that keeps the first and last 4 characters of the secret for identification.
|
||||||
|
func maskSecrets(s string) string {
|
||||||
|
return botTokenRe.ReplaceAllString(s, "${1}${2}****${3}")
|
||||||
|
}
|
||||||
|
|
||||||
// Logger implements common Logger interface
|
// Logger implements common Logger interface
|
||||||
type Logger struct {
|
type Logger struct {
|
||||||
|
|
@ -12,52 +25,52 @@ type Logger struct {
|
||||||
|
|
||||||
// Debug logs debug messages
|
// Debug logs debug messages
|
||||||
func (b *Logger) Debug(v ...any) {
|
func (b *Logger) Debug(v ...any) {
|
||||||
logMessage(DEBUG, b.component, fmt.Sprint(v...), nil)
|
logMessage(DEBUG, b.component, maskSecrets(fmt.Sprint(v...)), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info logs info messages
|
// Info logs info messages
|
||||||
func (b *Logger) Info(v ...any) {
|
func (b *Logger) Info(v ...any) {
|
||||||
logMessage(INFO, b.component, fmt.Sprint(v...), nil)
|
logMessage(INFO, b.component, maskSecrets(fmt.Sprint(v...)), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn logs warning messages
|
// Warn logs warning messages
|
||||||
func (b *Logger) Warn(v ...any) {
|
func (b *Logger) Warn(v ...any) {
|
||||||
logMessage(WARN, b.component, fmt.Sprint(v...), nil)
|
logMessage(WARN, b.component, maskSecrets(fmt.Sprint(v...)), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error logs error messages
|
// Error logs error messages
|
||||||
func (b *Logger) Error(v ...any) {
|
func (b *Logger) Error(v ...any) {
|
||||||
logMessage(ERROR, b.component, fmt.Sprint(v...), nil)
|
logMessage(ERROR, b.component, maskSecrets(fmt.Sprint(v...)), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debugf logs formatted debug messages
|
// Debugf logs formatted debug messages
|
||||||
func (b *Logger) Debugf(format string, v ...any) {
|
func (b *Logger) Debugf(format string, v ...any) {
|
||||||
logMessage(DEBUG, b.component, fmt.Sprintf(format, v...), nil)
|
logMessage(DEBUG, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infof logs formatted info messages
|
// Infof logs formatted info messages
|
||||||
func (b *Logger) Infof(format string, v ...any) {
|
func (b *Logger) Infof(format string, v ...any) {
|
||||||
logMessage(INFO, b.component, fmt.Sprintf(format, v...), nil)
|
logMessage(INFO, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warnf logs formatted warning messages
|
// Warnf logs formatted warning messages
|
||||||
func (b *Logger) Warnf(format string, v ...any) {
|
func (b *Logger) Warnf(format string, v ...any) {
|
||||||
logMessage(WARN, b.component, fmt.Sprintf(format, v...), nil)
|
logMessage(WARN, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warningf logs formatted warning messages
|
// Warningf logs formatted warning messages
|
||||||
func (b *Logger) Warningf(format string, v ...any) {
|
func (b *Logger) Warningf(format string, v ...any) {
|
||||||
logMessage(WARN, b.component, fmt.Sprintf(format, v...), nil)
|
logMessage(WARN, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errorf logs formatted error messages
|
// Errorf logs formatted error messages
|
||||||
func (b *Logger) Errorf(format string, v ...any) {
|
func (b *Logger) Errorf(format string, v ...any) {
|
||||||
logMessage(ERROR, b.component, fmt.Sprintf(format, v...), nil)
|
logMessage(ERROR, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fatalf logs formatted fatal messages and exits
|
// Fatalf logs formatted fatal messages and exits
|
||||||
func (b *Logger) Fatalf(format string, v ...any) {
|
func (b *Logger) Fatalf(format string, v ...any) {
|
||||||
logMessage(FATAL, b.component, fmt.Sprintf(format, v...), nil)
|
logMessage(FATAL, b.component, maskSecrets(fmt.Sprintf(format, v...)), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log logs a message at a given level with caller information
|
// Log logs a message at a given level with caller information
|
||||||
|
|
@ -75,7 +88,7 @@ func (b *Logger) Log(msgL, caller int, format string, a ...any) {
|
||||||
level = lvl
|
level = lvl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logMessage(level, b.component, fmt.Sprintf(format, a...), nil)
|
logMessage(level, b.component, maskSecrets(fmt.Sprintf(format, a...)), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync flushes log buffer (no-op for this implementation)
|
// Sync flushes log buffer (no-op for this implementation)
|
||||||
|
|
|
||||||
13
pkg/media/tempdir.go
Normal file
13
pkg/media/tempdir.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
const TempDirName = "picoclaw_media"
|
||||||
|
|
||||||
|
// TempDir returns the shared temporary directory used for downloaded media.
|
||||||
|
func TempDir() string {
|
||||||
|
return filepath.Join(os.TempDir(), TempDirName)
|
||||||
|
}
|
||||||
|
|
@ -221,11 +221,17 @@ func buildRequestBody(
|
||||||
|
|
||||||
// Add tool_use blocks
|
// Add tool_use blocks
|
||||||
for _, tc := range msg.ToolCalls {
|
for _, tc := range msg.ToolCalls {
|
||||||
|
// Handle nil Arguments (GLM-4 may return null input)
|
||||||
|
input := tc.Arguments
|
||||||
|
if input == nil {
|
||||||
|
input = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
toolUse := map[string]any{
|
toolUse := map[string]any{
|
||||||
"type": "tool_use",
|
"type": "tool_use",
|
||||||
"id": tc.ID,
|
"id": tc.ID,
|
||||||
"name": tc.Name,
|
"name": tc.Name,
|
||||||
"input": tc.Arguments,
|
"input": input,
|
||||||
}
|
}
|
||||||
content = append(content, toolUse)
|
content = append(content, toolUse)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,19 @@ func (p *ClaudeCliProvider) Chat(
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
if stderrStr := stderr.String(); stderrStr != "" {
|
stderrStr := strings.TrimSpace(stderr.String())
|
||||||
|
stdoutStr := strings.TrimSpace(stdout.String())
|
||||||
|
switch {
|
||||||
|
case stderrStr != "" && stdoutStr != "":
|
||||||
|
return nil, fmt.Errorf("claude cli error: %w\nstderr: %s\nstdout: %s", err, stderrStr, stdoutStr)
|
||||||
|
case stderrStr != "":
|
||||||
return nil, fmt.Errorf("claude cli error: %s", stderrStr)
|
return nil, fmt.Errorf("claude cli error: %s", stderrStr)
|
||||||
}
|
case stdoutStr != "":
|
||||||
|
return nil, fmt.Errorf("claude cli error: %w\noutput: %s", err, stdoutStr)
|
||||||
|
default:
|
||||||
return nil, fmt.Errorf("claude cli error: %w", err)
|
return nil, fmt.Errorf("claude cli error: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return p.parseClaudeCliResponse(stdout.String())
|
return p.parseClaudeCliResponse(stdout.String())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ type CronTool struct {
|
||||||
executor JobExecutor
|
executor JobExecutor
|
||||||
msgBus *bus.MessageBus
|
msgBus *bus.MessageBus
|
||||||
execTool *ExecTool
|
execTool *ExecTool
|
||||||
|
allowCommand bool
|
||||||
|
execEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCronTool creates a new CronTool
|
// NewCronTool creates a new CronTool
|
||||||
|
|
@ -32,17 +34,32 @@ func NewCronTool(
|
||||||
cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,
|
cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,
|
||||||
execTimeout time.Duration, config *config.Config,
|
execTimeout time.Duration, config *config.Config,
|
||||||
) (*CronTool, error) {
|
) (*CronTool, error) {
|
||||||
execTool, err := NewExecToolWithConfig(workspace, restrict, config)
|
allowCommand := true
|
||||||
|
execEnabled := true
|
||||||
|
if config != nil {
|
||||||
|
allowCommand = config.Tools.Cron.AllowCommand
|
||||||
|
execEnabled = config.Tools.Exec.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var execTool *ExecTool
|
||||||
|
if execEnabled {
|
||||||
|
var err error
|
||||||
|
execTool, err = NewExecToolWithConfig(workspace, restrict, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unable to configure exec tool: %w", err)
|
return nil, fmt.Errorf("unable to configure exec tool: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if execTool != nil {
|
||||||
execTool.SetTimeout(execTimeout)
|
execTool.SetTimeout(execTimeout)
|
||||||
|
}
|
||||||
return &CronTool{
|
return &CronTool{
|
||||||
cronService: cronService,
|
cronService: cronService,
|
||||||
executor: executor,
|
executor: executor,
|
||||||
msgBus: msgBus,
|
msgBus: msgBus,
|
||||||
execTool: execTool,
|
execTool: execTool,
|
||||||
|
allowCommand: allowCommand,
|
||||||
|
execEnabled: execEnabled,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,7 +93,7 @@ func (t *CronTool) Parameters() map[string]any {
|
||||||
},
|
},
|
||||||
"command_confirm": map[string]any{
|
"command_confirm": map[string]any{
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Required when using command=true. Must be true to explicitly confirm scheduling a shell command.",
|
"description": "Optional explicit confirmation flag for scheduling a shell command. Command execution must also be enabled via tools.cron.allow_command.",
|
||||||
},
|
},
|
||||||
"at_seconds": map[string]any{
|
"at_seconds": map[string]any{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
|
|
@ -96,7 +113,7 @@ func (t *CronTool) Parameters() map[string]any {
|
||||||
},
|
},
|
||||||
"deliver": map[string]any{
|
"deliver": map[string]any{
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: true",
|
"description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: false",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": []string{"action"},
|
"required": []string{"action"},
|
||||||
|
|
@ -174,22 +191,26 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
|
||||||
return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required")
|
return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read deliver parameter, default to true
|
// Read deliver parameter, default to false so scheduled tasks execute through the agent
|
||||||
deliver := true
|
deliver := false
|
||||||
if d, ok := args["deliver"].(bool); ok {
|
if d, ok := args["deliver"].(bool); ok {
|
||||||
deliver = d
|
deliver = d
|
||||||
}
|
}
|
||||||
|
|
||||||
// GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel + explicit confirm.
|
// GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When
|
||||||
// Non-command reminders (plain messages) remain open to all channels.
|
// allow_command is disabled, explicit confirmation is required as an override.
|
||||||
|
// Non-command reminders remain open to all channels.
|
||||||
command, _ := args["command"].(string)
|
command, _ := args["command"].(string)
|
||||||
commandConfirm, _ := args["command_confirm"].(bool)
|
commandConfirm, _ := args["command_confirm"].(bool)
|
||||||
if command != "" {
|
if command != "" {
|
||||||
|
if !t.execEnabled {
|
||||||
|
return ErrorResult("command execution is disabled")
|
||||||
|
}
|
||||||
if !constants.IsInternalChannel(channel) {
|
if !constants.IsInternalChannel(channel) {
|
||||||
return ErrorResult("scheduling command execution is restricted to internal channels")
|
return ErrorResult("scheduling command execution is restricted to internal channels")
|
||||||
}
|
}
|
||||||
if !commandConfirm {
|
if !t.allowCommand && !commandConfirm {
|
||||||
return ErrorResult("command_confirm=true is required to schedule command execution")
|
return ErrorResult("command_confirm=true is required when allow_command is disabled")
|
||||||
}
|
}
|
||||||
deliver = false
|
deliver = false
|
||||||
}
|
}
|
||||||
|
|
@ -290,6 +311,18 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
|
||||||
|
|
||||||
// Execute command if present
|
// Execute command if present
|
||||||
if job.Payload.Command != "" {
|
if job.Payload.Command != "" {
|
||||||
|
if !t.execEnabled || t.execTool == nil {
|
||||||
|
output := "Error executing scheduled command: command execution is disabled"
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
|
Content: output,
|
||||||
|
})
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"command": job.Payload.Command,
|
"command": job.Payload.Command,
|
||||||
"__channel": channel,
|
"__channel": channel,
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,18 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/cron"
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestCronTool(t *testing.T) *CronTool {
|
func newTestCronToolWithConfig(t *testing.T, cfg *config.Config) *CronTool {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
storePath := filepath.Join(t.TempDir(), "cron.json")
|
storePath := filepath.Join(t.TempDir(), "cron.json")
|
||||||
cronService := cron.NewCronService(storePath, nil)
|
cronService := cron.NewCronService(storePath, nil)
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
tool, err := NewCronTool(cronService, nil, msgBus, t.TempDir(), true, 0, cfg)
|
tool, err := NewCronTool(cronService, nil, msgBus, t.TempDir(), true, 0, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewCronTool() error: %v", err)
|
t.Fatalf("NewCronTool() error: %v", err)
|
||||||
|
|
@ -24,6 +24,11 @@ func newTestCronTool(t *testing.T) *CronTool {
|
||||||
return tool
|
return tool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newTestCronTool(t *testing.T) *CronTool {
|
||||||
|
t.Helper()
|
||||||
|
return newTestCronToolWithConfig(t, config.DefaultConfig())
|
||||||
|
}
|
||||||
|
|
||||||
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels
|
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels
|
||||||
func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
|
func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
|
||||||
tool := newTestCronTool(t)
|
tool := newTestCronTool(t)
|
||||||
|
|
@ -44,8 +49,7 @@ func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCronTool_CommandRequiresConfirm verifies command_confirm=true is required
|
func TestCronTool_CommandDoesNotRequireConfirmByDefault(t *testing.T) {
|
||||||
func TestCronTool_CommandRequiresConfirm(t *testing.T) {
|
|
||||||
tool := newTestCronTool(t)
|
tool := newTestCronTool(t)
|
||||||
ctx := WithToolContext(context.Background(), "cli", "direct")
|
ctx := WithToolContext(context.Background(), "cli", "direct")
|
||||||
result := tool.Execute(ctx, map[string]any{
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
|
@ -55,11 +59,79 @@ func TestCronTool_CommandRequiresConfirm(t *testing.T) {
|
||||||
"at_seconds": float64(60),
|
"at_seconds": float64(60),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected command scheduling without confirm to succeed by default, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "Cron job added") {
|
||||||
|
t.Errorf("expected 'Cron job added', got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_CommandRequiresConfirmWhenAllowCommandDisabled(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.AllowCommand = false
|
||||||
|
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
ctx := WithToolContext(context.Background(), "cli", "direct")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "check disk",
|
||||||
|
"command": "df -h",
|
||||||
|
"at_seconds": float64(60),
|
||||||
|
})
|
||||||
|
|
||||||
if !result.IsError {
|
if !result.IsError {
|
||||||
t.Fatal("expected error when command_confirm is missing")
|
t.Fatal("expected command scheduling to require confirm when allow_command is disabled")
|
||||||
}
|
}
|
||||||
if !strings.Contains(result.ForLLM, "command_confirm=true") {
|
if !strings.Contains(result.ForLLM, "command_confirm=true") {
|
||||||
t.Errorf("expected 'command_confirm=true' message, got: %s", result.ForLLM)
|
t.Errorf("expected command_confirm requirement message, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_CommandAllowedWithConfirmWhenAllowCommandDisabled(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.AllowCommand = false
|
||||||
|
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
ctx := WithToolContext(context.Background(), "cli", "direct")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "check disk",
|
||||||
|
"command": "df -h",
|
||||||
|
"command_confirm": true,
|
||||||
|
"at_seconds": float64(60),
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected command scheduling with confirm to succeed when allow_command is disabled, got: %s",
|
||||||
|
result.ForLLM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "Cron job added") {
|
||||||
|
t.Errorf("expected 'Cron job added', got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_CommandBlockedWhenExecDisabled(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Exec.Enabled = false
|
||||||
|
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
ctx := WithToolContext(context.Background(), "cli", "direct")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "check disk",
|
||||||
|
"command": "df -h",
|
||||||
|
"command_confirm": true,
|
||||||
|
"at_seconds": float64(60),
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatal("expected command scheduling to be blocked when exec is disabled")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "command execution is disabled") {
|
||||||
|
t.Errorf("expected exec disabled message, got: %s", result.ForLLM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,3 +186,54 @@ func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) {
|
||||||
t.Fatalf("expected non-command reminder to succeed from remote channel, got: %s", result.ForLLM)
|
t.Fatalf("expected non-command reminder to succeed from remote channel, got: %s", result.ForLLM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCronTool_NonCommandJobDefaultsDeliverToFalse(t *testing.T) {
|
||||||
|
tool := newTestCronTool(t)
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "send me a poem",
|
||||||
|
"at_seconds": float64(600),
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected non-command reminder to succeed, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs := tool.cronService.ListJobs(false)
|
||||||
|
if len(jobs) != 1 {
|
||||||
|
t.Fatalf("expected 1 job, got %d", len(jobs))
|
||||||
|
}
|
||||||
|
if jobs[0].Payload.Deliver {
|
||||||
|
t.Fatal("expected deliver=false by default for non-command jobs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Exec.Enabled = false
|
||||||
|
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
job := &cron.CronJob{}
|
||||||
|
job.Payload.Channel = "cli"
|
||||||
|
job.Payload.To = "direct"
|
||||||
|
job.Payload.Command = "df -h"
|
||||||
|
|
||||||
|
if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
|
||||||
|
t.Fatalf("ExecuteJob() = %q, want ok", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var msg bus.OutboundMessage
|
||||||
|
select {
|
||||||
|
case msg = <-tool.msgBus.OutboundChan():
|
||||||
|
// got message
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("timeout waiting for outbound message")
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.Content, "command execution is disabled") {
|
||||||
|
t.Fatalf("expected exec disabled message, got: %s", msg.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,7 @@ import (
|
||||||
|
|
||||||
const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow
|
const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow
|
||||||
|
|
||||||
// validatePath ensures the given path is within the workspace if restrict is true.
|
func validatePathWithAllowPaths(path, workspace string, restrict bool, patterns []*regexp.Regexp) (string, error) {
|
||||||
func validatePath(path, workspace string, restrict bool) (string, error) {
|
|
||||||
if workspace == "" {
|
if workspace == "" {
|
||||||
return path, fmt.Errorf("workspace is not defined")
|
return path, fmt.Errorf("workspace is not defined")
|
||||||
}
|
}
|
||||||
|
|
@ -42,6 +41,10 @@ func validatePath(path, workspace string, restrict bool) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if restrict {
|
if restrict {
|
||||||
|
if isAllowedPath(absPath, patterns) {
|
||||||
|
return absPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
if !isWithinWorkspace(absPath, absWorkspace) {
|
if !isWithinWorkspace(absPath, absWorkspace) {
|
||||||
return "", fmt.Errorf("access denied: path is outside the workspace")
|
return "", fmt.Errorf("access denied: path is outside the workspace")
|
||||||
}
|
}
|
||||||
|
|
@ -73,6 +76,137 @@ func validatePath(path, workspace string, restrict bool) (string, error) {
|
||||||
return absPath, nil
|
return absPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAllowedPath(path string, patterns []*regexp.Regexp) bool {
|
||||||
|
if len(patterns) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned := filepath.Clean(path)
|
||||||
|
if !filepath.IsAbs(cleaned) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !matchesAllowedPath(cleaned, patterns) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := resolvePathAgainstExistingAncestor(cleaned)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchesAllowedPath(resolved, patterns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesAllowedPath(path string, patterns []*regexp.Regexp) bool {
|
||||||
|
cleaned := filepath.Clean(path)
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
if pattern.MatchString(cleaned) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if root, ok := extractAllowedPathRoot(pattern); ok && isWithinAllowedRoot(cleaned, root) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAllowedPathRoot(pattern *regexp.Regexp) (string, bool) {
|
||||||
|
raw := pattern.String()
|
||||||
|
if !strings.HasPrefix(raw, "^") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
literal := strings.TrimPrefix(raw, "^")
|
||||||
|
|
||||||
|
// Recognize the common "directory prefix" form: ^<literal>(?:/|$)
|
||||||
|
literal = strings.TrimSuffix(literal, "(?:/|$)")
|
||||||
|
literal = strings.TrimSuffix(literal, `(?:\\|$)`)
|
||||||
|
|
||||||
|
// Reject patterns that still contain regex operators after removing the
|
||||||
|
// optional anchored-directory suffix. That keeps arbitrary regex behavior
|
||||||
|
// unchanged and only enables normalized prefix matching for literal paths.
|
||||||
|
if containsUnescapedRegexMeta(literal) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
unescaped, ok := unescapeRegexLiteral(literal)
|
||||||
|
if !ok || unescaped == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Clean(unescaped), filepath.IsAbs(unescaped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUniquePath(paths []string, path string) []string {
|
||||||
|
for _, existing := range paths {
|
||||||
|
if existing == path {
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(paths, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsUnescapedRegexMeta(s string) bool {
|
||||||
|
escaped := false
|
||||||
|
for _, r := range s {
|
||||||
|
if escaped {
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == '\\' {
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch r {
|
||||||
|
case '.', '+', '*', '?', '(', ')', '[', ']', '{', '}', '|':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return escaped
|
||||||
|
}
|
||||||
|
|
||||||
|
func unescapeRegexLiteral(s string) (string, bool) {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(s))
|
||||||
|
|
||||||
|
escaped := false
|
||||||
|
for _, r := range s {
|
||||||
|
if escaped {
|
||||||
|
b.WriteRune(r)
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == '\\' {
|
||||||
|
escaped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if escaped {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWithinAllowedRoot(path, root string) bool {
|
||||||
|
candidate := filepath.Clean(path)
|
||||||
|
allowedVariants := []string{filepath.Clean(root)}
|
||||||
|
|
||||||
|
if resolvedRoot, err := resolvePathAgainstExistingAncestor(root); err == nil {
|
||||||
|
allowedVariants = appendUniquePath(allowedVariants, filepath.Clean(resolvedRoot))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, allowedRoot := range allowedVariants {
|
||||||
|
if isWithinWorkspace(candidate, allowedRoot) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func resolveExistingAncestor(path string) (string, error) {
|
func resolveExistingAncestor(path string) (string, error) {
|
||||||
for current := filepath.Clean(path); ; current = filepath.Dir(current) {
|
for current := filepath.Clean(path); ; current = filepath.Dir(current) {
|
||||||
if resolved, err := filepath.EvalSymlinks(current); err == nil {
|
if resolved, err := filepath.EvalSymlinks(current); err == nil {
|
||||||
|
|
@ -86,9 +220,32 @@ func resolveExistingAncestor(path string) (string, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolvePathAgainstExistingAncestor(path string) (string, error) {
|
||||||
|
cleaned := filepath.Clean(path)
|
||||||
|
for current := cleaned; ; current = filepath.Dir(current) {
|
||||||
|
resolved, err := filepath.EvalSymlinks(current)
|
||||||
|
if err == nil {
|
||||||
|
suffix, relErr := filepath.Rel(current, cleaned)
|
||||||
|
if relErr != nil {
|
||||||
|
return "", relErr
|
||||||
|
}
|
||||||
|
if suffix == "." {
|
||||||
|
return filepath.Clean(resolved), nil
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.Join(resolved, suffix)), nil
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if filepath.Dir(current) == current {
|
||||||
|
return "", os.ErrNotExist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func isWithinWorkspace(candidate, workspace string) bool {
|
func isWithinWorkspace(candidate, workspace string) bool {
|
||||||
rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate))
|
rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate))
|
||||||
return err == nil && filepath.IsLocal(rel)
|
return err == nil && (rel == "." || filepath.IsLocal(rel))
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReadFileTool struct {
|
type ReadFileTool struct {
|
||||||
|
|
@ -625,12 +782,7 @@ type whitelistFs struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *whitelistFs) matches(path string) bool {
|
func (w *whitelistFs) matches(path string) bool {
|
||||||
for _, p := range w.patterns {
|
return isAllowedPath(path, w.patterns)
|
||||||
if p.MatchString(path) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *whitelistFs) ReadFile(path string) ([]byte, error) {
|
func (w *whitelistFs) ReadFile(path string) ([]byte, error) {
|
||||||
|
|
|
||||||
|
|
@ -521,6 +521,90 @@ func TestWhitelistFs_AllowsMatchingPaths(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWhitelistFs_BlocksSymlinkEscapeInAllowedDir(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
allowedDir := t.TempDir()
|
||||||
|
secretDir := t.TempDir()
|
||||||
|
secretFile := filepath.Join(secretDir, "secret.txt")
|
||||||
|
if err := os.WriteFile(secretFile, []byte("top secret"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(secretFile) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
linkPath := filepath.Join(allowedDir, "link_out")
|
||||||
|
if err := os.Symlink(secretDir, linkPath); err != nil {
|
||||||
|
t.Skipf("symlink not supported in this environment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))}
|
||||||
|
tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"path": filepath.Join(linkPath, "secret.txt")})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhitelistFs_WriteAllowsNewFileUnderAllowedDir(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
rootDir := t.TempDir()
|
||||||
|
allowedDir := filepath.Join(rootDir, "allowed")
|
||||||
|
targetFile := filepath.Join(allowedDir, "nested", "file.txt")
|
||||||
|
|
||||||
|
patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))}
|
||||||
|
tool := NewWriteFileTool(workspace, true, patterns)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"path": targetFile,
|
||||||
|
"content": "outside write",
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected whitelisted write to succeed, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(targetFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(targetFile) error = %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "outside write" {
|
||||||
|
t.Fatalf("target file content = %q, want %q", string(data), "outside write")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWhitelistFs_AllowsResolvedAllowedRootAlias(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
realDir := t.TempDir()
|
||||||
|
linkParent := t.TempDir()
|
||||||
|
allowedAlias := filepath.Join(linkParent, "allowed-link")
|
||||||
|
|
||||||
|
if err := os.Symlink(realDir, allowedAlias); err != nil {
|
||||||
|
t.Skipf("symlink not supported in this environment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetFile := filepath.Join(allowedAlias, "nested", "alias.txt")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(targetFile), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(targetFile dir) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(targetFile, []byte("through alias"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(targetFile) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
patterns := []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(
|
||||||
|
"^" + regexp.QuoteMeta(filepath.Clean(allowedAlias)) +
|
||||||
|
"(?:" + regexp.QuoteMeta(string(os.PathSeparator)) + "|$)",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"path": targetFile})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected symlink-backed allowed root to be readable, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "through alias") {
|
||||||
|
t.Fatalf("expected file content, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestReadFileTool_ChunkedReading verifies the pagination logic of the tool
|
// TestReadFileTool_ChunkedReading verifies the pagination logic of the tool
|
||||||
// by reading a file in multiple chunks using 'offset' and 'length'.
|
// by reading a file in multiple chunks using 'offset' and 'length'.
|
||||||
func TestReadFileTool_ChunkedReading(t *testing.T) {
|
func TestReadFileTool_ChunkedReading(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"mime"
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/h2non/filetype"
|
"github.com/h2non/filetype"
|
||||||
|
|
@ -21,20 +22,32 @@ type SendFileTool struct {
|
||||||
restrict bool
|
restrict bool
|
||||||
maxFileSize int
|
maxFileSize int
|
||||||
mediaStore media.MediaStore
|
mediaStore media.MediaStore
|
||||||
|
allowPaths []*regexp.Regexp
|
||||||
|
|
||||||
defaultChannel string
|
defaultChannel string
|
||||||
defaultChatID string
|
defaultChatID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSendFileTool(workspace string, restrict bool, maxFileSize int, store media.MediaStore) *SendFileTool {
|
func NewSendFileTool(
|
||||||
|
workspace string,
|
||||||
|
restrict bool,
|
||||||
|
maxFileSize int,
|
||||||
|
store media.MediaStore,
|
||||||
|
allowPaths ...[]*regexp.Regexp,
|
||||||
|
) *SendFileTool {
|
||||||
if maxFileSize <= 0 {
|
if maxFileSize <= 0 {
|
||||||
maxFileSize = config.DefaultMaxMediaSize
|
maxFileSize = config.DefaultMaxMediaSize
|
||||||
}
|
}
|
||||||
|
var patterns []*regexp.Regexp
|
||||||
|
if len(allowPaths) > 0 {
|
||||||
|
patterns = allowPaths[0]
|
||||||
|
}
|
||||||
return &SendFileTool{
|
return &SendFileTool{
|
||||||
workspace: workspace,
|
workspace: workspace,
|
||||||
restrict: restrict,
|
restrict: restrict,
|
||||||
maxFileSize: maxFileSize,
|
maxFileSize: maxFileSize,
|
||||||
mediaStore: store,
|
mediaStore: store,
|
||||||
|
allowPaths: patterns,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,7 +105,7 @@ func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
return ErrorResult("media store not configured")
|
return ErrorResult("media store not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
resolved, err := validatePath(path, t.workspace, t.restrict)
|
resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("invalid path: %v", err))
|
return ErrorResult(fmt.Sprintf("invalid path: %v", err))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
|
@ -128,6 +129,44 @@ func TestSendFileTool_CustomFilename(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendFileTool_AllowsWhitelistedMediaTempPath(t *testing.T) {
|
||||||
|
workspace := t.TempDir()
|
||||||
|
mediaDir := media.TempDir()
|
||||||
|
if err := os.MkdirAll(mediaDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(mediaDir) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile, err := os.CreateTemp(mediaDir, "send-file-*.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTemp(mediaDir) error = %v", err)
|
||||||
|
}
|
||||||
|
testPath := testFile.Name()
|
||||||
|
if _, err := testFile.WriteString("forward me"); err != nil {
|
||||||
|
testFile.Close()
|
||||||
|
t.Fatalf("WriteString(testFile) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := testFile.Close(); err != nil {
|
||||||
|
t.Fatalf("Close(testFile) error = %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Remove(testPath) })
|
||||||
|
|
||||||
|
pattern := regexp.MustCompile(
|
||||||
|
"^" + regexp.QuoteMeta(filepath.Clean(mediaDir)) + "(?:" + regexp.QuoteMeta(string(os.PathSeparator)) + "|$)",
|
||||||
|
)
|
||||||
|
|
||||||
|
store := media.NewFileMediaStore()
|
||||||
|
tool := NewSendFileTool(workspace, true, 0, store, []*regexp.Regexp{pattern})
|
||||||
|
tool.SetContext("feishu", "chat123")
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"path": testPath})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected whitelisted temp media file to be sendable, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if len(result.Media) != 1 {
|
||||||
|
t.Fatalf("expected 1 media ref, got %d", len(result.Media))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetectMediaType_MagicBytes(t *testing.T) {
|
func TestDetectMediaType_MagicBytes(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ type ExecTool struct {
|
||||||
denyPatterns []*regexp.Regexp
|
denyPatterns []*regexp.Regexp
|
||||||
allowPatterns []*regexp.Regexp
|
allowPatterns []*regexp.Regexp
|
||||||
customAllowPatterns []*regexp.Regexp
|
customAllowPatterns []*regexp.Regexp
|
||||||
|
allowedPathPatterns []*regexp.Regexp
|
||||||
restrictToWorkspace bool
|
restrictToWorkspace bool
|
||||||
allowRemote bool
|
allowRemote bool
|
||||||
}
|
}
|
||||||
|
|
@ -95,14 +96,23 @@ var (
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) {
|
func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regexp) (*ExecTool, error) {
|
||||||
return NewExecToolWithConfig(workingDir, restrict, nil)
|
return NewExecToolWithConfig(workingDir, restrict, nil, allowPaths...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) {
|
func NewExecToolWithConfig(
|
||||||
|
workingDir string,
|
||||||
|
restrict bool,
|
||||||
|
config *config.Config,
|
||||||
|
allowPaths ...[]*regexp.Regexp,
|
||||||
|
) (*ExecTool, error) {
|
||||||
denyPatterns := make([]*regexp.Regexp, 0)
|
denyPatterns := make([]*regexp.Regexp, 0)
|
||||||
customAllowPatterns := make([]*regexp.Regexp, 0)
|
customAllowPatterns := make([]*regexp.Regexp, 0)
|
||||||
|
var allowedPathPatterns []*regexp.Regexp
|
||||||
allowRemote := true
|
allowRemote := true
|
||||||
|
if len(allowPaths) > 0 {
|
||||||
|
allowedPathPatterns = allowPaths[0]
|
||||||
|
}
|
||||||
|
|
||||||
if config != nil {
|
if config != nil {
|
||||||
execConfig := config.Tools.Exec
|
execConfig := config.Tools.Exec
|
||||||
|
|
@ -146,6 +156,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
|
||||||
denyPatterns: denyPatterns,
|
denyPatterns: denyPatterns,
|
||||||
allowPatterns: nil,
|
allowPatterns: nil,
|
||||||
customAllowPatterns: customAllowPatterns,
|
customAllowPatterns: customAllowPatterns,
|
||||||
|
allowedPathPatterns: allowedPathPatterns,
|
||||||
restrictToWorkspace: restrict,
|
restrictToWorkspace: restrict,
|
||||||
allowRemote: allowRemote,
|
allowRemote: allowRemote,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
@ -198,7 +209,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
cwd := t.workingDir
|
cwd := t.workingDir
|
||||||
if wd, ok := args["working_dir"].(string); ok && wd != "" {
|
if wd, ok := args["working_dir"].(string); ok && wd != "" {
|
||||||
if t.restrictToWorkspace && t.workingDir != "" {
|
if t.restrictToWorkspace && t.workingDir != "" {
|
||||||
resolvedWD, err := validatePath(wd, t.workingDir, true)
|
resolvedWD, err := validatePathWithAllowPaths(wd, t.workingDir, true, t.allowedPathPatterns)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult("Command blocked by safety guard (" + err.Error() + ")")
|
return ErrorResult("Command blocked by safety guard (" + err.Error() + ")")
|
||||||
}
|
}
|
||||||
|
|
@ -226,6 +237,9 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrorResult(fmt.Sprintf("Command blocked by safety guard (path resolution failed: %v)", err))
|
return ErrorResult(fmt.Sprintf("Command blocked by safety guard (path resolution failed: %v)", err))
|
||||||
}
|
}
|
||||||
|
if isAllowedPath(resolved, t.allowedPathPatterns) {
|
||||||
|
cwd = resolved
|
||||||
|
} else {
|
||||||
absWorkspace, _ := filepath.Abs(t.workingDir)
|
absWorkspace, _ := filepath.Abs(t.workingDir)
|
||||||
wsResolved, _ := filepath.EvalSymlinks(absWorkspace)
|
wsResolved, _ := filepath.EvalSymlinks(absWorkspace)
|
||||||
if wsResolved == "" {
|
if wsResolved == "" {
|
||||||
|
|
@ -237,6 +251,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
}
|
}
|
||||||
cwd = resolved
|
cwd = resolved
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// timeout == 0 means no timeout
|
// timeout == 0 means no timeout
|
||||||
var cmdCtx context.Context
|
var cmdCtx context.Context
|
||||||
|
|
@ -412,6 +427,9 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||||
if safePaths[p] {
|
if safePaths[p] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if isAllowedPath(p, t.allowedPathPatterns) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
rel, err := filepath.Rel(cwdPath, p)
|
rel, err := filepath.Rel(cwdPath, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
178
pkg/tools/spawn_status.go
Normal file
178
pkg/tools/spawn_status.go
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SpawnStatusTool reports the status of subagents that were spawned via the
|
||||||
|
// spawn tool. It can query a specific task by ID, or list every known task with
|
||||||
|
// a summary count broken-down by status.
|
||||||
|
type SpawnStatusTool struct {
|
||||||
|
manager *SubagentManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSpawnStatusTool creates a SpawnStatusTool backed by the given manager.
|
||||||
|
func NewSpawnStatusTool(manager *SubagentManager) *SpawnStatusTool {
|
||||||
|
return &SpawnStatusTool{manager: manager}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SpawnStatusTool) Name() string {
|
||||||
|
return "spawn_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SpawnStatusTool) Description() string {
|
||||||
|
return "Get the status of spawned subagents. " +
|
||||||
|
"Returns a list of all subagents and their current state " +
|
||||||
|
"(running, completed, failed, or canceled), or retrieves details " +
|
||||||
|
"for a specific subagent task when task_id is provided. " +
|
||||||
|
"Results are scoped to the current conversation's channel and chat ID; " +
|
||||||
|
"all tasks are listed only when no channel/chat context is injected " +
|
||||||
|
"(e.g. direct programmatic calls via Execute)."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SpawnStatusTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"task_id": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional task ID (e.g. \"subagent-1\") to inspect a specific " +
|
||||||
|
"subagent. When omitted, all visible subagents are listed.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *SpawnStatusTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
if t.manager == nil {
|
||||||
|
return ErrorResult("Subagent manager not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the calling conversation's identity so we can scope results to the
|
||||||
|
// current chat only — preventing cross-conversation task leakage in
|
||||||
|
// multi-user deployments.
|
||||||
|
callerChannel := ToolChannel(ctx)
|
||||||
|
callerChatID := ToolChatID(ctx)
|
||||||
|
|
||||||
|
var taskID string
|
||||||
|
if rawTaskID, ok := args["task_id"]; ok && rawTaskID != nil {
|
||||||
|
taskIDStr, ok := rawTaskID.(string)
|
||||||
|
if !ok {
|
||||||
|
return ErrorResult("task_id must be a string")
|
||||||
|
}
|
||||||
|
taskID = strings.TrimSpace(taskIDStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if taskID != "" {
|
||||||
|
// GetTaskCopy returns a consistent snapshot under the manager lock,
|
||||||
|
// eliminating any data race with the concurrent subagent goroutine.
|
||||||
|
taskCopy, ok := t.manager.GetTaskCopy(taskID)
|
||||||
|
if !ok {
|
||||||
|
return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restrict lookup to tasks that belong to this conversation.
|
||||||
|
if callerChannel != "" && taskCopy.OriginChannel != "" && taskCopy.OriginChannel != callerChannel {
|
||||||
|
return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID))
|
||||||
|
}
|
||||||
|
if callerChatID != "" && taskCopy.OriginChatID != "" && taskCopy.OriginChatID != callerChatID {
|
||||||
|
return ErrorResult(fmt.Sprintf("No subagent found with task ID: %s", taskID))
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewToolResult(spawnStatusFormatTask(&taskCopy))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTaskCopies returns consistent snapshots under the manager lock.
|
||||||
|
origTasks := t.manager.ListTaskCopies()
|
||||||
|
if len(origTasks) == 0 {
|
||||||
|
return NewToolResult("No subagents have been spawned yet.")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := make([]*SubagentTask, 0, len(origTasks))
|
||||||
|
for i := range origTasks {
|
||||||
|
cpy := &origTasks[i]
|
||||||
|
|
||||||
|
// Filter to tasks that originate from the current conversation only.
|
||||||
|
if callerChannel != "" && cpy.OriginChannel != "" && cpy.OriginChannel != callerChannel {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if callerChatID != "" && cpy.OriginChatID != "" && cpy.OriginChatID != callerChatID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks = append(tasks, cpy)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
return NewToolResult("No subagents found for this conversation.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order by creation time (ascending) so spawning order is preserved.
|
||||||
|
// Fall back to ID string for tasks created in the same millisecond.
|
||||||
|
sort.Slice(tasks, func(i, j int) bool {
|
||||||
|
if tasks[i].Created != tasks[j].Created {
|
||||||
|
return tasks[i].Created < tasks[j].Created
|
||||||
|
}
|
||||||
|
return tasks[i].ID < tasks[j].ID
|
||||||
|
})
|
||||||
|
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, task := range tasks {
|
||||||
|
counts[task.Status]++
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Subagent status report (%d total):\n", len(tasks)))
|
||||||
|
for _, status := range []string{"running", "completed", "failed", "canceled"} {
|
||||||
|
if n := counts[status]; n > 0 {
|
||||||
|
label := strings.ToUpper(status[:1]) + status[1:] + ":"
|
||||||
|
sb.WriteString(fmt.Sprintf(" %-10s %d\n", label, n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
for _, task := range tasks {
|
||||||
|
sb.WriteString(spawnStatusFormatTask(task))
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewToolResult(strings.TrimRight(sb.String(), "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawnStatusFormatTask renders a single SubagentTask as a human-readable block.
|
||||||
|
func spawnStatusFormatTask(task *SubagentTask) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
header := fmt.Sprintf("[%s] status=%s", task.ID, task.Status)
|
||||||
|
if task.Label != "" {
|
||||||
|
header += fmt.Sprintf(" label=%q", task.Label)
|
||||||
|
}
|
||||||
|
if task.AgentID != "" {
|
||||||
|
header += fmt.Sprintf(" agent=%s", task.AgentID)
|
||||||
|
}
|
||||||
|
if task.Created > 0 {
|
||||||
|
created := time.UnixMilli(task.Created).UTC().Format("2006-01-02 15:04:05 UTC")
|
||||||
|
header += fmt.Sprintf(" created=%s", created)
|
||||||
|
}
|
||||||
|
sb.WriteString(header)
|
||||||
|
|
||||||
|
if task.Task != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n task: %s", task.Task))
|
||||||
|
}
|
||||||
|
if task.Result != "" {
|
||||||
|
result := task.Result
|
||||||
|
const maxResultLen = 300
|
||||||
|
runes := []rune(result)
|
||||||
|
if len(runes) > maxResultLen {
|
||||||
|
result = string(runes[:maxResultLen]) + "…"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\n result: %s", result))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
406
pkg/tools/spawn_status_test.go
Normal file
406
pkg/tools/spawn_status_test.go
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_Name(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
workspace := t.TempDir()
|
||||||
|
manager := NewSubagentManager(provider, "test-model", workspace)
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
if tool.Name() != "spawn_status" {
|
||||||
|
t.Errorf("Expected name 'spawn_status', got '%s'", tool.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_Description(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
workspace := t.TempDir()
|
||||||
|
manager := NewSubagentManager(provider, "test-model", workspace)
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
desc := tool.Description()
|
||||||
|
if desc == "" {
|
||||||
|
t.Error("Description should not be empty")
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(desc), "subagent") {
|
||||||
|
t.Errorf("Description should mention 'subagent', got: %s", desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_Parameters(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
workspace := t.TempDir()
|
||||||
|
manager := NewSubagentManager(provider, "test-model", workspace)
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
params := tool.Parameters()
|
||||||
|
if params["type"] != "object" {
|
||||||
|
t.Errorf("Expected type 'object', got: %v", params["type"])
|
||||||
|
}
|
||||||
|
props, ok := params["properties"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Expected 'properties' to be a map")
|
||||||
|
}
|
||||||
|
if _, hasTaskID := props["task_id"]; !hasTaskID {
|
||||||
|
t.Error("Expected 'task_id' parameter in properties")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_NilManager(t *testing.T) {
|
||||||
|
tool := &SpawnStatusTool{manager: nil}
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Error("Expected error result when manager is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_Empty(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
workspace := t.TempDir()
|
||||||
|
manager := NewSubagentManager(provider, "test-model", workspace)
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Expected success, got error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "No subagents") {
|
||||||
|
t.Errorf("Expected 'No subagents' message, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_ListAll(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
workspace := t.TempDir()
|
||||||
|
manager := NewSubagentManager(provider, "test-model", workspace)
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.tasks["subagent-1"] = &SubagentTask{
|
||||||
|
ID: "subagent-1",
|
||||||
|
Task: "Do task A",
|
||||||
|
Label: "task-a",
|
||||||
|
Status: "running",
|
||||||
|
Created: now,
|
||||||
|
}
|
||||||
|
manager.tasks["subagent-2"] = &SubagentTask{
|
||||||
|
ID: "subagent-2",
|
||||||
|
Task: "Do task B",
|
||||||
|
Label: "task-b",
|
||||||
|
Status: "completed",
|
||||||
|
Result: "Done successfully",
|
||||||
|
Created: now,
|
||||||
|
}
|
||||||
|
manager.tasks["subagent-3"] = &SubagentTask{
|
||||||
|
ID: "subagent-3",
|
||||||
|
Task: "Do task C",
|
||||||
|
Status: "failed",
|
||||||
|
Result: "Error: something went wrong",
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Expected success, got error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary header
|
||||||
|
if !strings.Contains(result.ForLLM, "3 total") {
|
||||||
|
t.Errorf("Expected total count in header, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual task IDs
|
||||||
|
for _, id := range []string{"subagent-1", "subagent-2", "subagent-3"} {
|
||||||
|
if !strings.Contains(result.ForLLM, id) {
|
||||||
|
t.Errorf("Expected task %s in output, got:\n%s", id, result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status values
|
||||||
|
for _, status := range []string{"running", "completed", "failed"} {
|
||||||
|
if !strings.Contains(result.ForLLM, status) {
|
||||||
|
t.Errorf("Expected status '%s' in output, got:\n%s", status, result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result content
|
||||||
|
if !strings.Contains(result.ForLLM, "Done successfully") {
|
||||||
|
t.Errorf("Expected result text in output, got:\n%s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_GetByID(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.tasks["subagent-42"] = &SubagentTask{
|
||||||
|
ID: "subagent-42",
|
||||||
|
Task: "Specific task",
|
||||||
|
Label: "my-task",
|
||||||
|
Status: "failed",
|
||||||
|
Result: "Something went wrong",
|
||||||
|
Created: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-42"})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Expected success, got error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "subagent-42") {
|
||||||
|
t.Errorf("Expected task ID in output, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "failed") {
|
||||||
|
t.Errorf("Expected status 'failed' in output, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "Something went wrong") {
|
||||||
|
t.Errorf("Expected result text in output, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "my-task") {
|
||||||
|
t.Errorf("Expected label in output, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_GetByID_NotFound(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"task_id": "nonexistent-999"})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error for nonexistent task, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "nonexistent-999") {
|
||||||
|
t.Errorf("Expected task ID in error message, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_TaskID_NonString(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
for _, badVal := range []any{42, 3.14, true, map[string]any{"x": 1}, []string{"a"}} {
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"task_id": badVal})
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error for task_id=%T(%v), got success: %s", badVal, badVal, result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "task_id must be a string") {
|
||||||
|
t.Errorf("Expected type-error message, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_ResultTruncation(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
longResult := strings.Repeat("X", 500)
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.tasks["subagent-1"] = &SubagentTask{
|
||||||
|
ID: "subagent-1",
|
||||||
|
Task: "Long task",
|
||||||
|
Status: "completed",
|
||||||
|
Result: longResult,
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-1"})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
// Output should be shorter than the raw result due to truncation
|
||||||
|
if len(result.ForLLM) >= len(longResult) {
|
||||||
|
t.Errorf("Expected result to be truncated, but ForLLM is %d chars", len(result.ForLLM))
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "…") {
|
||||||
|
t.Errorf("Expected truncation indicator '…' in output, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_ResultTruncation_Unicode(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
// Each CJK rune is 3 bytes; 400 runes = 1200 bytes — well over the 300-rune limit.
|
||||||
|
cjkChar := string(rune(0x5b57))
|
||||||
|
longResult := strings.Repeat(cjkChar, 400)
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.tasks["subagent-1"] = &SubagentTask{
|
||||||
|
ID: "subagent-1",
|
||||||
|
Task: "Unicode task",
|
||||||
|
Status: "completed",
|
||||||
|
Result: longResult,
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{"task_id": "subagent-1"})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "…") {
|
||||||
|
t.Errorf("Expected truncation indicator in output")
|
||||||
|
}
|
||||||
|
// The truncated result must be valid UTF-8 (no split rune boundaries).
|
||||||
|
if !strings.Contains(result.ForLLM, cjkChar) {
|
||||||
|
t.Errorf("Expected CJK runes to appear intact in output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_StatusCounts(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
for i, status := range []string{"running", "running", "completed", "failed", "canceled"} {
|
||||||
|
id := fmt.Sprintf("subagent-%d", i+1)
|
||||||
|
manager.tasks[id] = &SubagentTask{ID: id, Task: "t", Status: status}
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
// The summary line should mention all statuses that have counts
|
||||||
|
for _, want := range []string{"Running:", "Completed:", "Failed:", "Canceled:"} {
|
||||||
|
if !strings.Contains(result.ForLLM, want) {
|
||||||
|
t.Errorf("Expected %q in summary, got:\n%s", want, result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_SortByCreatedTimestamp(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
manager.mu.Lock()
|
||||||
|
// Intentionally insert with out-of-order IDs and timestamps that reflect
|
||||||
|
// true spawn order: subagent-2 was spawned first, subagent-10 second.
|
||||||
|
manager.tasks["subagent-10"] = &SubagentTask{
|
||||||
|
ID: "subagent-10", Task: "second", Status: "running",
|
||||||
|
Created: now + 1,
|
||||||
|
}
|
||||||
|
manager.tasks["subagent-2"] = &SubagentTask{
|
||||||
|
ID: "subagent-2", Task: "first", Status: "running",
|
||||||
|
Created: now,
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
pos2 := strings.Index(result.ForLLM, "subagent-2")
|
||||||
|
pos10 := strings.Index(result.ForLLM, "subagent-10")
|
||||||
|
if pos2 < 0 || pos10 < 0 {
|
||||||
|
t.Fatalf("Both task IDs should appear in output:\n%s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if pos2 > pos10 {
|
||||||
|
t.Errorf("Expected subagent-2 (created first) to appear before subagent-10, but got:\n%s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_ChannelFiltering_ListAll(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.tasks["subagent-1"] = &SubagentTask{
|
||||||
|
ID: "subagent-1", Task: "mine", Status: "running",
|
||||||
|
OriginChannel: "telegram", OriginChatID: "chat-A",
|
||||||
|
}
|
||||||
|
manager.tasks["subagent-2"] = &SubagentTask{
|
||||||
|
ID: "subagent-2", Task: "other user", Status: "running",
|
||||||
|
OriginChannel: "telegram", OriginChatID: "chat-B",
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
// Caller is chat-A — should only see subagent-1.
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-A")
|
||||||
|
result := tool.Execute(ctx, map[string]any{})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "subagent-1") {
|
||||||
|
t.Errorf("Expected own task in output, got:\n%s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if strings.Contains(result.ForLLM, "subagent-2") {
|
||||||
|
t.Errorf("Should NOT see other chat's task, got:\n%s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_ChannelFiltering_GetByID(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.tasks["subagent-99"] = &SubagentTask{
|
||||||
|
ID: "subagent-99", Task: "secret", Status: "completed", Result: "private data",
|
||||||
|
OriginChannel: "slack", OriginChatID: "room-Z",
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
// Different chat trying to look up subagent-99 by ID.
|
||||||
|
ctx := WithToolContext(context.Background(), "slack", "room-OTHER")
|
||||||
|
result := tool.Execute(ctx, map[string]any{"task_id": "subagent-99"})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected error (cross-chat lookup blocked), got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpawnStatusTool_ChannelFiltering_NoContext(t *testing.T) {
|
||||||
|
provider := &MockLLMProvider{}
|
||||||
|
manager := NewSubagentManager(provider, "test-model", "/tmp/test")
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.tasks["subagent-1"] = &SubagentTask{
|
||||||
|
ID: "subagent-1", Task: "t", Status: "completed",
|
||||||
|
OriginChannel: "telegram", OriginChatID: "chat-A",
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
tool := NewSpawnStatusTool(manager)
|
||||||
|
|
||||||
|
// No ToolContext injected (e.g. a direct programmatic call that bypasses
|
||||||
|
// WithToolContext entirely) — callerChannel and callerChatID are both "".
|
||||||
|
// Note: the normal CLI path uses ProcessDirectWithChannel("cli", "direct"),
|
||||||
|
// which *does* inject a non-empty context; this test covers the case where
|
||||||
|
// no context injection happens at all.
|
||||||
|
// The filter conditions require a non-empty caller value, so all tasks pass through.
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("Unexpected error: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "subagent-1") {
|
||||||
|
t.Errorf("Expected task visible from no-context caller, got:\n%s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -109,9 +109,6 @@ func (sm *SubagentManager) Spawn(
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) {
|
func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) {
|
||||||
task.Status = "running"
|
|
||||||
task.Created = time.Now().UnixMilli()
|
|
||||||
|
|
||||||
// Build system prompt for subagent
|
// Build system prompt for subagent
|
||||||
systemPrompt := `You are a subagent. Complete the given task independently and report the result.
|
systemPrompt := `You are a subagent. Complete the given task independently and report the result.
|
||||||
You have access to tools - use them as needed to complete your task.
|
You have access to tools - use them as needed to complete your task.
|
||||||
|
|
@ -219,6 +216,18 @@ func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {
|
||||||
return task, ok
|
return task, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTaskCopy returns a copy of the task with the given ID, taken under the
|
||||||
|
// read lock, so the caller receives a consistent snapshot with no data race.
|
||||||
|
func (sm *SubagentManager) GetTaskCopy(taskID string) (SubagentTask, bool) {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
task, ok := sm.tasks[taskID]
|
||||||
|
if !ok {
|
||||||
|
return SubagentTask{}, false
|
||||||
|
}
|
||||||
|
return *task, true
|
||||||
|
}
|
||||||
|
|
||||||
func (sm *SubagentManager) ListTasks() []*SubagentTask {
|
func (sm *SubagentManager) ListTasks() []*SubagentTask {
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
defer sm.mu.RUnlock()
|
defer sm.mu.RUnlock()
|
||||||
|
|
@ -230,6 +239,19 @@ func (sm *SubagentManager) ListTasks() []*SubagentTask {
|
||||||
return tasks
|
return tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListTaskCopies returns value copies of all tasks, taken under the read lock,
|
||||||
|
// so callers receive consistent snapshots with no data race.
|
||||||
|
func (sm *SubagentManager) ListTaskCopies() []SubagentTask {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
|
||||||
|
copies := make([]SubagentTask, 0, len(sm.tasks))
|
||||||
|
for _, task := range sm.tasks {
|
||||||
|
copies = append(copies, *task)
|
||||||
|
}
|
||||||
|
return copies
|
||||||
|
}
|
||||||
|
|
||||||
// SubagentTool executes a subagent task synchronously and returns the result.
|
// SubagentTool executes a subagent task synchronously and returns the result.
|
||||||
// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion
|
// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion
|
||||||
// and returns the result directly in the ToolResult.
|
// and returns the result directly in the ToolResult.
|
||||||
|
|
|
||||||
195
pkg/tools/web.go
195
pkg/tools/web.go
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
@ -15,6 +16,7 @@ import (
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -776,22 +778,49 @@ type WebFetchTool struct {
|
||||||
maxChars int
|
maxChars int
|
||||||
proxy string
|
proxy string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
format string
|
||||||
fetchLimitBytes int64
|
fetchLimitBytes int64
|
||||||
|
whitelist *privateHostWhitelist
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebFetchTool(maxChars int, fetchLimitBytes int64) (*WebFetchTool, error) {
|
type privateHostWhitelist struct {
|
||||||
|
exact map[string]struct{}
|
||||||
|
cidrs []*net.IPNet
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) {
|
||||||
// createHTTPClient cannot fail with an empty proxy string.
|
// createHTTPClient cannot fail with an empty proxy string.
|
||||||
return NewWebFetchToolWithProxy(maxChars, "", fetchLimitBytes)
|
return NewWebFetchToolWithConfig(maxChars, "", format, fetchLimitBytes, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowPrivateWebFetchHosts controls whether loopback/private hosts are allowed.
|
// allowPrivateWebFetchHosts controls whether loopback/private hosts are allowed.
|
||||||
// This is false in normal runtime to reduce SSRF exposure, and tests can override it temporarily.
|
// This is false in normal runtime to reduce SSRF exposure, and tests can override it temporarily.
|
||||||
var allowPrivateWebFetchHosts atomic.Bool
|
var allowPrivateWebFetchHosts atomic.Bool
|
||||||
|
|
||||||
func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64) (*WebFetchTool, error) {
|
func NewWebFetchToolWithProxy(
|
||||||
|
maxChars int,
|
||||||
|
proxy string,
|
||||||
|
format string,
|
||||||
|
fetchLimitBytes int64,
|
||||||
|
privateHostWhitelist []string,
|
||||||
|
) (*WebFetchTool, error) {
|
||||||
|
return NewWebFetchToolWithConfig(maxChars, proxy, format, fetchLimitBytes, privateHostWhitelist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebFetchToolWithConfig(
|
||||||
|
maxChars int,
|
||||||
|
proxy string,
|
||||||
|
format string,
|
||||||
|
fetchLimitBytes int64,
|
||||||
|
privateHostWhitelist []string,
|
||||||
|
) (*WebFetchTool, error) {
|
||||||
if maxChars <= 0 {
|
if maxChars <= 0 {
|
||||||
maxChars = defaultMaxChars
|
maxChars = defaultMaxChars
|
||||||
}
|
}
|
||||||
|
whitelist, err := newPrivateHostWhitelist(privateHostWhitelist)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse web fetch private host whitelist: %w", err)
|
||||||
|
}
|
||||||
client, err := utils.CreateHTTPClient(proxy, fetchTimeout)
|
client, err := utils.CreateHTTPClient(proxy, fetchTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err)
|
return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err)
|
||||||
|
|
@ -801,13 +830,13 @@ func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64)
|
||||||
Timeout: 15 * time.Second,
|
Timeout: 15 * time.Second,
|
||||||
KeepAlive: 30 * time.Second,
|
KeepAlive: 30 * time.Second,
|
||||||
}
|
}
|
||||||
transport.DialContext = newSafeDialContext(dialer)
|
transport.DialContext = newSafeDialContext(dialer, whitelist)
|
||||||
}
|
}
|
||||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
if len(via) >= maxRedirects {
|
if len(via) >= maxRedirects {
|
||||||
return fmt.Errorf("stopped after %d redirects", maxRedirects)
|
return fmt.Errorf("stopped after %d redirects", maxRedirects)
|
||||||
}
|
}
|
||||||
if isObviousPrivateHost(req.URL.Hostname()) {
|
if isObviousPrivateHost(req.URL.Hostname(), whitelist) {
|
||||||
return fmt.Errorf("redirect target is private or local network host")
|
return fmt.Errorf("redirect target is private or local network host")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -819,7 +848,9 @@ func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64)
|
||||||
maxChars: maxChars,
|
maxChars: maxChars,
|
||||||
proxy: proxy,
|
proxy: proxy,
|
||||||
client: client,
|
client: client,
|
||||||
|
format: format,
|
||||||
fetchLimitBytes: fetchLimitBytes,
|
fetchLimitBytes: fetchLimitBytes,
|
||||||
|
whitelist: whitelist,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -871,7 +902,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
// Lightweight pre-flight: block obvious localhost/literal-IP without DNS resolution.
|
// Lightweight pre-flight: block obvious localhost/literal-IP without DNS resolution.
|
||||||
// The real SSRF guard is newSafeDialContext at connect time.
|
// The real SSRF guard is newSafeDialContext at connect time.
|
||||||
hostname := parsedURL.Hostname()
|
hostname := parsedURL.Hostname()
|
||||||
if isObviousPrivateHost(hostname) {
|
if isObviousPrivateHost(hostname, t.whitelist) {
|
||||||
return ErrorResult("fetching private or local network hosts is not allowed")
|
return ErrorResult("fetching private or local network hosts is not allowed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -906,26 +937,68 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
|
return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bodyStr := string(body)
|
||||||
contentType := resp.Header.Get("Content-Type")
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
|
||||||
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil {
|
||||||
|
// The most common error here is "mime: no media type" if the header is empty.
|
||||||
|
logger.WarnCF("tool", "Failed to parse Content-Type", map[string]any{
|
||||||
|
"raw_header": contentType,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// security fallback
|
||||||
|
mediaType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
charset, hasCharset := params["charset"]
|
||||||
|
if hasCharset {
|
||||||
|
// If the charset is not utf-8, we might have to convert the bodyStr
|
||||||
|
// before passing it to the HTML/Markdown parser
|
||||||
|
if strings.ToLower(charset) != "utf-8" {
|
||||||
|
logger.WarnCF("tool", "Note: the content is not in UTF-8", map[string]any{"charset": charset})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var text, extractor string
|
var text, extractor string
|
||||||
|
|
||||||
if strings.Contains(contentType, "application/json") {
|
switch {
|
||||||
|
case mediaType == "application/json":
|
||||||
var jsonData any
|
var jsonData any
|
||||||
if err := json.Unmarshal(body, &jsonData); err == nil {
|
if err := json.Unmarshal(body, &jsonData); err != nil {
|
||||||
formatted, _ := json.MarshalIndent(jsonData, "", " ")
|
text = bodyStr
|
||||||
|
extractor = "raw"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted, err := json.MarshalIndent(jsonData, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
text = bodyStr
|
||||||
|
extractor = "raw"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
text = string(formatted)
|
text = string(formatted)
|
||||||
extractor = "json"
|
extractor = "json"
|
||||||
} else {
|
|
||||||
text = string(body)
|
case mediaType == "text/html" || looksLikeHTML(bodyStr):
|
||||||
extractor = "raw"
|
switch strings.ToLower(t.format) {
|
||||||
|
case "markdown":
|
||||||
|
var err error
|
||||||
|
text, err = utils.HtmlToMarkdown(bodyStr)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("failed to HTML to markdown: %v", err))
|
||||||
}
|
}
|
||||||
} else if strings.Contains(contentType, "text/html") || len(body) > 0 &&
|
extractor = "markdown"
|
||||||
(strings.HasPrefix(string(body), "<!DOCTYPE") || strings.HasPrefix(strings.ToLower(string(body)), "<html")) {
|
|
||||||
text = t.extractText(string(body))
|
default:
|
||||||
|
text = t.extractText(bodyStr)
|
||||||
extractor = "text"
|
extractor = "text"
|
||||||
} else {
|
}
|
||||||
text = string(body)
|
|
||||||
|
default:
|
||||||
|
text = bodyStr
|
||||||
extractor = "raw"
|
extractor = "raw"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -957,6 +1030,17 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func looksLikeHTML(body string) bool {
|
||||||
|
if body == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := strings.ToLower(body)
|
||||||
|
|
||||||
|
return strings.HasPrefix(body, "<!doctype") ||
|
||||||
|
strings.HasPrefix(lower, "<html")
|
||||||
|
}
|
||||||
|
|
||||||
func (t *WebFetchTool) extractText(htmlContent string) string {
|
func (t *WebFetchTool) extractText(htmlContent string) string {
|
||||||
result := reScript.ReplaceAllLiteralString(htmlContent, "")
|
result := reScript.ReplaceAllLiteralString(htmlContent, "")
|
||||||
result = reStyle.ReplaceAllLiteralString(result, "")
|
result = reStyle.ReplaceAllLiteralString(result, "")
|
||||||
|
|
@ -981,7 +1065,10 @@ func (t *WebFetchTool) extractText(htmlContent string) string {
|
||||||
|
|
||||||
// newSafeDialContext re-resolves DNS at connect time to mitigate DNS rebinding (TOCTOU)
|
// newSafeDialContext re-resolves DNS at connect time to mitigate DNS rebinding (TOCTOU)
|
||||||
// where a hostname resolves to a public IP during pre-flight but a private IP at connect time.
|
// where a hostname resolves to a public IP during pre-flight but a private IP at connect time.
|
||||||
func newSafeDialContext(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) {
|
func newSafeDialContext(
|
||||||
|
dialer *net.Dialer,
|
||||||
|
whitelist *privateHostWhitelist,
|
||||||
|
) func(context.Context, string, string) (net.Conn, error) {
|
||||||
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
if allowPrivateWebFetchHosts.Load() {
|
if allowPrivateWebFetchHosts.Load() {
|
||||||
return dialer.DialContext(ctx, network, address)
|
return dialer.DialContext(ctx, network, address)
|
||||||
|
|
@ -996,7 +1083,7 @@ func newSafeDialContext(dialer *net.Dialer) func(context.Context, string, string
|
||||||
}
|
}
|
||||||
|
|
||||||
if ip := net.ParseIP(host); ip != nil {
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
if isPrivateOrRestrictedIP(ip) {
|
if shouldBlockPrivateIP(ip, whitelist) {
|
||||||
return nil, fmt.Errorf("blocked private or local target: %s", host)
|
return nil, fmt.Errorf("blocked private or local target: %s", host)
|
||||||
}
|
}
|
||||||
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||||
|
|
@ -1010,7 +1097,7 @@ func newSafeDialContext(dialer *net.Dialer) func(context.Context, string, string
|
||||||
attempted := 0
|
attempted := 0
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for _, ipAddr := range ipAddrs {
|
for _, ipAddr := range ipAddrs {
|
||||||
if isPrivateOrRestrictedIP(ipAddr.IP) {
|
if shouldBlockPrivateIP(ipAddr.IP, whitelist) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
attempted++
|
attempted++
|
||||||
|
|
@ -1022,7 +1109,7 @@ func newSafeDialContext(dialer *net.Dialer) func(context.Context, string, string
|
||||||
}
|
}
|
||||||
|
|
||||||
if attempted == 0 {
|
if attempted == 0 {
|
||||||
return nil, fmt.Errorf("all resolved addresses for %s are private or restricted", host)
|
return nil, fmt.Errorf("all resolved addresses for %s are private, restricted, or not whitelisted", host)
|
||||||
}
|
}
|
||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
return nil, fmt.Errorf("failed connecting to public addresses for %s: %w", host, lastErr)
|
return nil, fmt.Errorf("failed connecting to public addresses for %s: %w", host, lastErr)
|
||||||
|
|
@ -1031,10 +1118,72 @@ func newSafeDialContext(dialer *net.Dialer) func(context.Context, string, string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newPrivateHostWhitelist(entries []string) (*privateHostWhitelist, error) {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
whitelist := &privateHostWhitelist{
|
||||||
|
exact: make(map[string]struct{}),
|
||||||
|
cidrs: make([]*net.IPNet, 0, len(entries)),
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
entry = strings.TrimSpace(entry)
|
||||||
|
if entry == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(entry); ip != nil {
|
||||||
|
whitelist.exact[normalizeWhitelistIP(ip).String()] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, network, err := net.ParseCIDR(entry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid entry %q: expected IP or CIDR", entry)
|
||||||
|
}
|
||||||
|
whitelist.cidrs = append(whitelist.cidrs, network)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(whitelist.exact) == 0 && len(whitelist.cidrs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return whitelist, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *privateHostWhitelist) Contains(ip net.IP) bool {
|
||||||
|
if w == nil || ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := normalizeWhitelistIP(ip)
|
||||||
|
if _, ok := w.exact[normalized.String()]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, network := range w.cidrs {
|
||||||
|
if network.Contains(normalized) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWhitelistIP(ip net.IP) net.IP {
|
||||||
|
if ip == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
return ip4
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldBlockPrivateIP(ip net.IP, whitelist *privateHostWhitelist) bool {
|
||||||
|
return isPrivateOrRestrictedIP(ip) && !whitelist.Contains(ip)
|
||||||
|
}
|
||||||
|
|
||||||
// isObviousPrivateHost performs a lightweight, no-DNS check for obviously private hosts.
|
// isObviousPrivateHost performs a lightweight, no-DNS check for obviously private hosts.
|
||||||
// It catches localhost, literal private IPs, and empty hosts. It does NOT resolve DNS —
|
// It catches localhost, literal private IPs, and empty hosts. It does NOT resolve DNS —
|
||||||
// the real SSRF guard is newSafeDialContext which checks IPs at connect time.
|
// the real SSRF guard is newSafeDialContext which checks IPs at connect time.
|
||||||
func isObviousPrivateHost(host string) bool {
|
func isObviousPrivateHost(host string, whitelist *privateHostWhitelist) bool {
|
||||||
if allowPrivateWebFetchHosts.Load() {
|
if allowPrivateWebFetchHosts.Load() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -1050,7 +1199,7 @@ func isObviousPrivateHost(host string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
if ip := net.ParseIP(h); ip != nil {
|
if ip := net.ParseIP(h); ip != nil {
|
||||||
return isPrivateOrRestrictedIP(ip)
|
return shouldBlockPrivateIP(ip, whitelist)
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,15 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testFetchLimit = int64(10 * 1024 * 1024)
|
const (
|
||||||
|
testFetchLimit = int64(10 * 1024 * 1024)
|
||||||
|
format = "plaintext"
|
||||||
|
)
|
||||||
|
|
||||||
// TestWebTool_WebFetch_Success verifies successful URL fetching
|
// TestWebTool_WebFetch_Success verifies successful URL fetching
|
||||||
func TestWebTool_WebFetch_Success(t *testing.T) {
|
func TestWebTool_WebFetch_Success(t *testing.T) {
|
||||||
|
|
@ -27,7 +31,7 @@ func TestWebTool_WebFetch_Success(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create web fetch tool: %v", err)
|
t.Fatalf("Failed to create web fetch tool: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +73,7 @@ func TestWebTool_WebFetch_JSON(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +98,7 @@ func TestWebTool_WebFetch_JSON(t *testing.T) {
|
||||||
|
|
||||||
// TestWebTool_WebFetch_InvalidURL verifies error handling for invalid URL
|
// TestWebTool_WebFetch_InvalidURL verifies error handling for invalid URL
|
||||||
func TestWebTool_WebFetch_InvalidURL(t *testing.T) {
|
func TestWebTool_WebFetch_InvalidURL(t *testing.T) {
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +123,7 @@ func TestWebTool_WebFetch_InvalidURL(t *testing.T) {
|
||||||
|
|
||||||
// TestWebTool_WebFetch_UnsupportedScheme verifies error handling for non-http URLs
|
// TestWebTool_WebFetch_UnsupportedScheme verifies error handling for non-http URLs
|
||||||
func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) {
|
func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) {
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +148,7 @@ func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) {
|
||||||
|
|
||||||
// TestWebTool_WebFetch_MissingURL verifies error handling for missing URL
|
// TestWebTool_WebFetch_MissingURL verifies error handling for missing URL
|
||||||
func TestWebTool_WebFetch_MissingURL(t *testing.T) {
|
func TestWebTool_WebFetch_MissingURL(t *testing.T) {
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +182,7 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
tool, err := NewWebFetchTool(1000, testFetchLimit) // Limit to 1000 chars
|
tool, err := NewWebFetchTool(1000, format, testFetchLimit) // Limit to 1000 chars
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -228,7 +232,7 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) {
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
// Initialize the tool
|
// Initialize the tool
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +315,7 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -423,8 +427,31 @@ func withPrivateWebFetchHostsAllowed(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serverHostAndPort(t *testing.T, rawURL string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
hostPort := strings.TrimPrefix(rawURL, "http://")
|
||||||
|
hostPort = strings.TrimPrefix(hostPort, "https://")
|
||||||
|
host, port, err := net.SplitHostPort(hostPort)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to split host/port from %q: %v", rawURL, err)
|
||||||
|
}
|
||||||
|
return host, port
|
||||||
|
}
|
||||||
|
|
||||||
|
func singleHostCIDR(t *testing.T, host string) string {
|
||||||
|
t.Helper()
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("failed to parse IP %q", host)
|
||||||
|
}
|
||||||
|
if ip.To4() != nil {
|
||||||
|
return ip.String() + "/32"
|
||||||
|
}
|
||||||
|
return ip.String() + "/128"
|
||||||
|
}
|
||||||
|
|
||||||
func TestWebTool_WebFetch_PrivateHostBlocked(t *testing.T) {
|
func TestWebTool_WebFetch_PrivateHostBlocked(t *testing.T) {
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create web fetch tool: %v", err)
|
t.Fatalf("Failed to create web fetch tool: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -441,6 +468,56 @@ func TestWebTool_WebFetch_PrivateHostBlocked(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWebTool_WebFetch_PrivateHostAllowedByExactWhitelist(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("exact whitelist ok"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
host, _ := serverHostAndPort(t, server.URL)
|
||||||
|
tool, err := NewWebFetchToolWithConfig(50000, "", format, testFetchLimit, []string{host})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create web fetch tool: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"url": server.URL,
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected success for exact whitelisted private IP, got %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "exact whitelist ok") {
|
||||||
|
t.Fatalf("expected fetched content, got %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebTool_WebFetch_PrivateHostAllowedByCIDRWhitelist(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("cidr whitelist ok"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
host, _ := serverHostAndPort(t, server.URL)
|
||||||
|
tool, err := NewWebFetchToolWithConfig(50000, "", format, testFetchLimit, []string{singleHostCIDR(t, host)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create web fetch tool: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"url": server.URL,
|
||||||
|
})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected success for CIDR-whitelisted private IP, got %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "cidr whitelist ok") {
|
||||||
|
t.Fatalf("expected fetched content, got %q", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) {
|
func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) {
|
||||||
withPrivateWebFetchHostsAllowed(t)
|
withPrivateWebFetchHostsAllowed(t)
|
||||||
|
|
||||||
|
|
@ -451,7 +528,7 @@ func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create web fetch tool: %v", err)
|
t.Fatalf("Failed to create web fetch tool: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -466,7 +543,7 @@ func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) {
|
||||||
|
|
||||||
// TestWebFetch_BlocksIPv4MappedIPv6Loopback verifies ::ffff:127.0.0.1 is blocked
|
// TestWebFetch_BlocksIPv4MappedIPv6Loopback verifies ::ffff:127.0.0.1 is blocked
|
||||||
func TestWebFetch_BlocksIPv4MappedIPv6Loopback(t *testing.T) {
|
func TestWebFetch_BlocksIPv4MappedIPv6Loopback(t *testing.T) {
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create web fetch tool: %v", err)
|
t.Fatalf("Failed to create web fetch tool: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -481,7 +558,7 @@ func TestWebFetch_BlocksIPv4MappedIPv6Loopback(t *testing.T) {
|
||||||
|
|
||||||
// TestWebFetch_BlocksMetadataIP verifies 169.254.169.254 is blocked
|
// TestWebFetch_BlocksMetadataIP verifies 169.254.169.254 is blocked
|
||||||
func TestWebFetch_BlocksMetadataIP(t *testing.T) {
|
func TestWebFetch_BlocksMetadataIP(t *testing.T) {
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create web fetch tool: %v", err)
|
t.Fatalf("Failed to create web fetch tool: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -496,7 +573,7 @@ func TestWebFetch_BlocksMetadataIP(t *testing.T) {
|
||||||
|
|
||||||
// TestWebFetch_BlocksIPv6UniqueLocal verifies fc00::/7 addresses are blocked
|
// TestWebFetch_BlocksIPv6UniqueLocal verifies fc00::/7 addresses are blocked
|
||||||
func TestWebFetch_BlocksIPv6UniqueLocal(t *testing.T) {
|
func TestWebFetch_BlocksIPv6UniqueLocal(t *testing.T) {
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create web fetch tool: %v", err)
|
t.Fatalf("Failed to create web fetch tool: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -511,7 +588,7 @@ func TestWebFetch_BlocksIPv6UniqueLocal(t *testing.T) {
|
||||||
|
|
||||||
// TestWebFetch_Blocks6to4WithPrivateEmbed verifies 6to4 with private embedded IPv4 is blocked
|
// TestWebFetch_Blocks6to4WithPrivateEmbed verifies 6to4 with private embedded IPv4 is blocked
|
||||||
func TestWebFetch_Blocks6to4WithPrivateEmbed(t *testing.T) {
|
func TestWebFetch_Blocks6to4WithPrivateEmbed(t *testing.T) {
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create web fetch tool: %v", err)
|
t.Fatalf("Failed to create web fetch tool: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -527,7 +604,7 @@ func TestWebFetch_Blocks6to4WithPrivateEmbed(t *testing.T) {
|
||||||
|
|
||||||
// TestWebFetch_Allows6to4WithPublicEmbed verifies 6to4 with public embedded IPv4 is NOT blocked
|
// TestWebFetch_Allows6to4WithPublicEmbed verifies 6to4 with public embedded IPv4 is NOT blocked
|
||||||
func TestWebFetch_Allows6to4WithPublicEmbed(t *testing.T) {
|
func TestWebFetch_Allows6to4WithPublicEmbed(t *testing.T) {
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create web fetch tool: %v", err)
|
t.Fatalf("Failed to create web fetch tool: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -557,7 +634,7 @@ func TestWebFetch_RedirectToPrivateBlocked(t *testing.T) {
|
||||||
allowPrivateWebFetchHosts.Store(false)
|
allowPrivateWebFetchHosts.Store(false)
|
||||||
defer allowPrivateWebFetchHosts.Store(true)
|
defer allowPrivateWebFetchHosts.Store(true)
|
||||||
|
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create web fetch tool: %v", err)
|
t.Fatalf("Failed to create web fetch tool: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -570,6 +647,69 @@ func TestWebFetch_RedirectToPrivateBlocked(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewSafeDialContext_BlocksPrivateDNSResolutionWithoutWhitelist(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to listen on loopback: %v", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
_, port, err := net.SplitHostPort(listener.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to split listener address: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialContext := newSafeDialContext(&net.Dialer{Timeout: time.Second}, nil)
|
||||||
|
_, err = dialContext(context.Background(), "tcp", net.JoinHostPort("localhost", port))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected localhost DNS resolution to be blocked without whitelist")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "private") && !strings.Contains(err.Error(), "whitelisted") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSafeDialContext_AllowsWhitelistedPrivateDNSResolution(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to listen on loopback: %v", err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
accepted := make(chan struct{}, 1)
|
||||||
|
go func() {
|
||||||
|
conn, acceptErr := listener.Accept()
|
||||||
|
if acceptErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
accepted <- struct{}{}
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, port, err := net.SplitHostPort(listener.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to split listener address: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
whitelist, err := newPrivateHostWhitelist([]string{"127.0.0.0/8"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse whitelist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialContext := newSafeDialContext(&net.Dialer{Timeout: time.Second}, whitelist)
|
||||||
|
conn, err := dialContext(context.Background(), "tcp", net.JoinHostPort("localhost", port))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected localhost DNS resolution to succeed with whitelist, got %v", err)
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-accepted:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("expected localhost listener to accept a connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestIsPrivateOrRestrictedIP_Table tests IP classification logic
|
// TestIsPrivateOrRestrictedIP_Table tests IP classification logic
|
||||||
func TestIsPrivateOrRestrictedIP_Table(t *testing.T) {
|
func TestIsPrivateOrRestrictedIP_Table(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|
@ -615,7 +755,7 @@ func TestIsPrivateOrRestrictedIP_Table(t *testing.T) {
|
||||||
|
|
||||||
// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain
|
// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain
|
||||||
func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
|
func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
|
||||||
tool, err := NewWebFetchTool(50000, testFetchLimit)
|
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -639,7 +779,7 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewWebFetchToolWithProxy(t *testing.T) {
|
func TestNewWebFetchToolWithProxy(t *testing.T) {
|
||||||
tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", testFetchLimit)
|
tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", format, testFetchLimit, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
} else if tool.maxChars != 1024 {
|
} else if tool.maxChars != 1024 {
|
||||||
|
|
@ -650,7 +790,7 @@ func TestNewWebFetchToolWithProxy(t *testing.T) {
|
||||||
t.Fatalf("proxy = %q, want %q", tool.proxy, "http://127.0.0.1:7890")
|
t.Fatalf("proxy = %q, want %q", tool.proxy, "http://127.0.0.1:7890")
|
||||||
}
|
}
|
||||||
|
|
||||||
tool, err = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890", testFetchLimit)
|
tool, err = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890", format, testFetchLimit, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -660,6 +800,16 @@ func TestNewWebFetchToolWithProxy(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewWebFetchToolWithConfig_InvalidPrivateHostWhitelist(t *testing.T) {
|
||||||
|
_, err := NewWebFetchToolWithConfig(1024, "", format, testFetchLimit, []string{"not-an-ip-or-cidr"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid whitelist entry to fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid entry") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
|
func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
|
||||||
t.Run("perplexity", func(t *testing.T) {
|
t.Run("perplexity", func(t *testing.T) {
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
|
|
|
||||||
411
pkg/utils/markdown.go
Normal file
411
pkg/utils/markdown.go
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
reSpaces = regexp.MustCompile(`[ \t]+`)
|
||||||
|
reNewlines = regexp.MustCompile(`\n{3,}`)
|
||||||
|
reEmptyListItem = regexp.MustCompile(`(?m)^[-*]\s*$`)
|
||||||
|
reImageOnlyLink = regexp.MustCompile(`\[!\[\]\(<[^>]*>\)\]\(<[^>]*>\)`)
|
||||||
|
reEmptyHeader = regexp.MustCompile(`(?m)^#{1,6}\s*$`)
|
||||||
|
reLeadingLineSpace = regexp.MustCompile(`(?m)^([ \t])([^ \t\n])`)
|
||||||
|
)
|
||||||
|
|
||||||
|
var skipTags = map[string]bool{
|
||||||
|
"script": true, "style": true, "head": true,
|
||||||
|
"noscript": true, "template": true,
|
||||||
|
"nav": true, "footer": true, "aside": true, "header": true, "form": true, "dialog": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSafeHref(href string) bool {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(href))
|
||||||
|
if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "vbscript:") ||
|
||||||
|
strings.HasPrefix(lower, "data:") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
u, err := url.Parse(strings.TrimSpace(href))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
scheme := strings.ToLower(u.Scheme)
|
||||||
|
return scheme == "" || scheme == "http" || scheme == "https" || scheme == "mailto"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSafeImageSrc(src string) bool {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(src))
|
||||||
|
if strings.HasPrefix(lower, "data:image/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isSafeHref(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeMdAlt(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||||
|
s = strings.ReplaceAll(s, `[`, `\[`)
|
||||||
|
s = strings.ReplaceAll(s, `]`, `\]`)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAttr(n *html.Node, key string) string {
|
||||||
|
for _, a := range n.Attr {
|
||||||
|
if a.Key == key {
|
||||||
|
return a.Val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAttr(val string) string {
|
||||||
|
val = strings.ReplaceAll(val, "\n", "")
|
||||||
|
val = strings.ReplaceAll(val, "\r", "")
|
||||||
|
val = strings.ReplaceAll(val, "\t", "")
|
||||||
|
return strings.TrimSpace(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUnlikelyNode(n *html.Node) bool {
|
||||||
|
if n.Type != html.ElementNode {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
classId := strings.ToLower(getAttr(n, "class") + " " + getAttr(n, "id"))
|
||||||
|
if classId == " " {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(classId, "article") || strings.Contains(classId, "main") ||
|
||||||
|
strings.Contains(classId, "content") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
unlikelyKeywords := []string{
|
||||||
|
"menu",
|
||||||
|
"nav",
|
||||||
|
"footer",
|
||||||
|
"sidebar",
|
||||||
|
"cookie",
|
||||||
|
"banner",
|
||||||
|
"sponsor",
|
||||||
|
"advert",
|
||||||
|
"popup",
|
||||||
|
"modal",
|
||||||
|
"newsletter",
|
||||||
|
"share",
|
||||||
|
"social",
|
||||||
|
}
|
||||||
|
for _, keyword := range unlikelyKeywords {
|
||||||
|
if strings.Contains(classId, keyword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type converter struct {
|
||||||
|
stack []*bytes.Buffer
|
||||||
|
linkHrefs []string
|
||||||
|
linkStates []bool
|
||||||
|
emphStack []string // Tracks "**", "*", "~~" for buffered emphasis
|
||||||
|
olCounters []int
|
||||||
|
inPre bool
|
||||||
|
listDepth int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConverter() *converter {
|
||||||
|
return &converter{
|
||||||
|
stack: []*bytes.Buffer{{}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *converter) write(s string) {
|
||||||
|
c.stack[len(c.stack)-1].WriteString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *converter) pushBuf() {
|
||||||
|
c.stack = append(c.stack, &bytes.Buffer{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *converter) popBuf() string {
|
||||||
|
top := c.stack[len(c.stack)-1]
|
||||||
|
c.stack = c.stack[:len(c.stack)-1]
|
||||||
|
return top.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *converter) walk(n *html.Node) {
|
||||||
|
if n.Type == html.ElementNode {
|
||||||
|
if skipTags[n.Data] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isUnlikelyNode(n) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.Type == html.TextNode {
|
||||||
|
text := n.Data
|
||||||
|
if !c.inPre {
|
||||||
|
text = strings.ReplaceAll(text, "\n", " ")
|
||||||
|
text = reSpaces.ReplaceAllString(text, " ")
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
|
c.write(text)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.Type != html.ElementNode {
|
||||||
|
for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
|
||||||
|
c.walk(ch)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening Tags
|
||||||
|
switch n.Data {
|
||||||
|
// Buffer emphasis content so we can TrimSpace the inner text,
|
||||||
|
// avoiding the regex-across-boundaries bug.
|
||||||
|
case "b", "strong":
|
||||||
|
c.emphStack = append(c.emphStack, "**")
|
||||||
|
c.pushBuf()
|
||||||
|
case "i", "em":
|
||||||
|
c.emphStack = append(c.emphStack, "*")
|
||||||
|
c.pushBuf()
|
||||||
|
case "del", "s":
|
||||||
|
c.emphStack = append(c.emphStack, "~~")
|
||||||
|
c.pushBuf()
|
||||||
|
|
||||||
|
case "a":
|
||||||
|
href := normalizeAttr(getAttr(n, "href"))
|
||||||
|
if href != "" && !isSafeHref(href) {
|
||||||
|
href = "#"
|
||||||
|
}
|
||||||
|
hasHref := href != ""
|
||||||
|
c.linkStates = append(c.linkStates, hasHref)
|
||||||
|
if hasHref {
|
||||||
|
c.linkHrefs = append(c.linkHrefs, href)
|
||||||
|
c.pushBuf()
|
||||||
|
}
|
||||||
|
|
||||||
|
case "h1":
|
||||||
|
c.write("\n\n# ")
|
||||||
|
case "h2":
|
||||||
|
c.write("\n\n## ")
|
||||||
|
case "h3":
|
||||||
|
c.write("\n\n### ")
|
||||||
|
case "h4":
|
||||||
|
c.write("\n\n#### ")
|
||||||
|
case "h5":
|
||||||
|
c.write("\n\n##### ")
|
||||||
|
case "h6":
|
||||||
|
c.write("\n\n###### ")
|
||||||
|
|
||||||
|
case "p":
|
||||||
|
c.write("\n\n")
|
||||||
|
case "br":
|
||||||
|
c.write("\n")
|
||||||
|
case "hr":
|
||||||
|
c.write("\n\n---\n\n")
|
||||||
|
|
||||||
|
case "ol":
|
||||||
|
c.olCounters = append(c.olCounters, 1)
|
||||||
|
// Only write leading newline for top-level list.
|
||||||
|
if c.listDepth == 0 {
|
||||||
|
c.write("\n")
|
||||||
|
}
|
||||||
|
c.listDepth++
|
||||||
|
case "ul":
|
||||||
|
if c.listDepth == 0 {
|
||||||
|
c.write("\n")
|
||||||
|
}
|
||||||
|
c.listDepth++
|
||||||
|
case "li":
|
||||||
|
c.write("\n")
|
||||||
|
if c.listDepth > 1 {
|
||||||
|
c.write(strings.Repeat(" ", c.listDepth-1))
|
||||||
|
}
|
||||||
|
if n.Parent != nil && n.Parent.Data == "ol" && len(c.olCounters) > 0 {
|
||||||
|
idx := c.olCounters[len(c.olCounters)-1]
|
||||||
|
c.write(strconv.Itoa(idx) + ". ")
|
||||||
|
c.olCounters[len(c.olCounters)-1]++
|
||||||
|
} else {
|
||||||
|
c.write("- ")
|
||||||
|
}
|
||||||
|
|
||||||
|
case "pre":
|
||||||
|
c.inPre = true
|
||||||
|
c.write("\n\n```\n")
|
||||||
|
case "code":
|
||||||
|
if !c.inPre {
|
||||||
|
c.write("`")
|
||||||
|
}
|
||||||
|
|
||||||
|
case "blockquote":
|
||||||
|
c.pushBuf()
|
||||||
|
for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
|
||||||
|
c.walk(ch)
|
||||||
|
}
|
||||||
|
inner := strings.TrimSpace(c.popBuf())
|
||||||
|
lines := strings.Split(inner, "\n")
|
||||||
|
var quoted []string
|
||||||
|
for _, l := range lines {
|
||||||
|
if strings.TrimSpace(l) == "" {
|
||||||
|
quoted = append(quoted, ">")
|
||||||
|
} else {
|
||||||
|
quoted = append(quoted, "> "+l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var deduped []string
|
||||||
|
for i, line := range quoted {
|
||||||
|
if line == ">" && i > 0 && deduped[len(deduped)-1] == ">" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deduped = append(deduped, line)
|
||||||
|
}
|
||||||
|
c.write("\n\n" + strings.Join(deduped, "\n") + "\n\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
case "img":
|
||||||
|
src := normalizeAttr(getAttr(n, "src"))
|
||||||
|
if src == "" {
|
||||||
|
src = normalizeAttr(getAttr(n, "data-src"))
|
||||||
|
}
|
||||||
|
if src == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
alt := escapeMdAlt(normalizeAttr(getAttr(n, "alt")))
|
||||||
|
if isSafeImageSrc(src) {
|
||||||
|
c.write("")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traverse Children
|
||||||
|
for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
|
||||||
|
c.walk(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing Tags
|
||||||
|
switch n.Data {
|
||||||
|
// Pop buffer, trim, wrap with the correct marker.
|
||||||
|
case "b", "strong", "i", "em", "del", "s":
|
||||||
|
if len(c.emphStack) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
marker := c.emphStack[len(c.emphStack)-1]
|
||||||
|
c.emphStack = c.emphStack[:len(c.emphStack)-1]
|
||||||
|
inner := strings.TrimSpace(c.popBuf())
|
||||||
|
if inner != "" {
|
||||||
|
c.write(marker + inner + marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "a":
|
||||||
|
if len(c.linkStates) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
hasHref := c.linkStates[len(c.linkStates)-1]
|
||||||
|
c.linkStates = c.linkStates[:len(c.linkStates)-1]
|
||||||
|
if !hasHref {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
href := c.linkHrefs[len(c.linkHrefs)-1]
|
||||||
|
c.linkHrefs = c.linkHrefs[:len(c.linkHrefs)-1]
|
||||||
|
inner := strings.TrimSpace(c.popBuf())
|
||||||
|
if strings.Contains(inner, "\n") {
|
||||||
|
lines := strings.Split(inner, "\n")
|
||||||
|
linked := false
|
||||||
|
for i, l := range lines {
|
||||||
|
cleanLine := strings.TrimSpace(l)
|
||||||
|
if cleanLine != "" && !strings.HasPrefix(cleanLine, "![") && !linked {
|
||||||
|
lines[i] = "[" + cleanLine + "](" + href + ")"
|
||||||
|
linked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.write(strings.Join(lines, "\n"))
|
||||||
|
} else {
|
||||||
|
c.write("[" + inner + "](" + href + ")")
|
||||||
|
}
|
||||||
|
|
||||||
|
case "h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
"p",
|
||||||
|
"div",
|
||||||
|
"section",
|
||||||
|
"article",
|
||||||
|
"header",
|
||||||
|
"footer",
|
||||||
|
"aside",
|
||||||
|
"nav",
|
||||||
|
"figure":
|
||||||
|
c.write("\n")
|
||||||
|
|
||||||
|
case "ol":
|
||||||
|
c.listDepth--
|
||||||
|
if len(c.olCounters) > 0 {
|
||||||
|
c.olCounters = c.olCounters[:len(c.olCounters)-1]
|
||||||
|
}
|
||||||
|
if c.listDepth == 0 {
|
||||||
|
c.write("\n")
|
||||||
|
}
|
||||||
|
case "ul":
|
||||||
|
c.listDepth--
|
||||||
|
if c.listDepth == 0 {
|
||||||
|
c.write("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
case "pre":
|
||||||
|
c.inPre = false
|
||||||
|
c.write("\n```\n\n")
|
||||||
|
case "code":
|
||||||
|
if !c.inPre {
|
||||||
|
c.write("`")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HtmlToMarkdown(htmlStr string) (string, error) {
|
||||||
|
doc, err := html.Parse(strings.NewReader(htmlStr))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
c := newConverter()
|
||||||
|
c.walk(doc)
|
||||||
|
|
||||||
|
res := c.stack[0].String()
|
||||||
|
|
||||||
|
// Post-processing
|
||||||
|
res = reImageOnlyLink.ReplaceAllString(res, "")
|
||||||
|
res = reEmptyListItem.ReplaceAllString(res, "")
|
||||||
|
res = reEmptyHeader.ReplaceAllString(res, "")
|
||||||
|
|
||||||
|
lines := strings.Split(res, "\n")
|
||||||
|
var cleanLines []string
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimRight(line, " \t")
|
||||||
|
cleanTest := strings.TrimSpace(line)
|
||||||
|
if cleanTest == "[](</>)" || cleanTest == "[](#)" || cleanTest == "-" {
|
||||||
|
cleanLines = append(cleanLines, "")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleanLines = append(cleanLines, line)
|
||||||
|
}
|
||||||
|
res = strings.Join(cleanLines, "\n")
|
||||||
|
|
||||||
|
res = strings.TrimSpace(res)
|
||||||
|
res = reNewlines.ReplaceAllString(res, "\n\n")
|
||||||
|
|
||||||
|
// Strip a single leading space from lines that are NOT list indentation.
|
||||||
|
// "(?m)^([ \t])([^ \t\n])" matches exactly one space/tab at line start followed
|
||||||
|
// by a non-whitespace char, so " - nested" (4 spaces) is left untouched.
|
||||||
|
res = reLeadingLineSpace.ReplaceAllString(res, "$2")
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
245
pkg/utils/markdown_test.go
Normal file
245
pkg/utils/markdown_test.go
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHtmlToMarkdown(t *testing.T) {
|
||||||
|
// Define our test cases
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Removes scripts and styles",
|
||||||
|
input: `<script>alert("hello");</script><style>body { color: red; }</style><p>Clean text</p>`,
|
||||||
|
expected: "Clean text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Extracts links correctly",
|
||||||
|
input: `Visit my <a href="https://example.com">website</a> for info.`,
|
||||||
|
expected: "Visit my [website](https://example.com) for info.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Converts headers (H1, H2, H3)",
|
||||||
|
input: `<h1>Main Title</h1><h2>Subtitle</h2><h3>Section</h3>`,
|
||||||
|
expected: "# Main Title\n\n## Subtitle\n\n### Section",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Handles bold and italics",
|
||||||
|
input: `Text <b>bold</b> and <strong>strong</strong>, then <i>italic</i> and <em>em</em>.`,
|
||||||
|
expected: "Text **bold** and **strong**, then *italic* and *em*.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Converts lists",
|
||||||
|
input: `<ul><li>First element</li><li>Second element</li></ul>`,
|
||||||
|
expected: "- First element\n- Second element",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Handles paragraphs and line breaks (<br>)",
|
||||||
|
input: `<p>First paragraph</p><p>Second paragraph with<br>a line break.</p>`,
|
||||||
|
expected: "First paragraph\n\nSecond paragraph with\na line break.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Decodes HTML entities",
|
||||||
|
input: `Math: 5 > 3 & 2 < 4. A "quote".`,
|
||||||
|
expected: "Math: 5 > 3 & 2 < 4. A \"quote\".",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cleans up residual HTML tags",
|
||||||
|
input: `<div><span>Text inside div and span</span></div>`,
|
||||||
|
expected: "Text inside div and span",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Removes multiple spaces and excessive empty lines",
|
||||||
|
input: `This text has too many spaces. <br><br><br><br> And too many newlines.`,
|
||||||
|
expected: "This text has too many spaces.\n\nAnd too many newlines.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Nested lists with indentation",
|
||||||
|
input: "<ul><li>One<ul><li>Two</li></ul></li></ul>",
|
||||||
|
// Expect the sub-element to have 4 spaces of indentation
|
||||||
|
expected: "- One\n - Two",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Image support",
|
||||||
|
input: `<img src="image.jpg" alt="alternative text">`,
|
||||||
|
// Correct Markdown syntax for images
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Image support without alt-text",
|
||||||
|
input: `<img src="image.jpg">`,
|
||||||
|
// If alt is missing, square brackets remain empty
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "XSS Bypass on Links (Obfuscated HTML entities)",
|
||||||
|
// The Go HTML parser resolves entities, so this becomes "javascript:alert(1)"
|
||||||
|
input: `<a href="jav	ascript:alert(1)">Click here</a>`,
|
||||||
|
// Our isSafeHref (if updated with net/url) should neutralize it to "#"
|
||||||
|
expected: "[Click here](#)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty link or used as anchor",
|
||||||
|
input: `<a name="top"></a>`,
|
||||||
|
// With no text or href, it shouldn't print anything (not even empty brackets)
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Link without href but with text (Textual anchor)",
|
||||||
|
input: `<a id="top">Back to top</a>`,
|
||||||
|
// Should extract only plain text, without generating a broken Markdown link like [Back to top](#) or [Back to top]()
|
||||||
|
expected: "Back to top",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Badly spaced bold and italics (Edge Case)",
|
||||||
|
input: `<b> Text </b>`,
|
||||||
|
// In Markdown `** Text **` is often not formatted correctly. The ideal is `**Text**`
|
||||||
|
expected: "**Text**",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Complex Test - Real Article",
|
||||||
|
input: `
|
||||||
|
<h1>Article Title</h1>
|
||||||
|
<p>This is an <strong>introductory text</strong> with a <a href="http://link.com">link</a>.</p>
|
||||||
|
<h2>Subtitle</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Point one</li>
|
||||||
|
<li>Point two</li>
|
||||||
|
</ul>
|
||||||
|
<script>console.log("do not show me")</script>
|
||||||
|
`,
|
||||||
|
// Note: The indentation of the real HTML test will generate spaces that
|
||||||
|
// regex will clean up.
|
||||||
|
expected: "# Article Title\n\nThis is an **introductory text** with a [link](http://link.com).\n\n## Subtitle\n\n- Point one\n- Point two",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ordered list (OL)",
|
||||||
|
input: `<ol><li>First</li><li>Second</li><li>Third</li></ol>`,
|
||||||
|
expected: "1. First\n2. Second\n3. Third",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ordered list nested in unordered list",
|
||||||
|
input: `<ul><li>Fruits<ol><li>Apples</li><li>Pears</li></ol></li><li>Vegetables</li></ul>`,
|
||||||
|
expected: "- Fruits\n 1. Apples\n 2. Pears\n- Vegetables",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Code block (pre/code)",
|
||||||
|
input: "<pre><code>func main() {\n fmt.Println(\"hello\")\n}</code></pre>",
|
||||||
|
expected: "```\nfunc main() {\n fmt.Println(\"hello\")\n}\n```",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Inline code",
|
||||||
|
input: `<p>Use the command <code>go test ./...</code> to run the tests.</p>`,
|
||||||
|
expected: "Use the command `go test ./...` to run the tests.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Simple blockquote",
|
||||||
|
input: `<blockquote><p>An important quote.</p></blockquote>`,
|
||||||
|
expected: "> An important quote.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiline blockquote",
|
||||||
|
input: `<blockquote><p>First line of the quote.</p><p>Second line of the quote.</p></blockquote>`,
|
||||||
|
expected: "> First line of the quote.\n>\n> Second line of the quote.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Strikethrough text (del/s)",
|
||||||
|
input: `This text is <del>deleted</del> and this is <s>crossed out</s>.`,
|
||||||
|
expected: "This text is ~~deleted~~ and this is ~~crossed out~~.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Horizontal separator (HR)",
|
||||||
|
input: `<p>Above the line</p><hr><p>Below the line</p>`,
|
||||||
|
expected: "Above the line\n\n---\n\nBelow the line",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bold nested in link",
|
||||||
|
input: `<a href="https://example.com"><strong>Linked bold text</strong></a>`,
|
||||||
|
expected: "[**Linked bold text**](https://example.com)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data-src Image (lazy loading)",
|
||||||
|
input: `<img data-src="lazy.jpg" alt="Lazy image">`,
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Image with javascript: src blocked",
|
||||||
|
input: `<img src="javascript:alert(1)" alt="XSS">`,
|
||||||
|
// src is not safe, so the image is not emitted
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Link with data: href blocked",
|
||||||
|
input: `<a href="data:text/html,<script>alert(1)</script>">Click</a>`,
|
||||||
|
expected: "[Click](#)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Deeply nested divs",
|
||||||
|
input: `<div><div><div><div><p>Deeply nested text</p></div></div></div></div>`,
|
||||||
|
expected: "Deeply nested text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Non-consecutive headers (H1, H3, H5)",
|
||||||
|
input: `<h1>Title</h1><h3>Subsection</h3><h5>Sub-subsection</h5>`,
|
||||||
|
expected: "# Title\n\n### Subsection\n\n##### Sub-subsection",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Paragraph with mixed multiple emphasis",
|
||||||
|
input: `<p><strong>Important:</strong> read the <strong><em>critical instructions</em></strong> <em>carefully</em>.</p>`,
|
||||||
|
expected: "**Important:** read the ***critical instructions*** *carefully*.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Article with nav and aside sections (noise to filter)",
|
||||||
|
input: `
|
||||||
|
<nav><a href="/home">Home</a><a href="/about-us">About us</a></nav>
|
||||||
|
<article>
|
||||||
|
<h2>Article title</h2>
|
||||||
|
<p>This is the body of the article.</p>
|
||||||
|
</article>
|
||||||
|
<aside><p>Advertisement</p></aside>
|
||||||
|
`,
|
||||||
|
expected: "## Article title\n\nThis is the body of the article.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Text with mixed special HTML entities",
|
||||||
|
input: `Copyright © 2024 — All rights reserved ®`,
|
||||||
|
expected: "Copyright © 2024 — All rights reserved ®",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mailto link",
|
||||||
|
input: `Write to us at <a href="mailto:info@example.com">info@example.com</a>`,
|
||||||
|
expected: "Write to us at [info@example.com](mailto:info@example.com)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Image inside a link (clickable figure)",
|
||||||
|
input: `<a href="https://example.com"><img src="photo.jpg" alt="Photo"></a>`,
|
||||||
|
// The image-link without text must not generate broken markup
|
||||||
|
expected: "[](https://example.com)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty content or only whitespace",
|
||||||
|
input: ` <p> </p> <div> </div> `,
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate over all test cases
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := HtmlToMarkdown(tt.input)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("tool", "Failed to parse html to markdown: %s", map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("\nTest case failed: %s\nInput: %q\nGot: %q\nExpected: %q",
|
||||||
|
tt.name, tt.input, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IsAudioFile checks if a file is an audio file based on its filename extension and content type.
|
// IsAudioFile checks if a file is an audio file based on its filename extension and content type.
|
||||||
|
|
@ -67,7 +68,7 @@ func DownloadFile(urlStr, filename string, opts DownloadOptions) string {
|
||||||
opts.LoggerPrefix = "utils"
|
opts.LoggerPrefix = "utils"
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
|
mediaDir := media.TempDir()
|
||||||
if err := os.MkdirAll(mediaDir, 0o700); err != nil {
|
if err := os.MkdirAll(mediaDir, 0o700); err != nil {
|
||||||
logger.ErrorCF(opts.LoggerPrefix, "Failed to create media directory", map[string]any{
|
logger.ErrorCF(opts.LoggerPrefix, "Failed to create media directory", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
|
||||||
63
web/Makefile
63
web/Makefile
|
|
@ -1,5 +1,60 @@
|
||||||
.PHONY: dev dev-frontend dev-backend build test lint clean
|
.PHONY: dev dev-frontend dev-backend build test lint clean
|
||||||
|
|
||||||
|
# Go variables
|
||||||
|
GO?=CGO_ENABLED=0 go
|
||||||
|
WEB_GO?=$(GO)
|
||||||
|
GOFLAGS?=-v -tags stdjson
|
||||||
|
|
||||||
|
# Version
|
||||||
|
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
|
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
|
||||||
|
BUILD_TIME=$(shell date +%FT%T%z)
|
||||||
|
GO_VERSION=$(shell $(WEB_GO) version | awk '{print $$3}')
|
||||||
|
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
|
||||||
|
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
|
||||||
|
|
||||||
|
|
||||||
|
# OS detection
|
||||||
|
UNAME_S:=$(shell uname -s)
|
||||||
|
UNAME_M:=$(shell uname -m)
|
||||||
|
|
||||||
|
# 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),armv81)
|
||||||
|
ARCH=arm64
|
||||||
|
else ifeq ($(UNAME_M),loongarch64)
|
||||||
|
ARCH=loong64
|
||||||
|
else ifeq ($(UNAME_M),riscv64)
|
||||||
|
ARCH=riscv64
|
||||||
|
else ifeq ($(UNAME_M),mipsel)
|
||||||
|
ARCH=mipsle
|
||||||
|
else
|
||||||
|
ARCH=$(UNAME_M)
|
||||||
|
endif
|
||||||
|
else ifeq ($(UNAME_S),Darwin)
|
||||||
|
PLATFORM=darwin
|
||||||
|
WEB_GO=CGO_ENABLED=1 go
|
||||||
|
ifeq ($(UNAME_M),x86_64)
|
||||||
|
ARCH=amd64
|
||||||
|
else ifeq ($(UNAME_M),arm64)
|
||||||
|
ARCH=arm64
|
||||||
|
else
|
||||||
|
ARCH=$(UNAME_M)
|
||||||
|
endif
|
||||||
|
else ifeq ($(UNAME_S),Windows)
|
||||||
|
PLATFORM=windows
|
||||||
|
ARCH=$(UNAME_M)
|
||||||
|
LDFLAGS=-H=windowsgui $(LDFLAGS)
|
||||||
|
else
|
||||||
|
PLATFORM=$(UNAME_S)
|
||||||
|
ARCH=$(UNAME_M)
|
||||||
|
endif
|
||||||
|
|
||||||
# Run both frontend and backend dev servers
|
# Run both frontend and backend dev servers
|
||||||
dev:
|
dev:
|
||||||
@if [ ! -f backend/picoclaw-web ] || [ ! -d backend/dist ]; then \
|
@if [ ! -f backend/picoclaw-web ] || [ ! -d backend/dist ]; then \
|
||||||
|
|
@ -15,21 +70,21 @@ dev-frontend:
|
||||||
|
|
||||||
# Start backend dev server
|
# Start backend dev server
|
||||||
dev-backend:
|
dev-backend:
|
||||||
cd backend && go run .
|
cd backend && ${WEB_GO} run -ldflags "$(LDFLAGS)" .
|
||||||
|
|
||||||
# Build frontend and embed into Go binary
|
# Build frontend and embed into Go binary
|
||||||
build:
|
build:
|
||||||
cd frontend && pnpm build:backend
|
cd frontend && pnpm build:backend
|
||||||
cd backend && go build -o picoclaw-web .
|
cd backend && ${WEB_GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o picoclaw-web .
|
||||||
|
|
||||||
# Run all tests
|
# Run all tests
|
||||||
test:
|
test:
|
||||||
cd backend && go test ./...
|
cd backend && ${WEB_GO} test ./...
|
||||||
cd frontend && pnpm lint
|
cd frontend && pnpm lint
|
||||||
|
|
||||||
# Lint and format
|
# Lint and format
|
||||||
lint:
|
lint:
|
||||||
cd backend && go vet ./...
|
cd backend && ${WEB_GO} vet ./...
|
||||||
cd frontend && pnpm check
|
cd frontend && pnpm check
|
||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
@ -188,6 +189,27 @@ func validateConfig(cfg *config.Config) []string {
|
||||||
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
|
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.Tools.Exec.Enabled {
|
||||||
|
if cfg.Tools.Exec.EnableDenyPatterns {
|
||||||
|
errs = append(
|
||||||
|
errs,
|
||||||
|
validateRegexPatterns("tools.exec.custom_deny_patterns", cfg.Tools.Exec.CustomDenyPatterns)...)
|
||||||
|
}
|
||||||
|
errs = append(
|
||||||
|
errs,
|
||||||
|
validateRegexPatterns("tools.exec.custom_allow_patterns", cfg.Tools.Exec.CustomAllowPatterns)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRegexPatterns(field string, patterns []string) []string {
|
||||||
|
var errs []string
|
||||||
|
for index, pattern := range patterns {
|
||||||
|
if _, err := regexp.Compile(pattern); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("%s[%d] is not a valid regular expression: %v", field, index, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,3 +86,82 @@ func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) {
|
||||||
t.Fatalf("model_list[0].api_base = %q, want empty string", got)
|
t.Fatalf("model_list[0].api_base = %q, want empty string", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_RejectsInvalidExecRegexPatterns(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"tools": {
|
||||||
|
"exec": {
|
||||||
|
"custom_deny_patterns": ["("]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte("custom_deny_patterns")) {
|
||||||
|
t.Fatalf("expected validation error mentioning custom_deny_patterns, body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"tools": {
|
||||||
|
"exec": {
|
||||||
|
"enabled": false,
|
||||||
|
"custom_deny_patterns": ["("],
|
||||||
|
"custom_allow_patterns": ["("]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"tools": {
|
||||||
|
"exec": {
|
||||||
|
"enabled": true,
|
||||||
|
"enable_deny_patterns": false,
|
||||||
|
"custom_deny_patterns": ["("]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GatewayEvent represents a state change event for the gateway process.
|
|
||||||
type GatewayEvent struct {
|
|
||||||
Status string `json:"gateway_status"` // "running", "starting", "restarting", "stopped", "error"
|
|
||||||
PID int `json:"pid,omitempty"`
|
|
||||||
BootDefaultModel string `json:"boot_default_model,omitempty"`
|
|
||||||
ConfigDefaultModel string `json:"config_default_model,omitempty"`
|
|
||||||
RestartRequired bool `json:"gateway_restart_required,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// EventBroadcaster manages SSE client subscriptions and broadcasts events.
|
|
||||||
type EventBroadcaster struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
clients map[chan string]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewEventBroadcaster creates a new broadcaster.
|
|
||||||
func NewEventBroadcaster() *EventBroadcaster {
|
|
||||||
return &EventBroadcaster{
|
|
||||||
clients: make(map[chan string]struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe adds a new listener channel and returns it.
|
|
||||||
// The caller must call Unsubscribe when done.
|
|
||||||
func (b *EventBroadcaster) Subscribe() chan string {
|
|
||||||
ch := make(chan string, 8)
|
|
||||||
b.mu.Lock()
|
|
||||||
b.clients[ch] = struct{}{}
|
|
||||||
b.mu.Unlock()
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unsubscribe removes a listener channel and closes it.
|
|
||||||
func (b *EventBroadcaster) Unsubscribe(ch chan string) {
|
|
||||||
b.mu.Lock()
|
|
||||||
delete(b.clients, ch)
|
|
||||||
b.mu.Unlock()
|
|
||||||
close(ch)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast sends a GatewayEvent to all connected SSE clients.
|
|
||||||
func (b *EventBroadcaster) Broadcast(event GatewayEvent) {
|
|
||||||
data, err := json.Marshal(event)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
b.mu.RLock()
|
|
||||||
defer b.mu.RUnlock()
|
|
||||||
|
|
||||||
for ch := range b.clients {
|
|
||||||
// Non-blocking send; drop event if client is slow
|
|
||||||
select {
|
|
||||||
case ch <- string(data):
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,6 +3,7 @@ package api
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
|
@ -18,6 +19,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/health"
|
||||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -29,11 +31,9 @@ var gateway = struct {
|
||||||
runtimeStatus string
|
runtimeStatus string
|
||||||
startupDeadline time.Time
|
startupDeadline time.Time
|
||||||
logs *LogBuffer
|
logs *LogBuffer
|
||||||
events *EventBroadcaster
|
|
||||||
}{
|
}{
|
||||||
runtimeStatus: "stopped",
|
runtimeStatus: "stopped",
|
||||||
logs: NewLogBuffer(200),
|
logs: NewLogBuffer(200),
|
||||||
events: NewEventBroadcaster(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -48,10 +48,38 @@ var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response,
|
||||||
return client.Get(url)
|
return client.Get(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getGatewayHealth checks the gateway health endpoint and returns the status response
|
||||||
|
// Returns (*health.StatusResponse, statusCode, error). If error is not nil, the other values are not valid.
|
||||||
|
func (h *Handler) getGatewayHealth(cfg *config.Config, timeout time.Duration) (*health.StatusResponse, int, error) {
|
||||||
|
port := 18790
|
||||||
|
if cfg != nil && cfg.Gateway.Port != 0 {
|
||||||
|
port = cfg.Gateway.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
probeHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
||||||
|
url := "http://" + net.JoinHostPort(probeHost, strconv.Itoa(port)) + "/health"
|
||||||
|
|
||||||
|
return getGatewayHealthByURL(url, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGatewayHealthByURL(url string, timeout time.Duration) (*health.StatusResponse, int, error) {
|
||||||
|
resp, err := gatewayHealthGet(url, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var healthResponse health.StatusResponse
|
||||||
|
if decErr := json.NewDecoder(resp.Body).Decode(&healthResponse); decErr != nil {
|
||||||
|
return nil, resp.StatusCode, decErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return &healthResponse, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
|
// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux.
|
||||||
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
|
func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
|
mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus)
|
||||||
mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents)
|
|
||||||
mux.HandleFunc("GET /api/gateway/logs", h.handleGatewayLogs)
|
mux.HandleFunc("GET /api/gateway/logs", h.handleGatewayLogs)
|
||||||
mux.HandleFunc("POST /api/gateway/logs/clear", h.handleGatewayClearLogs)
|
mux.HandleFunc("POST /api/gateway/logs/clear", h.handleGatewayClearLogs)
|
||||||
mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart)
|
mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart)
|
||||||
|
|
@ -62,12 +90,35 @@ func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) {
|
||||||
// TryAutoStartGateway checks whether gateway start preconditions are met and
|
// TryAutoStartGateway checks whether gateway start preconditions are met and
|
||||||
// starts it when possible. Intended to be called by the backend at startup.
|
// starts it when possible. Intended to be called by the backend at startup.
|
||||||
func (h *Handler) TryAutoStartGateway() {
|
func (h *Handler) TryAutoStartGateway() {
|
||||||
|
// Check if gateway is already running via health endpoint
|
||||||
|
cfg, cfgErr := config.LoadConfig(h.configPath)
|
||||||
|
if cfgErr == nil && cfg != nil {
|
||||||
|
healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second)
|
||||||
|
if err == nil && statusCode == http.StatusOK {
|
||||||
|
// Gateway is already running, attach to the existing process
|
||||||
|
pid := healthResp.Pid
|
||||||
|
gateway.mu.Lock()
|
||||||
|
defer gateway.mu.Unlock()
|
||||||
|
ready, reason, err := h.gatewayStartReady()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Skip auto-starting gateway: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ready {
|
||||||
|
log.Printf("Skip auto-starting gateway: %s", reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = h.startGatewayLocked("starting", pid)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to attach to running gateway (PID: %d): %v", pid, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
gateway.mu.Lock()
|
gateway.mu.Lock()
|
||||||
defer gateway.mu.Unlock()
|
defer gateway.mu.Unlock()
|
||||||
|
|
||||||
if isGatewayProcessAliveLocked() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
||||||
gateway.cmd = nil
|
gateway.cmd = nil
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +133,7 @@ func (h *Handler) TryAutoStartGateway() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pid, err := h.startGatewayLocked("starting")
|
pid, err := h.startGatewayLocked("starting", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to auto-start gateway: %v", err)
|
log.Printf("Failed to auto-start gateway: %v", err)
|
||||||
return
|
return
|
||||||
|
|
@ -125,8 +176,14 @@ func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig
|
||||||
return modelCfg
|
return modelCfg
|
||||||
}
|
}
|
||||||
|
|
||||||
func isGatewayProcessAliveLocked() bool {
|
func gatewayRestartRequired(configDefaultModel, bootDefaultModel, gatewayStatus string) bool {
|
||||||
return isCmdProcessAliveLocked(gateway.cmd)
|
if gatewayStatus != "running" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(configDefaultModel) == "" || strings.TrimSpace(bootDefaultModel) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return configDefaultModel != bootDefaultModel
|
||||||
}
|
}
|
||||||
|
|
||||||
func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
|
func isCmdProcessAliveLocked(cmd *exec.Cmd) bool {
|
||||||
|
|
@ -157,7 +214,29 @@ func setGatewayRuntimeStatusLocked(status string) {
|
||||||
gateway.startupDeadline = time.Time{}
|
gateway.startupDeadline = time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func gatewayStatusOnHealthFailureLocked() string {
|
// attachToGatewayProcess attaches to an existing gateway process by PID
|
||||||
|
// and updates the gateway state accordingly.
|
||||||
|
// Assumes gateway.mu is held by the caller.
|
||||||
|
func attachToGatewayProcessLocked(pid int, cfg *config.Config) error {
|
||||||
|
process, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find process for PID %d: %w", pid, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway.cmd = &exec.Cmd{Process: process}
|
||||||
|
setGatewayRuntimeStatusLocked("running")
|
||||||
|
|
||||||
|
// Update bootDefaultModel from config
|
||||||
|
if cfg != nil {
|
||||||
|
defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
||||||
|
gateway.bootDefaultModel = defaultModelName
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Attached to gateway process (PID: %d)", pid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func gatewayStatusWithoutHealthLocked() string {
|
||||||
if gateway.runtimeStatus == "starting" || gateway.runtimeStatus == "restarting" {
|
if gateway.runtimeStatus == "starting" || gateway.runtimeStatus == "restarting" {
|
||||||
if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) {
|
if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) {
|
||||||
return gateway.runtimeStatus
|
return gateway.runtimeStatus
|
||||||
|
|
@ -170,24 +249,8 @@ func gatewayStatusOnHealthFailureLocked() string {
|
||||||
if gateway.runtimeStatus == "error" {
|
if gateway.runtimeStatus == "error" {
|
||||||
return "error"
|
return "error"
|
||||||
}
|
}
|
||||||
return "error"
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentGatewayStatusLocked(processAlive bool) string {
|
|
||||||
if !processAlive {
|
|
||||||
if gateway.runtimeStatus == "restarting" {
|
|
||||||
if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) {
|
|
||||||
return "restarting"
|
|
||||||
}
|
|
||||||
return "error"
|
|
||||||
}
|
|
||||||
if gateway.runtimeStatus == "error" {
|
|
||||||
return "error"
|
|
||||||
}
|
|
||||||
return "stopped"
|
return "stopped"
|
||||||
}
|
}
|
||||||
return gatewayStatusOnHealthFailureLocked()
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForGatewayProcessExit(cmd *exec.Cmd, timeout time.Duration) bool {
|
func waitForGatewayProcessExit(cmd *exec.Cmd, timeout time.Duration) bool {
|
||||||
if cmd == nil || cmd.Process == nil {
|
if cmd == nil || cmd.Process == nil {
|
||||||
|
|
@ -238,24 +301,32 @@ func stopGatewayProcessForRestart(cmd *exec.Cmd) error {
|
||||||
return fmt.Errorf("existing gateway did not exit before restart")
|
return fmt.Errorf("existing gateway did not exit before restart")
|
||||||
}
|
}
|
||||||
|
|
||||||
func gatewayRestartRequired(status, bootDefaultModel, configDefaultModel string) bool {
|
func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int, error) {
|
||||||
return status == "running" &&
|
|
||||||
bootDefaultModel != "" &&
|
|
||||||
configDefaultModel != "" &&
|
|
||||||
bootDefaultModel != configDefaultModel
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
|
||||||
cfg, err := config.LoadConfig(h.configPath)
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to load config: %w", err)
|
return 0, fmt.Errorf("failed to load config: %w", err)
|
||||||
}
|
}
|
||||||
defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
var pid int
|
||||||
|
|
||||||
|
if existingPid > 0 {
|
||||||
|
// Attach to existing process
|
||||||
|
pid = existingPid
|
||||||
|
gateway.cmd = nil // Clear first to ensure clean state
|
||||||
|
if err = attachToGatewayProcessLocked(pid, cfg); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return pid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new process
|
||||||
// Locate the picoclaw executable
|
// Locate the picoclaw executable
|
||||||
execPath := utils.FindPicoclawBinary()
|
execPath := utils.FindPicoclawBinary()
|
||||||
|
|
||||||
cmd := exec.Command(execPath, "gateway")
|
cmd = exec.Command(execPath, "gateway", "-E")
|
||||||
cmd.Env = os.Environ()
|
cmd.Env = os.Environ()
|
||||||
// Forward the launcher's config path via the environment variable that
|
// Forward the launcher's config path via the environment variable that
|
||||||
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
// GetConfigPath() already reads, so the gateway sub-process uses the same
|
||||||
|
|
@ -293,18 +364,9 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
||||||
gateway.cmd = cmd
|
gateway.cmd = cmd
|
||||||
gateway.bootDefaultModel = defaultModelName
|
gateway.bootDefaultModel = defaultModelName
|
||||||
setGatewayRuntimeStatusLocked(initialStatus)
|
setGatewayRuntimeStatusLocked(initialStatus)
|
||||||
pid := cmd.Process.Pid
|
pid = cmd.Process.Pid
|
||||||
log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)
|
log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)
|
||||||
|
|
||||||
// Broadcast the launch state immediately so clients can reflect it without polling.
|
|
||||||
gateway.events.Broadcast(GatewayEvent{
|
|
||||||
Status: initialStatus,
|
|
||||||
PID: pid,
|
|
||||||
BootDefaultModel: defaultModelName,
|
|
||||||
ConfigDefaultModel: defaultModelName,
|
|
||||||
RestartRequired: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Capture stdout/stderr in background
|
// Capture stdout/stderr in background
|
||||||
go scanPipe(stdoutPipe, gateway.logs)
|
go scanPipe(stdoutPipe, gateway.logs)
|
||||||
go scanPipe(stderrPipe, gateway.logs)
|
go scanPipe(stderrPipe, gateway.logs)
|
||||||
|
|
@ -318,26 +380,17 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
gateway.mu.Lock()
|
gateway.mu.Lock()
|
||||||
shouldBroadcastStopped := false
|
|
||||||
if gateway.cmd == cmd {
|
if gateway.cmd == cmd {
|
||||||
gateway.cmd = nil
|
gateway.cmd = nil
|
||||||
gateway.bootDefaultModel = ""
|
gateway.bootDefaultModel = ""
|
||||||
if gateway.runtimeStatus != "restarting" {
|
if gateway.runtimeStatus != "restarting" {
|
||||||
setGatewayRuntimeStatusLocked("stopped")
|
setGatewayRuntimeStatusLocked("stopped")
|
||||||
shouldBroadcastStopped = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gateway.mu.Unlock()
|
gateway.mu.Unlock()
|
||||||
|
|
||||||
if shouldBroadcastStopped {
|
|
||||||
gateway.events.Broadcast(GatewayEvent{
|
|
||||||
Status: "stopped",
|
|
||||||
RestartRequired: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Start a goroutine to probe health and broadcast "running" once ready
|
// Start a goroutine to probe health and update the runtime state once ready.
|
||||||
go func() {
|
go func() {
|
||||||
for i := 0; i < 30; i++ { // try for up to 15 seconds
|
for i := 0; i < 30; i++ { // try for up to 15 seconds
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
@ -351,32 +404,17 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
healthHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
healthResp, statusCode, err := h.getGatewayHealth(cfg, 1*time.Second)
|
||||||
healthPort := cfg.Gateway.Port
|
if err == nil && statusCode == http.StatusOK && healthResp.Pid == pid {
|
||||||
if healthPort == 0 {
|
// Verify the health endpoint returns the expected pid
|
||||||
healthPort = 18790
|
|
||||||
}
|
|
||||||
healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort)))
|
|
||||||
resp, err := gatewayHealthGet(healthURL, 1*time.Second)
|
|
||||||
if err == nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
gateway.mu.Lock()
|
gateway.mu.Lock()
|
||||||
if gateway.cmd == cmd {
|
if gateway.cmd == cmd {
|
||||||
setGatewayRuntimeStatusLocked("running")
|
setGatewayRuntimeStatusLocked("running")
|
||||||
}
|
}
|
||||||
gateway.mu.Unlock()
|
gateway.mu.Unlock()
|
||||||
gateway.events.Broadcast(GatewayEvent{
|
|
||||||
Status: "running",
|
|
||||||
PID: pid,
|
|
||||||
BootDefaultModel: defaultModelName,
|
|
||||||
ConfigDefaultModel: defaultModelName,
|
|
||||||
RestartRequired: false,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return pid, nil
|
return pid, nil
|
||||||
|
|
@ -386,19 +424,54 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) {
|
||||||
//
|
//
|
||||||
// POST /api/gateway/start
|
// POST /api/gateway/start
|
||||||
func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Prevent duplicate starts by checking health endpoint
|
||||||
|
cfg, cfgErr := config.LoadConfig(h.configPath)
|
||||||
|
if cfgErr == nil && cfg != nil {
|
||||||
|
healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second)
|
||||||
|
if err == nil && statusCode == http.StatusOK {
|
||||||
|
// Gateway is already running, attach to the existing process
|
||||||
|
pid := healthResp.Pid
|
||||||
gateway.mu.Lock()
|
gateway.mu.Lock()
|
||||||
defer gateway.mu.Unlock()
|
ready, reason, err := h.gatewayStartReady()
|
||||||
|
if err != nil {
|
||||||
// Prevent duplicate starts
|
gateway.mu.Unlock()
|
||||||
if isGatewayProcessAliveLocked() {
|
http.Error(
|
||||||
|
w,
|
||||||
|
fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
|
||||||
|
http.StatusInternalServerError,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ready {
|
||||||
|
gateway.mu.Unlock()
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusConflict)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
"status": "already_running",
|
"status": "precondition_failed",
|
||||||
"pid": gateway.cmd.Process.Pid,
|
"message": reason,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
_, err = h.startGatewayLocked("starting", pid)
|
||||||
|
gateway.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to attach to running gateway (PID: %d): %v", pid, err)
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to attach to gateway: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "ok",
|
||||||
|
"pid": pid,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway.mu.Lock()
|
||||||
|
defer gateway.mu.Unlock()
|
||||||
|
|
||||||
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
||||||
gateway.cmd = nil
|
gateway.cmd = nil
|
||||||
setGatewayRuntimeStatusLocked("stopped")
|
setGatewayRuntimeStatusLocked("stopped")
|
||||||
|
|
@ -423,7 +496,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pid, err := h.startGatewayLocked("starting")
|
pid, err := h.startGatewayLocked("starting", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -475,36 +548,21 @@ func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGatewayRestart stops the gateway (if running) and starts a new instance.
|
// RestartGateway restarts the gateway process. This is a non-blocking operation
|
||||||
//
|
// that stops the current gateway (if running) and starts a new one.
|
||||||
// POST /api/gateway/restart
|
// Returns the PID of the new gateway process or an error.
|
||||||
func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) RestartGateway() (int, error) {
|
||||||
ready, reason, err := h.gatewayStartReady()
|
ready, reason, err := h.gatewayStartReady()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
return 0, fmt.Errorf("failed to validate gateway start conditions: %w", err)
|
||||||
w,
|
|
||||||
fmt.Sprintf("Failed to validate gateway start conditions: %v", err),
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if !ready {
|
if !ready {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return 0, &preconditionFailedError{reason: reason}
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"status": "precondition_failed",
|
|
||||||
"message": reason,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gateway.mu.Lock()
|
gateway.mu.Lock()
|
||||||
previousCmd := gateway.cmd
|
previousCmd := gateway.cmd
|
||||||
setGatewayRuntimeStatusLocked("restarting")
|
setGatewayRuntimeStatusLocked("restarting")
|
||||||
gateway.events.Broadcast(GatewayEvent{
|
|
||||||
Status: "restarting",
|
|
||||||
RestartRequired: false,
|
|
||||||
})
|
|
||||||
gateway.mu.Unlock()
|
gateway.mu.Unlock()
|
||||||
|
|
||||||
if err = stopGatewayProcessForRestart(previousCmd); err != nil {
|
if err = stopGatewayProcessForRestart(previousCmd); err != nil {
|
||||||
|
|
@ -519,8 +577,7 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gateway.mu.Unlock()
|
gateway.mu.Unlock()
|
||||||
http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError)
|
return 0, fmt.Errorf("failed to stop gateway: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gateway.mu.Lock()
|
gateway.mu.Lock()
|
||||||
|
|
@ -528,7 +585,7 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
gateway.cmd = nil
|
gateway.cmd = nil
|
||||||
gateway.bootDefaultModel = ""
|
gateway.bootDefaultModel = ""
|
||||||
}
|
}
|
||||||
pid, err := h.startGatewayLocked("restarting")
|
pid, err := h.startGatewayLocked("restarting", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
gateway.cmd = nil
|
gateway.cmd = nil
|
||||||
gateway.bootDefaultModel = ""
|
gateway.bootDefaultModel = ""
|
||||||
|
|
@ -536,6 +593,43 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
gateway.mu.Unlock()
|
gateway.mu.Unlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to start gateway: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// preconditionFailedError is returned when gateway restart preconditions are not met
|
||||||
|
type preconditionFailedError struct {
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *preconditionFailedError) Error() string {
|
||||||
|
return e.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBadRequest returns true if the error should result in a 400 Bad Request status
|
||||||
|
func (e *preconditionFailedError) IsBadRequest() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGatewayRestart stops the gateway (if running) and starts a new instance.
|
||||||
|
//
|
||||||
|
// POST /api/gateway/restart
|
||||||
|
func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, err := h.RestartGateway()
|
||||||
|
if err != nil {
|
||||||
|
// Check if it's a precondition failed error
|
||||||
|
var precondErr *preconditionFailedError
|
||||||
|
if errors.As(err, &precondErr) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"status": "precondition_failed",
|
||||||
|
"message": precondErr.reason,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -572,8 +666,8 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
func (h *Handler) gatewayStatusData() map[string]any {
|
func (h *Handler) gatewayStatusData() map[string]any {
|
||||||
data := map[string]any{}
|
data := map[string]any{}
|
||||||
cfg, cfgErr := config.LoadConfig(h.configPath)
|
|
||||||
configDefaultModel := ""
|
configDefaultModel := ""
|
||||||
|
cfg, cfgErr := config.LoadConfig(h.configPath)
|
||||||
if cfgErr == nil && cfg != nil {
|
if cfgErr == nil && cfg != nil {
|
||||||
configDefaultModel = strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
configDefaultModel = strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
||||||
if configDefaultModel != "" {
|
if configDefaultModel != "" {
|
||||||
|
|
@ -581,74 +675,59 @@ func (h *Handler) gatewayStatusData() map[string]any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check process state
|
// Probe health endpoint to get pid and status
|
||||||
gateway.mu.Lock()
|
healthResp, statusCode, err := h.getGatewayHealth(cfg, 2*time.Second)
|
||||||
processAlive := isGatewayProcessAliveLocked()
|
|
||||||
bootDefaultModel := ""
|
|
||||||
if processAlive {
|
|
||||||
data["pid"] = gateway.cmd.Process.Pid
|
|
||||||
if gateway.bootDefaultModel != "" {
|
|
||||||
data["boot_default_model"] = gateway.bootDefaultModel
|
|
||||||
bootDefaultModel = gateway.bootDefaultModel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
gateway.mu.Unlock()
|
|
||||||
|
|
||||||
if !processAlive {
|
|
||||||
gateway.mu.Lock()
|
|
||||||
data["gateway_status"] = currentGatewayStatusLocked(false)
|
|
||||||
gateway.mu.Unlock()
|
|
||||||
} else {
|
|
||||||
// Process is alive — probe its health endpoint
|
|
||||||
host := "127.0.0.1"
|
|
||||||
port := 18790
|
|
||||||
if cfgErr == nil && cfg != nil {
|
|
||||||
host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg))
|
|
||||||
if cfg.Gateway.Port != 0 {
|
|
||||||
port = cfg.Gateway.Port
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port)))
|
|
||||||
resp, err := gatewayHealthGet(url, 2*time.Second)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
gateway.mu.Lock()
|
gateway.mu.Lock()
|
||||||
data["gateway_status"] = currentGatewayStatusLocked(true)
|
data["gateway_status"] = gatewayStatusWithoutHealthLocked()
|
||||||
gateway.mu.Unlock()
|
gateway.mu.Unlock()
|
||||||
|
log.Printf("Gateway health check failed: %v", err)
|
||||||
} else {
|
} else {
|
||||||
defer resp.Body.Close()
|
log.Printf("Gateway health status: %d", statusCode)
|
||||||
if resp.StatusCode != http.StatusOK {
|
if statusCode != http.StatusOK {
|
||||||
gateway.mu.Lock()
|
|
||||||
setGatewayRuntimeStatusLocked("error")
|
|
||||||
gateway.mu.Unlock()
|
|
||||||
data["gateway_status"] = "error"
|
|
||||||
data["status_code"] = resp.StatusCode
|
|
||||||
} else {
|
|
||||||
var healthData map[string]any
|
|
||||||
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
|
|
||||||
gateway.mu.Lock()
|
gateway.mu.Lock()
|
||||||
setGatewayRuntimeStatusLocked("error")
|
setGatewayRuntimeStatusLocked("error")
|
||||||
gateway.mu.Unlock()
|
gateway.mu.Unlock()
|
||||||
data["gateway_status"] = "error"
|
data["gateway_status"] = "error"
|
||||||
|
data["status_code"] = statusCode
|
||||||
} else {
|
} else {
|
||||||
gateway.mu.Lock()
|
gateway.mu.Lock()
|
||||||
setGatewayRuntimeStatusLocked("running")
|
setGatewayRuntimeStatusLocked("running")
|
||||||
gateway.mu.Unlock()
|
if gateway.cmd == nil || gateway.cmd.Process == nil || gateway.cmd.Process.Pid != healthResp.Pid {
|
||||||
for k, v := range healthData {
|
oldPid := "none"
|
||||||
data[k] = v
|
if gateway.cmd != nil && gateway.cmd.Process != nil {
|
||||||
}
|
oldPid = fmt.Sprintf("%d", gateway.cmd.Process.Pid)
|
||||||
data["gateway_status"] = "running"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
log.Printf(
|
||||||
|
"Detected gateway PID from health (old: %s, new: %d), attempting to attach",
|
||||||
|
oldPid,
|
||||||
|
healthResp.Pid,
|
||||||
|
)
|
||||||
|
if err := attachToGatewayProcessLocked(healthResp.Pid, cfg); err != nil {
|
||||||
|
log.Printf(
|
||||||
|
"Failed to attach to gateway process reported by health (PID: %d): %v",
|
||||||
|
healthResp.Pid,
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
status, _ := data["gateway_status"].(string)
|
bootDefaultModel := gateway.bootDefaultModel
|
||||||
|
if bootDefaultModel != "" {
|
||||||
|
data["boot_default_model"] = bootDefaultModel
|
||||||
|
}
|
||||||
|
data["gateway_status"] = "running"
|
||||||
|
data["pid"] = healthResp.Pid
|
||||||
|
gateway.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bootDefaultModel, _ := data["boot_default_model"].(string)
|
||||||
|
gatewayStatus, _ := data["gateway_status"].(string)
|
||||||
data["gateway_restart_required"] = gatewayRestartRequired(
|
data["gateway_restart_required"] = gatewayRestartRequired(
|
||||||
status,
|
|
||||||
bootDefaultModel,
|
|
||||||
configDefaultModel,
|
configDefaultModel,
|
||||||
|
bootDefaultModel,
|
||||||
|
gatewayStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
ready, reason, readyErr := h.gatewayStartReady()
|
ready, reason, readyErr := h.gatewayStartReady()
|
||||||
|
|
@ -719,51 +798,6 @@ func gatewayLogsData(r *http.Request) map[string]any {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGatewayEvents serves an SSE stream of gateway state change events.
|
|
||||||
//
|
|
||||||
// GET /api/gateway/events
|
|
||||||
func (h *Handler) handleGatewayEvents(w http.ResponseWriter, r *http.Request) {
|
|
||||||
flusher, ok := w.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "SSE not supported", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Connection", "keep-alive")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
|
|
||||||
// Subscribe to gateway events
|
|
||||||
ch := gateway.events.Subscribe()
|
|
||||||
defer gateway.events.Unsubscribe(ch)
|
|
||||||
|
|
||||||
// Send initial status so the client doesn't start blank
|
|
||||||
initial := h.currentGatewayStatus()
|
|
||||||
fmt.Fprintf(w, "data: %s\n\n", initial)
|
|
||||||
flusher.Flush()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-r.Context().Done():
|
|
||||||
return
|
|
||||||
case data, ok := <-ch:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// currentGatewayStatus returns the current gateway status as a JSON string.
|
|
||||||
func (h *Handler) currentGatewayStatus() string {
|
|
||||||
data := h.gatewayStatusData()
|
|
||||||
encoded, _ := json.Marshal(data)
|
|
||||||
return string(encoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF.
|
// scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF.
|
||||||
func scanPipe(r io.Reader, buf *LogBuffer) {
|
func scanPipe(r io.Reader, buf *LogBuffer) {
|
||||||
scanner := bufio.NewScanner(r)
|
scanner := bufio.NewScanner(r)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package api
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -46,6 +47,23 @@ func gatewayProbeHost(bindHost string) string {
|
||||||
return bindHost
|
return bindHost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) gatewayProxyURL() *url.URL {
|
||||||
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
|
port := 18790
|
||||||
|
bindHost := ""
|
||||||
|
if err == nil && cfg != nil {
|
||||||
|
if cfg.Gateway.Port != 0 {
|
||||||
|
port = cfg.Gateway.Port
|
||||||
|
}
|
||||||
|
bindHost = h.effectiveGatewayBindHost(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &url.URL{
|
||||||
|
Scheme: "http",
|
||||||
|
Host: net.JoinHostPort(gatewayProbeHost(bindHost), strconv.Itoa(port)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func requestHostName(r *http.Request) string {
|
func requestHostName(r *http.Request) string {
|
||||||
reqHost, _, err := net.SplitHostPort(r.Host)
|
reqHost, _, err := net.SplitHostPort(r.Host)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -57,10 +75,34 @@ func requestHostName(r *http.Request) string {
|
||||||
return "127.0.0.1"
|
return "127.0.0.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requestWSScheme(r *http.Request) string {
|
||||||
|
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
|
||||||
|
proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0]))
|
||||||
|
if proto == "https" || proto == "wss" {
|
||||||
|
return "wss"
|
||||||
|
}
|
||||||
|
if proto == "http" || proto == "ws" {
|
||||||
|
return "ws"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.TLS != nil {
|
||||||
|
return "wss"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "ws"
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string {
|
func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string {
|
||||||
host := h.effectiveGatewayBindHost(cfg)
|
host := h.effectiveGatewayBindHost(cfg)
|
||||||
if host == "" || host == "0.0.0.0" {
|
if host == "" || host == "0.0.0.0" {
|
||||||
host = requestHostName(r)
|
host = requestHostName(r)
|
||||||
}
|
}
|
||||||
return "ws://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws"
|
// Use web server port instead of gateway port to avoid exposing extra ports
|
||||||
|
// The WebSocket connection will be proxied by the backend to the gateway
|
||||||
|
wsPort := h.serverPort
|
||||||
|
if wsPort == 0 {
|
||||||
|
wsPort = 18800 // default web server port
|
||||||
|
}
|
||||||
|
return requestWSScheme(r) + "://" + net.JoinHostPort(host, strconv.Itoa(wsPort)) + "/pico/ws"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||||
|
|
@ -47,8 +51,8 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
|
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
|
||||||
req.Host = "192.168.1.9:18800"
|
req.Host = "192.168.1.9:18800"
|
||||||
|
|
||||||
if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18790/pico/ws" {
|
if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18800/pico/ws" {
|
||||||
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18790/pico/ws")
|
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18800/pico/ws")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,3 +61,128 @@ func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
|
||||||
t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1")
|
t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGatewayProxyURLUsesConfiguredHost(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Gateway.Host = "192.168.1.10"
|
||||||
|
cfg.Gateway.Port = 18791
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := h.gatewayProxyURL().String(); got != "http://192.168.1.10:18791" {
|
||||||
|
t.Fatalf("gatewayProxyURL() = %q, want %q", got, "http://192.168.1.10:18791")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetGatewayHealthUsesConfiguredHost(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Gateway.Host = "192.168.1.10"
|
||||||
|
cfg.Gateway.Port = 18791
|
||||||
|
|
||||||
|
originalHealthGet := gatewayHealthGet
|
||||||
|
t.Cleanup(func() {
|
||||||
|
gatewayHealthGet = originalHealthGet
|
||||||
|
})
|
||||||
|
|
||||||
|
var requestedURL string
|
||||||
|
gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) {
|
||||||
|
requestedURL = url
|
||||||
|
return nil, errors.New("probe failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, statusCode, err := h.getGatewayHealth(cfg, time.Second)
|
||||||
|
_ = statusCode
|
||||||
|
_ = err
|
||||||
|
|
||||||
|
if requestedURL != "http://192.168.1.10:18791/health" {
|
||||||
|
t.Fatalf("health url = %q, want %q", requestedURL, "http://192.168.1.10:18791/health")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetGatewayHealthUsesProbeHostForPublicLauncher(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
h.SetServerOptions(18800, true, true, nil)
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Gateway.Host = "127.0.0.1"
|
||||||
|
cfg.Gateway.Port = 18791
|
||||||
|
|
||||||
|
originalHealthGet := gatewayHealthGet
|
||||||
|
t.Cleanup(func() {
|
||||||
|
gatewayHealthGet = originalHealthGet
|
||||||
|
})
|
||||||
|
|
||||||
|
var requestedURL string
|
||||||
|
gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, error) {
|
||||||
|
requestedURL = url
|
||||||
|
return nil, errors.New("probe failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, statusCode, err := h.getGatewayHealth(cfg, time.Second)
|
||||||
|
_ = statusCode
|
||||||
|
_ = err
|
||||||
|
|
||||||
|
if requestedURL != "http://127.0.0.1:18791/health" {
|
||||||
|
t.Fatalf("health url = %q, want %q", requestedURL, "http://127.0.0.1:18791/health")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Gateway.Host = "0.0.0.0"
|
||||||
|
cfg.Gateway.Port = 18790
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
|
||||||
|
req.Host = "chat.example.com"
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
|
||||||
|
if got := h.buildWsURL(req, cfg); got != "wss://chat.example.com:18800/pico/ws" {
|
||||||
|
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Gateway.Host = "0.0.0.0"
|
||||||
|
cfg.Gateway.Port = 18790
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil)
|
||||||
|
req.Host = "secure.example.com"
|
||||||
|
req.TLS = &tls.ConnectionState{}
|
||||||
|
|
||||||
|
if got := h.buildWsURL(req, cfg); got != "wss://secure.example.com:18800/pico/ws" {
|
||||||
|
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Gateway.Host = "0.0.0.0"
|
||||||
|
cfg.Gateway.Port = 18790
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil)
|
||||||
|
req.Host = "chat.example.com"
|
||||||
|
req.TLS = &tls.ConnectionState{}
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "http")
|
||||||
|
|
||||||
|
if got := h.buildWsURL(req, cfg); got != "ws://chat.example.com:18800/pico/ws" {
|
||||||
|
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package api
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -36,6 +37,15 @@ func startLongRunningProcess(t *testing.T) *exec.Cmd {
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mockGatewayHealthResponse(statusCode, pid int) *http.Response {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Body: io.NopCloser(strings.NewReader(
|
||||||
|
`{"status":"ok","uptime":"1s","pid":` + strconv.Itoa(pid) + `}`,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func startIgnoringTermProcess(t *testing.T) *exec.Cmd {
|
func startIgnoringTermProcess(t *testing.T) *exec.Cmd {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|
@ -419,6 +429,125 @@ func TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGatewayStatusReportsRunningFromHealthProbe(t *testing.T) {
|
||||||
|
resetGatewayTestState(t)
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
cmd := startLongRunningProcess(t)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
_ = cmd.Wait()
|
||||||
|
})
|
||||||
|
|
||||||
|
gateway.mu.Lock()
|
||||||
|
setGatewayRuntimeStatusLocked("stopped")
|
||||||
|
gateway.mu.Unlock()
|
||||||
|
|
||||||
|
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
||||||
|
return mockGatewayHealthResponse(http.StatusOK, cmd.Process.Pid), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := body["gateway_status"]; got != "running" {
|
||||||
|
t.Fatalf("gateway_status = %#v, want %q", got, "running")
|
||||||
|
}
|
||||||
|
if got := body["pid"]; got != float64(cmd.Process.Pid) {
|
||||||
|
t.Fatalf("pid = %#v, want %d", got, cmd.Process.Pid)
|
||||||
|
}
|
||||||
|
if got := body["gateway_restart_required"]; got != false {
|
||||||
|
t.Fatalf("gateway_restart_required = %#v, want false", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayStatusRequiresRestartAfterDefaultModelChange(t *testing.T) {
|
||||||
|
resetGatewayTestState(t)
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||||
|
cfg.ModelList[0].APIKey = "test-key"
|
||||||
|
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "second-model",
|
||||||
|
Model: "openai/gpt-4.1",
|
||||||
|
APIKey: "second-key",
|
||||||
|
})
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
process, err := os.FindProcess(os.Getpid())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindProcess() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway.mu.Lock()
|
||||||
|
gateway.cmd = &exec.Cmd{Process: process}
|
||||||
|
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
|
||||||
|
setGatewayRuntimeStatusLocked("running")
|
||||||
|
gateway.mu.Unlock()
|
||||||
|
|
||||||
|
updatedCfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
updatedCfg.Agents.Defaults.ModelName = "second-model"
|
||||||
|
if err := config.SaveConfig(configPath, updatedCfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
||||||
|
return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := body["gateway_status"]; got != "running" {
|
||||||
|
t.Fatalf("gateway_status = %#v, want %q", got, "running")
|
||||||
|
}
|
||||||
|
if got := body["boot_default_model"]; got != cfg.ModelList[0].ModelName {
|
||||||
|
t.Fatalf("boot_default_model = %#v, want %q", got, cfg.ModelList[0].ModelName)
|
||||||
|
}
|
||||||
|
if got := body["config_default_model"]; got != "second-model" {
|
||||||
|
t.Fatalf("config_default_model = %#v, want %q", got, "second-model")
|
||||||
|
}
|
||||||
|
if got := body["gateway_restart_required"]; got != true {
|
||||||
|
t.Fatalf("gateway_restart_required = %#v, want true", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGatewayStatusReturnsErrorAfterStartupWindowExpires(t *testing.T) {
|
func TestGatewayStatusReturnsErrorAfterStartupWindowExpires(t *testing.T) {
|
||||||
resetGatewayTestState(t)
|
resetGatewayTestState(t)
|
||||||
|
|
||||||
|
|
@ -494,60 +623,6 @@ func TestGatewayStatusReturnsRestartingDuringRestartGap(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayStatusIncludesRestartRequiredWhenModelsDiffer(t *testing.T) {
|
|
||||||
resetGatewayTestState(t)
|
|
||||||
|
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
||||||
cfg := config.DefaultConfig()
|
|
||||||
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
|
||||||
cfg.ModelList[0].APIKey = "test-key"
|
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
|
||||||
t.Fatalf("SaveConfig() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h := NewHandler(configPath)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
h.RegisterRoutes(mux)
|
|
||||||
|
|
||||||
cmd := startLongRunningProcess(t)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
if cmd.Process != nil {
|
|
||||||
_ = cmd.Process.Kill()
|
|
||||||
}
|
|
||||||
_ = cmd.Wait()
|
|
||||||
})
|
|
||||||
|
|
||||||
gateway.mu.Lock()
|
|
||||||
gateway.cmd = cmd
|
|
||||||
gateway.bootDefaultModel = "previous-model"
|
|
||||||
setGatewayRuntimeStatusLocked("running")
|
|
||||||
gateway.mu.Unlock()
|
|
||||||
|
|
||||||
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
rec.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = rec.WriteString(`{"ok":true}`)
|
|
||||||
return rec.Result(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
|
||||||
mux.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body map[string]any
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
||||||
t.Fatalf("unmarshal response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := body["gateway_restart_required"]; got != true {
|
|
||||||
t.Fatalf("gateway_restart_required = %#v, want true", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) {
|
func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
@ -16,6 +17,30 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/pico/token", h.handleGetPicoToken)
|
mux.HandleFunc("GET /api/pico/token", h.handleGetPicoToken)
|
||||||
mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken)
|
mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken)
|
||||||
mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup)
|
mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup)
|
||||||
|
|
||||||
|
// WebSocket proxy: forward /pico/ws to gateway
|
||||||
|
// This allows the frontend to connect via the same port as the web UI,
|
||||||
|
// avoiding the need to expose extra ports for WebSocket communication.
|
||||||
|
mux.HandleFunc("GET /pico/ws", h.handleWebSocketProxy())
|
||||||
|
}
|
||||||
|
|
||||||
|
// createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint.
|
||||||
|
// The gateway bind host and port are resolved from the latest configuration.
|
||||||
|
func (h *Handler) createWsProxy() *httputil.ReverseProxy {
|
||||||
|
wsProxy := httputil.NewSingleHostReverseProxy(h.gatewayProxyURL())
|
||||||
|
wsProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
return wsProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections.
|
||||||
|
// The reverse proxy forwards the incoming upgrade handshake as-is.
|
||||||
|
func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
proxy := h.createWsProxy()
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetPicoToken returns the current WS token and URL for the frontend.
|
// handleGetPicoToken returns the current WS token and URL for the frontend.
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
@ -235,3 +238,77 @@ func TestHandlePicoSetup_Response(t *testing.T) {
|
||||||
t.Error("response should have changed=true on first setup")
|
t.Error("response should have changed=true on first setup")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
handler := h.handleWebSocketProxy()
|
||||||
|
|
||||||
|
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/pico/ws" {
|
||||||
|
t.Fatalf("server1 path = %q, want %q", r.URL.Path, "/pico/ws")
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = io.WriteString(w, "server1")
|
||||||
|
}))
|
||||||
|
defer server1.Close()
|
||||||
|
|
||||||
|
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/pico/ws" {
|
||||||
|
t.Fatalf("server2 path = %q, want %q", r.URL.Path, "/pico/ws")
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = io.WriteString(w, "server2")
|
||||||
|
}))
|
||||||
|
defer server2.Close()
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Gateway.Host = "127.0.0.1"
|
||||||
|
cfg.Gateway.Port = mustGatewayTestPort(t, server1.URL)
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req1 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil)
|
||||||
|
rec1 := httptest.NewRecorder()
|
||||||
|
handler(rec1, req1)
|
||||||
|
|
||||||
|
if rec1.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first status = %d, want %d", rec1.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if body := rec1.Body.String(); body != "server1" {
|
||||||
|
t.Fatalf("first body = %q, want %q", body, "server1")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Gateway.Port = mustGatewayTestPort(t, server2.URL)
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
handler(rec2, req2)
|
||||||
|
|
||||||
|
if rec2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second status = %d, want %d", rec2.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if body := rec2.Body.String(); body != "server2" {
|
||||||
|
t.Fatalf("second body = %q, want %q", body, "server2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustGatewayTestPort(t *testing.T, rawURL string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("url.Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := strconv.Atoi(parsed.Port())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Atoi(%q) error = %v", parsed.Port(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,3 +70,5 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
// Launcher service parameters (port/public)
|
// Launcher service parameters (port/public)
|
||||||
h.registerLauncherConfigRoutes(mux)
|
h.registerLauncherConfigRoutes(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Shutdown() {}
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,12 @@ var toolCatalog = []toolCatalogEntry{
|
||||||
Category: "agents",
|
Category: "agents",
|
||||||
ConfigKey: "spawn",
|
ConfigKey: "spawn",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "spawn_status",
|
||||||
|
Description: "Query the status of spawned subagents.",
|
||||||
|
Category: "agents",
|
||||||
|
ConfigKey: "spawn_status",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "i2c",
|
Name: "i2c",
|
||||||
Description: "Interact with I2C hardware devices exposed on the host.",
|
Description: "Interact with I2C hardware devices exposed on the host.",
|
||||||
|
|
@ -205,7 +211,7 @@ func buildToolSupport(cfg *config.Config) []toolSupportItem {
|
||||||
reasonCode = "requires_skills"
|
reasonCode = "requires_skills"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "spawn":
|
case "spawn", "spawn_status":
|
||||||
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
|
if cfg.Tools.IsToolEnabled(entry.ConfigKey) {
|
||||||
if cfg.Tools.IsToolEnabled("subagent") {
|
if cfg.Tools.IsToolEnabled("subagent") {
|
||||||
status = "enabled"
|
status = "enabled"
|
||||||
|
|
@ -300,6 +306,12 @@ func applyToolState(cfg *config.Config, toolName string, enabled bool) error {
|
||||||
if enabled {
|
if enabled {
|
||||||
cfg.Tools.Subagent.Enabled = true
|
cfg.Tools.Subagent.Enabled = true
|
||||||
}
|
}
|
||||||
|
case "spawn_status":
|
||||||
|
cfg.Tools.SpawnStatus.Enabled = enabled
|
||||||
|
if enabled {
|
||||||
|
cfg.Tools.Spawn.Enabled = true
|
||||||
|
cfg.Tools.Subagent.Enabled = true
|
||||||
|
}
|
||||||
case "i2c":
|
case "i2c":
|
||||||
cfg.Tools.I2C.Enabled = enabled
|
cfg.Tools.I2C.Enabled = enabled
|
||||||
case "spi":
|
case "spi":
|
||||||
|
|
|
||||||
46
web/backend/app_runtime.go
Normal file
46
web/backend/app_runtime.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
browserDelay = 500 * time.Millisecond
|
||||||
|
shutdownTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func shutdownApp() {
|
||||||
|
fmt.Println(T(Exiting))
|
||||||
|
|
||||||
|
if apiHandler != nil {
|
||||||
|
apiHandler.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
if server != nil {
|
||||||
|
server.SetKeepAlivesEnabled(false)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
|
if err == context.DeadlineExceeded {
|
||||||
|
logger.Infof("Server shutdown timeout after %v, forcing close", shutdownTimeout)
|
||||||
|
} else {
|
||||||
|
logger.Errorf("Server shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Infof("Server shutdown completed successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBrowser() error {
|
||||||
|
if serverAddr == "" {
|
||||||
|
return fmt.Errorf("server address not set")
|
||||||
|
}
|
||||||
|
return utils.OpenBrowser(serverAddr)
|
||||||
|
}
|
||||||
120
web/backend/i18n.go
Normal file
120
web/backend/i18n.go
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Language represents the supported languages
|
||||||
|
type Language string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LanguageEnglish Language = "en"
|
||||||
|
LanguageChinese Language = "zh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// current language (default: English)
|
||||||
|
var currentLang Language = LanguageEnglish
|
||||||
|
|
||||||
|
// TranslationKey represents a translation key used for i18n
|
||||||
|
type TranslationKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AppTooltip TranslationKey = "AppTooltip"
|
||||||
|
MenuOpen TranslationKey = "MenuOpen"
|
||||||
|
MenuOpenTooltip TranslationKey = "MenuOpenTooltip"
|
||||||
|
MenuAbout TranslationKey = "MenuAbout"
|
||||||
|
MenuAboutTooltip TranslationKey = "MenuAboutTooltip"
|
||||||
|
MenuVersion TranslationKey = "MenuVersion"
|
||||||
|
MenuVersionTooltip TranslationKey = "MenuVersionTooltip"
|
||||||
|
MenuGitHub TranslationKey = "MenuGitHub"
|
||||||
|
MenuDocs TranslationKey = "MenuDocs"
|
||||||
|
MenuRestart TranslationKey = "MenuRestart"
|
||||||
|
MenuRestartTooltip TranslationKey = "MenuRestartTooltip"
|
||||||
|
MenuQuit TranslationKey = "MenuQuit"
|
||||||
|
MenuQuitTooltip TranslationKey = "MenuQuitTooltip"
|
||||||
|
Exiting TranslationKey = "Exiting"
|
||||||
|
DocUrl TranslationKey = "DocUrl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Translation tables
|
||||||
|
// Chinese translations intentionally contain Han script
|
||||||
|
//
|
||||||
|
//nolint:gosmopolitan
|
||||||
|
var translations = map[Language]map[TranslationKey]string{
|
||||||
|
LanguageEnglish: {
|
||||||
|
AppTooltip: "%s - Web Console",
|
||||||
|
MenuOpen: "Open Console",
|
||||||
|
MenuOpenTooltip: "Open PicoClaw console in browser",
|
||||||
|
MenuAbout: "About",
|
||||||
|
MenuAboutTooltip: "About PicoClaw",
|
||||||
|
MenuVersion: "Version: %s",
|
||||||
|
MenuVersionTooltip: "Current version number",
|
||||||
|
MenuGitHub: "GitHub",
|
||||||
|
MenuDocs: "Documentation",
|
||||||
|
MenuRestart: "Restart Service",
|
||||||
|
MenuRestartTooltip: "Restart Gateway service",
|
||||||
|
MenuQuit: "Quit",
|
||||||
|
MenuQuitTooltip: "Exit PicoClaw",
|
||||||
|
Exiting: "Exiting PicoClaw...",
|
||||||
|
DocUrl: "https://docs.picoclaw.io/docs/",
|
||||||
|
},
|
||||||
|
LanguageChinese: {
|
||||||
|
AppTooltip: "%s - Web Console",
|
||||||
|
MenuOpen: "打开控制台",
|
||||||
|
MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台",
|
||||||
|
MenuAbout: "关于",
|
||||||
|
MenuAboutTooltip: "关于 PicoClaw",
|
||||||
|
MenuVersion: "版本: %s",
|
||||||
|
MenuVersionTooltip: "当前版本号",
|
||||||
|
MenuGitHub: "GitHub",
|
||||||
|
MenuDocs: "文档",
|
||||||
|
MenuRestart: "重启服务",
|
||||||
|
MenuRestartTooltip: "重启核心服务",
|
||||||
|
MenuQuit: "退出",
|
||||||
|
MenuQuitTooltip: "退出 PicoClaw",
|
||||||
|
Exiting: "正在退出 PicoClaw...",
|
||||||
|
DocUrl: "https://docs.picoclaw.io/zh-Hans/docs/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLanguage sets the current language
|
||||||
|
func SetLanguage(lang string) {
|
||||||
|
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||||
|
|
||||||
|
// Extract language code before first underscore or dot
|
||||||
|
// e.g., "en_US.UTF-8" -> "en", "zh_CN" -> "zh"
|
||||||
|
if idx := strings.IndexAny(lang, "_."); idx > 0 {
|
||||||
|
lang = lang[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if lang == "zh" || lang == "zh-cn" || lang == "chinese" {
|
||||||
|
currentLang = LanguageChinese
|
||||||
|
} else {
|
||||||
|
currentLang = LanguageEnglish
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLanguage returns the current language
|
||||||
|
func GetLanguage() Language {
|
||||||
|
return currentLang
|
||||||
|
}
|
||||||
|
|
||||||
|
// T translates a key to the current language
|
||||||
|
func T(key TranslationKey, args ...any) string {
|
||||||
|
if trans, ok := translations[currentLang][key]; ok {
|
||||||
|
if len(args) > 0 {
|
||||||
|
return fmt.Sprintf(trans, args...)
|
||||||
|
}
|
||||||
|
return trans
|
||||||
|
}
|
||||||
|
return string(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize i18n from environment variable
|
||||||
|
func init() {
|
||||||
|
if lang := os.Getenv("LANG"); lang != "" {
|
||||||
|
SetLanguage(lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
web/backend/icon.png
Normal file
BIN
web/backend/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
|
|
@ -22,16 +22,32 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/web/backend/api"
|
"github.com/sipeed/picoclaw/web/backend/api"
|
||||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
appName = "PicoClaw"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
appVersion = config.Version
|
||||||
|
|
||||||
|
server *http.Server
|
||||||
|
serverAddr string
|
||||||
|
apiHandler *api.Handler
|
||||||
|
|
||||||
|
noBrowser *bool
|
||||||
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := flag.String("port", "18800", "Port to listen on")
|
port := flag.String("port", "18800", "Port to listen on")
|
||||||
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
|
||||||
noBrowser := flag.Bool("no-browser", false, "Do not auto-open browser on startup")
|
noBrowser = flag.Bool("no-browser", false, "Do not auto-open browser on startup")
|
||||||
|
lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale")
|
||||||
|
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
|
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
|
||||||
|
|
@ -51,6 +67,11 @@ func main() {
|
||||||
}
|
}
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
// Set language from command line or auto-detect
|
||||||
|
if *lang != "" {
|
||||||
|
SetLanguage(*lang)
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve config path
|
// Resolve config path
|
||||||
configPath := utils.GetDefaultConfigPath()
|
configPath := utils.GetDefaultConfigPath()
|
||||||
if flag.NArg() > 0 {
|
if flag.NArg() > 0 {
|
||||||
|
|
@ -113,7 +134,7 @@ func main() {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// API Routes (e.g. /api/status)
|
// API Routes (e.g. /api/status)
|
||||||
apiHandler := api.NewHandler(absPath)
|
apiHandler = api.NewHandler(absPath)
|
||||||
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
||||||
apiHandler.RegisterRoutes(mux)
|
apiHandler.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -145,16 +166,10 @@ func main() {
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Auto-open browser
|
// Share the local URL with the launcher runtime.
|
||||||
if !*noBrowser {
|
serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort)
|
||||||
go func() {
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
// Auto-open browser will be handled by the launcher runtime.
|
||||||
url := "http://localhost:" + effectivePort
|
|
||||||
if err := utils.OpenBrowser(url); err != nil {
|
|
||||||
log.Printf("Warning: Failed to auto-open browser: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-start gateway after backend starts listening.
|
// Auto-start gateway after backend starts listening.
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -162,8 +177,14 @@ func main() {
|
||||||
apiHandler.TryAutoStartGateway()
|
apiHandler.TryAutoStartGateway()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Start the Server
|
// Start the Server in a goroutine
|
||||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
server = &http.Server{Addr: addr, Handler: handler}
|
||||||
|
go func() {
|
||||||
|
log.Printf("Server listening on %s", addr)
|
||||||
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatalf("Server failed to start: %v", err)
|
log.Fatalf("Server failed to start: %v", err)
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
runTray()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,14 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JSONContentType sets the Content-Type header to application/json for
|
// JSONContentType sets the Content-Type header to application/json for
|
||||||
// API requests handled by the wrapped handler.
|
// API requests handled by the wrapped handler.
|
||||||
// SSE endpoints (text/event-stream) are excluded.
|
|
||||||
func JSONContentType(next http.Handler) http.Handler {
|
func JSONContentType(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/") && !strings.HasSuffix(r.URL.Path, "/events") {
|
if len(r.URL.Path) >= 5 && r.URL.Path[:5] == "/api/" {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|
@ -32,7 +30,6 @@ func (rr *responseRecorder) WriteHeader(code int) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush delegates to the underlying ResponseWriter if it implements http.Flusher.
|
// Flush delegates to the underlying ResponseWriter if it implements http.Flusher.
|
||||||
// This is required for SSE (Server-Sent Events) to work through the middleware.
|
|
||||||
func (rr *responseRecorder) Flush() {
|
func (rr *responseRecorder) Flush() {
|
||||||
if f, ok := rr.ResponseWriter.(http.Flusher); ok {
|
if f, ok := rr.ResponseWriter.(http.Flusher); ok {
|
||||||
f.Flush()
|
f.Flush()
|
||||||
|
|
|
||||||
95
web/backend/systray.go
Normal file
95
web/backend/systray.go
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
//go:build (!darwin && !freebsd) || cgo
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"fyne.io/systray"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runTray() {
|
||||||
|
systray.Run(onReady, shutdownApp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// onReady is called when the system tray is ready
|
||||||
|
func onReady() {
|
||||||
|
// Set icon and tooltip
|
||||||
|
systray.SetIcon(getIcon())
|
||||||
|
systray.SetTooltip(fmt.Sprintf(T(AppTooltip), appName))
|
||||||
|
|
||||||
|
// Create menu items
|
||||||
|
mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip))
|
||||||
|
mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip))
|
||||||
|
|
||||||
|
// Add version info under About menu
|
||||||
|
mVersion := mAbout.AddSubMenuItem(fmt.Sprintf(T(MenuVersion), appVersion), T(MenuVersionTooltip))
|
||||||
|
mVersion.Disable()
|
||||||
|
mRepo := mAbout.AddSubMenuItem(T(MenuGitHub), "")
|
||||||
|
mDocs := mAbout.AddSubMenuItem(T(MenuDocs), "")
|
||||||
|
|
||||||
|
systray.AddSeparator()
|
||||||
|
|
||||||
|
// Add restart option
|
||||||
|
mRestart := systray.AddMenuItem(T(MenuRestart), T(MenuRestartTooltip))
|
||||||
|
|
||||||
|
systray.AddSeparator()
|
||||||
|
|
||||||
|
// Quit option
|
||||||
|
mQuit := systray.AddMenuItem(T(MenuQuit), T(MenuQuitTooltip))
|
||||||
|
|
||||||
|
// Handle menu clicks
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mOpen.ClickedCh:
|
||||||
|
if err := openBrowser(); err != nil {
|
||||||
|
logger.Errorf("Failed to open browser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-mVersion.ClickedCh:
|
||||||
|
// Version info - do nothing, just shows current version
|
||||||
|
|
||||||
|
case <-mRepo.ClickedCh:
|
||||||
|
if err := utils.OpenBrowser("https://github.com/sipeed/picoclaw"); err != nil {
|
||||||
|
logger.Errorf("Failed to open GitHub: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-mDocs.ClickedCh:
|
||||||
|
if err := utils.OpenBrowser(T(DocUrl)); err != nil {
|
||||||
|
logger.Errorf("Failed to open docs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-mRestart.ClickedCh:
|
||||||
|
fmt.Println("Restart request received...")
|
||||||
|
if apiHandler != nil {
|
||||||
|
if pid, err := apiHandler.RestartGateway(); err != nil {
|
||||||
|
logger.Errorf("Failed to restart gateway: %v", err)
|
||||||
|
} else {
|
||||||
|
logger.Infof("Gateway restarted (PID: %d)", pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-mQuit.ClickedCh:
|
||||||
|
systray.Quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if !*noBrowser {
|
||||||
|
// Auto-open browser after systray is ready (if not disabled)
|
||||||
|
// Check no-browser flag via environment or pass as parameter if needed
|
||||||
|
if err := openBrowser(); err != nil {
|
||||||
|
logger.Errorf("Warning: Failed to auto-open browser: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getIcon returns the system tray icon
|
||||||
|
func getIcon() []byte {
|
||||||
|
return iconData
|
||||||
|
}
|
||||||
8
web/backend/systray_unix.go
Normal file
8
web/backend/systray_unix.go
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed icon.png
|
||||||
|
var iconData []byte
|
||||||
8
web/backend/systray_windows.go
Normal file
8
web/backend/systray_windows.go
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed icon.ico
|
||||||
|
var iconData []byte
|
||||||
33
web/backend/tray_stub_nocgo.go
Normal file
33
web/backend/tray_stub_nocgo.go
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
//go:build (darwin || freebsd) && !cgo
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"runtime"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runTray() {
|
||||||
|
logger.Infof("System tray is unavailable in %s builds without cgo; running without tray", runtime.GOOS)
|
||||||
|
|
||||||
|
if !*noBrowser {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(browserDelay)
|
||||||
|
if err := openBrowser(); err != nil {
|
||||||
|
logger.Errorf("Warning: Failed to auto-open browser: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
shutdownApp()
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"build:backend": "tsc -b && vite build --outDir ../backend/dist --emptyOutDir",
|
"build:backend": "tsc -b && vite build --outDir ../backend/dist --emptyOutDir && node ./scripts/ensure-backend-gitkeep.cjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --check .",
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
"wrap-ansi": "^10.0.0"
|
"wrap-ansi": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.3",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/router-plugin": "^1.164.0",
|
"@tanstack/router-plugin": "^1.164.0",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.3",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
|
|
||||||
28
web/frontend/pnpm-lock.yaml
generated
28
web/frontend/pnpm-lock.yaml
generated
|
|
@ -85,7 +85,7 @@ importers:
|
||||||
version: 10.0.0
|
version: 10.0.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.39.1
|
specifier: ^9.39.3
|
||||||
version: 9.39.3
|
version: 9.39.3
|
||||||
'@tailwindcss/typography':
|
'@tailwindcss/typography':
|
||||||
specifier: ^0.5.19
|
specifier: ^0.5.19
|
||||||
|
|
@ -112,7 +112,7 @@ importers:
|
||||||
specifier: ^5.2.0
|
specifier: ^5.2.0
|
||||||
version: 5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
|
version: 5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.39.1
|
specifier: ^9.39.3
|
||||||
version: 9.39.3(jiti@2.6.1)
|
version: 9.39.3(jiti@2.6.1)
|
||||||
eslint-config-prettier:
|
eslint-config-prettier:
|
||||||
specifier: ^10.1.8
|
specifier: ^10.1.8
|
||||||
|
|
@ -469,8 +469,8 @@ packages:
|
||||||
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
|
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
|
||||||
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
||||||
|
|
||||||
'@eslint/config-array@0.21.1':
|
'@eslint/config-array@0.21.2':
|
||||||
resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==}
|
resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@eslint/config-helpers@0.4.2':
|
'@eslint/config-helpers@0.4.2':
|
||||||
|
|
@ -481,8 +481,8 @@ packages:
|
||||||
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
|
resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@eslint/eslintrc@3.3.4':
|
'@eslint/eslintrc@3.3.5':
|
||||||
resolution: {integrity: sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==}
|
resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@eslint/js@9.39.3':
|
'@eslint/js@9.39.3':
|
||||||
|
|
@ -2362,8 +2362,8 @@ packages:
|
||||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
flatted@3.3.3:
|
flatted@3.4.1:
|
||||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==}
|
||||||
|
|
||||||
formdata-polyfill@4.0.10:
|
formdata-polyfill@4.0.10:
|
||||||
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
|
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
|
||||||
|
|
@ -4285,7 +4285,7 @@ snapshots:
|
||||||
|
|
||||||
'@eslint-community/regexpp@4.12.2': {}
|
'@eslint-community/regexpp@4.12.2': {}
|
||||||
|
|
||||||
'@eslint/config-array@0.21.1':
|
'@eslint/config-array@0.21.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint/object-schema': 2.1.7
|
'@eslint/object-schema': 2.1.7
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
|
|
@ -4301,7 +4301,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/json-schema': 7.0.15
|
'@types/json-schema': 7.0.15
|
||||||
|
|
||||||
'@eslint/eslintrc@3.3.4':
|
'@eslint/eslintrc@3.3.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
ajv: 6.14.0
|
ajv: 6.14.0
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
|
|
@ -6077,10 +6077,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1))
|
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1))
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
'@eslint/config-array': 0.21.1
|
'@eslint/config-array': 0.21.2
|
||||||
'@eslint/config-helpers': 0.4.2
|
'@eslint/config-helpers': 0.4.2
|
||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 0.17.0
|
||||||
'@eslint/eslintrc': 3.3.4
|
'@eslint/eslintrc': 3.3.5
|
||||||
'@eslint/js': 9.39.3
|
'@eslint/js': 9.39.3
|
||||||
'@eslint/plugin-kit': 0.4.1
|
'@eslint/plugin-kit': 0.4.1
|
||||||
'@humanfs/node': 0.16.7
|
'@humanfs/node': 0.16.7
|
||||||
|
|
@ -6270,10 +6270,10 @@ snapshots:
|
||||||
|
|
||||||
flat-cache@4.0.1:
|
flat-cache@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
flatted: 3.3.3
|
flatted: 3.4.1
|
||||||
keyv: 4.5.4
|
keyv: 4.5.4
|
||||||
|
|
||||||
flatted@3.3.3: {}
|
flatted@3.4.1: {}
|
||||||
|
|
||||||
formdata-polyfill@4.0.10:
|
formdata-polyfill@4.0.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
9
web/frontend/scripts/ensure-backend-gitkeep.cjs
Normal file
9
web/frontend/scripts/ensure-backend-gitkeep.cjs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
const fs = require("node:fs")
|
||||||
|
const path = require("node:path")
|
||||||
|
|
||||||
|
const gitkeepPath = path.resolve(__dirname, "../../backend/dist/.gitkeep")
|
||||||
|
const gitkeepContents =
|
||||||
|
"# Keep the embedded web backend dist directory in version control.\n"
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(gitkeepPath), { recursive: true })
|
||||||
|
fs.writeFileSync(gitkeepPath, gitkeepContents)
|
||||||
|
|
@ -56,14 +56,20 @@ export function AppHeader() {
|
||||||
const isRunning = gwState === "running"
|
const isRunning = gwState === "running"
|
||||||
const isStarting = gwState === "starting"
|
const isStarting = gwState === "starting"
|
||||||
const isRestarting = gwState === "restarting"
|
const isRestarting = gwState === "restarting"
|
||||||
|
const isStopping = gwState === "stopping"
|
||||||
const isStopped = gwState === "stopped" || gwState === "unknown"
|
const isStopped = gwState === "stopped" || gwState === "unknown"
|
||||||
const showNotConnectedHint =
|
const showNotConnectedHint =
|
||||||
!isRestarting && canStart && (gwState === "stopped" || gwState === "error")
|
!isRestarting &&
|
||||||
|
!isStopping &&
|
||||||
|
canStart &&
|
||||||
|
(gwState === "stopped" || gwState === "error")
|
||||||
|
|
||||||
const [showStopDialog, setShowStopDialog] = React.useState(false)
|
const [showStopDialog, setShowStopDialog] = React.useState(false)
|
||||||
|
|
||||||
const handleGatewayToggle = () => {
|
const handleGatewayToggle = () => {
|
||||||
if (gwLoading || isRestarting || (!isRunning && !canStart)) return
|
if (gwLoading || isRestarting || isStopping || (!isRunning && !canStart)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
setShowStopDialog(true)
|
setShowStopDialog(true)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -137,7 +143,7 @@ export function AppHeader() {
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 hover:text-amber-800 dark:text-amber-300 dark:hover:bg-amber-500/25"
|
className="bg-amber-500/15 text-amber-700 hover:bg-amber-500/25 hover:text-amber-800 dark:text-amber-300 dark:hover:bg-amber-500/25"
|
||||||
onClick={handleGatewayRestart}
|
onClick={handleGatewayRestart}
|
||||||
disabled={gwLoading || isRestarting || !canStart}
|
disabled={gwLoading || isRestarting || isStopping || !canStart}
|
||||||
aria-label={t("header.gateway.action.restart")}
|
aria-label={t("header.gateway.action.restart")}
|
||||||
>
|
>
|
||||||
<IconRefresh className="size-4" />
|
<IconRefresh className="size-4" />
|
||||||
|
|
@ -168,21 +174,27 @@ export function AppHeader() {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant={isStarting || isRestarting ? "secondary" : "default"}
|
variant={
|
||||||
|
isStarting || isRestarting || isStopping ? "secondary" : "default"
|
||||||
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
className={`h-8 gap-2 px-3 ${
|
className={`h-8 gap-2 px-3 ${
|
||||||
isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
|
isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
|
||||||
}`}
|
}`}
|
||||||
onClick={handleGatewayToggle}
|
onClick={handleGatewayToggle}
|
||||||
disabled={gwLoading || isStarting || isRestarting || !canStart}
|
disabled={
|
||||||
|
gwLoading || isStarting || isRestarting || isStopping || !canStart
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{gwLoading || isStarting || isRestarting ? (
|
{gwLoading || isStarting || isRestarting || isStopping ? (
|
||||||
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
|
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
|
||||||
) : (
|
) : (
|
||||||
<IconPlayerPlay className="h-4 w-4 opacity-80" />
|
<IconPlayerPlay className="h-4 w-4 opacity-80" />
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-semibold">
|
<span className="text-xs font-semibold">
|
||||||
{isRestarting
|
{isStopping
|
||||||
|
? t("header.gateway.status.stopping")
|
||||||
|
: isRestarting
|
||||||
? t("header.gateway.status.restarting")
|
? t("header.gateway.status.restarting")
|
||||||
: isStarting
|
: isStarting
|
||||||
? t("header.gateway.status.starting")
|
? t("header.gateway.status.starting")
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export function ChatComposer({
|
||||||
placeholder={t("chat.placeholder")}
|
placeholder={t("chat.placeholder")}
|
||||||
disabled={!canInput}
|
disabled={!canInput}
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
|
"placeholder:text-muted-foreground max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
|
||||||
!canInput && "cursor-not-allowed",
|
!canInput && "cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
minRows={1}
|
minRows={1}
|
||||||
|
|
@ -56,7 +56,7 @@ export function ChatComposer({
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
|
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
|
||||||
onClick={onSend}
|
onClick={onSend}
|
||||||
disabled={!input.trim() || !isConnected}
|
disabled={!input.trim() || !canInput}
|
||||||
>
|
>
|
||||||
<IconArrowUp className="size-4" />
|
<IconArrowUp className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export function ChatEmptyState({
|
||||||
<p className="text-muted-foreground mb-4 text-center text-sm">
|
<p className="text-muted-foreground mb-4 text-center text-sm">
|
||||||
{t("chat.empty.noConfiguredModelDescription")}
|
{t("chat.empty.noConfiguredModelDescription")}
|
||||||
</p>
|
</p>
|
||||||
<Button asChild variant="secondary" size="sm" className="px-4">
|
<Button asChild variant="outline" size="sm" className="px-4">
|
||||||
<Link to="/models">{t("chat.empty.goToModels")}</Link>
|
<Link to="/models">{t("chat.empty.goToModels")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue