Merge branch 'sipeed:main' into main

This commit is contained in:
Orange Pi Vietnam 2026-04-08 21:20:30 +07:00 committed by GitHub
commit 3e2b29d0ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 3099 additions and 283 deletions

View file

@ -12,6 +12,7 @@ import (
"sync/atomic"
"time"
"github.com/sipeed/picoclaw/pkg/isolation"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/tools"
)
@ -122,7 +123,9 @@ func NewProcessHook(ctx context.Context, name string, opts ProcessHookOptions) (
if err != nil {
return nil, fmt.Errorf("create process hook stderr: %w", err)
}
if err := cmd.Start(); err != nil {
// Route hook subprocess startup through the shared isolation entry point so
// process hooks inherit the same isolation behavior as other child processes.
if err := isolation.Start(cmd); err != nil {
return nil, fmt.Errorf("start process hook: %w", err)
}

View file

@ -7,10 +7,13 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/isolation"
"github.com/sipeed/picoclaw/pkg/providers"
)
@ -178,6 +181,76 @@ func TestAgentLoop_MountProcessHook_ApprovalDeny(t *testing.T) {
}
}
func TestAgentLoop_MountProcessHook_IsolationSupportsRelativeDirAndCommand(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("linux-only isolation path handling")
}
provider := &llmHookTestProvider{}
al, agent, cleanup := newHookTestLoop(t, provider)
defer cleanup()
root := t.TempDir()
t.Setenv(config.EnvHome, filepath.Join(root, "picoclaw-home"))
binDir := filepath.Join(root, "bin")
hookDir := filepath.Join(root, "hooks")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(hookDir, 0o755); err != nil {
t.Fatal(err)
}
writeFakeBwrap(t, filepath.Join(binDir, "bwrap"))
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
linkTestBinary(t, os.Args[0], filepath.Join(hookDir, "hook-helper"))
cfg := config.DefaultConfig()
cfg.Isolation.Enabled = true
isolation.Configure(cfg)
t.Cleanup(func() { isolation.Configure(config.DefaultConfig()) })
cwd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
relHookDir, err := filepath.Rel(cwd, hookDir)
if err != nil {
t.Fatal(err)
}
mountErr := al.MountProcessHook(context.Background(), "ipc-relative", ProcessHookOptions{
Command: []string{"./hook-helper", "-test.run=TestProcessHook_HelperProcess", "--"},
Dir: relHookDir,
Env: processHookHelperEnv("rewrite", ""),
InterceptLLM: true,
})
if mountErr != nil {
t.Fatalf("MountProcessHook failed with relative dir/command under isolation: %v", mountErr)
}
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
SessionKey: "session-relative",
Channel: "cli",
ChatID: "direct",
UserMessage: "hello",
DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
})
if err != nil {
t.Fatalf("runAgentLoop failed: %v", err)
}
if resp != "provider content|ipc" {
t.Fatalf("expected process-hooked llm content, got %q", resp)
}
provider.mu.Lock()
lastModel := provider.lastModel
provider.mu.Unlock()
if lastModel != "process-model" {
t.Fatalf("expected process model, got %q", lastModel)
}
}
func processHookHelperCommand() []string {
return []string{os.Args[0], "-test.run=TestProcessHook_HelperProcess", "--"}
}
@ -193,6 +266,59 @@ func processHookHelperEnv(mode, eventLog string) []string {
return env
}
func writeFakeBwrap(t *testing.T, path string) {
t.Helper()
script := `#!/bin/sh
set -eu
workdir=
while [ "$#" -gt 0 ]; do
case "$1" in
--)
shift
break
;;
--chdir)
workdir="$2"
shift 2
;;
--bind|--ro-bind)
shift 3
;;
--proc|--dev)
shift 2
;;
--die-with-parent|--unshare-ipc)
shift
;;
*)
shift
;;
esac
done
if [ -n "$workdir" ]; then
cd "$workdir"
fi
exec "$@"
`
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write fake bwrap: %v", err)
}
}
func linkTestBinary(t *testing.T, source, target string) {
t.Helper()
if err := os.Symlink(source, target); err == nil {
return
}
data, err := os.ReadFile(source)
if err != nil {
t.Fatalf("read test binary: %v", err)
}
if err := os.WriteFile(target, data, 0o755); err != nil {
t.Fatalf("create hook helper binary: %v", err)
}
}
func waitForFileContains(t *testing.T, path, substring string) {
t.Helper()

View file

@ -9,6 +9,7 @@ import (
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/isolation"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/memory"
@ -64,6 +65,12 @@ func NewAgentInstance(
cfg *config.Config,
provider providers.LLMProvider,
) *AgentInstance {
if cfg != nil {
// Keep the subprocess isolation runtime aligned with the latest loaded config
// before any tools or providers start spawning child processes.
isolation.Configure(cfg)
}
workspace := resolveAgentWorkspace(agentCfg, defaults)
os.MkdirAll(workspace, 0o755)

View file

@ -671,21 +671,21 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
return
}
alreadySent := false
alreadySentToSameChat := false
defaultAgent := al.GetRegistry().GetDefaultAgent()
if defaultAgent != nil {
if tool, ok := defaultAgent.Tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok {
alreadySent = mt.HasSentInRound()
alreadySentToSameChat = mt.HasSentTo(channel, chatID)
}
}
}
if alreadySent {
if alreadySentToSameChat {
logger.DebugCF(
"agent",
"Skipped outbound (message tool already sent)",
map[string]any{"channel": channel},
"Skipped outbound (message tool already sent to same chat)",
map[string]any{"channel": channel, "chat_id": chatID},
)
return
}

View file

@ -24,20 +24,21 @@ var rrCounter atomic.Uint64
// CurrentVersion is the latest config schema version
const CurrentVersion = 2
// Config is the current config structure with version support
// Config is the current config structure with version support.
type Config struct {
Version int `json:"version" yaml:"-"` // Config schema version for migration
Agents AgentsConfig `json:"agents" yaml:"-"`
Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"`
Session SessionConfig `json:"session,omitempty" yaml:"-"`
Channels ChannelsConfig `json:"channels" yaml:"channels"`
ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration
Gateway GatewayConfig `json:"gateway" yaml:"-"`
Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"`
Tools ToolsConfig `json:"tools" yaml:",inline"`
Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"`
Devices DevicesConfig `json:"devices" yaml:"-"`
Voice VoiceConfig `json:"voice" yaml:"-"`
Version int `json:"version" yaml:"-"` // Config schema version for migration
Isolation IsolationConfig `json:"isolation,omitempty" yaml:"-"`
Agents AgentsConfig `json:"agents" yaml:"-"`
Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"`
Session SessionConfig `json:"session,omitempty" yaml:"-"`
Channels ChannelsConfig `json:"channels" yaml:"channels"`
ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration
Gateway GatewayConfig `json:"gateway" yaml:"-"`
Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"`
Tools ToolsConfig `json:"tools" yaml:",inline"`
Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"`
Devices DevicesConfig `json:"devices" yaml:"-"`
Voice VoiceConfig `json:"voice" yaml:"-"`
// BuildInfo contains build-time version information
BuildInfo BuildInfo `json:"build_info,omitempty" yaml:"-"`
@ -45,6 +46,21 @@ type Config struct {
sensitiveCache *SensitiveDataCache
}
// IsolationConfig controls subprocess isolation for commands started by PicoClaw.
// It is applied by the isolation package rather than by sandboxing the main process.
type IsolationConfig struct {
Enabled bool `json:"enabled,omitempty"`
ExposePaths []ExposePath `json:"expose_paths,omitempty"`
}
// ExposePath describes a host path that should remain visible inside the isolated
// child-process environment. This is currently implemented on Linux only.
type ExposePath struct {
Source string `json:"source"`
Target string `json:"target,omitempty"`
Mode string `json:"mode"`
}
// FilterSensitiveData filters sensitive values from content before sending to LLM.
// This prevents the LLM from seeing its own credentials.
// Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig).

View file

@ -852,6 +852,37 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
}
}
func TestDefaultConfig_IsolationEnabled(t *testing.T) {
cfg := DefaultConfig()
if cfg.Isolation.Enabled {
t.Fatal("DefaultConfig().Isolation.Enabled should be false")
}
}
func TestConfig_UnmarshalIsolation(t *testing.T) {
cfg := DefaultConfig()
raw := []byte(`{
"isolation": {
"enabled": false,
"expose_paths": [
{"source":"/src","target":"/dst","mode":"ro"}
]
}
}`)
if err := json.Unmarshal(raw, cfg); err != nil {
t.Fatalf("json.Unmarshal isolation config: %v", err)
}
if cfg.Isolation.Enabled {
t.Fatal("Isolation.Enabled should be false after unmarshal")
}
if len(cfg.Isolation.ExposePaths) != 1 {
t.Fatalf("ExposePaths len = %d, want 1", len(cfg.Isolation.ExposePaths))
}
if got := cfg.Isolation.ExposePaths[0]; got.Source != "/src" || got.Target != "/dst" || got.Mode != "ro" {
t.Fatalf("ExposePaths[0] = %+v, want source=/src target=/dst mode=ro", got)
}
}
// TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators
func TestFlexibleStringSlice_UnmarshalText(t *testing.T) {
tests := []struct {

View file

@ -17,6 +17,11 @@ func DefaultConfig() *Config {
return &Config{
Version: CurrentVersion,
// Isolation is opt-in so existing installations keep their current behavior
// until the user explicitly enables subprocess sandboxing.
Isolation: IsolationConfig{
Enabled: false,
},
Agents: AgentsConfig{
Defaults: AgentDefaults{
Workspace: workspacePath,

238
pkg/isolation/README.md Normal file
View file

@ -0,0 +1,238 @@
# `pkg/isolation`
`pkg/isolation` provides process-level isolation for child processes started by `picoclaw`.
It does not sandbox the main `picoclaw` process itself.
## Scope
The current scope is the child-process startup path:
- `exec` tool
- CLI providers such as `claude-cli` and `codex-cli`
- process hooks
- MCP `stdio` servers
## One-Sentence Model
- The `picoclaw` main process still runs in the host environment.
- Every child process should enter the shared `pkg/isolation` startup path first.
- The startup path applies platform-specific isolation according to config.
## Architecture
The implementation has four layers:
1. Configuration layer: reads `config.Config.Isolation` and injects it through `isolation.Configure(cfg)`.
2. Instance layout layer: resolves `config.GetHome()`, prepares instance directories, and builds the runtime user environment.
3. Platform backend layer: Linux uses `bwrap`; Windows uses a restricted token, low integrity, and a `Job Object`; other platforms are not implemented.
4. Unified startup layer: `PrepareCommand(cmd)`, `Start(cmd)`, and `Run(cmd)`.
All integrations that spawn subprocesses should reuse these helpers instead of calling `cmd.Start` or `cmd.Run` directly.
## Configuration
Isolation lives under:
```json
{
"isolation": {
"enabled": false,
"expose_paths": []
}
}
```
Field meanings:
- `enabled`: enables or disables subprocess isolation. Default: `false`.
- `expose_paths`: explicitly exposes host paths inside the isolated environment. It only matters when `enabled=true`. This is currently supported on Linux only.
Example:
```json
{
"isolation": {
"enabled": true,
"expose_paths": [
{
"source": "/opt/toolchains/go",
"target": "/opt/toolchains/go",
"mode": "ro"
},
{
"source": "/data/shared-assets",
"target": "/opt/picoclaw-instance-a/workspace/assets",
"mode": "rw"
}
]
}
}
```
Rules for `expose_paths`:
- `source` is a host path.
- `target` is the path inside the isolated environment.
- `mode` must be `ro` or `rw`.
- When `target` is empty, it defaults to `source`.
- Only one final rule may exist for the same `target`.
- Later-loaded config overrides earlier rules for the same `target`.
Platform note:
- Linux uses a real `source -> target` mount view.
- Windows does not currently support `expose_paths`.
## Instance Root And Directories
The instance root follows `config.GetHome()`:
- If `PICOCLAW_HOME` is set, use it.
- Otherwise use the default `.picoclaw` directory under the user home.
If `config.GetHome()` falls back to `.` while isolation is enabled, startup should fail.
Default instance directories include:
- instance root
- `skills`
- `logs`
- `cache`
- `state`
- `runtime-user-env`
`workspace` is derived from `cfg.WorkspacePath()` when configured, otherwise from the default workspace rule.
Windows also prepares:
- `runtime-user-env/AppData/Roaming`
- `runtime-user-env/AppData/Local`
## User Environment Redirect
When isolation is enabled, child processes receive a redirected per-instance user environment.
Linux variables:
- `HOME`
- `TMPDIR`
- `XDG_CONFIG_HOME`
- `XDG_CACHE_HOME`
- `XDG_STATE_HOME`
Windows variables:
- `USERPROFILE`
- `HOME`
- `TEMP`
- `TMP`
- `APPDATA`
- `LOCALAPPDATA`
These paths point into `runtime-user-env` under the instance root.
## Platform Behavior
### Linux
The Linux backend currently depends on `bwrap` (`bubblewrap`).
Capabilities:
- minimal filesystem view
- `ipc` namespace isolation
- redirected child-process user environment
- `source -> target` read-only or read-write mounts
Default mounts include the instance root plus the minimum runtime system paths such as `/usr`, `/bin`, `/lib`, `/lib64`, and `/etc/resolv.conf`.
At runtime, PicoClaw also adds the executable path, its directory, the effective working directory, and absolute path arguments when needed.
There is no automatic fallback when `bwrap` is missing.
Install examples:
- `apt install bubblewrap`
- `dnf install bubblewrap`
- `yum install bubblewrap`
- `pacman -S bubblewrap`
- `apk add bubblewrap`
If isolation must be disabled temporarily:
```json
{
"isolation": {
"enabled": false
}
}
```
Disabling isolation increases the risk that child processes can access or modify more host files.
### Windows
Windows isolation currently supports process-level restrictions such as restricted tokens, low integrity, job objects, and redirected user-environment directories.
`expose_paths` is not currently supported on Windows. If it is configured, startup should fail instead of pretending the paths were exposed.
The Windows backend currently uses:
- a restricted primary token
- low integrity level
- a `Job Object`
- redirected child-process user environment
It does not currently implement true `source -> target` filesystem remapping.
### macOS And Other Platforms
They are not implemented yet.
When isolation is explicitly enabled on an unsupported platform, the higher-level runtime should surface that as an unsupported configuration instead of pretending isolation succeeded.
## Logging And Debugging
When isolation is enabled, PicoClaw logs the generated isolation plan.
Linux log name:
- `linux isolation mount plan`
Windows log name:
- `windows isolation access rules`
If you suspect isolation is ineffective, check whether unexpected host paths appear in those logs.
## Relationship To `restrict_to_workspace`
- `restrict_to_workspace` limits the paths an agent is normally allowed to access.
- `pkg/isolation` limits what a child process can see and where its user environment points.
They complement each other and do not replace each other.
## Current Limits
- Linux isolation is implemented with `bwrap`, not a custom in-process isolation runtime.
- Linux does not currently enable a dedicated `pid` namespace by default.
- Windows does not yet implement full host ACL enforcement for every allowed or denied path.
- macOS is not implemented.
- The current design isolates child processes, not the main `picoclaw` process.
## Suggested Reading Order
If you are new to this code, read it in this order:
1. `pkg/config/config.go`
2. `pkg/isolation/runtime.go`
3. `pkg/isolation/platform_linux.go`
4. `pkg/isolation/platform_windows.go`
5. Call sites:
6. `pkg/tools/shell.go`
7. `pkg/providers/*.go`
8. `pkg/agent/hook_process.go`
9. `pkg/mcp/manager.go`
That path gives the fastest overview of the configuration model, runtime flow, and platform-specific limits.

238
pkg/isolation/README_CN.md Normal file
View file

@ -0,0 +1,238 @@
# `pkg/isolation`
`pkg/isolation``picoclaw` 启动的子进程提供进程级隔离能力。
它当前不会把 `picoclaw` 主进程自身放进沙箱中运行。
## 生效范围
当前生效范围是子进程启动链路:
- `exec` 工具
- `claude-cli``codex-cli` 等 CLI provider
- 进程型 hooks
- MCP `stdio` server
## 一句话理解
- `picoclaw` 主进程仍运行在宿主环境中。
- 所有子进程都应先经过 `pkg/isolation` 的统一启动入口。
- 入口会根据配置和平台,为子进程施加对应隔离。
## 架构
当前实现可以分为四层:
1. 配置层:读取 `config.Config.Isolation`,并通过 `isolation.Configure(cfg)` 注入运行时。
2. 实例目录层:解析 `config.GetHome()`,准备实例目录,并构建运行时用户环境目录。
3. 平台后端层Linux 使用 `bwrap`Windows 使用受限 token、低完整性级别和 `Job Object`;其他平台未实现。
4. 统一启动层:`PrepareCommand(cmd)``Start(cmd)``Run(cmd)`
所有启动子进程的接入点都应复用这组入口,而不是各自直接调用 `cmd.Start``cmd.Run`
## 配置
隔离配置位于:
```json
{
"isolation": {
"enabled": false,
"expose_paths": []
}
}
```
字段说明:
- `enabled`:是否启用子进程隔离。默认值:`false`
- `expose_paths`:显式把宿主路径带入隔离环境。仅在 `enabled=true` 时生效。目前只在 Linux 上支持。
示例:
```json
{
"isolation": {
"enabled": true,
"expose_paths": [
{
"source": "/opt/toolchains/go",
"target": "/opt/toolchains/go",
"mode": "ro"
},
{
"source": "/data/shared-assets",
"target": "/opt/picoclaw-instance-a/workspace/assets",
"mode": "rw"
}
]
}
}
```
`expose_paths` 规则:
- `source`:宿主机路径。
- `target`:隔离环境内的目标路径。
- `mode`:只能是 `ro``rw`
- `target` 为空时,默认等于 `source`
- 同一个 `target` 最终只能保留一条规则。
- 后加载的配置会覆盖先加载的同目标规则。
平台说明:
- Linux 会真实使用 `source -> target` 挂载视图。
- Windows 当前不支持 `expose_paths`
## 实例根与目录
实例根遵循 `config.GetHome()`
- 如果设置了 `PICOCLAW_HOME`,使用该值。
- 否则默认使用用户目录下的 `.picoclaw`
如果 `config.GetHome()` 在隔离开启时最终回退到当前目录 `.`,启动应直接失败。
默认实例目录包括:
- 实例根本身
- `skills`
- `logs`
- `cache`
- `state`
- `runtime-user-env`
`workspace` 优先使用 `cfg.WorkspacePath()` 的结果;未显式配置时才按默认规则派生。
Windows 还会额外准备:
- `runtime-user-env/AppData/Roaming`
- `runtime-user-env/AppData/Local`
## 用户环境重定向
隔离开启后,子进程会收到重定向到实例目录下的独立用户环境。
Linux 注入变量:
- `HOME`
- `TMPDIR`
- `XDG_CONFIG_HOME`
- `XDG_CACHE_HOME`
- `XDG_STATE_HOME`
Windows 注入变量:
- `USERPROFILE`
- `HOME`
- `TEMP`
- `TMP`
- `APPDATA`
- `LOCALAPPDATA`
这些路径都会指向实例根下的 `runtime-user-env`
## 平台行为
### Linux
Linux 后端当前依赖 `bwrap``bubblewrap`)。
能力:
- 最小文件系统视图
- `ipc namespace`
- 子进程用户环境重定向
- `source -> target` 只读或读写挂载
默认映射包括实例根,以及 `/usr``/bin``/lib``/lib64``/etc/resolv.conf` 等最小运行时系统路径。
运行时还会按需补充可执行文件本身、其所在目录、生效后的工作目录,以及命令行中的绝对路径参数。
缺少 `bwrap` 时不会自动回退。
安装示例:
- `apt install bubblewrap`
- `dnf install bubblewrap`
- `yum install bubblewrap`
- `pacman -S bubblewrap`
- `apk add bubblewrap`
如果需要临时关闭隔离:
```json
{
"isolation": {
"enabled": false
}
}
```
关闭隔离后,子进程访问或修改更多宿主文件的风险会明显上升。
### Windows
Windows 隔离当前提供的是进程级限制,例如 restricted token、low integrity、job object以及用户环境目录重定向。
`expose_paths` 目前不支持 Windows。如果配置了该字段启动应直接失败而不是假装这些路径已经被暴露进隔离环境。
Windows 后端当前使用:
- 受限 primary token
- 低完整性级别
- `Job Object`
- 子进程用户环境重定向
它当前不会实现真正的 `source -> target` 文件系统重映射。
### macOS 与其他平台
当前尚未实现。
当在未支持的平台上显式开启隔离时,上层运行时应将其视为不支持的配置,而不是假装隔离成功。
## 日志与排障
隔离开启后PicoClaw 会打印生成后的隔离计划,便于排障。
Linux 日志名:
- `linux isolation mount plan`
Windows 日志名:
- `windows isolation access rules`
如果你怀疑隔离未生效,先检查这些日志里是否出现了不应暴露的宿主路径。
## 与 `restrict_to_workspace` 的关系
- `restrict_to_workspace` 限制的是 agent 默认可访问的路径。
- `pkg/isolation` 限制的是子进程运行时能看到什么文件系统,以及它的用户环境指向哪里。
两者互补,不互相替代。
## 当前限制
- Linux 基于 `bwrap` 实现,而不是纯内建 isolation runtime。
- Linux 当前没有默认启用独立的 `pid namespace`
- Windows 还没有对所有允许/拒绝路径做完整 ACL 落地。
- macOS 尚未实现。
- 当前隔离的是子进程,不是 `picoclaw` 主进程自身。
## 建议阅读顺序
如果你是第一次看这部分代码,建议按这个顺序阅读:
1. `pkg/config/config.go`
2. `pkg/isolation/runtime.go`
3. `pkg/isolation/platform_linux.go`
4. `pkg/isolation/platform_windows.go`
5. 调用点:
6. `pkg/tools/shell.go`
7. `pkg/providers/*.go`
8. `pkg/agent/hook_process.go`
9. `pkg/mcp/manager.go`
这样能最快建立对配置模型、运行流程和平台边界的整体理解。

View file

@ -0,0 +1,264 @@
//go:build linux
package isolation
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
if !isolation.Enabled {
return nil
}
// Bubblewrap is the only supported Linux backend right now. Fail closed when
// it is unavailable instead of silently running the child process unisolated.
bwrapPath, err := exec.LookPath("bwrap")
if err != nil {
hint := bwrapInstallHint()
disableHint := `set "isolation.enabled": false in config.json`
logger.WarnCF("isolation", "bubblewrap is required for Linux isolation",
map[string]any{
"binary": "bwrap",
"install": hint,
"disable_isolation": disableHint,
"risk": "disabling isolation lets child processes run without Linux filesystem isolation",
})
return fmt.Errorf(
"linux isolation requires bwrap and does not fall back automatically: %w; install bubblewrap with one of: %s; or disable isolation by setting %s; disabling isolation means child processes can run without Linux filesystem isolation and may access or modify more host files",
err,
hint,
disableHint,
)
}
if cmd == nil || cmd.Path == "" || len(cmd.Args) == 0 {
return nil
}
originalPath := cmd.Path
originalArgs := append([]string{}, cmd.Args...)
_, execDir, err := resolveLinuxWorkingDir(cmd.Dir, originalPath)
if err != nil {
return err
}
resolvedPath, err := resolveLinuxCommandPath(originalPath, execDir)
if err != nil {
return err
}
// Start from the configured mount plan, then add only the executable, its
// resolved path, the effective working directory, and any absolute path
// arguments needed to preserve the original command semantics.
plan := BuildLinuxMountPlan(root, isolation.ExposePaths)
plan = ensureLinuxMountRule(plan, resolvedPath, resolvedPath, "ro")
plan = ensureLinuxMountRule(plan, filepath.Dir(resolvedPath), filepath.Dir(resolvedPath), "ro")
if resolved, resolveErr := filepath.EvalSymlinks(resolvedPath); resolveErr == nil && resolved != resolvedPath {
plan = ensureLinuxMountRule(plan, resolved, resolved, "ro")
plan = ensureLinuxMountRule(plan, filepath.Dir(resolved), filepath.Dir(resolved), "ro")
}
if execDir != "" {
plan = ensureLinuxMountRule(plan, execDir, execDir, "rw")
if resolved, resolveErr := filepath.EvalSymlinks(execDir); resolveErr == nil && resolved != execDir {
plan = ensureLinuxMountRule(plan, resolved, resolved, "rw")
}
}
plan = appendLinuxArgumentMounts(plan, originalArgs[1:])
logger.DebugCF("isolation", "linux isolation mount plan",
map[string]any{
"root": root,
"command": resolvedPath,
"working_dir": execDir,
"mounts": formatLinuxMountPlan(plan),
})
bwrapArgs, err := buildLinuxBwrapArgs(originalPath, resolvedPath, originalArgs, execDir, plan)
if err != nil {
return err
}
cmd.Path = bwrapPath
cmd.Args = bwrapArgs
cmd.Dir = ""
return nil
}
func bwrapInstallHint() string {
return "apt install bubblewrap; dnf install bubblewrap; yum install bubblewrap; pacman -S bubblewrap; apk add bubblewrap"
}
// formatLinuxMountPlan reshapes the internal plan for structured logging.
func formatLinuxMountPlan(plan []MountRule) []map[string]string {
formatted := make([]map[string]string, 0, len(plan))
for _, rule := range plan {
formatted = append(formatted, map[string]string{
"source": rule.Source,
"target": rule.Target,
"mode": rule.Mode,
})
}
return formatted
}
func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
return nil
}
func cleanupPendingPlatformResources(cmd *exec.Cmd) {
}
// buildLinuxBwrapArgs translates the mount plan into the bubblewrap command
// line that re-executes the original process inside the isolated mount view.
func buildLinuxBwrapArgs(
originalPath string,
resolvedPath string,
originalArgs []string,
execDir string,
plan []MountRule,
) ([]string, error) {
bwrapArgs := []string{
"bwrap",
"--die-with-parent",
"--unshare-ipc",
"--proc", "/proc",
"--dev", "/dev",
}
for _, rule := range plan {
flag, err := linuxBindFlag(rule)
if err != nil {
return nil, err
}
bwrapArgs = append(bwrapArgs, flag, rule.Source, rule.Target)
}
if execDir != "" {
bwrapArgs = append(bwrapArgs, "--chdir", execDir)
}
execPath := originalPath
if isRelativeCommandPath(originalPath) {
execPath = resolvedPath
}
bwrapArgs = append(bwrapArgs, "--", execPath)
if len(originalArgs) > 1 {
bwrapArgs = append(bwrapArgs, originalArgs[1:]...)
}
return bwrapArgs, nil
}
func resolveLinuxWorkingDir(originalDir, originalPath string) (string, string, error) {
if originalDir != "" {
resolved, err := filepath.Abs(originalDir)
if err != nil {
return "", "", fmt.Errorf("resolve command dir %s: %w", originalDir, err)
}
return resolved, resolved, nil
}
if !isRelativeCommandPath(originalPath) {
return "", "", nil
}
wd, err := os.Getwd()
if err != nil {
return "", "", fmt.Errorf("resolve current working dir: %w", err)
}
return "", wd, nil
}
func resolveLinuxCommandPath(originalPath, execDir string) (string, error) {
if filepath.IsAbs(originalPath) || !isRelativeCommandPath(originalPath) {
return filepath.Clean(originalPath), nil
}
base := execDir
if base == "" {
var err error
base, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("resolve current working dir: %w", err)
}
}
return filepath.Clean(filepath.Join(base, originalPath)), nil
}
func appendLinuxArgumentMounts(plan []MountRule, args []string) []MountRule {
for _, arg := range args {
path, ok := linuxArgumentPath(arg)
if !ok {
continue
}
clean := filepath.Clean(path)
if info, err := os.Stat(clean); err == nil {
mode := "ro"
if info.IsDir() {
mode = "rw"
}
plan = ensureLinuxMountRule(plan, clean, clean, mode)
if resolved, resolveErr := filepath.EvalSymlinks(clean); resolveErr == nil && resolved != clean {
plan = ensureLinuxMountRule(plan, resolved, resolved, mode)
}
continue
} else if !errors.Is(err, os.ErrNotExist) {
continue
}
parent := filepath.Dir(clean)
if parent == clean {
continue
}
if _, err := os.Stat(parent); err == nil {
plan = ensureLinuxMountRule(plan, parent, parent, "rw")
}
}
return plan
}
func linuxArgumentPath(arg string) (string, bool) {
if filepath.IsAbs(arg) {
return arg, true
}
idx := strings.IndexRune(arg, '=')
if idx <= 0 || idx == len(arg)-1 {
return "", false
}
value := arg[idx+1:]
if !filepath.IsAbs(value) {
return "", false
}
return value, true
}
func isRelativeCommandPath(path string) bool {
return !filepath.IsAbs(path) && strings.ContainsRune(path, filepath.Separator)
}
// ensureLinuxMountRule appends a mount rule unless another rule already owns
// the same target path.
func ensureLinuxMountRule(plan []MountRule, source, target, mode string) []MountRule {
cleanSource := filepath.Clean(source)
cleanTarget := filepath.Clean(target)
for _, rule := range plan {
if filepath.Clean(rule.Target) == cleanTarget {
return plan
}
}
return append(plan, MountRule{Source: cleanSource, Target: cleanTarget, Mode: mode})
}
// linuxBindFlag selects the correct bubblewrap bind flag based on mount mode.
func linuxBindFlag(rule MountRule) (string, error) {
info, err := os.Stat(rule.Source)
if err != nil {
return "", fmt.Errorf("stat linux mount source %s: %w", rule.Source, err)
}
if !info.IsDir() {
if rule.Mode == "rw" {
return "--bind", nil
}
return "--ro-bind", nil
}
if rule.Mode == "rw" {
return "--bind", nil
}
return "--ro-bind", nil
}

View file

@ -0,0 +1,148 @@
//go:build linux
package isolation
import (
"os"
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestBuildLinuxBwrapArgs_IncludesNamespaceFlagsAndExec(t *testing.T) {
root := t.TempDir()
binaryDir := filepath.Join(root, "bin")
if err := os.MkdirAll(binaryDir, 0o755); err != nil {
t.Fatal(err)
}
binaryPath := filepath.Join(binaryDir, "tool")
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
plan := BuildLinuxMountPlan(root, []config.ExposePath{{Source: binaryDir, Target: binaryDir, Mode: "ro"}})
args, err := buildLinuxBwrapArgs(binaryPath, binaryPath, []string{binaryPath, "--flag"}, root, plan)
if err != nil {
t.Fatalf("buildLinuxBwrapArgs() error = %v", err)
}
hasNet := false
hasIPC := false
hasExec := false
for i := range args {
switch args[i] {
case "--unshare-net":
hasNet = true
case "--unshare-ipc":
hasIPC = true
case "--":
if i+1 < len(args) && args[i+1] == binaryPath {
hasExec = true
}
}
}
if hasNet {
t.Fatalf("bwrap args should not unshare net by default: %v", args)
}
if !hasIPC || !hasExec {
t.Fatalf("bwrap args missing required items: %v", args)
}
}
func TestResolveLinuxWorkingDir_ResolvesRelativeDir(t *testing.T) {
cwd := t.TempDir()
previous, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
defer func() {
if chdirErr := os.Chdir(previous); chdirErr != nil {
t.Fatalf("restore cwd: %v", chdirErr)
}
}()
if chdirErr := os.Chdir(cwd); chdirErr != nil {
t.Fatal(chdirErr)
}
resolvedDir, execDir, err := resolveLinuxWorkingDir("./hooks", "./hook.sh")
if err != nil {
t.Fatalf("resolveLinuxWorkingDir() error = %v", err)
}
want := filepath.Join(cwd, "hooks")
if resolvedDir != want || execDir != want {
t.Fatalf("resolveLinuxWorkingDir() = (%q, %q), want (%q, %q)", resolvedDir, execDir, want, want)
}
}
func TestResolveLinuxCommandPath_UsesExecDirForRelativeCommand(t *testing.T) {
execDir := filepath.Join(t.TempDir(), "hooks")
got, err := resolveLinuxCommandPath("./hook.sh", execDir)
if err != nil {
t.Fatalf("resolveLinuxCommandPath() error = %v", err)
}
want := filepath.Join(execDir, "hook.sh")
if got != want {
t.Fatalf("resolveLinuxCommandPath() = %q, want %q", got, want)
}
}
func TestBuildLinuxBwrapArgs_UsesResolvedPathForRelativeCommand(t *testing.T) {
root := t.TempDir()
execDir := filepath.Join(root, "hooks")
if err := os.MkdirAll(execDir, 0o755); err != nil {
t.Fatal(err)
}
resolvedPath := filepath.Join(execDir, "hook.sh")
if err := os.WriteFile(resolvedPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
plan := []MountRule{
{Source: execDir, Target: execDir, Mode: "rw"},
{Source: resolvedPath, Target: resolvedPath, Mode: "ro"},
}
args, err := buildLinuxBwrapArgs("./hook.sh", resolvedPath, []string{"./hook.sh"}, execDir, plan)
if err != nil {
t.Fatalf("buildLinuxBwrapArgs() error = %v", err)
}
hasExecDir := false
for _, arg := range args {
if arg == execDir {
hasExecDir = true
break
}
}
if !hasExecDir {
t.Fatalf("buildLinuxBwrapArgs() missing resolved chdir: %v", args)
}
for i := range args {
if args[i] == "--" {
if i+1 >= len(args) || args[i+1] != resolvedPath {
t.Fatalf("buildLinuxBwrapArgs() exec path = %v, want %q after --", args, resolvedPath)
}
return
}
}
t.Fatalf("buildLinuxBwrapArgs() missing exec delimiter: %v", args)
}
func TestAppendLinuxArgumentMounts_AddsAbsoluteArgumentPaths(t *testing.T) {
root := t.TempDir()
input := filepath.Join(root, "input.txt")
if err := os.WriteFile(input, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
output := filepath.Join(root, "out", "result.txt")
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
t.Fatal(err)
}
plan := appendLinuxArgumentMounts(nil, []string{input, "--output=" + output})
if len(plan) != 2 {
t.Fatalf("appendLinuxArgumentMounts() len = %d, want 2", len(plan))
}
if plan[0].Source != input || plan[0].Mode != "ro" {
t.Fatalf("appendLinuxArgumentMounts()[0] = %+v, want source=%q mode=ro", plan[0], input)
}
if plan[1].Source != filepath.Dir(output) || plan[1].Mode != "rw" {
t.Fatalf("appendLinuxArgumentMounts()[1] = %+v, want source=%q mode=rw", plan[1], filepath.Dir(output))
}
}

View file

@ -0,0 +1,22 @@
//go:build !linux && !windows
package isolation
import (
"os/exec"
"github.com/sipeed/picoclaw/pkg/config"
)
func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
// Unsupported platforms currently keep the command unchanged. Callers rely on
// Preflight and higher-level checks to surface unsupported isolation modes.
return nil
}
func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
return nil
}
func cleanupPendingPlatformResources(cmd *exec.Cmd) {
}

View file

@ -0,0 +1,217 @@
//go:build windows
package isolation
import (
"fmt"
"os/exec"
"sync"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
const disableMaxPrivilege = 0x1
// windowsProcessResources holds native handles that must live for the lifetime
// of an isolated child process.
type windowsProcessResources struct {
job windows.Handle
token windows.Token
}
var (
windowsProcessResourcesByPID sync.Map
windowsPendingResources sync.Map
advapi32 = windows.NewLazySystemDLL("advapi32.dll")
procCreateRestrictedToken = advapi32.NewProc("CreateRestrictedToken")
)
func applyPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
if !isolation.Enabled || cmd == nil {
return nil
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
rules := BuildWindowsAccessRules(root, isolation.ExposePaths)
logger.InfoCF("isolation", "windows isolation process constraints",
map[string]any{
"root": root,
"command": cmd.Path,
"rules": formatWindowsAccessRules(rules),
"note": "Windows currently enforces restricted token, low integrity, and job object limits; expose_paths filesystem remapping is rejected during preflight",
})
// Create the restricted token before the process starts so CreateProcess uses
// the reduced privilege set from the first instruction.
restrictedToken, err := createRestrictedPrimaryToken()
if err != nil {
return fmt.Errorf("create restricted primary token: %w", err)
}
cmd.SysProcAttr.CreationFlags |= windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_BREAKAWAY_FROM_JOB
cmd.SysProcAttr.Token = syscall.Token(restrictedToken)
windowsPendingResources.Store(cmd, windowsProcessResources{token: restrictedToken})
return nil
}
func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, root string) error {
if !isolation.Enabled || cmd == nil || cmd.Process == nil {
return nil
}
resourcesAny, _ := windowsPendingResources.LoadAndDelete(cmd)
resources, _ := resourcesAny.(windowsProcessResources)
// Job objects can only be attached after the process exists, so the Windows
// backend finishes isolation in this post-start hook.
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
if resources.token != 0 {
_ = resources.token.Close()
}
return fmt.Errorf("create windows job object: %w", err)
}
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if _, err := windows.SetInformationJobObject(
job,
windows.JobObjectExtendedLimitInformation,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
); err != nil {
_ = windows.CloseHandle(job)
if resources.token != 0 {
_ = resources.token.Close()
}
return fmt.Errorf("set windows job object info: %w", err)
}
proc, err := windows.OpenProcess(
windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.SYNCHRONIZE,
false,
uint32(cmd.Process.Pid),
)
if err != nil {
_ = windows.CloseHandle(job)
if resources.token != 0 {
_ = resources.token.Close()
}
return fmt.Errorf("open process for job assignment: %w", err)
}
if err := windows.AssignProcessToJobObject(job, proc); err != nil {
_ = windows.CloseHandle(proc)
_ = windows.CloseHandle(job)
if resources.token != 0 {
_ = resources.token.Close()
}
return fmt.Errorf("assign process to job object: %w", err)
}
if resources.token != 0 {
_ = resources.token.Close()
}
resources.job = job
windowsProcessResourcesByPID.Store(cmd.Process.Pid, resources)
go reapWindowsProcessResources(cmd.Process.Pid, proc, job)
return nil
}
func cleanupPendingPlatformResources(cmd *exec.Cmd) {
if cmd == nil {
return
}
resourcesAny, ok := windowsPendingResources.LoadAndDelete(cmd)
if !ok {
return
}
resources, _ := resourcesAny.(windowsProcessResources)
if resources.token != 0 {
_ = resources.token.Close()
}
}
func reapWindowsProcessResources(pid int, proc windows.Handle, job windows.Handle) {
_, _ = windows.WaitForSingleObject(proc, windows.INFINITE)
_ = windows.CloseHandle(proc)
_ = windows.CloseHandle(job)
windowsProcessResourcesByPID.Delete(pid)
}
// createRestrictedPrimaryToken duplicates the current process token, removes
// maximum privileges, and lowers integrity before it is assigned to a child.
func createRestrictedPrimaryToken() (windows.Token, error) {
var current windows.Token
if err := windows.OpenProcessToken(
windows.CurrentProcess(),
windows.TOKEN_DUPLICATE|windows.TOKEN_ASSIGN_PRIMARY|windows.TOKEN_QUERY|windows.TOKEN_ADJUST_DEFAULT,
&current,
); err != nil {
return 0, err
}
defer current.Close()
var restricted windows.Token
r1, _, e1 := procCreateRestrictedToken.Call(
uintptr(current),
uintptr(disableMaxPrivilege),
0,
0,
0,
0,
0,
0,
0,
uintptr(unsafe.Pointer(&restricted)),
)
if r1 == 0 {
if e1 != nil && e1 != syscall.Errno(0) {
return 0, e1
}
return 0, syscall.EINVAL
}
if err := setTokenLowIntegrity(restricted); err != nil {
_ = restricted.Close()
return 0, err
}
return restricted, nil
}
// setTokenLowIntegrity lowers the token integrity level so writes to higher
// integrity locations are blocked by the OS.
func setTokenLowIntegrity(token windows.Token) error {
lowSID, err := windows.CreateWellKnownSid(windows.WinLowLabelSid)
if err != nil {
return fmt.Errorf("create low integrity sid: %w", err)
}
tml := windows.Tokenmandatorylabel{
Label: windows.SIDAndAttributes{
Sid: lowSID,
Attributes: windows.SE_GROUP_INTEGRITY,
},
}
if err := windows.SetTokenInformation(
token,
windows.TokenIntegrityLevel,
(*byte)(unsafe.Pointer(&tml)),
tml.Size(),
); err != nil {
return fmt.Errorf("set token low integrity: %w", err)
}
return nil
}
// formatWindowsAccessRules reshapes the internal rules for structured logging.
func formatWindowsAccessRules(rules []AccessRule) []map[string]string {
formatted := make([]map[string]string, 0, len(rules))
for _, rule := range rules {
formatted = append(formatted, map[string]string{
"path": rule.Path,
"mode": rule.Mode,
})
}
return formatted
}

443
pkg/isolation/runtime.go Normal file
View file

@ -0,0 +1,443 @@
package isolation
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/config"
)
// MountRule describes a source-to-target mount exposed inside the Linux
// isolation view.
type MountRule struct {
Source string
Target string
Mode string
}
// AccessRule describes the effective Windows-side access rule for a host path.
type AccessRule struct {
Path string
Mode string
}
// UserEnv contains the redirected per-instance user directories injected into
// isolated child processes.
type UserEnv struct {
Home string
Tmp string
Config string
Cache string
State string
AppData string
LocalAppData string
}
var (
isolationMu sync.RWMutex
currentIsolation = config.DefaultConfig().Isolation
)
// Configure updates the process-wide isolation state used by subsequent child
// process launches.
func Configure(cfg *config.Config) {
isolationMu.Lock()
defer isolationMu.Unlock()
if cfg == nil {
defaults := config.DefaultConfig()
currentIsolation = defaults.Isolation
return
}
currentIsolation = cfg.Isolation
}
// CurrentConfig returns the currently active isolation settings.
func CurrentConfig() config.IsolationConfig {
isolationMu.RLock()
defer isolationMu.RUnlock()
return currentIsolation
}
// ResolveInstanceRoot resolves the instance root used to build the isolated
// filesystem and redirected user environment.
func ResolveInstanceRoot() (string, error) {
root := filepath.Clean(config.GetHome())
if root == "." {
return "", fmt.Errorf("instance root resolved to current directory")
}
return root, nil
}
// PrepareInstanceRoot creates the directories required by the isolation runtime.
func PrepareInstanceRoot(root string) error {
for _, dir := range InstanceDirs(root) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("prepare instance dir %s: %w", dir, err)
}
}
return nil
}
// InstanceDirs returns the directories that must exist under the instance root
// for isolation-aware child processes.
func InstanceDirs(root string) []string {
dirs := []string{
root,
filepath.Join(root, "skills"),
filepath.Join(root, "logs"),
filepath.Join(root, "cache"),
filepath.Join(root, "state"),
filepath.Join(root, "runtime-user-env"),
filepath.Join(root, "runtime-user-env", "home"),
filepath.Join(root, "runtime-user-env", "tmp"),
filepath.Join(root, "runtime-user-env", "config"),
filepath.Join(root, "runtime-user-env", "cache"),
filepath.Join(root, "runtime-user-env", "state"),
}
dirs = append(dirs, filepath.Join(root, pkg.WorkspaceName))
if runtime.GOOS == "windows" {
dirs = append(dirs,
filepath.Join(root, "runtime-user-env", "AppData", "Roaming"),
filepath.Join(root, "runtime-user-env", "AppData", "Local"),
)
}
return dirs
}
// ResolveUserEnv derives the redirected user directories rooted under the
// instance runtime area.
func ResolveUserEnv(root string) UserEnv {
base := filepath.Join(root, "runtime-user-env")
return UserEnv{
Home: filepath.Join(base, "home"),
Tmp: filepath.Join(base, "tmp"),
Config: filepath.Join(base, "config"),
Cache: filepath.Join(base, "cache"),
State: filepath.Join(base, "state"),
AppData: filepath.Join(base, "AppData", "Roaming"),
LocalAppData: filepath.Join(base, "AppData", "Local"),
}
}
// ApplyUserEnv rewrites the child process environment so home, temp, and
// platform-specific user-data directories point into the instance root.
func ApplyUserEnv(cmd *exec.Cmd, root string) {
userEnv := ResolveUserEnv(root)
envMap := make(map[string]string)
for _, item := range cmd.Environ() {
if idx := strings.IndexRune(item, '='); idx > 0 {
envMap[item[:idx]] = item[idx+1:]
}
}
if runtime.GOOS == "windows" {
envMap["USERPROFILE"] = userEnv.Home
envMap["HOME"] = userEnv.Home
envMap["TEMP"] = userEnv.Tmp
envMap["TMP"] = userEnv.Tmp
envMap["APPDATA"] = userEnv.AppData
envMap["LOCALAPPDATA"] = userEnv.LocalAppData
} else {
envMap["HOME"] = userEnv.Home
envMap["TMPDIR"] = userEnv.Tmp
envMap["XDG_CONFIG_HOME"] = userEnv.Config
envMap["XDG_CACHE_HOME"] = userEnv.Cache
envMap["XDG_STATE_HOME"] = userEnv.State
}
env := make([]string, 0, len(envMap))
for k, v := range envMap {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
cmd.Env = env
}
// ValidateExposePaths verifies the user-supplied path exposure rules before a
// child process is started.
func ValidateExposePaths(items []config.ExposePath) error {
seen := map[string]struct{}{}
for _, item := range items {
if item.Source == "" {
return fmt.Errorf("source is required")
}
if item.Mode != "ro" && item.Mode != "rw" {
return fmt.Errorf("invalid expose_paths mode: %s", item.Mode)
}
source := filepath.Clean(item.Source)
target := item.Target
if target == "" {
target = source
}
target = filepath.Clean(target)
if !filepath.IsAbs(source) || !filepath.IsAbs(target) {
return fmt.Errorf("source and target must be absolute paths")
}
if _, ok := seen[target]; ok {
return fmt.Errorf("duplicate expose_path target: %s", target)
}
seen[target] = struct{}{}
}
return nil
}
// NormalizeExposePath fills implicit defaults and cleans path values so merge
// and validation logic can work with canonical paths.
func NormalizeExposePath(item config.ExposePath) config.ExposePath {
source := filepath.Clean(item.Source)
target := item.Target
if target == "" {
target = source
}
return config.ExposePath{
Source: source,
Target: filepath.Clean(target),
Mode: item.Mode,
}
}
// DefaultExposePaths returns the minimum built-in host paths required for the
// current platform to run isolated child processes.
func DefaultExposePaths(root string) []config.ExposePath {
items := []config.ExposePath{{
Source: root,
Target: root,
Mode: "rw",
}}
if runtime.GOOS == "linux" {
items = append(items, defaultLinuxSystemExposePaths()...)
}
return items
}
func defaultLinuxSystemExposePaths() []config.ExposePath {
return existingExposePaths([]config.ExposePath{
{Source: "/usr", Target: "/usr", Mode: "ro"},
{Source: "/bin", Target: "/bin", Mode: "ro"},
{Source: "/lib", Target: "/lib", Mode: "ro"},
{Source: "/lib64", Target: "/lib64", Mode: "ro"},
{Source: "/etc/resolv.conf", Target: "/etc/resolv.conf", Mode: "ro"},
{Source: "/etc/hosts", Target: "/etc/hosts", Mode: "ro"},
{Source: "/etc/nsswitch.conf", Target: "/etc/nsswitch.conf", Mode: "ro"},
{Source: "/etc/passwd", Target: "/etc/passwd", Mode: "ro"},
{Source: "/etc/group", Target: "/etc/group", Mode: "ro"},
{Source: "/etc/ssl", Target: "/etc/ssl", Mode: "ro"},
{Source: "/etc/pki", Target: "/etc/pki", Mode: "ro"},
{Source: "/etc/ca-certificates", Target: "/etc/ca-certificates", Mode: "ro"},
{Source: "/usr/share/ca-certificates", Target: "/usr/share/ca-certificates", Mode: "ro"},
{Source: "/usr/local/share/ca-certificates", Target: "/usr/local/share/ca-certificates", Mode: "ro"},
{Source: "/etc/alternatives", Target: "/etc/alternatives", Mode: "ro"},
{Source: "/usr/share/zoneinfo", Target: "/usr/share/zoneinfo", Mode: "ro"},
{Source: "/etc/localtime", Target: "/etc/localtime", Mode: "ro"},
})
}
// existingExposePaths keeps only the builtin host paths that exist on the
// current machine so Linux isolation does not fail on distro-specific paths.
func existingExposePaths(items []config.ExposePath) []config.ExposePath {
filtered := make([]config.ExposePath, 0, len(items))
for _, item := range items {
if _, err := os.Stat(item.Source); err == nil {
filtered = append(filtered, item)
}
}
return filtered
}
// MergeExposePaths merges built-in rules with user overrides. Rules are keyed
// by target path so later entries replace earlier ones for the same target.
func MergeExposePaths(defaults []config.ExposePath, overrides []config.ExposePath) []config.ExposePath {
merged := make([]config.ExposePath, 0, len(defaults)+len(overrides))
indexByTarget := make(map[string]int, len(defaults)+len(overrides))
appendOrReplace := func(item config.ExposePath) {
normalized := NormalizeExposePath(item)
if idx, ok := indexByTarget[normalized.Target]; ok {
merged[idx] = normalized
return
}
indexByTarget[normalized.Target] = len(merged)
merged = append(merged, normalized)
}
for _, item := range defaults {
appendOrReplace(item)
}
for _, item := range overrides {
appendOrReplace(item)
}
return merged
}
// BuildLinuxMountPlan converts the merged expose-path configuration into the
// mount rules consumed by the Linux bubblewrap backend.
func BuildLinuxMountPlan(root string, overrides []config.ExposePath) []MountRule {
merged := MergeExposePaths(DefaultExposePaths(root), overrides)
plan := make([]MountRule, 0, len(merged))
for _, item := range merged {
plan = append(plan, MountRule{Source: item.Source, Target: item.Target, Mode: item.Mode})
}
return plan
}
// BuildWindowsAccessRules derives the host-path access policy used by the
// Windows restricted-token backend.
func BuildWindowsAccessRules(root string, overrides []config.ExposePath) []AccessRule {
merged := MergeExposePaths(nil, overrides)
rules := make([]AccessRule, 0, len(merged)+1)
rules = append(rules, AccessRule{Path: root, Mode: "rw"})
for _, item := range merged {
rules = append(rules, AccessRule{Path: item.Source, Mode: item.Mode})
}
return rules
}
func validateWindowsExposePaths(items []config.ExposePath) error {
if len(items) == 0 {
return nil
}
return fmt.Errorf("windows isolation does not yet support expose_paths filesystem rules")
}
// IsSupported reports whether the current platform has an implemented isolation
// backend.
func IsSupported() bool {
return isSupportedOn(runtime.GOOS)
}
func isSupportedOn(goos string) bool {
switch goos {
case "linux", "windows":
return true
default:
return false
}
}
// Preflight validates the configured isolation state and prepares the instance
// runtime directories before any child process is launched.
func Preflight() error {
isolation := CurrentConfig()
if !isolation.Enabled {
return nil
}
if !IsSupported() {
return fmt.Errorf("subprocess isolation is not supported on %s", runtime.GOOS)
}
root, err := ResolveInstanceRoot()
if err != nil {
return err
}
if err := PrepareInstanceRoot(root); err != nil {
return err
}
if err := ValidateExposePaths(isolation.ExposePaths); err != nil {
return err
}
if runtime.GOOS == "linux" {
for _, rule := range BuildLinuxMountPlan(root, isolation.ExposePaths) {
if rule.Source == "" || rule.Target == "" {
return fmt.Errorf("invalid linux mount rule")
}
}
}
if runtime.GOOS == "windows" {
if err := validateWindowsExposePaths(isolation.ExposePaths); err != nil {
return err
}
for _, rule := range BuildWindowsAccessRules(root, isolation.ExposePaths) {
if rule.Path == "" {
return fmt.Errorf("invalid windows access rule")
}
}
}
return nil
}
// Start prepares isolation for the command, starts it, and applies any
// post-start platform hooks required by the active backend.
func Start(cmd *exec.Cmd) error {
if err := PrepareCommand(cmd); err != nil {
return err
}
if err := cmd.Start(); err != nil {
cleanupPendingPlatformResources(cmd)
return err
}
isolation := CurrentConfig()
root := ""
if isolation.Enabled {
var err error
root, err = ResolveInstanceRoot()
if err != nil {
terminateStartedCommand(cmd)
return err
}
}
if err := postStartPlatformIsolation(cmd, isolation, root); err != nil {
terminateStartedCommand(cmd)
return err
}
return nil
}
// Run is the Start-and-Wait helper that keeps the same isolation behavior as
// Start while returning the command's final exit status.
func Run(cmd *exec.Cmd) error {
if err := PrepareCommand(cmd); err != nil {
return err
}
if err := cmd.Start(); err != nil {
cleanupPendingPlatformResources(cmd)
return err
}
isolation := CurrentConfig()
root := ""
if isolation.Enabled {
var err error
root, err = ResolveInstanceRoot()
if err != nil {
terminateStartedCommand(cmd)
return err
}
}
if err := postStartPlatformIsolation(cmd, isolation, root); err != nil {
terminateStartedCommand(cmd)
return err
}
return cmd.Wait()
}
func terminateStartedCommand(cmd *exec.Cmd) {
cleanupPendingPlatformResources(cmd)
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
// PrepareCommand mutates the command in-place so it inherits the configured
// isolated environment before being started by the caller.
func PrepareCommand(cmd *exec.Cmd) error {
isolation := CurrentConfig()
if err := Preflight(); err != nil {
return err
}
if isolation.Enabled {
root, err := ResolveInstanceRoot()
if err != nil {
return err
}
ApplyUserEnv(cmd, root)
if err := applyPlatformIsolation(cmd, isolation, root); err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,245 @@
package isolation
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestResolveInstanceRoot_UsesPicoclawHome(t *testing.T) {
t.Setenv(config.EnvHome, "/custom/picoclaw/home")
root, err := ResolveInstanceRoot()
if err != nil {
t.Fatalf("ResolveInstanceRoot() error = %v", err)
}
if root != "/custom/picoclaw/home" {
t.Fatalf("ResolveInstanceRoot() = %q, want %q", root, "/custom/picoclaw/home")
}
}
func TestPrepareInstanceRoot_CreatesDirectories(t *testing.T) {
root := filepath.Join(t.TempDir(), "instance")
if err := PrepareInstanceRoot(root); err != nil {
t.Fatalf("PrepareInstanceRoot() error = %v", err)
}
for _, dir := range InstanceDirs(root) {
if info, err := os.Stat(dir); err != nil {
t.Fatalf("os.Stat(%q): %v", dir, err)
} else if !info.IsDir() {
t.Fatalf("%q is not a directory", dir)
}
}
}
func TestInstanceDirs_UsesInstanceWorkspaceNotGlobalState(t *testing.T) {
root := filepath.Join(t.TempDir(), "instance")
cfg := config.DefaultConfig()
cfg.Isolation.Enabled = true
cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "external-workspace")
Configure(cfg)
t.Cleanup(func() { Configure(config.DefaultConfig()) })
dirs := InstanceDirs(root)
wantWorkspace := filepath.Join(root, pkg.WorkspaceName)
found := false
for _, dir := range dirs {
if dir == wantWorkspace {
found = true
}
if dir == cfg.WorkspacePath() {
t.Fatalf("InstanceDirs() should not depend on process-wide workspace state: %q", dir)
}
}
if !found {
t.Fatalf("InstanceDirs() missing instance workspace dir %q", wantWorkspace)
}
}
func TestIsSupportedOn(t *testing.T) {
tests := []struct {
goos string
want bool
}{
{goos: "linux", want: true},
{goos: "windows", want: true},
{goos: "darwin", want: false},
{goos: "freebsd", want: false},
}
for _, tt := range tests {
if got := isSupportedOn(tt.goos); got != tt.want {
t.Fatalf("isSupportedOn(%q) = %v, want %v", tt.goos, got, tt.want)
}
}
}
func TestValidateExposePaths(t *testing.T) {
err := ValidateExposePaths([]config.ExposePath{{Source: "/src", Target: "/dst", Mode: "ro"}})
if err != nil {
t.Fatalf("ValidateExposePaths() error = %v", err)
}
err = ValidateExposePaths([]config.ExposePath{{Source: "/src", Target: "/dst", Mode: "bad"}})
if err == nil {
t.Fatal("ValidateExposePaths() expected invalid mode error")
}
err = ValidateExposePaths(
[]config.ExposePath{
{Source: "/src", Target: "/dst", Mode: "ro"},
{Source: "/other", Target: "/dst", Mode: "rw"},
},
)
if err == nil {
t.Fatal("ValidateExposePaths() expected duplicate target error")
}
}
func TestMergeExposePaths_OverrideByTarget(t *testing.T) {
merged := MergeExposePaths(
[]config.ExposePath{{Source: "/src-a", Target: "/dst", Mode: "ro"}},
[]config.ExposePath{{Source: "/src-b", Target: "/dst", Mode: "rw"}},
)
if len(merged) != 1 {
t.Fatalf("MergeExposePaths len = %d, want 1", len(merged))
}
if got := merged[0]; got.Source != "/src-b" || got.Target != "/dst" || got.Mode != "rw" {
t.Fatalf("merged[0] = %+v, want source=/src-b target=/dst mode=rw", got)
}
}
func TestBuildLinuxMountPlan(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("linux-only default mount set")
}
plan := BuildLinuxMountPlan("/rootdir", []config.ExposePath{{Source: "/src", Target: "/dst", Mode: "ro"}})
if len(plan) == 0 {
t.Fatal("BuildLinuxMountPlan returned empty plan")
}
foundRoot := false
foundOverride := false
for _, rule := range plan {
if rule.Source == "/rootdir" && rule.Target == "/rootdir" && rule.Mode == "rw" {
foundRoot = true
}
if rule.Source == "/src" && rule.Target == "/dst" && rule.Mode == "ro" {
foundOverride = true
}
}
if !foundRoot {
t.Fatal("BuildLinuxMountPlan missing root mapping")
}
if !foundOverride {
t.Fatal("BuildLinuxMountPlan missing override mapping")
}
}
func TestBuildWindowsAccessRules(t *testing.T) {
rules := BuildWindowsAccessRules(
`C:\picoclaw`,
[]config.ExposePath{{Source: `D:\data`, Target: `C:\mapped`, Mode: "ro"}},
)
if len(rules) == 0 {
t.Fatal("BuildWindowsAccessRules returned empty rules")
}
foundRoot := false
foundOverride := false
for _, rule := range rules {
if rule.Path == `C:\picoclaw` && rule.Mode == "rw" {
foundRoot = true
}
if rule.Path == `D:\data` && rule.Mode == "ro" {
foundOverride = true
}
}
if !foundRoot {
t.Fatal("BuildWindowsAccessRules missing root rule")
}
if !foundOverride {
t.Fatal("BuildWindowsAccessRules missing override rule")
}
}
func TestValidateWindowsExposePaths(t *testing.T) {
if err := validateWindowsExposePaths(nil); err != nil {
t.Fatalf("validateWindowsExposePaths(nil) error = %v", err)
}
err := validateWindowsExposePaths([]config.ExposePath{{Source: `D:\data`, Target: `D:\data`, Mode: "ro"}})
if err == nil {
t.Fatal("validateWindowsExposePaths() expected error for expose_paths")
}
}
func TestDefaultLinuxSystemExposePaths(t *testing.T) {
paths := defaultLinuxSystemExposePaths()
needed := map[string]bool{}
for _, path := range []string{"/etc/hosts", "/etc/nsswitch.conf", "/etc/ssl", "/usr/share/zoneinfo", "/etc/localtime"} {
if _, err := os.Stat(path); err == nil {
needed[path] = false
}
}
for _, item := range paths {
if _, ok := needed[item.Source]; ok {
needed[item.Source] = true
}
}
for path, found := range needed {
if !found {
t.Fatalf("defaultLinuxSystemExposePaths missing %s", path)
}
}
}
func TestExistingExposePaths_SkipsMissingPaths(t *testing.T) {
existing := filepath.Join(t.TempDir(), "existing")
if err := os.MkdirAll(existing, 0o755); err != nil {
t.Fatalf("os.MkdirAll() error = %v", err)
}
filtered := existingExposePaths([]config.ExposePath{
{Source: existing, Target: existing, Mode: "ro"},
{Source: filepath.Join(t.TempDir(), "missing"), Target: "/missing", Mode: "ro"},
})
if len(filtered) != 1 {
t.Fatalf("existingExposePaths() len = %d, want 1", len(filtered))
}
if got := filtered[0]; got.Source != existing {
t.Fatalf("existingExposePaths()[0] = %+v, want source=%q", got, existing)
}
}
func TestPrepareCommand_AppliesUserEnv(t *testing.T) {
t.Setenv(config.EnvHome, filepath.Join(t.TempDir(), "home"))
if runtime.GOOS == "linux" {
binDir := filepath.Join(t.TempDir(), "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("os.MkdirAll() error = %v", err)
}
fakeBwrap := filepath.Join(binDir, "bwrap")
if err := os.WriteFile(fakeBwrap, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
cfg := config.DefaultConfig()
cfg.Isolation.Enabled = true
Configure(cfg)
t.Cleanup(func() { Configure(config.DefaultConfig()) })
cmd := exec.Command("sh", "-c", "true")
if err := PrepareCommand(cmd); err != nil {
t.Fatalf("PrepareCommand() error = %v", err)
}
hasHome := false
for _, env := range cmd.Env {
if len(env) > 5 && env[:5] == "HOME=" {
hasHome = true
break
}
}
if runtime.GOOS != "windows" && !hasHome {
t.Fatal("PrepareCommand() did not inject HOME")
}
}

View file

@ -0,0 +1,226 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"sync"
"syscall"
"time"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/isolation"
)
var isolatedCommandTerminateDuration = 5 * time.Second
// isolatedCommandTransport mirrors the SDK command transport but routes
// process startup through pkg/isolation so Windows post-start hooks run too.
type isolatedCommandTransport struct {
Command *exec.Cmd
TerminateDuration time.Duration
}
func (t *isolatedCommandTransport) Connect(ctx context.Context) (sdkmcp.Connection, error) {
stdout, err := t.Command.StdoutPipe()
if err != nil {
return nil, err
}
stdout = io.NopCloser(stdout)
stdin, err := t.Command.StdinPipe()
if err != nil {
return nil, err
}
if err := isolation.Start(t.Command); err != nil {
return nil, err
}
td := t.TerminateDuration
if td <= 0 {
td = isolatedCommandTerminateDuration
}
return newIsolatedIOConn(&isolatedPipeRWC{cmd: t.Command, stdout: stdout, stdin: stdin, terminateDuration: td}), nil
}
type isolatedPipeRWC struct {
cmd *exec.Cmd
stdout io.ReadCloser
stdin io.WriteCloser
terminateDuration time.Duration
}
func (s *isolatedPipeRWC) Read(p []byte) (n int, err error) {
return s.stdout.Read(p)
}
func (s *isolatedPipeRWC) Write(p []byte) (n int, err error) {
return s.stdin.Write(p)
}
func (s *isolatedPipeRWC) Close() error {
if err := s.stdin.Close(); err != nil {
return fmt.Errorf("closing stdin: %v", err)
}
resChan := make(chan error, 1)
go func() {
resChan <- s.cmd.Wait()
}()
wait := func() (error, bool) {
select {
case err := <-resChan:
return err, true
case <-time.After(s.terminateDuration):
}
return nil, false
}
if err, ok := wait(); ok {
return err
}
if err := s.cmd.Process.Signal(syscall.SIGTERM); err == nil {
if err, ok := wait(); ok {
return err
}
}
if err := s.cmd.Process.Kill(); err != nil {
return err
}
if err, ok := wait(); ok {
return err
}
return fmt.Errorf("unresponsive subprocess")
}
type isolatedIOConn struct {
writeMu sync.Mutex
rwc io.ReadWriteCloser
incoming <-chan isolatedMsgOrErr
queue []jsonrpc.Message
closeOnce sync.Once
closed chan struct{}
closeErr error
}
type isolatedMsgOrErr struct {
msg json.RawMessage
err error
}
func newIsolatedIOConn(rwc io.ReadWriteCloser) *isolatedIOConn {
incoming := make(chan isolatedMsgOrErr)
closed := make(chan struct{})
go func() {
dec := json.NewDecoder(rwc)
for {
var raw json.RawMessage
err := dec.Decode(&raw)
if err == nil {
var tr [1]byte
if n, readErr := dec.Buffered().Read(tr[:]); n > 0 {
if tr[0] != '\n' && tr[0] != '\r' {
err = fmt.Errorf("invalid trailing data at the end of stream")
}
} else if readErr != nil && readErr != io.EOF {
err = readErr
}
}
select {
case incoming <- isolatedMsgOrErr{msg: raw, err: err}:
case <-closed:
return
}
if err != nil {
return
}
}
}()
return &isolatedIOConn{rwc: rwc, incoming: incoming, closed: closed}
}
func (c *isolatedIOConn) SessionID() string { return "" }
func (c *isolatedIOConn) Read(ctx context.Context) (jsonrpc.Message, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if len(c.queue) > 0 {
next := c.queue[0]
c.queue = c.queue[1:]
return next, nil
}
var raw json.RawMessage
select {
case <-ctx.Done():
return nil, ctx.Err()
case v := <-c.incoming:
if v.err != nil {
return nil, v.err
}
raw = v.msg
case <-c.closed:
return nil, io.EOF
}
msgs, err := readIsolatedBatch(raw)
if err != nil {
return nil, err
}
c.queue = msgs[1:]
return msgs[0], nil
}
func readIsolatedBatch(data []byte) ([]jsonrpc.Message, error) {
var rawBatch []json.RawMessage
if err := json.Unmarshal(data, &rawBatch); err == nil {
if len(rawBatch) == 0 {
return nil, fmt.Errorf("empty batch")
}
msgs := make([]jsonrpc.Message, 0, len(rawBatch))
for _, raw := range rawBatch {
msg, err := jsonrpc.DecodeMessage(raw)
if err != nil {
return nil, err
}
msgs = append(msgs, msg)
}
return msgs, nil
}
msg, err := jsonrpc.DecodeMessage(data)
if err != nil {
return nil, err
}
return []jsonrpc.Message{msg}, nil
}
func (c *isolatedIOConn) Write(ctx context.Context, msg jsonrpc.Message) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
data, err := jsonrpc.EncodeMessage(msg)
if err != nil {
return fmt.Errorf("marshaling message: %v", err)
}
data = append(data, '\n')
_, err = c.rwc.Write(data)
return err
}
func (c *isolatedIOConn) Close() error {
c.closeOnce.Do(func() {
c.closeErr = c.rwc.Close()
close(c.closed)
})
return c.closeErr
}
var (
_ sdkmcp.Transport = (*isolatedCommandTransport)(nil)
_ sdkmcp.Connection = (*isolatedIOConn)(nil)
)

View file

@ -365,8 +365,7 @@ func (m *Manager) ConnectServer(
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
cmd.Env = env
transport = &mcp.CommandTransport{Command: cmd}
transport = &isolatedCommandTransport{Command: cmd}
default:
return fmt.Errorf(
"unsupported transport type: %s (supported: stdio, sse, http)",

View file

@ -7,6 +7,8 @@ import (
"fmt"
"os/exec"
"strings"
"github.com/sipeed/picoclaw/pkg/isolation"
)
// ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess.
@ -49,7 +51,9 @@ func (p *ClaudeCliProvider) Chat(
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// Execute the CLI through the shared isolation wrapper so external provider
// processes honor the configured isolation policy.
if err := isolation.Run(cmd); err != nil {
stderrStr := strings.TrimSpace(stderr.String())
stdoutStr := strings.TrimSpace(stdout.String())
switch {

View file

@ -8,6 +8,8 @@ import (
"fmt"
"os/exec"
"strings"
"github.com/sipeed/picoclaw/pkg/isolation"
)
// CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess.
@ -56,7 +58,9 @@ func (p *CodexCliProvider) Chat(
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
// Execute the CLI through the shared isolation wrapper so external provider
// processes honor the configured isolation policy.
err := isolation.Run(cmd)
// Parse JSONL from stdout even if exit code is non-zero,
// because codex writes diagnostic noise to stderr (e.g. rollout errors)

View file

@ -3,14 +3,21 @@ package tools
import (
"context"
"fmt"
"sync/atomic"
"sync"
)
type SendCallback func(channel, chatID, content, replyToMessageID string) error
// sentTarget records the channel+chatID that the message tool sent to.
type sentTarget struct {
Channel string
ChatID string
}
type MessageTool struct {
sendCallback SendCallback
sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round
mu sync.Mutex
sentTargets []sentTarget // Tracks all targets sent to in the current round
}
func NewMessageTool() *MessageTool {
@ -53,12 +60,30 @@ func (t *MessageTool) Parameters() map[string]any {
// ResetSentInRound resets the per-round send tracker.
// Called by the agent loop at the start of each inbound message processing round.
func (t *MessageTool) ResetSentInRound() {
t.sentInRound.Store(false)
t.mu.Lock()
t.sentTargets = t.sentTargets[:0]
t.mu.Unlock()
}
// HasSentInRound returns true if the message tool sent a message during the current round.
func (t *MessageTool) HasSentInRound() bool {
return t.sentInRound.Load()
t.mu.Lock()
defer t.mu.Unlock()
return len(t.sentTargets) > 0
}
// HasSentTo returns true if the message tool sent to the specific channel+chatID
// during the current round. Used by PublishResponseIfNeeded to avoid suppressing
// the final response when the message tool only sent to a different conversation.
func (t *MessageTool) HasSentTo(channel, chatID string) bool {
t.mu.Lock()
defer t.mu.Unlock()
for _, st := range t.sentTargets {
if st.Channel == channel && st.ChatID == chatID {
return true
}
}
return false
}
func (t *MessageTool) SetSendCallback(callback SendCallback) {
@ -98,7 +123,10 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
}
}
t.sentInRound.Store(true)
t.mu.Lock()
t.sentTargets = append(t.sentTargets, sentTarget{Channel: channel, ChatID: chatID})
t.mu.Unlock()
// Silent: user already received the message directly
return &ToolResult{
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),

View file

@ -20,6 +20,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/isolation"
)
var (
@ -120,7 +121,7 @@ func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regex
func NewExecToolWithConfig(
workingDir string,
restrict bool,
config *config.Config,
cfg *config.Config,
allowPaths ...[]*regexp.Regexp,
) (*ExecTool, error) {
denyPatterns := make([]*regexp.Regexp, 0)
@ -131,8 +132,8 @@ func NewExecToolWithConfig(
allowedPathPatterns = allowPaths[0]
}
if config != nil {
execConfig := config.Tools.Exec
if cfg != nil {
execConfig := cfg.Tools.Exec
enableDenyPatterns := execConfig.EnableDenyPatterns
allowRemote = execConfig.AllowRemote
if enableDenyPatterns {
@ -163,8 +164,8 @@ func NewExecToolWithConfig(
}
var timeout time.Duration
if config != nil && config.Tools.Exec.TimeoutSeconds > 0 {
timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second
if cfg != nil && cfg.Tools.Exec.TimeoutSeconds > 0 {
timeout = time.Duration(cfg.Tools.Exec.TimeoutSeconds) * time.Second
}
return &ExecTool{
@ -378,7 +379,9 @@ func (t *ExecTool) runSync(ctx context.Context, command, cwd string) *ToolResult
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
// Route shell execution through the shared isolation entry point so exec tool
// subprocesses receive the same isolation policy as other integrations.
if err := isolation.Start(cmd); err != nil {
return ErrorResult(fmt.Sprintf("failed to start command: %v", err))
}
@ -521,7 +524,9 @@ func (t *ExecTool) runBackground(ctx context.Context, command, cwd string, ptyEn
session.stdinWriter = stdinWriter
}
if err := cmd.Start(); err != nil {
// Background sessions use the same startup path so isolation stays consistent
// with synchronous exec runs.
if err := isolation.Start(cmd); err != nil {
if session.ptyMaster != nil {
session.ptyMaster.Close()
}

View file

@ -1,8 +1,10 @@
package api
import (
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
@ -10,34 +12,47 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware"
)
// LauncherAuthRouteOpts configures dashboard token login handlers.
// PasswordStore is the interface for bcrypt-backed dashboard password persistence.
// Implemented by dashboardauth.Store; a nil value falls back to the legacy
// static-token comparison.
type PasswordStore interface {
IsInitialized(ctx context.Context) (bool, error)
SetPassword(ctx context.Context, plain string) error
VerifyPassword(ctx context.Context, plain string) (bool, error)
}
// LauncherAuthRouteOpts configures dashboard auth handlers.
type LauncherAuthRouteOpts struct {
// DashboardToken is the fallback plaintext token used when PasswordStore is
// nil or not yet initialized (env-var / config-file source, and ?token= auto-login).
DashboardToken string
SessionCookie string
SecureCookie func(*http.Request) bool
// TokenHelp is returned on unauthenticated /api/auth/status responses (no secrets).
TokenHelp LauncherAuthTokenHelp
}
// LauncherAuthTokenHelp tells the login UI where users can find the dashboard token.
type LauncherAuthTokenHelp struct {
EnvVarName string `json:"env_var_name"`
LogFileAbs string `json:"log_file,omitempty"`
ConfigFileAbs string `json:"config_file,omitempty"`
TrayCopyMenu bool `json:"tray_copy_menu"`
ConsoleStdout bool `json:"console_stdout"`
// PasswordStore enables bcrypt-backed password persistence. When non-nil and
// initialized, web-form login verifies against the stored hash instead of
// the plaintext DashboardToken.
PasswordStore PasswordStore
// StoreError holds the error returned when opening the password store. When
// non-nil and PasswordStore is nil, the auth endpoints surface a recovery
// message instead of an opaque 501/503.
StoreError error
}
type launcherAuthLoginBody struct {
Token string `json:"token"`
Password string `json:"password"`
}
type launcherAuthSetupBody struct {
Password string `json:"password"`
Confirm string `json:"confirm"`
}
type launcherAuthStatusResponse struct {
Authenticated bool `json:"authenticated"`
TokenHelp *LauncherAuthTokenHelp `json:"token_help,omitempty"`
Authenticated bool `json:"authenticated"`
Initialized bool `json:"initialized"`
}
// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status.
// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status|setup.
func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) {
secure := opts.SecureCookie
if secure == nil {
@ -47,22 +62,44 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
token: opts.DashboardToken,
sessionCookie: opts.SessionCookie,
secureCookie: secure,
tokenHelp: opts.TokenHelp,
store: opts.PasswordStore,
storeErr: opts.StoreError,
loginLimit: newLoginRateLimiter(),
}
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
mux.HandleFunc("POST /api/auth/logout", h.handleLogout)
mux.HandleFunc("GET /api/auth/status", h.handleStatus)
mux.HandleFunc("POST /api/auth/setup", h.handleSetup)
}
type launcherAuthHandlers struct {
token string
sessionCookie string
secureCookie func(*http.Request) bool
tokenHelp LauncherAuthTokenHelp
store PasswordStore
storeErr error // set when the store failed to open; drives recovery messages
loginLimit *loginRateLimiter
}
// isStoreInitialized safely queries the store.
// Returns (false, nil) when no store is configured (storeErr also nil).
// Returns (false, err) on store errors — callers must treat this as a 5xx, not as
// "uninitialized", to keep auth fail-closed.
// Exception: handleLogin swallows storeErr and falls back to token auth so
// that a corrupt DB does not lock out all access.
func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) {
if h.store == nil {
if h.storeErr != nil {
return false, fmt.Errorf(
"password store unavailable (%w); "+
"to recover, stop the application, delete the database file and restart ",
h.storeErr)
}
return false, nil
}
return h.store.IsInitialized(ctx)
}
func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var body launcherAuthLoginBody
@ -77,10 +114,39 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques
_, _ = w.Write([]byte(`{"error":"too many login attempts"}`))
return
}
in := strings.TrimSpace(body.Token)
if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 {
in := strings.TrimSpace(body.Password)
var ok bool
initialized, initErr := h.isStoreInitialized(r.Context())
if initErr != nil {
if h.storeErr != nil {
// Store failed to open at startup — token login remains available.
initialized = false
} else {
w.WriteHeader(http.StatusInternalServerError)
writeErrorf(w, "%v", initErr)
return
}
}
if initialized {
// Bcrypt path: verify against the stored hash.
var err error
ok, err = h.store.VerifyPassword(r.Context(), in)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
writeErrorf(w, "password verification failed: %v", err)
return
}
} else {
// Fallback: constant-time compare against the plaintext token.
ok = len(in) == len(h.token) &&
subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) == 1
}
if !ok {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid token"}`))
_, _ = w.Write([]byte(`{"error":"invalid password"}`))
return
}
@ -121,23 +187,100 @@ func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Reque
func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
ok := false
authed := false
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
}
if ok {
_, _ = w.Write([]byte(`{"authenticated":true}`))
initialized, initErr := h.isStoreInitialized(r.Context())
if initErr != nil {
w.WriteHeader(http.StatusServiceUnavailable)
writeErrorf(w, "%v", initErr)
return
}
resp := launcherAuthStatusResponse{
Authenticated: false,
TokenHelp: &h.tokenHelp,
Authenticated: authed,
Initialized: initialized,
}
enc, err := json.Marshal(resp)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"internal error"}`))
writeErrorf(w, "marshal response failed: %v", err)
return
}
_, _ = w.Write(enc)
}
// handleSetup sets or changes the dashboard password.
//
// Rules:
// - If the store has no password yet, the endpoint is open (no session required).
// - If a password is already set, the caller must hold a valid session cookie.
func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if h.store == nil {
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write([]byte(`{"error":"password store not configured"}`))
return
}
initialized, initErr := h.isStoreInitialized(r.Context())
if initErr != nil {
w.WriteHeader(http.StatusServiceUnavailable)
writeErrorf(w, "%v", initErr)
return
}
// If already initialized, require an active session (change-password flow).
if initialized {
authed := false
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
}
if !authed {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"must be authenticated to change password"}`))
return
}
}
var body launcherAuthSetupBody
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid JSON"}`))
return
}
pw := strings.TrimSpace(body.Password)
if pw == "" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"password must not be empty"}`))
return
}
if pw != strings.TrimSpace(body.Confirm) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"passwords do not match"}`))
return
}
if len([]rune(pw)) < 8 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"password must be at least 8 characters"}`))
return
}
if err := h.store.SetPassword(r.Context(), pw); err != nil {
w.WriteHeader(http.StatusInternalServerError)
writeErrorf(w, "failed to save password: %v", err)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
// writeErrorf writes a JSON error response with a formatted message.
// json.Marshal is used to safely escape the message string.
func writeErrorf(w http.ResponseWriter, format string, args ...any) {
msg, _ := json.Marshal(fmt.Sprintf(format, args...))
_, _ = w.Write([]byte(`{"error":` + string(msg) + `}`))
}

View file

@ -23,12 +23,6 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{
EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
LogFileAbs: "/tmp/launcher.log",
TrayCopyMenu: true,
ConsoleStdout: false,
},
})
t.Run("status_unauthenticated", func(t *testing.T) {
@ -38,23 +32,20 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
t.Fatalf("status code = %d", rec.Code)
}
var body struct {
Authenticated bool `json:"authenticated"`
TokenHelp *LauncherAuthTokenHelp `json:"token_help"`
Authenticated bool `json:"authenticated"`
Initialized bool `json:"initialized"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Authenticated || body.TokenHelp == nil {
t.Fatalf("unexpected body: %+v", body)
}
if body.TokenHelp.EnvVarName != "PICOCLAW_LAUNCHER_TOKEN" || body.TokenHelp.LogFileAbs != "/tmp/launcher.log" {
t.Fatalf("token_help = %+v", body.TokenHelp)
if body.Authenticated {
t.Fatalf("unexpected authenticated=true: %+v", body)
}
})
t.Run("login_ok", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"token":"`+tok+`"}`))
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:12345"
mux.ServeHTTP(rec, req)
@ -91,7 +82,6 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"},
})
rec := httptest.NewRecorder()
@ -125,11 +115,10 @@ func TestLauncherAuthLoginRateLimit(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
// 11 failing logins by wrong token; each consumes allow() slot after valid JSON.
wrongBody := `{"token":"wrong"}`
wrongBody := `{"password":"wrong"}`
for i := 0; i < loginAttemptsPerIP; i++ {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody))
@ -187,7 +176,6 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
@ -206,7 +194,6 @@ func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))

View file

@ -0,0 +1,24 @@
package dashboardauth
const (
// DBFilename is the SQLite database file stored under the PicoClaw home directory.
DBFilename = "launcher-auth.db"
sqliteDriver = "sqlite"
// bcryptCost is deliberately high enough to slow brute-force attempts.
bcryptCost = 12
sqlCreateTable = `
CREATE TABLE IF NOT EXISTS dashboard_credentials (
id INTEGER PRIMARY KEY CHECK (id = 1),
bcrypt_hash TEXT NOT NULL
)`
sqlCountCredentials = `SELECT COUNT(*) FROM dashboard_credentials WHERE id = 1`
sqlUpsertHash = `
INSERT INTO dashboard_credentials (id, bcrypt_hash) VALUES (1, ?)
ON CONFLICT(id) DO UPDATE SET bcrypt_hash = excluded.bcrypt_hash`
sqlSelectHash = `SELECT bcrypt_hash FROM dashboard_credentials WHERE id = 1`
)

View file

@ -0,0 +1,94 @@
// Package dashboardauth provides a bcrypt-backed SQLite store for the
// launcher dashboard password. The database contains a single row (id=1)
// with the bcrypt hash; no plaintext is ever persisted.
package dashboardauth
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite" // register "sqlite" driver
)
// Store holds a handle to the SQLite database that stores the bcrypt hash.
type Store struct {
db *sql.DB
path string // absolute path to the SQLite file
}
// New opens (or creates) the database inside dir, using the package's
// canonical filename. This is the preferred constructor for most callers.
// Any error is wrapped with the resolved path so callers get actionable output.
func New(dir string) (*Store, error) {
path := filepath.Join(dir, DBFilename)
s, err := Open(path)
if err != nil {
return nil, fmt.Errorf("open %q: %w", path, err)
}
return s, nil
}
// Open opens (or creates) the SQLite database at path and migrates the schema.
func Open(path string) (*Store, error) {
db, err := sql.Open(sqliteDriver, path)
if err != nil {
return nil, err
}
if _, err = db.Exec(sqlCreateTable); err != nil {
_ = db.Close()
return nil, err
}
return &Store{db: db, path: path}, nil
}
// Close releases the database handle.
func (s *Store) Close() error { return s.db.Close() }
// DBPath returns the absolute path to the SQLite database file.
func (s *Store) DBPath() string { return s.path }
// IsInitialized reports whether a password hash has been stored.
func (s *Store) IsInitialized(ctx context.Context) (bool, error) {
var n int
err := s.db.QueryRowContext(ctx, sqlCountCredentials).Scan(&n)
if err != nil {
return false, err
}
return n > 0, nil
}
// SetPassword hashes plain with bcrypt (cost 12) and stores (or replaces) it.
// The plaintext is never written to disk.
func (s *Store) SetPassword(ctx context.Context, plain string) error {
if len([]rune(plain)) == 0 {
return errors.New("password must not be empty")
}
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, sqlUpsertHash, string(hash))
return err
}
// VerifyPassword returns true iff plain matches the stored bcrypt hash.
// Returns (false, nil) when no password has been set yet.
func (s *Store) VerifyPassword(ctx context.Context, plain string) (bool, error) {
var hash string
err := s.db.QueryRowContext(ctx, sqlSelectHash).Scan(&hash)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return false, nil
}
return err == nil, err
}

View file

@ -24,8 +24,6 @@ const (
AppTooltip TranslationKey = "AppTooltip"
MenuOpen TranslationKey = "MenuOpen"
MenuOpenTooltip TranslationKey = "MenuOpenTooltip"
MenuCopyToken TranslationKey = "MenuCopyToken"
MenuCopyTokenHint TranslationKey = "MenuCopyTokenHint"
MenuAbout TranslationKey = "MenuAbout"
MenuAboutTooltip TranslationKey = "MenuAboutTooltip"
MenuVersion TranslationKey = "MenuVersion"
@ -49,8 +47,6 @@ var translations = map[Language]map[TranslationKey]string{
AppTooltip: "%s - Web Console",
MenuOpen: "Open Console",
MenuOpenTooltip: "Open PicoClaw console in browser",
MenuCopyToken: "Copy dashboard token",
MenuCopyTokenHint: "Copy the current web console access token to the clipboard",
MenuAbout: "About",
MenuAboutTooltip: "About PicoClaw",
MenuVersion: "Version: %s",
@ -68,8 +64,6 @@ var translations = map[Language]map[TranslationKey]string{
AppTooltip: "%s - Web Console",
MenuOpen: "打开控制台",
MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台",
MenuCopyToken: "复制控制台口令",
MenuCopyTokenHint: "将当前 Web 控制台访问口令复制到剪贴板",
MenuAbout: "关于",
MenuAboutTooltip: "关于 PicoClaw",
MenuVersion: "版本: %s",

View file

@ -27,6 +27,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/dashboardauth"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware"
"github.com/sipeed/picoclaw/web/backend/utils"
@ -49,8 +50,6 @@ var (
// Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use.
browserLaunchURL string
apiHandler *api.Handler
// launcherDashboardTokenForClipboard is read by the system tray "copy token" action (GUI mode).
launcherDashboardTokenForClipboard string
noBrowser *bool
)
@ -66,6 +65,24 @@ func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, la
return launcherPath
}
// maskSecret masks a secret for display. It always shows up to the first 3
// runes. The last 4 runes are only appended when at least 5 runes remain
// hidden in the middle (i.e. string length >= 12), so an 8-char minimum
// password never exposes its tail. Strings of 3 chars or fewer are fully
// masked.
func maskSecret(s string) string {
runes := []rune(s)
n := len(runes)
const prefixLen, suffixLen, minHidden = 3, 4, 5
if n < prefixLen+suffixLen+minHidden {
if n <= prefixLen {
return "**********"
}
return string(runes[:prefixLen]) + "**********"
}
return string(runes[:prefixLen]) + "**********" + string(runes[n-suffixLen:])
}
func main() {
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")
@ -209,7 +226,15 @@ func main() {
logger.Fatalf("Dashboard auth setup failed: %v", dashErr)
}
dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken)
launcherDashboardTokenForClipboard = dashboardToken
// Open the bcrypt password store (creates the DB file on first run).
authStore, authStoreErr := dashboardauth.New(picoHome)
if authStoreErr != nil {
logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
authStore = nil
} else {
defer authStore.Close()
}
// Determine listen address
var addr string
@ -222,20 +247,11 @@ func main() {
// Initialize Server components
mux := http.NewServeMux()
tokenLogFileAbs := ""
if fileLoggingEnabled {
tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile)
}
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
DashboardToken: dashboardToken,
SessionCookie: dashboardSessionCookie,
TokenHelp: api.LauncherAuthTokenHelp{
EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
LogFileAbs: tokenLogFileAbs,
ConfigFileAbs: dashboardTokenConfigHelpPath(dashboardTokenSource, launcherPath),
TrayCopyMenu: trayOffersDashboardTokenCopy(),
ConsoleStdout: enableConsole,
},
PasswordStore: authStore,
StoreError: authStoreErr,
})
// API Routes (e.g. /api/status)
@ -284,23 +300,23 @@ func main() {
fmt.Println()
switch dashboardTokenSource {
case launcherconfig.DashboardTokenSourceRandom:
fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken)
fmt.Printf(" Dashboard password (this run): %s\n", maskSecret(dashboardToken))
case launcherconfig.DashboardTokenSourceEnv:
fmt.Printf(" Dashboard token: %s (from PICOCLAW_LAUNCHER_TOKEN)\n", dashboardToken)
fmt.Printf(" Dashboard password: from environment variable PICOCLAW_LAUNCHER_TOKEN\n")
case launcherconfig.DashboardTokenSourceConfig:
fmt.Printf(" Dashboard token: %s (from %s)\n", dashboardToken, launcherPath)
fmt.Printf(" Dashboard password: configured in %s\n", launcherPath)
}
fmt.Println()
}
switch dashboardTokenSource {
case launcherconfig.DashboardTokenSourceEnv:
logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN")
logger.InfoC("web", "Dashboard password: environment PICOCLAW_LAUNCHER_TOKEN")
case launcherconfig.DashboardTokenSourceConfig:
logger.InfoC("web", fmt.Sprintf("Dashboard token: configured in %s", launcherPath))
logger.InfoC("web", fmt.Sprintf("Dashboard password: configured in %s", launcherPath))
case launcherconfig.DashboardTokenSourceRandom:
if !enableConsole {
logger.InfoC("web", "Dashboard token (this run): "+dashboardToken)
logger.InfoC("web", "Dashboard password (this run): "+maskSecret(dashboardToken))
}
}

View file

@ -67,3 +67,31 @@ func TestDashboardTokenConfigHelpPath(t *testing.T) {
})
}
}
func TestMaskSecret(t *testing.T) {
tests := []struct {
input string
want string
}{
// Long token (>=12 chars): first 3 + 10 stars + last 4
{"sdhjflsjdflksdf", "sdh**********ksdf"},
{"abcdefghijklmnopqrstuvwxyz", "abc**********wxyz"},
// Exactly 12 chars (3+4+5 hidden): suffix shown
{"abcdefghijkl", "abc**********ijkl"},
// 8 chars (minimum password length): suffix NOT shown — only prefix+stars
{"abcdefgh", "abc**********"},
// 11 chars (one below threshold): suffix NOT shown
{"abcdefghijk", "abc**********"},
// 4..3 chars: prefix shown, no suffix
{"abcdefg", "abc**********"},
{"abcd", "abc**********"},
// <=3 chars: fully masked
{"abc", "**********"},
{"", "**********"},
}
for _, tt := range tests {
if got := maskSecret(tt.input); got != tt.want {
t.Errorf("maskSecret(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}

View file

@ -173,6 +173,8 @@ func isPublicLauncherDashboardPath(method, p string) bool {
return method == http.MethodPost
case "/api/auth/status":
return method == http.MethodGet
case "/api/auth/setup":
return method == http.MethodPost
}
return false
}
@ -183,7 +185,7 @@ func isPublicLauncherDashboardStatic(method, p string) bool {
if method != http.MethodGet && method != http.MethodHead {
return false
}
if p == "/launcher-login" {
if p == "/launcher-login" || p == "/launcher-setup" {
return true
}
if strings.HasPrefix(p, "/assets/") {

View file

@ -6,7 +6,6 @@ import (
"fmt"
"fyne.io/systray"
"github.com/atotto/clipboard"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/web/backend/utils"
@ -24,7 +23,6 @@ func onReady() {
// Create menu items
mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip))
mCopyTok := systray.AddMenuItem(T(MenuCopyToken), T(MenuCopyTokenHint))
mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip))
// Add version info under About menu
@ -52,17 +50,6 @@ func onReady() {
logger.Errorf("Failed to open browser: %v", err)
}
case <-mCopyTok.ClickedCh:
if launcherDashboardTokenForClipboard == "" {
logger.WarnC("web", "Dashboard token is empty; cannot copy")
continue
}
if err := clipboard.WriteAll(launcherDashboardTokenForClipboard); err != nil {
logger.Errorf("Failed to copy dashboard token: %v", err)
} else {
logger.InfoC("web", "Dashboard token copied to clipboard")
}
case <-mVersion.ClickedCh:
// Version info - do nothing, just shows current version

View file

@ -1,5 +0,0 @@
//go:build (!darwin && !freebsd) || cgo
package main
func trayOffersDashboardTokenCopy() bool { return true }

View file

@ -1,5 +0,0 @@
//go:build (darwin || freebsd) && !cgo
package main
func trayOffersDashboardTokenCopy() bool { return false }

View file

@ -1,14 +1,14 @@
import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
import { isLauncherAuthPathname } from "@/lib/launcher-login-path"
function isLauncherLoginPath(): boolean {
function isLauncherAuthPath(): boolean {
if (typeof globalThis.location === "undefined") {
return false
}
if (isLauncherLoginPathname(globalThis.location.pathname || "/")) {
if (isLauncherAuthPathname(globalThis.location.pathname || "/")) {
return true
}
try {
return isLauncherLoginPathname(
return isLauncherAuthPathname(
new URL(globalThis.location.href).pathname || "/",
)
} catch {
@ -18,7 +18,7 @@ function isLauncherLoginPath(): boolean {
/**
* Same-origin fetch that sends cookies; redirects to launcher login on 401 JSON responses.
* Skips redirect while already on the login page to avoid reload loops (e.g. gateway poll).
* Skips redirect while already on an auth page (login or setup) to avoid reload loops.
*/
export async function launcherFetch(
input: RequestInfo | URL,
@ -33,7 +33,7 @@ export async function launcherFetch(
if (
ct.includes("application/json") &&
typeof globalThis.location !== "undefined" &&
!isLauncherLoginPath()
!isLauncherAuthPath()
) {
globalThis.location.assign("/launcher-login")
}

View file

@ -1,30 +1,23 @@
/**
* Dashboard launcher token login. Uses plain fetch (not launcherFetch) to avoid
* redirect loops on 401 while on the login page.
* Dashboard launcher auth API.
* Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages.
*/
export async function postLauncherDashboardLogin(
token: string,
password: string,
): Promise<boolean> {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ token: token.trim() }),
body: JSON.stringify({ password: password.trim() }),
})
return res.ok
}
export type LauncherAuthTokenHelp = {
env_var_name: string
log_file?: string
config_file?: string
tray_copy_menu: boolean
console_stdout: boolean
}
export type LauncherAuthStatus = {
authenticated: boolean
token_help?: LauncherAuthTokenHelp
/** true when a bcrypt password has been stored in the DB */
initialized: boolean
}
export async function getLauncherAuthStatus(): Promise<LauncherAuthStatus> {
@ -47,3 +40,28 @@ export async function postLauncherDashboardLogout(): Promise<boolean> {
})
return res.ok
}
export type SetupResult =
| { ok: true }
| { ok: false; error: string }
export async function postLauncherDashboardSetup(
password: string,
confirm: string,
): Promise<SetupResult> {
const res = await fetch("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ password: password.trim(), confirm: confirm.trim() }),
})
if (res.ok) return { ok: true }
let msg = "Unknown error"
try {
const j = (await res.json()) as { error?: string }
if (j.error) msg = j.error
} catch {
/* ignore */
}
return { ok: false, error: msg }
}

View file

@ -2,6 +2,7 @@ import {
IconBook,
IconLanguage,
IconLoader2,
IconLogout,
IconMenu2,
IconMoon,
IconPlayerPlay,
@ -39,6 +40,7 @@ import {
} from "@/components/ui/tooltip"
import { useGateway } from "@/hooks/use-gateway.ts"
import { useTheme } from "@/hooks/use-theme.ts"
import { postLauncherDashboardLogout } from "@/api/launcher-auth"
export function AppHeader() {
const { i18n, t } = useTranslation()
@ -47,10 +49,12 @@ export function AppHeader() {
state: gwState,
loading: gwLoading,
canStart,
startReason,
restartRequired,
start,
restart,
stop,
error: gwError,
} = useGateway()
const isRunning = gwState === "running"
@ -65,6 +69,12 @@ export function AppHeader() {
(gwState === "stopped" || gwState === "error")
const [showStopDialog, setShowStopDialog] = React.useState(false)
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
const handleLogout = async () => {
await postLauncherDashboardLogout()
globalThis.location.assign("/launcher-login")
}
const handleGatewayToggle = () => {
if (gwLoading || isRestarting || isStopping || (!isRunning && !canStart)) {
@ -134,6 +144,23 @@ export function AppHeader() {
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("header.logout.tooltip")}</AlertDialogTitle>
<AlertDialogDescription>
{t("header.logout.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => void handleLogout()}>
{t("header.logout.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="text-muted-foreground flex items-center gap-1 text-sm font-medium md:gap-2">
{restartRequired && (
<Tooltip delayDuration={700}>
@ -171,38 +198,50 @@ export function AppHeader() {
<IconPower className="h-4 w-4 opacity-80" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("header.gateway.action.stop")}</TooltipContent>
<TooltipContent>{gwError ?? t("header.gateway.action.stop")}</TooltipContent>
</Tooltip>
) : (
<Button
variant={
isStarting || isRestarting || isStopping ? "secondary" : "default"
}
size="sm"
data-tour="gateway-button"
className={`h-8 gap-2 px-3 ${
isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
}`}
onClick={handleGatewayToggle}
disabled={
gwLoading || isStarting || isRestarting || isStopping || !canStart
}
>
{gwLoading || isStarting || isRestarting || isStopping ? (
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
) : (
<IconPlayerPlay className="h-4 w-4 opacity-80" />
)}
<span className="text-xs font-semibold">
{isStopping
? t("header.gateway.status.stopping")
: isRestarting
? t("header.gateway.status.restarting")
: isStarting
? t("header.gateway.status.starting")
: t("header.gateway.action.start")}
</span>
</Button>
<Tooltip delayDuration={(gwError || (!canStart && startReason)) ? 0 : 700}>
<TooltipTrigger asChild>
{/* Wrap in span so the tooltip still fires when the button is disabled */}
<span
className={!canStart && startReason ? "cursor-not-allowed" : undefined}
tabIndex={!canStart && startReason ? 0 : undefined}
>
<Button
variant={
isStarting || isRestarting || isStopping ? "secondary" : "default"
}
size="sm"
data-tour="gateway-button"
className={`h-8 gap-2 px-3 ${isStopped ? "bg-green-500 text-white hover:bg-green-600" : ""
} ${!canStart ? "pointer-events-none" : ""}`}
onClick={handleGatewayToggle}
disabled={
gwLoading || isStarting || isRestarting || isStopping || !canStart
}
>
{gwLoading || isStarting || isRestarting || isStopping ? (
<IconLoader2 className="h-4 w-4 animate-spin opacity-70" />
) : (
<IconPlayerPlay className="h-4 w-4 opacity-80" />
)}
<span className="text-xs font-semibold">
{isStopping
? t("header.gateway.status.stopping")
: isRestarting
? t("header.gateway.status.restarting")
: isStarting
? t("header.gateway.status.starting")
: t("header.gateway.action.start")}
</span>
</Button>
</span>
</TooltipTrigger>
{(gwError || (!canStart && startReason)) ? (
<TooltipContent>{gwError ?? startReason}</TooltipContent>
) : null}
</Tooltip>
)}
<Separator
@ -241,6 +280,21 @@ export function AppHeader() {
</DropdownMenu>
{/* Theme Toggle */}
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => setShowLogoutDialog(true)}
aria-label={t("header.logout.tooltip")}
>
<IconLogout className="size-4.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("header.logout.tooltip")}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"

View file

@ -13,8 +13,9 @@ import {
export function useGateway() {
const gateway = useAtomValue(gatewayAtom)
const { status: state, canStart, restartRequired } = gateway
const { status: state, canStart, startReason, restartRequired } = gateway
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
return subscribeGatewayPolling()
@ -23,6 +24,7 @@ export function useGateway() {
const start = useCallback(async () => {
if (!canStart) return
setError(null)
setLoading(true)
try {
await startGateway()
@ -32,6 +34,7 @@ export function useGateway() {
})
} catch (err) {
console.error("Failed to start gateway:", err)
setError(err instanceof Error ? err.message : String(err))
} finally {
await refreshGatewayState({ force: true })
setLoading(false)
@ -39,12 +42,14 @@ export function useGateway() {
}, [canStart])
const stop = useCallback(async () => {
setError(null)
setLoading(true)
beginGatewayStoppingTransition()
try {
await stopGateway()
} catch (err) {
console.error("Failed to stop gateway:", err)
setError(err instanceof Error ? err.message : String(err))
cancelGatewayStoppingTransition()
} finally {
await refreshGatewayState({ force: true })
@ -55,6 +60,7 @@ export function useGateway() {
const restart = useCallback(async () => {
if (state !== "running") return
setError(null)
setLoading(true)
try {
await restartGateway()
@ -64,11 +70,12 @@ export function useGateway() {
})
} catch (err) {
console.error("Failed to restart gateway:", err)
setError(err instanceof Error ? err.message : String(err))
} finally {
await refreshGatewayState({ force: true })
setLoading(false)
}
}, [state])
return { state, loading, canStart, restartRequired, start, stop, restart }
return { state, loading, canStart, startReason, restartRequired, start, stop, restart, error }
}

View file

@ -16,19 +16,24 @@
"logs": "Logs"
},
"launcherLogin": {
"title": "Launcher access",
"description": "Sign in with the dashboard access token for this launcher process (it may change after each restart unless you pin it with an environment variable or launcher config).",
"tokenLabel": "Token",
"tokenPlaceholder": "Enter access token",
"submit": "Continue to Dashboard",
"errorInvalid": "Invalid token. Please try again.",
"errorNetwork": "Network error. Please try again.",
"helpTitle": "Where to find the token",
"helpConsole": "Console mode: printed in the terminal when the launcher starts.",
"helpTray": "Tray mode: menu «Copy dashboard token».",
"helpConfig": "Launcher config file: {{path}}",
"helpLogFile": "Log file (startup line includes the token): {{path}}",
"helpEnv": "Stable token: set {{env}}."
"title": "Sign in",
"description": "Enter the dashboard password to continue.",
"passwordLabel": "Password",
"passwordPlaceholder": "Enter password",
"submit": "Sign in",
"errorInvalid": "Incorrect password. Please try again.",
"errorNetwork": "Network error. Please try again."
},
"launcherSetup": {
"title": "Set dashboard password",
"description": "Choose a password to protect access to this dashboard. You will use it every time you sign in.",
"passwordLabel": "Password",
"passwordPlaceholder": "At least 8 characters",
"confirmLabel": "Confirm password",
"confirmPlaceholder": "Repeat password",
"submit": "Set password",
"errorMismatch": "Passwords do not match.",
"errorNetwork": "Network error. Please try again."
},
"chat": {
"welcome": "How can I help you today?",
@ -72,6 +77,11 @@
}
},
"header": {
"logout": {
"tooltip": "Sign out",
"confirm": "Sign out",
"description": "Are you sure you want to sign out of the dashboard?"
},
"gateway": {
"stopDialog": {
"title": "Stop Gateway Service?",
@ -645,4 +655,4 @@
"description": "Need more help? Click the documentation button in the top right corner to view detailed guides and configuration docs."
}
}
}
}

View file

@ -16,19 +16,24 @@
"logs": "日志"
},
"launcherLogin": {
"title": "Launcher 访问验证",
"description": "请使用当前 Launcher 进程的访问口令登录(每次重启可能变化,除非用环境变量或 launcher 配置固定)",
"tokenLabel": "令牌",
"tokenPlaceholder": "输入访问令牌",
"submit": "进入 Dashboard",
"errorInvalid": "令牌错误,请重试",
"errorNetwork": "网络错误,请重试",
"helpTitle": "口令在哪里",
"helpConsole": "控制台模式:启动时在终端输出",
"helpTray": "托盘模式:菜单「复制控制台口令」",
"helpConfig": "Launcher 配置文件:{{path}}",
"helpLogFile": "日志文件(启动时会写入口令):{{path}}",
"helpEnv": "固定口令:设置环境变量 {{env}}"
"title": "登录",
"description": "请输入控制台密码以继续。",
"passwordLabel": "密码",
"passwordPlaceholder": "输入密码",
"submit": "登录",
"errorInvalid": "密码错误,请重试。",
"errorNetwork": "网络错误,请重试。"
},
"launcherSetup": {
"title": "设置控制台密码",
"description": "设置一个密码来保护控制台访问权限,登录时需要输入此密码。",
"passwordLabel": "密码",
"passwordPlaceholder": "至少 8 个字符",
"confirmLabel": "确认密码",
"confirmPlaceholder": "再次输入密码",
"submit": "设置密码",
"errorMismatch": "两次输入的密码不一致。",
"errorNetwork": "网络错误,请重试。"
},
"chat": {
"welcome": "今天我能为您做些什么?",
@ -72,6 +77,11 @@
}
},
"header": {
"logout": {
"tooltip": "退出登录",
"confirm": "退出登录",
"description": "确定要退出仪表盘登录吗?"
},
"gateway": {
"stopDialog": {
"title": "停止服务?",
@ -645,4 +655,4 @@
"description": "需要更多帮助?点击右上角的文档按钮,查看详细的使用文档和配置指南。"
}
}
}
}

View file

@ -7,3 +7,12 @@ export function normalizePathname(p: string): string {
export function isLauncherLoginPathname(pathname: string): boolean {
return normalizePathname(pathname) === "/launcher-login"
}
export function isLauncherSetupPathname(pathname: string): boolean {
return normalizePathname(pathname) === "/launcher-setup"
}
/** True for any page that is part of the auth flow (login or setup). */
export function isLauncherAuthPathname(pathname: string): boolean {
return isLauncherLoginPathname(pathname) || isLauncherSetupPathname(pathname)
}

View file

@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as ModelsRouteImport } from './routes/models'
import { Route as LogsRouteImport } from './routes/logs'
import { Route as LauncherSetupRouteImport } from './routes/launcher-setup'
import { Route as LauncherLoginRouteImport } from './routes/launcher-login'
import { Route as CredentialsRouteImport } from './routes/credentials'
import { Route as ConfigRouteImport } from './routes/config'
@ -33,6 +34,11 @@ const LogsRoute = LogsRouteImport.update({
path: '/logs',
getParentRoute: () => rootRouteImport,
} as any)
const LauncherSetupRoute = LauncherSetupRouteImport.update({
id: '/launcher-setup',
path: '/launcher-setup',
getParentRoute: () => rootRouteImport,
} as any)
const LauncherLoginRoute = LauncherLoginRouteImport.update({
id: '/launcher-login',
path: '/launcher-login',
@ -96,6 +102,7 @@ export interface FileRoutesByFullPath {
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/launcher-login': typeof LauncherLoginRoute
'/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
@ -111,6 +118,7 @@ export interface FileRoutesByTo {
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/launcher-login': typeof LauncherLoginRoute
'/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
@ -127,6 +135,7 @@ export interface FileRoutesById {
'/config': typeof ConfigRouteWithChildren
'/credentials': typeof CredentialsRoute
'/launcher-login': typeof LauncherLoginRoute
'/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/hub': typeof AgentHubRoute
@ -144,6 +153,7 @@ export interface FileRouteTypes {
| '/config'
| '/credentials'
| '/launcher-login'
| '/launcher-setup'
| '/logs'
| '/models'
| '/agent/hub'
@ -159,6 +169,7 @@ export interface FileRouteTypes {
| '/config'
| '/credentials'
| '/launcher-login'
| '/launcher-setup'
| '/logs'
| '/models'
| '/agent/hub'
@ -174,6 +185,7 @@ export interface FileRouteTypes {
| '/config'
| '/credentials'
| '/launcher-login'
| '/launcher-setup'
| '/logs'
| '/models'
| '/agent/hub'
@ -190,6 +202,7 @@ export interface RootRouteChildren {
ConfigRoute: typeof ConfigRouteWithChildren
CredentialsRoute: typeof CredentialsRoute
LauncherLoginRoute: typeof LauncherLoginRoute
LauncherSetupRoute: typeof LauncherSetupRoute
LogsRoute: typeof LogsRoute
ModelsRoute: typeof ModelsRoute
}
@ -210,6 +223,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LogsRouteImport
parentRoute: typeof rootRouteImport
}
'/launcher-setup': {
id: '/launcher-setup'
path: '/launcher-setup'
fullPath: '/launcher-setup'
preLoaderRoute: typeof LauncherSetupRouteImport
parentRoute: typeof rootRouteImport
}
'/launcher-login': {
id: '/launcher-login'
path: '/launcher-login'
@ -334,6 +354,7 @@ const rootRouteChildren: RootRouteChildren = {
ConfigRoute: ConfigRouteWithChildren,
CredentialsRoute: CredentialsRoute,
LauncherLoginRoute: LauncherLoginRoute,
LauncherSetupRoute: LauncherSetupRoute,
LogsRoute: LogsRoute,
ModelsRoute: ModelsRoute,
}

View file

@ -1,15 +1,16 @@
import { Outlet, createRootRoute, useRouterState } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import { useEffect } from "react"
import { useEffect, useState } from "react"
import { getLauncherAuthStatus } from "@/api/launcher-auth"
import { AppLayout } from "@/components/app-layout"
import { initializeChatStore } from "@/features/chat/controller"
import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
import { isLauncherAuthPathname } from "@/lib/launcher-login-path"
const RootLayout = () => {
// Prefer the real address bar path: stale embedded bundles may not register
// /launcher-login in the route tree, which would otherwise keep AppLayout +
// gateway polling → 401 → launcherFetch redirect loop.
// /launcher-login or /launcher-setup in the route tree, which would otherwise
// keep AppLayout + gateway polling → 401 → launcherFetch redirect loop.
const routerState = useRouterState({
select: (s) => ({
pathname: s.location.pathname,
@ -22,19 +23,50 @@ const RootLayout = () => {
? globalThis.location.pathname || "/"
: routerState.pathname
const isLauncherLogin =
isLauncherLoginPathname(windowPath) ||
isLauncherLoginPathname(routerState.pathname) ||
routerState.matches.some((m) => m.routeId === "/launcher-login")
const isAuthPage =
isLauncherAuthPathname(windowPath) ||
isLauncherAuthPathname(routerState.pathname) ||
routerState.matches.some(
(m) => m.routeId === "/launcher-login" || m.routeId === "/launcher-setup",
)
const [authError, setAuthError] = useState<string | null>(null)
// Session guard: proactively check auth status on every page load.
// This catches the case where ?token= auto-login bypassed the login/setup UI.
useEffect(() => {
if (isAuthPage) return
void getLauncherAuthStatus()
.then((s) => {
if (!s.initialized) {
globalThis.location.assign("/launcher-setup")
} else if (!s.authenticated) {
globalThis.location.assign("/launcher-login")
}
})
.catch((err: unknown) => {
// On 401/403, redirect to login — the session is invalid.
// On 5xx (e.g. 503 when the auth store is unavailable) or network errors,
// do NOT redirect: a subsequent successful login would loop straight back here.
// launcherFetch handles 401 on real API calls regardless.
if (err instanceof Error && /^status 40[13]$/.test(err.message)) {
globalThis.location.assign("/launcher-login")
} else {
setAuthError(
err instanceof Error ? err.message : "Auth service unavailable, please try to delete the launcher-auth.db at picoclaw home directory and restart the application.",
)
}
})
}, [isAuthPage])
useEffect(() => {
if (isLauncherLogin) {
if (isAuthPage) {
return
}
initializeChatStore()
}, [isLauncherLogin])
}, [isAuthPage])
if (isLauncherLogin) {
if (isAuthPage) {
return (
<>
<Outlet />
@ -44,10 +76,24 @@ const RootLayout = () => {
}
return (
<AppLayout>
<Outlet />
{import.meta.env.DEV ? <TanStackRouterDevtools /> : null}
</AppLayout>
<>
{authError && (
<div className="bg-destructive text-destructive-foreground fixed inset-x-0 top-0 z-[100] flex items-center justify-between px-4 py-2 text-sm shadow-md">
<span>Auth service error: {authError}</span>
<button
className="ml-4 opacity-70 hover:opacity-100"
onClick={() => setAuthError(null)}
aria-label="Dismiss"
>
</button>
</div>
)}
<AppLayout>
<Outlet />
{import.meta.env.DEV ? <TanStackRouterDevtools /> : null}
</AppLayout>
</>
)
}

View file

@ -3,11 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"
import * as React from "react"
import { useTranslation } from "react-i18next"
import {
type LauncherAuthTokenHelp,
getLauncherAuthStatus,
postLauncherDashboardLogin,
} from "@/api/launcher-auth"
import { postLauncherDashboardLogin, getLauncherAuthStatus } from "@/api/launcher-auth"
import { Button } from "@/components/ui/button"
import {
Card,
@ -32,24 +28,16 @@ function LauncherLoginPage() {
const [token, setToken] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
const [error, setError] = React.useState("")
const [tokenHelp, setTokenHelp] =
React.useState<LauncherAuthTokenHelp | null>(null)
// If the password store has never been initialized, go to setup instead.
React.useEffect(() => {
let cancelled = false
void getLauncherAuthStatus()
.then((s) => {
if (cancelled || s.authenticated || !s.token_help) {
return
if (!s.initialized) {
globalThis.location.assign("/launcher-setup")
}
setTokenHelp(s.token_help)
})
.catch(() => {
/* ignore; login form still usable */
})
return () => {
cancelled = true
}
.catch(() => { /* network error — stay on login page */ })
}, [])
const loginWithToken = React.useCallback(
@ -120,17 +108,17 @@ function LauncherLoginPage() {
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="launcher-token">
{t("launcherLogin.tokenLabel")}
{t("launcherLogin.passwordLabel")}
</Label>
<Input
id="launcher-token"
name="token"
name="password"
type="password"
autoComplete="current-password"
required
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={t("launcherLogin.tokenPlaceholder")}
placeholder={t("launcherLogin.passwordPlaceholder")}
/>
</div>
<Button type="submit" disabled={submitting}>
@ -142,42 +130,6 @@ function LauncherLoginPage() {
</p>
) : null}
</form>
{tokenHelp ? (
<div className="border-border/60 mt-6 border-t pt-4">
<p className="text-muted-foreground mb-2 text-sm font-medium">
{t("launcherLogin.helpTitle")}
</p>
<ul className="text-muted-foreground list-inside list-disc space-y-1.5 text-sm">
{tokenHelp.console_stdout ? (
<li>{t("launcherLogin.helpConsole")}</li>
) : null}
{tokenHelp.tray_copy_menu ? (
<li>{t("launcherLogin.helpTray")}</li>
) : null}
{tokenHelp.config_file ? (
<li>
{t("launcherLogin.helpConfig", {
path: tokenHelp.config_file,
})}
</li>
) : null}
{tokenHelp.log_file ? (
<li>
{t("launcherLogin.helpLogFile", {
path: tokenHelp.log_file,
})}
</li>
) : null}
{tokenHelp.env_var_name ? (
<li>
{t("launcherLogin.helpEnv", {
env: tokenHelp.env_var_name,
})}
</li>
) : null}
</ul>
</div>
) : null}
</CardContent>
</Card>
</div>

View file

@ -0,0 +1,146 @@
import { IconLanguage, IconMoon, IconSun } from "@tabler/icons-react"
import { createFileRoute } from "@tanstack/react-router"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { postLauncherDashboardSetup } from "@/api/launcher-auth"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useTheme } from "@/hooks/use-theme"
function LauncherSetupPage() {
const { t, i18n } = useTranslation()
const { theme, toggleTheme } = useTheme()
const [password, setPassword] = React.useState("")
const [confirm, setConfirm] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
const [error, setError] = React.useState("")
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setError("")
if (password !== confirm) {
setError(t("launcherSetup.errorMismatch"))
return
}
setSubmitting(true)
try {
const result = await postLauncherDashboardSetup(password, confirm)
if (result.ok) {
globalThis.location.assign("/launcher-login")
return
}
setError(result.error)
} catch {
setError(t("launcherSetup.errorNetwork"))
} finally {
setSubmitting(false)
}
}
return (
<div className="bg-background text-foreground flex min-h-dvh flex-col">
<header className="border-border/50 flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" aria-label="Language">
<IconLanguage className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
English
</DropdownMenuItem>
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon"
type="button"
onClick={() => toggleTheme()}
aria-label={theme === "dark" ? "Light mode" : "Dark mode"}
>
{theme === "dark" ? (
<IconSun className="size-4" />
) : (
<IconMoon className="size-4" />
)}
</Button>
</header>
<div className="flex flex-1 items-center justify-center p-4">
<Card className="w-full max-w-md" size="sm">
<CardHeader>
<CardTitle>{t("launcherSetup.title")}</CardTitle>
<CardDescription>{t("launcherSetup.description")}</CardDescription>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="setup-password">
{t("launcherSetup.passwordLabel")}
</Label>
<Input
id="setup-password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("launcherSetup.passwordPlaceholder")}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="setup-confirm">
{t("launcherSetup.confirmLabel")}
</Label>
<Input
id="setup-confirm"
name="confirm"
type="password"
autoComplete="new-password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={t("launcherSetup.confirmPlaceholder")}
/>
</div>
<Button type="submit" disabled={submitting}>
{submitting ? t("labels.loading") : t("launcherSetup.submit")}
</Button>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
</form>
</CardContent>
</Card>
</div>
</div>
)
}
export const Route = createFileRoute("/launcher-setup")({
component: LauncherSetupPage,
})

View file

@ -14,6 +14,7 @@ export type GatewayState =
export interface GatewayStoreState {
status: GatewayState
canStart: boolean
startReason?: string
restartRequired: boolean
}
@ -57,6 +58,7 @@ function normalizeGatewayStoreState(
if (
next.status === prev.status &&
next.canStart === prev.canStart &&
next.startReason === prev.startReason &&
next.restartRequired === prev.restartRequired
) {
return prev
@ -108,7 +110,10 @@ export function applyGatewayStatusToStore(
data: Partial<
Pick<
GatewayStatusResponse,
"gateway_status" | "gateway_start_allowed" | "gateway_restart_required"
| "gateway_status"
| "gateway_start_allowed"
| "gateway_start_reason"
| "gateway_restart_required"
>
>,
) {
@ -121,6 +126,10 @@ export function applyGatewayStatusToStore(
prev.status === "stopping" && data.gateway_status === "running"
? false
: (data.gateway_start_allowed ?? prev.canStart),
startReason:
prev.status === "stopping" && data.gateway_status === "running"
? prev.startReason
: (data.gateway_start_reason ?? prev.startReason),
restartRequired:
prev.status === "stopping" && data.gateway_status === "running"
? false