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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 252 additions and 107 deletions

View file

@ -23,6 +23,7 @@ type LauncherAuthRouteOpts struct {
type LauncherAuthTokenHelp struct { type LauncherAuthTokenHelp struct {
EnvVarName string `json:"env_var_name"` EnvVarName string `json:"env_var_name"`
LogFileAbs string `json:"log_file,omitempty"` LogFileAbs string `json:"log_file,omitempty"`
ConfigFileAbs string `json:"config_file,omitempty"`
TrayCopyMenu bool `json:"tray_copy_menu"` TrayCopyMenu bool `json:"tray_copy_menu"`
ConsoleStdout bool `json:"console_stdout"` ConsoleStdout bool `json:"console_stdout"`
} }

View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"github.com/sipeed/picoclaw/web/backend/launcherconfig" "github.com/sipeed/picoclaw/web/backend/launcherconfig"
) )
@ -12,6 +13,7 @@ type launcherConfigPayload struct {
Port int `json:"port"` Port int `json:"port"`
Public bool `json:"public"` Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs"` AllowedCIDRs []string `json:"allowed_cidrs"`
LauncherToken string `json:"launcher_token"`
} }
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) { func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
@ -51,6 +53,7 @@ func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request
Port: cfg.Port, Port: cfg.Port,
Public: cfg.Public, Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
LauncherToken: cfg.LauncherToken,
}) })
} }
@ -65,6 +68,7 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
Port: payload.Port, Port: payload.Port,
Public: payload.Public, Public: payload.Public,
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...), AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
LauncherToken: strings.TrimSpace(payload.LauncherToken),
} }
if err := launcherconfig.Validate(cfg); err != nil { if err := launcherconfig.Validate(cfg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
@ -81,5 +85,6 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
Port: cfg.Port, Port: cfg.Port,
Public: cfg.Public, Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
LauncherToken: cfg.LauncherToken,
}) })
} }

View file

