mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "更新日志等级失败。",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user