diff --git a/.gitignore b/.gitignore index 8797862..f7f5548 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,8 @@ /*.test /agentdock.killed* -# Windows WPF 控制面板的本地构建中间文件。 +# Windows WPF 控制面板的本地构建产物。 +/desktop/windows/control-panel/bin/ /desktop/windows/control-panel/obj/ # Python 辅助脚本的本地缓存不得进入 Skill 源码和发布包。 diff --git a/README.md b/README.md index 9c9524b..416f8aa 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ After you save the plugin, the browser opens the AgentDock authorization page. C A public endpoint must use HTTPS. `AGENTDOCK_SERVER_URL` must contain only the origin, without `/mcp`. See [Connect ChatGPT to AgentDock](https://uvwt.github.io/agentdock-docs/docs/guides/chatgpt) for the complete procedure, endpoint checks, and troubleshooting. -On Windows desktop installs, create `oauth-access-token-ttl.txt` in the runtime root to persist the access-token lifetime using the same syntax as `AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL`. The file overrides an inherited environment variable; for example, `never` keeps standard startup and elevated scheduled-task startup consistent. The desktop runtime also normalizes an accidentally pasted `https://agentdock.example.com/mcp` URL back to its origin. +On Windows desktop installs, configure the OAuth access-token TTL from Control Panel > Advanced Settings. It accepts the same syntax as `AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL`, including `1h`, `30d`, and `never`. Once saved, standard startup and elevated scheduled-task startup read the same `control-panel-settings.json`; leaving the field blank falls back to an inherited `AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL`, and if neither is set the core default is `1h`. The desktop runtime also normalizes an accidentally pasted `https://agentdock.example.com/mcp` URL back to its origin. ## Image variants diff --git a/README.zh-CN.md b/README.zh-CN.md index 43c6f1f..1804cf9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -286,7 +286,7 @@ https://agentdock.example.com/mcp 公网入口必须使用 HTTPS,`AGENTDOCK_SERVER_URL` 只填写 Origin,不附加 `/mcp`。完整步骤、端点验证和常见问题见 [ChatGPT 接入教程](https://uvwt.github.io/agentdock-docs/zh-CN/docs/guides/chatgpt)。 -Windows 桌面服务可在运行目录创建 `oauth-access-token-ttl.txt` 持久化 Access Token 有效期,内容语法与 `AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL` 相同。文件值优先于继承的环境变量;例如写入 `never` 可确保普通启动和管理员计划任务使用一致的不失效策略。桌面端也会把误填的 `https://agentdock.example.com/mcp` 自动规范为 Origin。 +Windows 桌面端可在控制面板“高级设置”中配置 OAuth Access Token TTL,支持 `1h`、`30d`、`never` 等与 `AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL` 相同的语法。保存后普通启动和管理员计划任务都会读取同一份 `control-panel-settings.json`;留空时才继承启动环境里的 `AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL`,两者都未设置则使用核心默认的 `1h`。桌面端也会把误填的 `https://agentdock.example.com/mcp` 自动规范为 Origin。 ## 镜像版本 diff --git a/desktop/windows/control-panel/MainWindow.xaml b/desktop/windows/control-panel/MainWindow.xaml index 4223414..622cd84 100644 --- a/desktop/windows/control-panel/MainWindow.xaml +++ b/desktop/windows/control-panel/MainWindow.xaml @@ -165,6 +165,7 @@ + @@ -185,6 +186,11 @@ + + + + + diff --git a/desktop/windows/control-panel/MainWindow.xaml.cs b/desktop/windows/control-panel/MainWindow.xaml.cs index ae8d4a2..616b052 100644 --- a/desktop/windows/control-panel/MainWindow.xaml.cs +++ b/desktop/windows/control-panel/MainWindow.xaml.cs @@ -100,6 +100,7 @@ private void ApplySnapshot(RuntimeSnapshot snapshot) PortTextBox.Text = snapshot.Settings.Port.ToString(); SelectLogLevel(snapshot.Settings.LogLevel); NexusEndpointTextBox.Text = snapshot.Settings.NexusEndpoint; + OAuthAccessTokenTtlTextBox.Text = snapshot.Settings.OAuthAccessTokenTtl; BrowserEnabledCheckBox.IsChecked = snapshot.Settings.BrowserEnabled; BrowserCdpUrlTextBox.Text = snapshot.Settings.BrowserCdpUrl; BrowserReuseExistingCdpCheckBox.IsChecked = snapshot.Settings.BrowserReuseExistingCdp; @@ -321,6 +322,7 @@ private async void SaveSettingsButton_Click(object sender, RoutedEventArgs e) Port = port, LogLevel = SelectedLogLevel(), NexusEndpoint = NexusEndpointTextBox.Text.Trim(), + OAuthAccessTokenTtl = OAuthAccessTokenTtlTextBox.Text.Trim(), BrowserEnabled = BrowserEnabledCheckBox.IsChecked == true, BrowserCdpUrl = BrowserCdpUrlTextBox.Text.Trim(), BrowserReuseExistingCdp = BrowserReuseExistingCdpCheckBox.IsChecked == true, diff --git a/desktop/windows/control-panel/Models/RuntimeModels.cs b/desktop/windows/control-panel/Models/RuntimeModels.cs index 528393d..130d9f6 100644 --- a/desktop/windows/control-panel/Models/RuntimeModels.cs +++ b/desktop/windows/control-panel/Models/RuntimeModels.cs @@ -64,6 +64,9 @@ public sealed class ControlPanelSettings [JsonPropertyName("nexus_endpoint")] public string NexusEndpoint { get; set; } = ""; + [JsonPropertyName("oauth_access_token_ttl")] + public string OAuthAccessTokenTtl { get; set; } = ""; + [JsonPropertyName("browser_enabled")] public bool BrowserEnabled { get; set; } diff --git a/desktop/windows/control-panel/Services/RuntimeService.cs b/desktop/windows/control-panel/Services/RuntimeService.cs index b2246f9..e4cc38e 100644 --- a/desktop/windows/control-panel/Services/RuntimeService.cs +++ b/desktop/windows/control-panel/Services/RuntimeService.cs @@ -227,6 +227,7 @@ public async Task SaveSettingsAsync( "--port", settings.Port.ToString(), "--log-level", settings.LogLevel, "--nexus-endpoint", settings.NexusEndpoint ?? "", + "--oauth-access-token-ttl", settings.OAuthAccessTokenTtl ?? "", $"--browser-enabled={settings.BrowserEnabled.ToString().ToLowerInvariant()}", "--browser-cdp-url", settings.BrowserCdpUrl ?? "", $"--browser-reuse-existing-cdp={settings.BrowserReuseExistingCdp.ToString().ToLowerInvariant()}", diff --git a/internal/config/config.go b/internal/config/config.go index 18f892f..4a35958 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -471,22 +471,48 @@ func getenvOAuthAccessTokenTTL(key string, fallback int64) (seconds int64, never if value == "" { return fallback, false, nil } + seconds, never, err = parseOAuthAccessTokenTTL(value) + if err != nil { + return 0, false, fmt.Errorf("parse %s as duration: %w", key, err) + } + return seconds, never, nil +} + +// ValidateOAuthAccessTokenTTL validates an explicit access-token lifetime using +// the same syntax and bounds as AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL. +func ValidateOAuthAccessTokenTTL(value string) error { + value = strings.TrimSpace(value) + if value == "" { + return errors.New("value is empty") + } + seconds, never, err := parseOAuthAccessTokenTTL(value) + if err != nil { + return err + } + if !never && (seconds < int64(time.Minute/time.Second) || seconds > maxOAuthAccessTokenTTLSeconds) { + return fmt.Errorf("value must be between 1m and 999999d: %ds", seconds) + } + return nil +} + +func parseOAuthAccessTokenTTL(value string) (seconds int64, never bool, err error) { + value = strings.TrimSpace(value) if strings.EqualFold(value, "never") { return 0, true, nil } if strings.HasSuffix(strings.ToLower(value), "d") { days, err := strconv.ParseInt(strings.TrimSpace(value[:len(value)-1]), 10, 64) if err != nil || days <= 0 || days > maxOAuthAccessTokenTTLSeconds/(24*60*60) { - return 0, false, fmt.Errorf("parse %s as duration: invalid day count %q", key, value) + return 0, false, fmt.Errorf("invalid day count %q", value) } return days * 24 * 60 * 60, false, nil } parsed, err := time.ParseDuration(value) if err != nil { - return 0, false, fmt.Errorf("parse %s as duration: %w", key, err) + return 0, false, err } if parsed%time.Second != 0 { - return 0, false, fmt.Errorf("parse %s as duration: value must use whole seconds", key) + return 0, false, errors.New("value must use whole seconds") } return int64(parsed / time.Second), false, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index da301ed..e914505 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -257,6 +257,19 @@ func TestFromEnvParsesNeverExpiringOAuthAccessToken(t *testing.T) { } } +func TestValidateOAuthAccessTokenTTL(t *testing.T) { + for _, value := range []string{"1m", "24h", "30d", "999999d", "never"} { + if err := ValidateOAuthAccessTokenTTL(value); err != nil { + t.Fatalf("ValidateOAuthAccessTokenTTL(%q) error = %v", value, err) + } + } + for _, value := range []string{"", "59s", "1000000d", "1.5s", "invalid"} { + if err := ValidateOAuthAccessTokenTTL(value); err == nil { + t.Fatalf("ValidateOAuthAccessTokenTTL(%q) accepted invalid value", value) + } + } +} + func TestNormalizeValidatesOAuthAccessTokenTTL(t *testing.T) { home := t.TempDir() for _, test := range []struct { diff --git a/internal/desktopruntime/config_command.go b/internal/desktopruntime/config_command.go index 8e2948d..ac29cb4 100644 --- a/internal/desktopruntime/config_command.go +++ b/internal/desktopruntime/config_command.go @@ -8,6 +8,8 @@ import ( "io" "net/url" "strings" + + agentconfig "github.com/uvwt/agentdock/internal/config" ) // ConfigUpdateRequest 是桌面端保存日常运行设置时使用的结构化请求。 @@ -17,6 +19,7 @@ type ConfigUpdateRequest struct { LogLevel string NexusEndpoint string NexusTokenFile string + OAuthAccessTokenTTL string BrowserEnabled bool BrowserCDPURL string BrowserReuseExistingCDP bool @@ -37,6 +40,7 @@ func RunConfigCommand(ctx context.Context, args []string, stdout, stderr io.Writ logLevel := flags.String("log-level", "info", "日志级别") nexusEndpoint := flags.String("nexus-endpoint", "", "Nexus endpoint") nexusTokenFile := flags.String("nexus-token-file", "", "Nexus Token 临时文件") + oauthAccessTokenTTL := flags.String("oauth-access-token-ttl", "", "OAuth Access Token 有效期;留空表示继承环境变量或使用默认值") browserEnabled := flags.Bool("browser-enabled", false, "启用浏览器") browserCDPURL := flags.String("browser-cdp-url", "", "已有 Chromium CDP 地址") browserReuseExistingCDP := flags.Bool("browser-reuse-existing-cdp", false, "自动发现并复用唯一已有 CDP") @@ -54,6 +58,7 @@ func RunConfigCommand(ctx context.Context, args []string, stdout, stderr io.Writ LogLevel: strings.ToLower(strings.TrimSpace(*logLevel)), NexusEndpoint: strings.TrimSpace(*nexusEndpoint), NexusTokenFile: strings.TrimSpace(*nexusTokenFile), + OAuthAccessTokenTTL: strings.TrimSpace(*oauthAccessTokenTTL), BrowserEnabled: *browserEnabled, BrowserCDPURL: strings.TrimSpace(*browserCDPURL), BrowserReuseExistingCDP: *browserReuseExistingCDP, @@ -88,6 +93,11 @@ func validateConfigUpdate(request ConfigUpdateRequest) error { if strings.ContainsAny(request.NexusEndpoint, "\r\n") { return errors.New("配置值不能包含换行符") } + if request.OAuthAccessTokenTTL != "" { + if err := agentconfig.ValidateOAuthAccessTokenTTL(request.OAuthAccessTokenTTL); err != nil { + return fmt.Errorf("OAuth Access Token 有效期无效: %w", err) + } + } if request.BrowserCDPURL != "" { parsed, err := url.Parse(request.BrowserCDPURL) if err != nil || parsed.Host == "" { diff --git a/internal/desktopruntime/config_command_test.go b/internal/desktopruntime/config_command_test.go index 9bb7b9e..216c4b4 100644 --- a/internal/desktopruntime/config_command_test.go +++ b/internal/desktopruntime/config_command_test.go @@ -25,6 +25,17 @@ func TestValidateConfigUpdate(t *testing.T) { t.Fatalf("valid ACP config rejected: %v", err) } + validTTL := valid + validTTL.OAuthAccessTokenTTL = "30d" + if err := validateConfigUpdate(validTTL); err != nil { + t.Fatalf("valid OAuth access token TTL rejected: %v", err) + } + validNeverTTL := valid + validNeverTTL.OAuthAccessTokenTTL = "never" + if err := validateConfigUpdate(validNeverTTL); err != nil { + t.Fatalf("valid never-expiring OAuth access token TTL rejected: %v", err) + } + cases := []ConfigUpdateRequest{ {Port: 8765, LogLevel: "info"}, {RuntimeRoot: "runtime", Port: 0, LogLevel: "info"}, @@ -34,6 +45,8 @@ func TestValidateConfigUpdate(t *testing.T) { {RuntimeRoot: "runtime", Port: 8765, LogLevel: "info", BrowserCDPURL: "http://user:pass@browser.internal:9222"}, {RuntimeRoot: "runtime", Port: 8765, LogLevel: "info", BrowserCDPURL: "http://browser.internal:9222/#fragment"}, {RuntimeRoot: "runtime", Port: 8765, LogLevel: "info", ACPEnabled: true, ACPAgent: "other"}, + {RuntimeRoot: "runtime", Port: 8765, LogLevel: "info", OAuthAccessTokenTTL: "59s"}, + {RuntimeRoot: "runtime", Port: 8765, LogLevel: "info", OAuthAccessTokenTTL: "1000000d"}, } for _, request := range cases { if err := validateConfigUpdate(request); err == nil { diff --git a/internal/desktopruntime/config_windows.go b/internal/desktopruntime/config_windows.go index 6be24fb..69bf91d 100644 --- a/internal/desktopruntime/config_windows.go +++ b/internal/desktopruntime/config_windows.go @@ -121,6 +121,7 @@ func platformUpdateConfig(ctx context.Context, request ConfigUpdateRequest) erro Port: request.Port, LogLevel: request.LogLevel, NexusEndpoint: request.NexusEndpoint, + OAuthAccessTokenTTL: request.OAuthAccessTokenTTL, BrowserEnabled: request.BrowserEnabled, BrowserCDPURL: request.BrowserCDPURL, BrowserReuseExistingCDP: request.BrowserReuseExistingCDP, diff --git a/internal/desktopruntime/service_environment_windows.go b/internal/desktopruntime/service_environment_windows.go index 9049c63..e6da8d9 100644 --- a/internal/desktopruntime/service_environment_windows.go +++ b/internal/desktopruntime/service_environment_windows.go @@ -13,6 +13,7 @@ import ( "strings" "unsafe" + agentconfig "github.com/uvwt/agentdock/internal/config" "golang.org/x/sys/windows" ) @@ -45,6 +46,7 @@ type controlPanelSettings struct { Port int `json:"port"` LogLevel string `json:"log_level"` NexusEndpoint string `json:"nexus_endpoint"` + OAuthAccessTokenTTL string `json:"oauth_access_token_ttl,omitempty"` BrowserEnabled bool `json:"browser_enabled"` BrowserCDPURL string `json:"browser_cdp_url"` BrowserReuseExistingCDP bool `json:"browser_reuse_existing_cdp"` @@ -63,7 +65,7 @@ func platformPrepareCoreEnvironment(runtimeRoot string) error { if err != nil { return err } - oauthAccessTokenTTL := strings.TrimSpace(os.Getenv("AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL")) + inheritedOAuthAccessTokenTTL := strings.TrimSpace(os.Getenv("AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL")) for _, name := range managedCoreEnvironment { if err := os.Unsetenv(name); err != nil { @@ -141,13 +143,7 @@ func platformPrepareCoreEnvironment(runtimeRoot string) error { managed["AGENTDOCK_OAUTH_PASSWORD"] = oauthPassword managed["AGENTDOCK_OAUTH_TOKEN_SECRET"] = oauthSecret } - storedOAuthAccessTokenTTL, err := readTrimmedText(filepath.Join(root, "oauth-access-token-ttl.txt")) - if err != nil { - return err - } - if storedOAuthAccessTokenTTL != "" { - oauthAccessTokenTTL = storedOAuthAccessTokenTTL - } + oauthAccessTokenTTL := effectiveOAuthAccessTokenTTL(settings.OAuthAccessTokenTTL, inheritedOAuthAccessTokenTTL) if oauthAccessTokenTTL != "" { managed["AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL"] = oauthAccessTokenTTL } @@ -183,6 +179,12 @@ func loadControlPanelSettings(runtimeRoot string, fallbackPort int) (controlPane return controlPanelSettings{}, fmt.Errorf("不支持的日志级别: %s", settings.LogLevel) } settings.NexusEndpoint = strings.TrimSpace(settings.NexusEndpoint) + settings.OAuthAccessTokenTTL = strings.TrimSpace(settings.OAuthAccessTokenTTL) + if settings.OAuthAccessTokenTTL != "" { + if err := agentconfig.ValidateOAuthAccessTokenTTL(settings.OAuthAccessTokenTTL); err != nil { + return controlPanelSettings{}, fmt.Errorf("OAuth Access Token 有效期无效: %w", err) + } + } settings.ACPAgent = strings.ToLower(strings.TrimSpace(settings.ACPAgent)) if settings.ACPAgent == "" { settings.ACPAgent = "codex" @@ -204,6 +206,13 @@ func loadControlPanelSettings(runtimeRoot string, fallbackPort int) (controlPane return settings, nil } +func effectiveOAuthAccessTokenTTL(configured, inherited string) string { + if configured = strings.TrimSpace(configured); configured != "" { + return configured + } + return strings.TrimSpace(inherited) +} + func readOptionalProtectedText(path, entropy string) (string, error) { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { return "", nil diff --git a/internal/desktopruntime/service_environment_windows_test.go b/internal/desktopruntime/service_environment_windows_test.go new file mode 100644 index 0000000..7508ebb --- /dev/null +++ b/internal/desktopruntime/service_environment_windows_test.go @@ -0,0 +1,51 @@ +//go:build windows + +package desktopruntime + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEffectiveOAuthAccessTokenTTL(t *testing.T) { + cases := []struct { + name string + configured string + inherited string + want string + }{ + {name: "persisted wins", configured: "30d", inherited: "1h", want: "30d"}, + {name: "env fallback", inherited: "24h", want: "24h"}, + {name: "core default", want: ""}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + if got := effectiveOAuthAccessTokenTTL(test.configured, test.inherited); got != test.want { + t.Fatalf("effectiveOAuthAccessTokenTTL(%q, %q) = %q, want %q", test.configured, test.inherited, got, test.want) + } + }) + } +} + +func TestLoadControlPanelSettingsValidatesOAuthAccessTokenTTL(t *testing.T) { + root := t.TempDir() + settingsPath := filepath.Join(root, "control-panel-settings.json") + if err := os.WriteFile(settingsPath, []byte(`{"port":8765,"log_level":"info","oauth_access_token_ttl":"never"}`), 0o600); err != nil { + t.Fatal(err) + } + settings, err := loadControlPanelSettings(root, 8765) + if err != nil { + t.Fatalf("loadControlPanelSettings() error = %v", err) + } + if settings.OAuthAccessTokenTTL != "never" { + t.Fatalf("OAuthAccessTokenTTL = %q, want never", settings.OAuthAccessTokenTTL) + } + + if err := os.WriteFile(settingsPath, []byte(`{"port":8765,"log_level":"info","oauth_access_token_ttl":"59s"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadControlPanelSettings(root, 8765); err == nil { + t.Fatal("loadControlPanelSettings() accepted invalid OAuth access token TTL") + } +} diff --git a/internal/desktopruntime/service_startup_windows_test.go b/internal/desktopruntime/service_startup_windows_test.go index 34963df..32e49cc 100644 --- a/internal/desktopruntime/service_startup_windows_test.go +++ b/internal/desktopruntime/service_startup_windows_test.go @@ -22,6 +22,21 @@ func TestParseScheduledTaskXMLAcceptsUTF16LE(t *testing.T) { } } +func TestParseScheduledTaskXMLAcceptsUTF16BE(t *testing.T) { + runes := utf16.Encode([]rune(`true`)) + data := []byte{0xfe, 0xff} + for _, value := range runes { + data = append(data, byte(value>>8), byte(value)) + } + task, err := parseScheduledTaskXML(data) + if err != nil { + t.Fatalf("parseScheduledTaskXML() error = %v", err) + } + if task.Settings.Enabled == nil || !*task.Settings.Enabled { + t.Fatal("scheduled task should be enabled") + } +} + func TestParseScheduledTaskXMLAcceptsUTF8(t *testing.T) { task, err := parseScheduledTaskXML([]byte(`false`)) if err != nil { diff --git a/scripts/install/manage-windows.ps1 b/scripts/install/manage-windows.ps1 index 6e63756..c2fad7f 100644 --- a/scripts/install/manage-windows.ps1 +++ b/scripts/install/manage-windows.ps1 @@ -536,58 +536,10 @@ function Invoke-LaunchCore { if (-not (Test-Path -LiteralPath $AgentDockBinary -PathType Leaf)) { throw "找不到 AgentDock 核心程序:$AgentDockBinary" } - Ensure-Credentials - $settings = Get-ControlPanelSettings - - $env:AGENTDOCK_AUTH_TOKEN = Read-ProtectedText -Path $AuthTokenPath -Entropy 'agentdock.startup.v1' - $env:AGENTDOCK_HOST = '127.0.0.1' - $env:AGENTDOCK_PORT = [string] $settings.port - $env:AGENTDOCK_LOG_LEVEL = [string] $settings.log_level - $env:AGENTDOCK_BROWSER_ENABLED = ([bool] $settings.browser_enabled).ToString().ToLowerInvariant() - $env:AGENTDOCK_ACP_ENABLED = ([bool] $settings.acp_enabled).ToString().ToLowerInvariant() - - foreach ($name in @( - 'AGENTDOCK_NEXUS_ENDPOINT', - 'AGENTDOCK_NEXUS_TOKEN', - 'AGENTDOCK_ACP_AGENT', - 'AGENTDOCK_ACP_COMMAND', - 'AGENTDOCK_ACP_ARGS_JSON', - 'AGENTDOCK_ACP_ENV_FROM_ENV_JSON', - 'AGENTDOCK_ACP_ALLOWED_ROOTS', - 'AGENTDOCK_ACP_MAX_CONCURRENT_PROMPTS', - 'AGENTDOCK_ACP_INTERACTION_TIMEOUT_MS', - 'AGENTDOCK_SERVER_URL', - 'AGENTDOCK_OAUTH_ENABLED', - 'AGENTDOCK_OAUTH_PASSWORD', - 'AGENTDOCK_OAUTH_TOKEN_SECRET' - )) { - Remove-Item -LiteralPath "Env:$name" -ErrorAction SilentlyContinue - } - - if (-not [string]::IsNullOrWhiteSpace([string] $settings.nexus_endpoint)) { - $env:AGENTDOCK_NEXUS_ENDPOINT = [string] $settings.nexus_endpoint - } - if (Test-Path -LiteralPath $NexusTokenPath -PathType Leaf) { - $env:AGENTDOCK_NEXUS_TOKEN = Read-ProtectedText -Path $NexusTokenPath -Entropy 'agentdock.nexus.token.v1' - } - if ([bool] $settings.acp_enabled) { - if (-not (Test-Path -LiteralPath ([string] $settings.acp_command) -PathType Leaf)) { - throw "找不到 Coding Agent 命令:$($settings.acp_command)" - } - $env:AGENTDOCK_ACP_AGENT = [string] $settings.acp_agent - $env:AGENTDOCK_ACP_COMMAND = [string] $settings.acp_command - $env:AGENTDOCK_ACP_ARGS_JSON = ConvertTo-Json -InputObject @($settings.acp_args) -Compress - } - - $activeServerUrl = Read-TextFile -Path $ServerUrlPath - if (-not [string]::IsNullOrWhiteSpace($activeServerUrl)) { - $env:AGENTDOCK_SERVER_URL = $activeServerUrl - $env:AGENTDOCK_OAUTH_ENABLED = 'true' - $env:AGENTDOCK_OAUTH_PASSWORD = Read-ProtectedText -Path $OAuthPasswordPath -Entropy 'agentdock.oauth.password.v1' - $env:AGENTDOCK_OAUTH_TOKEN_SECRET = Read-ProtectedText -Path $OAuthSecretPath -Entropy 'agentdock.oauth.secret.v1' - } - & $AgentDockBinary + Ensure-Credentials + # 兼容入口也统一交给原生 launch-core 恢复设置和 DPAPI 凭据,避免形成第二套启动配置逻辑。 + & $AgentDockBinary service launch-core --runtime-root $RuntimeRoot return $LASTEXITCODE } diff --git a/scripts/test/install_test.go b/scripts/test/install_test.go index 565c9bc..df81964 100644 --- a/scripts/test/install_test.go +++ b/scripts/test/install_test.go @@ -394,6 +394,7 @@ func TestWindowsManagerKeepsTunnelTransitionsAndManualTaskStartValid(t *testing. "-FilePath $TrayBinary", "'start-tunnel'", "'stop-tunnel'", + "& $AgentDockBinary service launch-core --runtime-root $RuntimeRoot", } { if !strings.Contains(script, want) { t.Fatalf("manage-windows.ps1 missing %q", want) @@ -451,6 +452,37 @@ func TestWindowsControlPanelUsesNativeTunnelCommands(t *testing.T) { } } +func TestWindowsControlPanelPersistsOAuthAccessTokenTTLThroughNativeConfig(t *testing.T) { + files := []string{ + filepath.Join("..", "..", "desktop", "windows", "control-panel", "Models", "RuntimeModels.cs"), + filepath.Join("..", "..", "desktop", "windows", "control-panel", "MainWindow.xaml"), + filepath.Join("..", "..", "desktop", "windows", "control-panel", "MainWindow.xaml.cs"), + filepath.Join("..", "..", "desktop", "windows", "control-panel", "Services", "RuntimeService.cs"), + filepath.Join("..", "..", "internal", "desktopruntime", "config_windows.go"), + filepath.Join("..", "..", "internal", "desktopruntime", "service_environment_windows.go"), + } + var source strings.Builder + for _, path := range files { + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + source.Write(data) + } + for _, want := range []string{ + `oauth_access_token_ttl`, + `OAuthAccessTokenTtlTextBox`, + `OAuthAccessTokenTtl = OAuthAccessTokenTtlTextBox.Text.Trim()`, + `"--oauth-access-token-ttl", settings.OAuthAccessTokenTtl`, + `OAuthAccessTokenTTL: request.OAuthAccessTokenTTL`, + `effectiveOAuthAccessTokenTTL(settings.OAuthAccessTokenTTL, inheritedOAuthAccessTokenTTL)`, + } { + if !strings.Contains(source.String(), want) { + t.Fatalf("Windows OAuth TTL persistence chain missing %q", want) + } + } +} + func TestWindowsControlPanelReadsVersionFromCoreBuildInfo(t *testing.T) { appData, err := os.ReadFile(filepath.Join("..", "..", "desktop", "windows", "control-panel", "App.xaml.cs")) if err != nil {