@ -34,6 +34,9 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
if got.Port != 19999 || !got.Public { if got.Port != 19999 || !got.Public {
t.Fatalf("response = %+v, want port=19999 public=true", got) 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" { 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) 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( req := httptest.NewRequest(
http.MethodPut, http.MethodPut,
"/api/system/launcher-config", "/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") req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req) mux.ServeHTTP(rec, req)
@ -67,6 +72,9 @@ func TestPutLauncherConfigPersists(t *testing.T) {
if cfg.Port != 18080 || !cfg.Public { if cfg.Port != 18080 || !cfg.Public {
t.Fatalf("saved config = %+v, want port=18080 public=true", cfg) 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" { 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) t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs)
} }

View file

@ -23,11 +23,20 @@ const (
dashboardTokenEntropyBytes = 32 dashboardTokenEntropyBytes = 32
) )
type DashboardTokenSource string
const (
DashboardTokenSourceEnv DashboardTokenSource = "env"
DashboardTokenSourceConfig DashboardTokenSource = "config"
DashboardTokenSourceRandom DashboardTokenSource = "random"
)
// Config stores launch parameters for the web backend service. // Config stores launch parameters for the web backend service.
type Config struct { type Config struct {
Port int `json:"port"` Port int `json:"port"`
Public bool `json:"public"` Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
LauncherToken string `json:"launcher_token,omitempty"`
} }
// Default returns default launcher settings. // 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 // 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 // process. The signing key is freshly random each call; the token comes from
// variable PICOCLAW_LAUNCHER_TOKEN when set, otherwise a new random token. // PICOCLAW_LAUNCHER_TOKEN when set, otherwise launcher-config.json launcher_token,
func EnsureDashboardSecrets() (effectiveToken string, signingKey []byte, newRandomDashboardToken bool, err error) { // otherwise a new random token.
func EnsureDashboardSecrets(
cfg Config,
) (effectiveToken string, signingKey []byte, source DashboardTokenSource, err error) {
signingKey = make([]byte, dashboardSigningKeyBytes) signingKey = make([]byte, dashboardSigningKeyBytes)
if _, err = rand.Read(signingKey); err != nil { if _, err = rand.Read(signingKey); err != nil {
return "", nil, false, err return "", nil, "", err
} }
effectiveToken = strings.TrimSpace(os.Getenv("PICOCLAW_LAUNCHER_TOKEN")) effectiveToken = strings.TrimSpace(os.Getenv("PICOCLAW_LAUNCHER_TOKEN"))
if effectiveToken != "" { 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() tok, genErr := randomDashboardToken()
if genErr != nil { if genErr != nil {
return "", nil, false, genErr return "", nil, "", genErr
} }
return tok, signingKey, true, nil return tok, signingKey, DashboardTokenSourceRandom, nil
} }
func randomDashboardToken() (string, error) { func randomDashboardToken() (string, error) {
@ -124,6 +140,7 @@ func Load(path string, fallback Config) (Config, error) {
return Config{}, err return Config{}, err
} }
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken)
if err := Validate(cfg); err != nil { if err := Validate(cfg); err != nil {
return Config{}, err return Config{}, err
} }
@ -133,6 +150,7 @@ func Load(path string, fallback Config) (Config, error) {
// Save writes launcher settings to disk. // Save writes launcher settings to disk.
func Save(path string, cfg Config) error { func Save(path string, cfg Config) error {
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken)
if err := Validate(cfg); err != nil { if err := Validate(cfg); err != nil {
return err return err
} }

View file

@ -28,6 +28,7 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
Port: 18080, Port: 18080,
Public: true, Public: true,
AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"}, AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
LauncherToken: "saved-launcher-token",
} }
if err := Save(path, want); err != nil { 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 { if got.Port != want.Port || got.Public != want.Public {
t.Fatalf("Load() = %+v, want %+v", got, want) 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) { if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) {
t.Fatalf("allowed_cidrs len = %d, want %d", 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) { func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) {
t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "") t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "")
tok, key, newTok, err := EnsureDashboardSecrets() tok, key, source, err := EnsureDashboardSecrets(Default())
if err != nil { if err != nil {
t.Fatalf("EnsureDashboardSecrets() error = %v", err) t.Fatalf("EnsureDashboardSecrets() error = %v", err)
} }
if !newTok || tok == "" || len(key) != dashboardSigningKeyBytes { if source != DashboardTokenSourceRandom || tok == "" || len(key) != dashboardSigningKeyBytes {
t.Fatalf("unexpected first call: newTok=%v tok=%q keyLen=%d", newTok, tok, len(key)) t.Fatalf("unexpected first call: source=%q tok=%q keyLen=%d", source, tok, len(key))
} }
mac := middleware.SessionCookieValue(key, tok) mac := middleware.SessionCookieValue(key, tok)
if mac == "" { if mac == "" {
t.Fatal("empty session mac") t.Fatal("empty session mac")
} }
tok2, key2, newTok2, err := EnsureDashboardSecrets() tok2, key2, source2, err := EnsureDashboardSecrets(Default())
if err != nil { if err != nil {
t.Fatalf("EnsureDashboardSecrets() second error = %v", err) t.Fatalf("EnsureDashboardSecrets() second error = %v", err)
} }
if !newTok2 { if source2 != DashboardTokenSourceRandom {
t.Fatal("second call without env should generate another random token") t.Fatalf("second call source = %q, want %q", source2, DashboardTokenSourceRandom)
} }
if tok2 == tok { if tok2 == tok {
t.Fatal("expected a new random dashboard token") t.Fatal("expected a new random dashboard token")
@ -110,15 +114,30 @@ func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) {
func TestEnsureDashboardSecrets_EnvOverridesGenerated(t *testing.T) { func TestEnsureDashboardSecrets_EnvOverridesGenerated(t *testing.T) {
t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "env-only-token-override") t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "env-only-token-override")
tok, _, newTok, err := EnsureDashboardSecrets() tok, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"})
if err != nil { if err != nil {
t.Fatalf("EnsureDashboardSecrets() error = %v", err) t.Fatalf("EnsureDashboardSecrets() error = %v", err)
} }
if tok != "env-only-token-override" { if tok != "env-only-token-override" {
t.Fatalf("token = %q, want env value", tok) t.Fatalf("token = %q, want env value", tok)
} }
if newTok { if source != DashboardTokenSourceEnv {
t.Fatal("newRandomDashboardToken should be false when env is set") 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)
} }
} }

View file

@ -59,6 +59,13 @@ func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool {
return !enableConsole || debug return !enableConsole || debug
} }
func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, launcherPath string) string {
if source != launcherconfig.DashboardTokenSourceConfig {
return ""
}
return launcherPath
}
func main() { func main() {
port := flag.String("port", "18800", "Port to listen on") port := flag.String("port", "18800", "Port to listen on")
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only") public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
@ -195,7 +202,9 @@ func main() {
logger.Fatalf("Invalid port %q: %v", effectivePort, err) logger.Fatalf("Invalid port %q: %v", effectivePort, err)
} }
dashboardToken, dashboardSigningKey, newDashTok, dashErr := launcherconfig.EnsureDashboardSecrets() dashboardToken, dashboardSigningKey, dashboardTokenSource, dashErr := launcherconfig.EnsureDashboardSecrets(
launcherCfg,
)
if dashErr != nil { if dashErr != nil {
logger.Fatalf("Dashboard auth setup failed: %v", dashErr) logger.Fatalf("Dashboard auth setup failed: %v", dashErr)
} }
@ -223,6 +232,7 @@ func main() {
TokenHelp: api.LauncherAuthTokenHelp{ TokenHelp: api.LauncherAuthTokenHelp{
EnvVarName: "PICOCLAW_LAUNCHER_TOKEN", EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
LogFileAbs: tokenLogFileAbs, LogFileAbs: tokenLogFileAbs,
ConfigFileAbs: dashboardTokenConfigHelpPath(dashboardTokenSource, launcherPath),
TrayCopyMenu: trayOffersDashboardTokenCopy(), TrayCopyMenu: trayOffersDashboardTokenCopy(),
ConsoleStdout: enableConsole, ConsoleStdout: enableConsole,
}, },
@ -272,20 +282,27 @@ func main() {
} }
} }
fmt.Println() fmt.Println()
if newDashTok { switch dashboardTokenSource {
case launcherconfig.DashboardTokenSourceRandom:
fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken) 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) 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() fmt.Println()
} }
if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" { switch dashboardTokenSource {
case launcherconfig.DashboardTokenSourceEnv:
logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN") logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN")
} case launcherconfig.DashboardTokenSourceConfig:
if !enableConsole && newDashTok { logger.InfoC("web", fmt.Sprintf("Dashboard token: configured in %s", launcherPath))
case launcherconfig.DashboardTokenSourceRandom:
if !enableConsole {
logger.InfoC("web", "Dashboard token (this run): "+dashboardToken) logger.InfoC("web", "Dashboard token (this run): "+dashboardToken)
} }
}
// Log startup info to file // Log startup info to file
logger.InfoC("web", fmt.Sprintf("Server will listen on http://localhost:%s", effectivePort)) logger.InfoC("web", fmt.Sprintf("Server will listen on http://localhost:%s", effectivePort))

