feat(web): persist dashboard token in launcher config (#2304)

- add `launcher_token` to launcher config API/schema and save/load flow
- update dashboard token resolution order: env var -> launcher config -> random
- expose token source in startup logs and auth help metadata (including config path)
- add launcher token input to the config page and wire frontend form/API updates
- update login help/i18n copy and extend backend tests for new token-source behavior
This commit is contained in:
wenjie
2026-04-03 14:54:27 +08:00
committed by GitHub
parent f2a19ab947
commit 7f7b4c430b
15 changed files with 252 additions and 107 deletions
+1
View File
@@ -23,6 +23,7 @@ type LauncherAuthRouteOpts struct {
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"`
}
+17 -12
View File
@@ -4,14 +4,16 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
type launcherConfigPayload struct {
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs"`
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs"`
LauncherToken string `json:"launcher_token"`
}
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
@@ -48,9 +50,10 @@ func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(launcherConfigPayload{
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
LauncherToken: cfg.LauncherToken,
})
}
@@ -62,9 +65,10 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
}
cfg := launcherconfig.Config{
Port: payload.Port,
Public: payload.Public,
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
Port: payload.Port,
Public: payload.Public,
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
LauncherToken: strings.TrimSpace(payload.LauncherToken),
}
if err := launcherconfig.Validate(cfg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -78,8 +82,9 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(launcherConfigPayload{
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
LauncherToken: cfg.LauncherToken,
})
}
+9 -1
View File
@@ -34,6 +34,9 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
if got.Port != 19999 || !got.Public {
t.Fatalf("response = %+v, want port=19999 public=true", got)
}
if got.LauncherToken != "" {
t.Fatalf("response launcher_token = %q, want empty", got.LauncherToken)
}
if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" {
t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs)
}
@@ -50,7 +53,9 @@ func TestPutLauncherConfigPersists(t *testing.T) {
req := httptest.NewRequest(
http.MethodPut,
"/api/system/launcher-config",
strings.NewReader(`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`),
strings.NewReader(
`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"],"launcher_token":"saved-token"}`,
),
)
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
@@ -67,6 +72,9 @@ func TestPutLauncherConfigPersists(t *testing.T) {
if cfg.Port != 18080 || !cfg.Public {
t.Fatalf("saved config = %+v, want port=18080 public=true", cfg)
}
if cfg.LauncherToken != "saved-token" {
t.Fatalf("saved launcher_token = %q, want %q", cfg.LauncherToken, "saved-token")
}
if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" {
t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs)
}
+28 -10
View File
@@ -23,11 +23,20 @@ const (
dashboardTokenEntropyBytes = 32
)
type DashboardTokenSource string
const (
DashboardTokenSourceEnv DashboardTokenSource = "env"
DashboardTokenSourceConfig DashboardTokenSource = "config"
DashboardTokenSourceRandom DashboardTokenSource = "random"
)
// Config stores launch parameters for the web backend service.
type Config struct {
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
LauncherToken string `json:"launcher_token,omitempty"`
}
// Default returns default launcher settings.
@@ -49,23 +58,30 @@ func Validate(cfg Config) error {
}
// EnsureDashboardSecrets returns signing key bytes and the effective dashboard token for this
// process. The signing key is freshly random each call; the token comes from the environment
// variable PICOCLAW_LAUNCHER_TOKEN when set, otherwise a new random token.
func EnsureDashboardSecrets() (effectiveToken string, signingKey []byte, newRandomDashboardToken bool, err error) {
// process. The signing key is freshly random each call; the token comes from
// PICOCLAW_LAUNCHER_TOKEN when set, otherwise launcher-config.json launcher_token,
// otherwise a new random token.
func EnsureDashboardSecrets(
cfg Config,
) (effectiveToken string, signingKey []byte, source DashboardTokenSource, err error) {
signingKey = make([]byte, dashboardSigningKeyBytes)
if _, err = rand.Read(signingKey); err != nil {
return "", nil, false, err
return "", nil, "", err
}
effectiveToken = strings.TrimSpace(os.Getenv("PICOCLAW_LAUNCHER_TOKEN"))
if effectiveToken != "" {
return effectiveToken, signingKey, false, nil
return effectiveToken, signingKey, DashboardTokenSourceEnv, nil
}
effectiveToken = strings.TrimSpace(cfg.LauncherToken)
if effectiveToken != "" {
return effectiveToken, signingKey, DashboardTokenSourceConfig, nil
}
tok, genErr := randomDashboardToken()
if genErr != nil {
return "", nil, false, genErr
return "", nil, "", genErr
}
return tok, signingKey, true, nil
return tok, signingKey, DashboardTokenSourceRandom, nil
}
func randomDashboardToken() (string, error) {
@@ -124,6 +140,7 @@ func Load(path string, fallback Config) (Config, error) {
return Config{}, err
}
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken)
if err := Validate(cfg); err != nil {
return Config{}, err
}
@@ -133,6 +150,7 @@ func Load(path string, fallback Config) (Config, error) {
// Save writes launcher settings to disk.
func Save(path string, cfg Config) error {
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken)
if err := Validate(cfg); err != nil {
return err
}
+31 -12
View File
@@ -25,9 +25,10 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "launcher-config.json")
want := Config{
Port: 18080,
Public: true,
AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
Port: 18080,
Public: true,
AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
LauncherToken: "saved-launcher-token",
}
if err := Save(path, want); err != nil {
@@ -40,6 +41,9 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
if got.Port != want.Port || got.Public != want.Public {
t.Fatalf("Load() = %+v, want %+v", got, want)
}
if got.LauncherToken != want.LauncherToken {
t.Fatalf("launcher_token = %q, want %q", got.LauncherToken, want.LauncherToken)
}
if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) {
t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs))
}
@@ -80,24 +84,24 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) {
func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) {
t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "")
tok, key, newTok, err := EnsureDashboardSecrets()
tok, key, source, err := EnsureDashboardSecrets(Default())
if err != nil {
t.Fatalf("EnsureDashboardSecrets() error = %v", err)
}
if !newTok || tok == "" || len(key) != dashboardSigningKeyBytes {
t.Fatalf("unexpected first call: newTok=%v tok=%q keyLen=%d", newTok, tok, len(key))
if source != DashboardTokenSourceRandom || tok == "" || len(key) != dashboardSigningKeyBytes {
t.Fatalf("unexpected first call: source=%q tok=%q keyLen=%d", source, tok, len(key))
}
mac := middleware.SessionCookieValue(key, tok)
if mac == "" {
t.Fatal("empty session mac")
}
tok2, key2, newTok2, err := EnsureDashboardSecrets()
tok2, key2, source2, err := EnsureDashboardSecrets(Default())
if err != nil {
t.Fatalf("EnsureDashboardSecrets() second error = %v", err)
}
if !newTok2 {
t.Fatal("second call without env should generate another random token")
if source2 != DashboardTokenSourceRandom {
t.Fatalf("second call source = %q, want %q", source2, DashboardTokenSourceRandom)
}
if tok2 == tok {
t.Fatal("expected a new random dashboard token")
@@ -110,15 +114,30 @@ func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) {
func TestEnsureDashboardSecrets_EnvOverridesGenerated(t *testing.T) {
t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "env-only-token-override")
tok, _, newTok, err := EnsureDashboardSecrets()
tok, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"})
if err != nil {
t.Fatalf("EnsureDashboardSecrets() error = %v", err)
}
if tok != "env-only-token-override" {
t.Fatalf("token = %q, want env value", tok)
}
if newTok {
t.Fatal("newRandomDashboardToken should be false when env is set")
if source != DashboardTokenSourceEnv {
t.Fatalf("source = %q, want %q", source, DashboardTokenSourceEnv)
}
}
func TestEnsureDashboardSecrets_ConfigOverridesGenerated(t *testing.T) {
t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "")
tok, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"})
if err != nil {
t.Fatalf("EnsureDashboardSecrets() error = %v", err)
}
if tok != "config-token" {
t.Fatalf("token = %q, want config value", tok)
}
if source != DashboardTokenSourceConfig {
t.Fatalf("source = %q, want %q", source, DashboardTokenSourceConfig)
}
}
+24 -7
View File
@@ -59,6 +59,13 @@ func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool {
return !enableConsole || debug
}
func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, launcherPath string) string {
if source != launcherconfig.DashboardTokenSourceConfig {
return ""
}
return launcherPath
}
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")
@@ -195,7 +202,9 @@ func main() {
logger.Fatalf("Invalid port %q: %v", effectivePort, err)
}
dashboardToken, dashboardSigningKey, newDashTok, dashErr := launcherconfig.EnsureDashboardSecrets()
dashboardToken, dashboardSigningKey, dashboardTokenSource, dashErr := launcherconfig.EnsureDashboardSecrets(
launcherCfg,
)
if dashErr != nil {
logger.Fatalf("Dashboard auth setup failed: %v", dashErr)
}
@@ -223,6 +232,7 @@ func main() {
TokenHelp: api.LauncherAuthTokenHelp{
EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
LogFileAbs: tokenLogFileAbs,
ConfigFileAbs: dashboardTokenConfigHelpPath(dashboardTokenSource, launcherPath),
TrayCopyMenu: trayOffersDashboardTokenCopy(),
ConsoleStdout: enableConsole,
},
@@ -272,19 +282,26 @@ func main() {
}
}
fmt.Println()
if newDashTok {
switch dashboardTokenSource {
case launcherconfig.DashboardTokenSourceRandom:
fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken)
} else if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" {
case launcherconfig.DashboardTokenSourceEnv:
fmt.Printf(" Dashboard token: %s (from PICOCLAW_LAUNCHER_TOKEN)\n", dashboardToken)
case launcherconfig.DashboardTokenSourceConfig:
fmt.Printf(" Dashboard token: %s (from %s)\n", dashboardToken, launcherPath)
}
fmt.Println()
}
if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" {
switch dashboardTokenSource {
case launcherconfig.DashboardTokenSourceEnv:
logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN")
}
if !enableConsole && newDashTok {
logger.InfoC("web", "Dashboard token (this run): "+dashboardToken)
case launcherconfig.DashboardTokenSourceConfig:
logger.InfoC("web", fmt.Sprintf("Dashboard token: configured in %s", launcherPath))
case launcherconfig.DashboardTokenSourceRandom:
if !enableConsole {
logger.InfoC("web", "Dashboard token (this run): "+dashboardToken)
}
}
// Log startup info to file
+39 -1
View File
@@ -1,6 +1,10 @@
package main
import "testing"
import (
"testing"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
func TestShouldEnableLauncherFileLogging(t *testing.T) {
tests := []struct {
@@ -29,3 +33,37 @@ func TestShouldEnableLauncherFileLogging(t *testing.T) {
})
}
}
func TestDashboardTokenConfigHelpPath(t *testing.T) {
const launcherPath = "/tmp/launcher-config.json"
tests := []struct {
name string
source launcherconfig.DashboardTokenSource
want string
}{
{
name: "env token does not expose config path",
source: launcherconfig.DashboardTokenSourceEnv,
want: "",
},
{
name: "config token exposes config path",
source: launcherconfig.DashboardTokenSourceConfig,
want: launcherPath,
},
{
name: "random token does not expose config path",
source: launcherconfig.DashboardTokenSourceRandom,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := dashboardTokenConfigHelpPath(tt.source, launcherPath); got != tt.want {
t.Fatalf("dashboardTokenConfigHelpPath(%q, %q) = %q, want %q", tt.source, launcherPath, got, tt.want)
}
})
}
}
+1
View File
@@ -17,6 +17,7 @@ export async function postLauncherDashboardLogin(
export type LauncherAuthTokenHelp = {
env_var_name: string
log_file?: string
config_file?: string
tray_copy_menu: boolean
console_stdout: boolean
}
+1
View File
@@ -11,6 +11,7 @@ export interface LauncherConfig {
port: number
public: boolean
allowed_cidrs: string[]
launcher_token: string
}
export interface SystemVersionInfo {
@@ -94,6 +94,7 @@ export function ConfigPage() {
port: String(launcherConfig.port),
publicAccess: launcherConfig.public,
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
launcherToken: launcherConfig.launcher_token ?? "",
}
setLauncherForm(parsed)
setLauncherBaseline(parsed)
@@ -264,6 +265,7 @@ export function ConfigPage() {
port,
public: launcherForm.publicAccess,
allowed_cidrs: allowedCIDRs,
launcher_token: launcherForm.launcherToken.trim(),
})
const parsedLauncher: LauncherForm = {
port: String(savedLauncherConfig.port),
@@ -271,6 +273,7 @@ export function ConfigPage() {
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
"\n",
),
launcherToken: savedLauncherConfig.launcher_token ?? "",
}
setLauncherForm(parsedLauncher)
setLauncherBaseline(parsedLauncher)
@@ -343,6 +346,12 @@ export function ConfigPage() {
</div>
)}
<LauncherSection
launcherForm={launcherForm}
onFieldChange={updateLauncherField}
disabled={saving || isLauncherLoading}
/>
<AgentDefaultsSection form={form} onFieldChange={updateField} />
<RuntimeSection form={form} onFieldChange={updateField} />
@@ -351,12 +360,6 @@ export function ConfigPage() {
<CronSection form={form} onFieldChange={updateField} />
<LauncherSection
launcherForm={launcherForm}
onFieldChange={updateLauncherField}
disabled={saving || isLauncherLoading}
/>
<DevicesSection
form={form}
onFieldChange={updateField}
@@ -100,9 +100,7 @@ export function AgentDefaultsSection({
hint={t("pages.config.split_on_marker_hint")}
layout="setting-row"
checked={form.splitOnMarker}
onCheckedChange={(checked) =>
onFieldChange("splitOnMarker", checked)
}
onCheckedChange={(checked) => onFieldChange("splitOnMarker", checked)}
/>
<SwitchCardField
@@ -519,7 +517,25 @@ export function LauncherSection({
const { t } = useTranslation()
return (
<ConfigSectionCard title={t("pages.config.sections.launcher")}>
<ConfigSectionCard
title={t("pages.config.sections.launcher")}
description={t("pages.config.launcher_token_section_hint")}
>
<Field
label={t("pages.config.launcher_token")}
hint={t("pages.config.launcher_token_hint")}
layout="setting-row"
>
<Input
type="password"
value={launcherForm.launcherToken}
disabled={disabled}
autoComplete="off"
placeholder={t("pages.config.launcher_token_placeholder")}
onChange={(e) => onFieldChange("launcherToken", e.target.value)}
/>
</Field>
<SwitchCardField
label={t("pages.config.lan_access")}
hint={t("pages.config.lan_access_hint")}
@@ -30,6 +30,7 @@ export interface LauncherForm {
port: string
publicAccess: boolean
allowedCIDRsText: string
launcherToken: string
}
export const DM_SCOPE_OPTIONS = [
@@ -93,6 +94,7 @@ export const EMPTY_LAUNCHER_FORM: LauncherForm = {
port: "18800",
publicAccess: false,
allowedCIDRsText: "",
launcherToken: "",
}
function asRecord(value: unknown): JsonRecord {
+7 -2
View File
@@ -17,7 +17,7 @@
},
"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).",
"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",
@@ -26,6 +26,7 @@
"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}}."
},
@@ -582,6 +583,10 @@
"autostart_load_error": "Failed to load launch-at-login status.",
"server_port": "Service Port",
"server_port_hint": "HTTP port used by PicoClaw Web.",
"launcher_token": "Login Token",
"launcher_token_section_hint": "Changes in this section take effect after the launcher restarts.",
"launcher_token_hint": "Used to sign in on the launcher login page.",
"launcher_token_placeholder": "Enter login token",
"lan_access": "Enable LAN Access",
"lan_access_hint": "Allow access from other devices on your local network.",
"allowed_cidrs": "Allowed Network CIDRs",
@@ -592,7 +597,7 @@
"runtime": "Runtime",
"exec": "Run Commands",
"cron": "Cron Tasks",
"launcher": "Service",
"launcher": "Launcher",
"devices": "Devices"
},
"open_raw": "Raw Config",
+53 -48
View File
@@ -17,17 +17,18 @@
},
"launcherLogin": {
"title": "Launcher 访问验证",
"description": "请使用当前 Launcher 进程的访问口令登录(每次重启可能变化,除非用环境变量固定)",
"description": "请使用当前 Launcher 进程的访问口令登录(每次重启可能变化,除非用环境变量或 launcher 配置固定)",
"tokenLabel": "令牌",
"tokenPlaceholder": "输入访问令牌",
"submit": "进入 Dashboard",
"errorInvalid": "令牌错误,请重试",
"errorNetwork": "网络错误,请重试",
"errorInvalid": "令牌错误,请重试",
"errorNetwork": "网络错误,请重试",
"helpTitle": "口令在哪里",
"helpConsole": "控制台模式:启动时在终端输出",
"helpTray": "托盘模式:菜单「复制控制台口令」",
"helpConsole": "控制台模式:启动时在终端输出",
"helpTray": "托盘模式:菜单「复制控制台口令」",
"helpConfig": "Launcher 配置文件:{{path}}",
"helpLogFile": "日志文件(启动时会写入口令):{{path}}",
"helpEnv": "固定口令:设置环境变量 {{env}}"
"helpEnv": "固定口令:设置环境变量 {{env}}"
},
"chat": {
"welcome": "今天我能为您做些什么?",
@@ -513,102 +514,106 @@
}
},
"config": {
"load_error": "加载配置失败,请刷新后重试",
"load_error": "加载配置失败,请刷新后重试",
"workspace": "工作目录",
"workspace_hint": "智能体执行文件读写操作时使用的基础目录",
"workspace_hint": "智能体执行文件读写操作时使用的基础目录",
"restrict_workspace": "限制工作目录访问",
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作",
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作",
"split_on_marker": "连续短消息",
"split_on_marker_hint": "像真人聊天一样,把长难句拆成多条短消息快速发出",
"tool_feedback_enabled": "工具反馈",
"tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览",
"tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览",
"tool_feedback_max_args_length": "工具反馈参数预览长度",
"tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的参数字符上限。设为 0 时使用默认值",
"tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的参数字符上限。设为 0 时使用默认值",
"exec_enabled": "允许命令执行",
"exec_enabled_hint": "控制应用是否允许执行命令。关闭后,所有命令请求都不会执行",
"exec_enabled_hint": "控制应用是否允许执行命令。关闭后,所有命令请求都不会执行",
"allow_remote": "允许远程命令执行",
"allow_remote_hint": "开启后,来自远程会话或非本地上下文的请求也可以执行命令;关闭后,仅允许本地安全上下文执行命令",
"allow_remote_hint": "开启后,来自远程会话或非本地上下文的请求也可以执行命令;关闭后,仅允许本地安全上下文执行命令",
"enable_deny_patterns": "启用黑名单",
"enable_deny_patterns_hint": "开启后,应用会拦截匹配内置危险模式以及下方自定义命令黑名单的命令",
"enable_deny_patterns_hint": "开启后,应用会拦截匹配内置危险模式以及下方自定义命令黑名单的命令",
"exec_timeout_seconds": "命令超时(秒)",
"exec_timeout_seconds_hint": "命令请求的最长运行时间。设置为 0 表示使用默认超时",
"exec_timeout_seconds_hint": "命令请求的最长运行时间。设置为 0 表示使用默认超时",
"custom_deny_patterns": "命令黑名单",
"custom_deny_patterns_hint": "用于补充额外的命令拦截规则,每行一个正则表达式。命中任意一条规则的命令都会被阻止",
"custom_deny_patterns_hint": "用于补充额外的命令拦截规则,每行一个正则表达式。命中任意一条规则的命令都会被阻止",
"custom_allow_patterns": "命令白名单",
"custom_allow_patterns_hint": "用于补充额外的命令放行规则,每行一个正则表达式。命中任意一条规则的命令会跳过黑名单检查,但仍受其他安全限制约束",
"custom_allow_patterns_hint": "用于补充额外的命令放行规则,每行一个正则表达式。命中任意一条规则的命令会跳过黑名单检查,但仍受其他安全限制约束",
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
"pattern_detector_title": "规则检测工具",
"pattern_detector_hint": "输入命令以检测其是否匹配黑名单或白名单规则",
"pattern_detector_hint": "输入命令以检测其是否匹配黑名单或白名单规则",
"pattern_detector_input_placeholder": "输入要检测的命令,例如 rm -rf /tmp",
"pattern_detector_test_button": "检测",
"pattern_detector_result_allowed": "允许(匹配白名单)",
"pattern_detector_result_blocked": "阻止(匹配黑名单)",
"pattern_detector_result_no_match": "无匹配(将使用默认规则)",
"allow_shell_execution": "允许定时任务运行命令",
"allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务",
"allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务",
"cron_exec_timeout": "定时命令超时(分钟)",
"cron_exec_timeout_hint": "定时任务中命令的最长运行时间。设置为 0 表示不限制超时",
"cron_exec_timeout_hint": "定时任务中命令的最长运行时间。设置为 0 表示不限制超时",
"max_tokens": "最大 Token 数",
"max_tokens_hint": "单次模型响应允许的最大 Token 数",
"max_tokens_hint": "单次模型响应允许的最大 Token 数",
"context_window": "上下文窗口",
"context_window_hint": "模型输入上下文容量(Token 数)。留空使用默认值(最大 Token 数的 4 倍)",
"context_window_hint": "模型输入上下文容量(Token 数)。留空使用默认值(最大 Token 数的 4 倍)",
"max_tool_iterations": "最大工具迭代次数",
"max_tool_iterations_hint": "单个任务中允许的工具调用循环上限",
"max_tool_iterations_hint": "单个任务中允许的工具调用循环上限",
"summarize_threshold": "触发摘要的消息阈值",
"summarize_threshold_hint": "消息数量达到该值后开始触发摘要",
"summarize_threshold_hint": "消息数量达到该值后开始触发摘要",
"summarize_token_percent": "摘要目标 Token 百分比",
"summarize_token_percent_hint": "在触发会话摘要时使用",
"summarize_token_percent_hint": "在触发会话摘要时使用",
"session_scope": "会话隔离范围",
"session_scope_hint": "定义不同用户/频道之间如何隔离会话上下文",
"session_scope_hint": "定义不同用户/频道之间如何隔离会话上下文",
"session_scope_per_channel_peer": "按频道+用户隔离",
"session_scope_per_channel_peer_desc": "同一频道内不同用户使用独立上下文",
"session_scope_per_channel_peer_desc": "同一频道内不同用户使用独立上下文",
"session_scope_per_channel": "按频道隔离",
"session_scope_per_channel_desc": "同一频道内共享一个上下文",
"session_scope_per_channel_desc": "同一频道内共享一个上下文",
"session_scope_per_peer": "按用户隔离",
"session_scope_per_peer_desc": "同一用户跨频道共享一个上下文",
"session_scope_per_peer_desc": "同一用户跨频道共享一个上下文",
"session_scope_global": "全局共享",
"session_scope_global_desc": "所有消息共用一个全局上下文",
"session_scope_global_desc": "所有消息共用一个全局上下文",
"heartbeat_enabled": "心跳开关",
"heartbeat_enabled_hint": "按间隔发送系统心跳",
"heartbeat_enabled_hint": "按间隔发送系统心跳",
"heartbeat_interval": "心跳间隔(分钟)",
"heartbeat_interval_hint": "两次心跳发送之间的分钟间隔",
"heartbeat_interval_hint": "两次心跳发送之间的分钟间隔",
"devices_enabled": "启用设备功能",
"devices_enabled_hint": "启用与本机硬件设备相关的能力",
"devices_enabled_hint": "启用与本机硬件设备相关的能力",
"monitor_usb": "监听 USB",
"monitor_usb_hint": "在启用设备功能时,监听 USB 插拔事件",
"monitor_usb_hint": "在启用设备功能时,监听 USB 插拔事件",
"autostart_label": "开机自启",
"autostart_hint": "登录系统后自动启动 PicoClaw Web",
"autostart_unsupported": "当前平台不支持开机自启",
"autostart_load_error": "加载开机自启状态失败",
"autostart_hint": "登录系统后自动启动 PicoClaw Web",
"autostart_unsupported": "当前平台不支持开机自启",
"autostart_load_error": "加载开机自启状态失败",
"server_port": "服务端口",
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口",
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口",
"launcher_token": "登录令牌",
"launcher_token_section_hint": "此分组中的改动需要在重启 launcher 后生效",
"launcher_token_hint": "用于在 launcher 登录页进行登录",
"launcher_token_placeholder": "输入登录令牌",
"lan_access": "启用局域网访问",
"lan_access_hint": "允许局域网中的其他设备访问当前服务",
"lan_access_hint": "允许局域网中的其他设备访问当前服务",
"allowed_cidrs": "允许访问网段",
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源",
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源",
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
"sections": {
"agent": "智能体",
"runtime": "运行时",
"exec": "运行命令",
"cron": "定时任务",
"launcher": "服务参数",
"launcher": "启动器",
"devices": "设备"
},
"open_raw": "原始配置",
"back_to_visual": "可视化配置",
"raw_json_title": "原始 JSON 配置",
"json_placeholder": "请输入有效的 JSON 配置...",
"save_success": "配置保存成功",
"save_error": "配置保存失败",
"save_success": "配置保存成功",
"save_error": "配置保存失败",
"reset_confirm_title": "重置更改",
"reset_confirm_desc": "您确定要重置回上次保存的状态吗?",
"reset_success": "更改已重置为上次保存的状态",
"invalid_json": "JSON 格式无效",
"format_success": "JSON 格式化成功",
"format_error": "JSON 格式无效",
"reset_success": "更改已重置为上次保存的状态",
"invalid_json": "JSON 格式无效",
"format_success": "JSON 格式化成功",
"format_error": "JSON 格式无效",
"format": "格式化",
"unsaved_changes": "您有未保存的更改"
"unsaved_changes": "您有未保存的更改"
},
"logs": {
"log_level_error": "更新日志等级失败。",
+10 -4
View File
@@ -4,9 +4,9 @@ import * as React from "react"
import { useTranslation } from "react-i18next"
import {
type LauncherAuthTokenHelp,
getLauncherAuthStatus,
postLauncherDashboardLogin,
type LauncherAuthTokenHelp,
} from "@/api/launcher-auth"
import { Button } from "@/components/ui/button"
import {
@@ -32,9 +32,8 @@ 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,
)
const [tokenHelp, setTokenHelp] =
React.useState<LauncherAuthTokenHelp | null>(null)
React.useEffect(() => {
let cancelled = false
@@ -155,6 +154,13 @@ function LauncherLoginPage() {
{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", {