View file

@ -1,6 +1,10 @@
package main package main
import "testing" import (
"testing"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
func TestShouldEnableLauncherFileLogging(t *testing.T) { func TestShouldEnableLauncherFileLogging(t *testing.T) {
tests := []struct { 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)
}
})
}
}

View file

@ -17,6 +17,7 @@ export async function postLauncherDashboardLogin(
export type LauncherAuthTokenHelp = { export type LauncherAuthTokenHelp = {
env_var_name: string env_var_name: string
log_file?: string log_file?: string
config_file?: string
tray_copy_menu: boolean tray_copy_menu: boolean
console_stdout: boolean console_stdout: boolean
} }

View file

@ -11,6 +11,7 @@ export interface LauncherConfig {
port: number port: number
public: boolean public: boolean
allowed_cidrs: string[] allowed_cidrs: string[]
launcher_token: string
} }
export interface SystemVersionInfo { export interface SystemVersionInfo {

View file

@ -94,6 +94,7 @@ export function ConfigPage() {
port: String(launcherConfig.port), port: String(launcherConfig.port),
publicAccess: launcherConfig.public, publicAccess: launcherConfig.public,
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"), allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
launcherToken: launcherConfig.launcher_token ?? "",
} }
setLauncherForm(parsed) setLauncherForm(parsed)
setLauncherBaseline(parsed) setLauncherBaseline(parsed)
@ -264,6 +265,7 @@ export function ConfigPage() {
port, port,
public: launcherForm.publicAccess, public: launcherForm.publicAccess,
allowed_cidrs: allowedCIDRs, allowed_cidrs: allowedCIDRs,
launcher_token: launcherForm.launcherToken.trim(),
}) })
const parsedLauncher: LauncherForm = { const parsedLauncher: LauncherForm = {
port: String(savedLauncherConfig.port), port: String(savedLauncherConfig.port),
@ -271,6 +273,7 @@ export function ConfigPage() {
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join( allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
"\n", "\n",
), ),
launcherToken: savedLauncherConfig.launcher_token ?? "",
} }
setLauncherForm(parsedLauncher) setLauncherForm(parsedLauncher)
setLauncherBaseline(parsedLauncher) setLauncherBaseline(parsedLauncher)
@ -343,6 +346,12 @@ export function ConfigPage() {
</div> </div>
)} )}
<LauncherSection
launcherForm={launcherForm}
onFieldChange={updateLauncherField}
disabled={saving || isLauncherLoading}
/>
<AgentDefaultsSection form={form} onFieldChange={updateField} /> <AgentDefaultsSection form={form} onFieldChange={updateField} />
<RuntimeSection form={form} onFieldChange={updateField} /> <RuntimeSection form={form} onFieldChange={updateField} />
@ -351,12 +360,6 @@ export function ConfigPage() {
<CronSection form={form} onFieldChange={updateField} /> <CronSection form={form} onFieldChange={updateField} />
<LauncherSection
launcherForm={launcherForm}
onFieldChange={updateLauncherField}
disabled={saving || isLauncherLoading}
/>
<DevicesSection <DevicesSection
form={form} form={form}
onFieldChange={updateField} onFieldChange={updateField}

View file

@ -100,9 +100,7 @@ export function AgentDefaultsSection({
hint={t("pages.config.split_on_marker_hint")} hint={t("pages.config.split_on_marker_hint")}
layout="setting-row" layout="setting-row"
checked={form.splitOnMarker} checked={form.splitOnMarker}
onCheckedChange={(checked) => onCheckedChange={(checked) => onFieldChange("splitOnMarker", checked)}
onFieldChange("splitOnMarker", checked)
}
/> />
<SwitchCardField <SwitchCardField
@ -519,7 +517,25 @@ export function LauncherSection({
const { t } = useTranslation() const { t } = useTranslation()
return ( 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 <SwitchCardField
label={t("pages.config.lan_access")} label={t("pages.config.lan_access")}
hint={t("pages.config.lan_access_hint")} hint={t("pages.config.lan_access_hint")}

View file

@ -30,6 +30,7 @@ export interface LauncherForm {
port: string port: string
publicAccess: boolean publicAccess: boolean
allowedCIDRsText: string allowedCIDRsText: string
launcherToken: string
} }
export const DM_SCOPE_OPTIONS = [ export const DM_SCOPE_OPTIONS = [
@ -93,6 +94,7 @@ export const EMPTY_LAUNCHER_FORM: LauncherForm = {
port: "18800", port: "18800",
publicAccess: false, publicAccess: false,
allowedCIDRsText: "", allowedCIDRsText: "",
launcherToken: "",
} }
function asRecord(value: unknown): JsonRecord { function asRecord(value: unknown): JsonRecord {

View file

@ -17,7 +17,7 @@
}, },
"launcherLogin": { "launcherLogin": {
"title": "Launcher access", "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", "tokenLabel": "Token",
"tokenPlaceholder": "Enter access token", "tokenPlaceholder": "Enter access token",
"submit": "Continue to Dashboard", "submit": "Continue to Dashboard",
@ -26,6 +26,7 @@
"helpTitle": "Where to find the token", "helpTitle": "Where to find the token",
"helpConsole": "Console mode: printed in the terminal when the launcher starts.", "helpConsole": "Console mode: printed in the terminal when the launcher starts.",
"helpTray": "Tray mode: menu «Copy dashboard token».", "helpTray": "Tray mode: menu «Copy dashboard token».",
"helpConfig": "Launcher config file: {{path}}",
"helpLogFile": "Log file (startup line includes the token): {{path}}", "helpLogFile": "Log file (startup line includes the token): {{path}}",
"helpEnv": "Stable token: set {{env}}." "helpEnv": "Stable token: set {{env}}."
}, },
@ -582,6 +583,10 @@
"autostart_load_error": "Failed to load launch-at-login status.", "autostart_load_error": "Failed to load launch-at-login status.",
"server_port": "Service Port", "server_port": "Service Port",
"server_port_hint": "HTTP port used by PicoClaw Web.", "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": "Enable LAN Access",
"lan_access_hint": "Allow access from other devices on your local network.", "lan_access_hint": "Allow access from other devices on your local network.",
"allowed_cidrs": "Allowed Network CIDRs", "allowed_cidrs": "Allowed Network CIDRs",
@ -592,7 +597,7 @@
"runtime": "Runtime", "runtime": "Runtime",
"exec": "Run Commands", "exec": "Run Commands",
"cron": "Cron Tasks", "cron": "Cron Tasks",
"launcher": "Service", "launcher": "Launcher",
"devices": "Devices" "devices": "Devices"
}, },
"open_raw": "Raw Config", "open_raw": "Raw Config",

View file

@ -17,17 +17,18 @@
}, },
"launcherLogin": { "launcherLogin": {
"title": "Launcher 访问验证", "title": "Launcher 访问验证",
"description": "请使用当前 Launcher 进程的访问口令登录(每次重启可能变化,除非用环境变量固定)", "description": "请使用当前 Launcher 进程的访问口令登录(每次重启可能变化,除非用环境变量或 launcher 配置固定)",
"tokenLabel": "令牌", "tokenLabel": "令牌",
"tokenPlaceholder": "输入访问令牌", "tokenPlaceholder": "输入访问令牌",
"submit": "进入 Dashboard", "submit": "进入 Dashboard",
"errorInvalid": "令牌错误,请重试", "errorInvalid": "令牌错误,请重试",
"errorNetwork": "网络错误,请重试", "errorNetwork": "网络错误,请重试",
"helpTitle": "口令在哪里", "helpTitle": "口令在哪里",
"helpConsole": "控制台模式:启动时在终端输出。", "helpConsole": "控制台模式:启动时在终端输出",
"helpTray": "托盘模式:菜单「复制控制台口令」。", "helpTray": "托盘模式:菜单「复制控制台口令」",
"helpConfig": "Launcher 配置文件:{{path}}",
"helpLogFile": "日志文件(启动时会写入口令):{{path}}", "helpLogFile": "日志文件(启动时会写入口令):{{path}}",
"helpEnv": "固定口令:设置环境变量 {{env}}" "helpEnv": "固定口令:设置环境变量 {{env}}"
}, },
"chat": { "chat": {
"welcome": "今天我能为您做些什么?", "welcome": "今天我能为您做些什么?",
@ -513,102 +514,106 @@
} }
}, },
"config": { "config": {
"load_error": "加载配置失败,请刷新后重试", "load_error": "加载配置失败,请刷新后重试",
"workspace": "工作目录", "workspace": "工作目录",
"workspace_hint": "智能体执行文件读写操作时使用的基础目录", "workspace_hint": "智能体执行文件读写操作时使用的基础目录",
"restrict_workspace": "限制工作目录访问", "restrict_workspace": "限制工作目录访问",
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作", "restrict_workspace_hint": "仅允许在工作目录内执行文件操作",
"split_on_marker": "连续短消息", "split_on_marker": "连续短消息",
"split_on_marker_hint": "像真人聊天一样,把长难句拆成多条短消息快速发出", "split_on_marker_hint": "像真人聊天一样,把长难句拆成多条短消息快速发出",
"tool_feedback_enabled": "工具反馈", "tool_feedback_enabled": "工具反馈",
"tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览", "tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览",
"tool_feedback_max_args_length": "工具反馈参数预览长度", "tool_feedback_max_args_length": "工具反馈参数预览长度",
"tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的参数字符上限。设为 0 时使用默认值", "tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的参数字符上限。设为 0 时使用默认值",
"exec_enabled": "允许命令执行", "exec_enabled": "允许命令执行",
"exec_enabled_hint": "控制应用是否允许执行命令。关闭后,所有命令请求都不会执行", "exec_enabled_hint": "控制应用是否允许执行命令。关闭后,所有命令请求都不会执行",
"allow_remote": "允许远程命令执行", "allow_remote": "允许远程命令执行",
"allow_remote_hint": "开启后,来自远程会话或非本地上下文的请求也可以执行命令;关闭后,仅允许本地安全上下文执行命令", "allow_remote_hint": "开启后,来自远程会话或非本地上下文的请求也可以执行命令;关闭后,仅允许本地安全上下文执行命令",
"enable_deny_patterns": "启用黑名单", "enable_deny_patterns": "启用黑名单",
"enable_deny_patterns_hint": "开启后,应用会拦截匹配内置危险模式以及下方自定义命令黑名单的命令", "enable_deny_patterns_hint": "开启后,应用会拦截匹配内置危险模式以及下方自定义命令黑名单的命令",
"exec_timeout_seconds": "命令超时(秒)", "exec_timeout_seconds": "命令超时(秒)",
"exec_timeout_seconds_hint": "命令请求的最长运行时间。设置为 0 表示使用默认超时", "exec_timeout_seconds_hint": "命令请求的最长运行时间。设置为 0 表示使用默认超时",
"custom_deny_patterns": "命令黑名单", "custom_deny_patterns": "命令黑名单",
"custom_deny_patterns_hint": "用于补充额外的命令拦截规则,每行一个正则表达式。命中任意一条规则的命令都会被阻止", "custom_deny_patterns_hint": "用于补充额外的命令拦截规则,每行一个正则表达式。命中任意一条规则的命令都会被阻止",
"custom_allow_patterns": "命令白名单", "custom_allow_patterns": "命令白名单",
"custom_allow_patterns_hint": "用于补充额外的命令放行规则,每行一个正则表达式。命中任意一条规则的命令会跳过黑名单检查,但仍受其他安全限制约束", "custom_allow_patterns_hint": "用于补充额外的命令放行规则,每行一个正则表达式。命中任意一条规则的命令会跳过黑名单检查,但仍受其他安全限制约束",
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b", "custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
"pattern_detector_title": "规则检测工具", "pattern_detector_title": "规则检测工具",
"pattern_detector_hint": "输入命令以检测其是否匹配黑名单或白名单规则", "pattern_detector_hint": "输入命令以检测其是否匹配黑名单或白名单规则",
"pattern_detector_input_placeholder": "输入要检测的命令,例如 rm -rf /tmp", "pattern_detector_input_placeholder": "输入要检测的命令,例如 rm -rf /tmp",
"pattern_detector_test_button": "检测", "pattern_detector_test_button": "检测",
"pattern_detector_result_allowed": "允许(匹配白名单)", "pattern_detector_result_allowed": "允许(匹配白名单)",
"pattern_detector_result_blocked": "阻止(匹配黑名单)", "pattern_detector_result_blocked": "阻止(匹配黑名单)",
"pattern_detector_result_no_match": "无匹配(将使用默认规则)", "pattern_detector_result_no_match": "无匹配(将使用默认规则)",
"allow_shell_execution": "允许定时任务运行命令", "allow_shell_execution": "允许定时任务运行命令",
"allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务", "allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务",
"cron_exec_timeout": "定时命令超时(分钟)", "cron_exec_timeout": "定时命令超时(分钟)",
"cron_exec_timeout_hint": "定时任务中命令的最长运行时间。设置为 0 表示不限制超时", "cron_exec_timeout_hint": "定时任务中命令的最长运行时间。设置为 0 表示不限制超时",
"max_tokens": "最大 Token 数", "max_tokens": "最大 Token 数",
"max_tokens_hint": "单次模型响应允许的最大 Token 数", "max_tokens_hint": "单次模型响应允许的最大 Token 数",
"context_window": "上下文窗口", "context_window": "上下文窗口",
"context_window_hint": "模型输入上下文容量Token 数)。留空使用默认值(最大 Token 数的 4 倍)", "context_window_hint": "模型输入上下文容量Token 数)。留空使用默认值(最大 Token 数的 4 倍)",
"max_tool_iterations": "最大工具迭代次数", "max_tool_iterations": "最大工具迭代次数",
"max_tool_iterations_hint": "单个任务中允许的工具调用循环上限", "max_tool_iterations_hint": "单个任务中允许的工具调用循环上限",
"summarize_threshold": "触发摘要的消息阈值", "summarize_threshold": "触发摘要的消息阈值",
"summarize_threshold_hint": "消息数量达到该值后开始触发摘要", "summarize_threshold_hint": "消息数量达到该值后开始触发摘要",
"summarize_token_percent": "摘要目标 Token 百分比", "summarize_token_percent": "摘要目标 Token 百分比",
"summarize_token_percent_hint": "在触发会话摘要时使用", "summarize_token_percent_hint": "在触发会话摘要时使用",
"session_scope": "会话隔离范围", "session_scope": "会话隔离范围",
"session_scope_hint": "定义不同用户/频道之间如何隔离会话上下文", "session_scope_hint": "定义不同用户/频道之间如何隔离会话上下文",
"session_scope_per_channel_peer": "按频道+用户隔离", "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": "按频道隔离",
"session_scope_per_channel_desc": "同一频道内共享一个上下文", "session_scope_per_channel_desc": "同一频道内共享一个上下文",
"session_scope_per_peer": "按用户隔离", "session_scope_per_peer": "按用户隔离",
"session_scope_per_peer_desc": "同一用户跨频道共享一个上下文", "session_scope_per_peer_desc": "同一用户跨频道共享一个上下文",
"session_scope_global": "全局共享", "session_scope_global": "全局共享",
"session_scope_global_desc": "所有消息共用一个全局上下文", "session_scope_global_desc": "所有消息共用一个全局上下文",
"heartbeat_enabled": "心跳开关", "heartbeat_enabled": "心跳开关",
"heartbeat_enabled_hint": "按间隔发送系统心跳", "heartbeat_enabled_hint": "按间隔发送系统心跳",
"heartbeat_interval": "心跳间隔(分钟)", "heartbeat_interval": "心跳间隔(分钟)",
"heartbeat_interval_hint": "两次心跳发送之间的分钟间隔", "heartbeat_interval_hint": "两次心跳发送之间的分钟间隔",
"devices_enabled": "启用设备功能", "devices_enabled": "启用设备功能",
"devices_enabled_hint": "启用与本机硬件设备相关的能力", "devices_enabled_hint": "启用与本机硬件设备相关的能力",
"monitor_usb": "监听 USB", "monitor_usb": "监听 USB",
"monitor_usb_hint": "在启用设备功能时,监听 USB 插拔事件", "monitor_usb_hint": "在启用设备功能时,监听 USB 插拔事件",
"autostart_label": "开机自启", "autostart_label": "开机自启",
"autostart_hint": "登录系统后自动启动 PicoClaw Web", "autostart_hint": "登录系统后自动启动 PicoClaw Web",
"autostart_unsupported": "当前平台不支持开机自启", "autostart_unsupported": "当前平台不支持开机自启",
"autostart_load_error": "加载开机自启状态失败", "autostart_load_error": "加载开机自启状态失败",
"server_port": "服务端口", "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": "启用局域网访问",
"lan_access_hint": "允许局域网中的其他设备访问当前服务。", "lan_access_hint": "允许局域网中的其他设备访问当前服务",
"allowed_cidrs": "允许访问网段", "allowed_cidrs": "允许访问网段",
"allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源。", "allowed_cidrs_hint": "仅允许这些 CIDR 网段的客户端访问服务。可按行或逗号分隔;留空表示允许所有来源",
"allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8", "allowed_cidrs_placeholder": "192.168.1.0/24\n10.0.0.0/8",
"sections": { "sections": {
"agent": "智能体", "agent": "智能体",
"runtime": "运行时", "runtime": "运行时",
"exec": "运行命令", "exec": "运行命令",
"cron": "定时任务", "cron": "定时任务",
"launcher": "服务参数", "launcher": "启动器",
"devices": "设备" "devices": "设备"
}, },
"open_raw": "原始配置", "open_raw": "原始配置",
"back_to_visual": "可视化配置", "back_to_visual": "可视化配置",
"raw_json_title": "原始 JSON 配置", "raw_json_title": "原始 JSON 配置",
"json_placeholder": "请输入有效的 JSON 配置...", "json_placeholder": "请输入有效的 JSON 配置...",
"save_success": "配置保存成功", "save_success": "配置保存成功",
"save_error": "配置保存失败", "save_error": "配置保存失败",
"reset_confirm_title": "重置更改", "reset_confirm_title": "重置更改",
"reset_confirm_desc": "您确定要重置回上次保存的状态吗?", "reset_confirm_desc": "您确定要重置回上次保存的状态吗?",
"reset_success": "更改已重置为上次保存的状态", "reset_success": "更改已重置为上次保存的状态",
"invalid_json": "JSON 格式无效", "invalid_json": "JSON 格式无效",
"format_success": "JSON 格式化成功", "format_success": "JSON 格式化成功",
"format_error": "JSON 格式无效", "format_error": "JSON 格式无效",
"format": "格式化", "format": "格式化",
"unsaved_changes": "您有未保存的更改" "unsaved_changes": "您有未保存的更改"
}, },
"logs": { "logs": {
"log_level_error": "更新日志等级失败。", "log_level_error": "更新日志等级失败。",

View file

@ -4,9 +4,9 @@ import * as React from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { import {
type LauncherAuthTokenHelp,
getLauncherAuthStatus, getLauncherAuthStatus,
postLauncherDashboardLogin, postLauncherDashboardLogin,
type LauncherAuthTokenHelp,
} from "@/api/launcher-auth" } from "@/api/launcher-auth"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
@ -32,9 +32,8 @@ function LauncherLoginPage() {
const [token, setToken] = React.useState("") const [token, setToken] = React.useState("")
const [submitting, setSubmitting] = React.useState(false) const [submitting, setSubmitting] = React.useState(false)
const [error, setError] = React.useState("") const [error, setError] = React.useState("")
const [tokenHelp, setTokenHelp] = React.useState<LauncherAuthTokenHelp | null>( const [tokenHelp, setTokenHelp] =
null, React.useState<LauncherAuthTokenHelp | null>(null)
)
React.useEffect(() => { React.useEffect(() => {
let cancelled = false let cancelled = false
@ -155,6 +154,13 @@ function LauncherLoginPage() {
{tokenHelp.tray_copy_menu ? ( {tokenHelp.tray_copy_menu ? (
<li>{t("launcherLogin.helpTray")}</li> <li>{t("launcherLogin.helpTray")}</li>
) : null} ) : null}
{tokenHelp.config_file ? (
<li>
{t("launcherLogin.helpConfig", {
path: tokenHelp.config_file,
})}
</li>
) : null}
{tokenHelp.log_file ? ( {tokenHelp.log_file ? (
<li> <li>
{t("launcherLogin.helpLogFile", { {t("launcherLogin.helpLogFile", {