From 6fd63c72e3cf604c1ea0565143af5ba0c393db24 Mon Sep 17 00:00:00 2001 From: siyuan <740665504@qq.com> Date: Thu, 20 Aug 2026 14:10:06 +0800 Subject: [PATCH] fix(windows): harden desktop OAuth startup --- .gitignore | 3 + README.md | 2 + README.zh-CN.md | 2 + .../control-panel/Services/RuntimeService.cs | 3 +- .../service_environment_windows.go | 19 +++++++ .../desktopruntime/service_startup_windows.go | 56 +++++++++++++++++-- .../service_startup_windows_test.go | 43 ++++++++++++++ .../desktopruntime/tunnel_state_windows.go | 3 +- .../tunnel_state_windows_test.go | 28 ++++++++++ scripts/install/uninstall-windows.ps1 | 1 + scripts/test/install_test.go | 1 + 11 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 internal/desktopruntime/service_startup_windows_test.go create mode 100644 internal/desktopruntime/tunnel_state_windows_test.go diff --git a/.gitignore b/.gitignore index bcd77f24..8797862f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ /*.test /agentdock.killed* +# Windows WPF 控制面板的本地构建中间文件。 +/desktop/windows/control-panel/obj/ + # Python 辅助脚本的本地缓存不得进入 Skill 源码和发布包。 __pycache__/ *.py[cod] diff --git a/README.md b/README.md index d62380ea..9c9524b8 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,8 @@ 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. + ## Image variants | Image tag | Purpose | diff --git a/README.zh-CN.md b/README.zh-CN.md index 59f7360b..43c6f1fe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -286,6 +286,8 @@ 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。 + ## 镜像版本 | 镜像标签 | 用途 | diff --git a/desktop/windows/control-panel/Services/RuntimeService.cs b/desktop/windows/control-panel/Services/RuntimeService.cs index dc1083a3..b2246f9c 100644 --- a/desktop/windows/control-panel/Services/RuntimeService.cs +++ b/desktop/windows/control-panel/Services/RuntimeService.cs @@ -1095,7 +1095,8 @@ private static bool IsCoreStartupEnabled(RuntimeManifest manifest) var enabledElement = XDocument.Parse(taskXml) .Descendants() .FirstOrDefault(element => element.Name.LocalName == "Enabled"); - return enabledElement is not null && bool.TryParse(enabledElement.Value, out var enabled) && enabled; + // Task Scheduler 省略 Enabled 时使用 schema 默认值 true。 + return enabledElement is null || bool.TryParse(enabledElement.Value, out var enabled) && enabled; } catch { diff --git a/internal/desktopruntime/service_environment_windows.go b/internal/desktopruntime/service_environment_windows.go index 03f40d06..9049c631 100644 --- a/internal/desktopruntime/service_environment_windows.go +++ b/internal/desktopruntime/service_environment_windows.go @@ -38,6 +38,7 @@ var managedCoreEnvironment = []string{ "AGENTDOCK_OAUTH_ENABLED", "AGENTDOCK_OAUTH_PASSWORD", "AGENTDOCK_OAUTH_TOKEN_SECRET", + "AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL", } type controlPanelSettings struct { @@ -62,6 +63,7 @@ func platformPrepareCoreEnvironment(runtimeRoot string) error { if err != nil { return err } + oauthAccessTokenTTL := strings.TrimSpace(os.Getenv("AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL")) for _, name := range managedCoreEnvironment { if err := os.Unsetenv(name); err != nil { @@ -119,6 +121,13 @@ func platformPrepareCoreEnvironment(runtimeRoot string) error { return err } if serverURL != "" { + serverURL, err = normalizeHTTPSOrigin(serverURL) + if err != nil { + return err + } + if err := writeRuntimeText(filepath.Join(root, "server-url.txt"), serverURL); err != nil { + return err + } oauthPassword, passwordErr := readProtectedText(filepath.Join(root, "oauth-password.dpapi"), "agentdock.oauth.password.v1") if passwordErr != nil { return fmt.Errorf("读取 OAuth 密码失败: %w", passwordErr) @@ -132,6 +141,16 @@ 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 + } + if oauthAccessTokenTTL != "" { + managed["AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL"] = oauthAccessTokenTTL + } for name, value := range managed { if err := os.Setenv(name, value); err != nil { diff --git a/internal/desktopruntime/service_startup_windows.go b/internal/desktopruntime/service_startup_windows.go index dbe885c0..b8741037 100644 --- a/internal/desktopruntime/service_startup_windows.go +++ b/internal/desktopruntime/service_startup_windows.go @@ -3,12 +3,15 @@ package desktopruntime import ( + "bytes" "context" "encoding/xml" "errors" "fmt" + "io" "os/exec" "strings" + "unicode/utf16" "golang.org/x/sys/windows/registry" ) @@ -17,7 +20,7 @@ const windowsRunKey = `Software\Microsoft\Windows\CurrentVersion\Run` type scheduledTaskXML struct { Settings struct { - Enabled bool `xml:"Enabled"` + Enabled *bool `xml:"Enabled"` } `xml:"Settings"` } @@ -61,15 +64,60 @@ func coreAutostartEnabled(ctx context.Context, manifest Manifest) (bool, error) if err != nil { return false, err } - var task scheduledTaskXML - if err := xml.Unmarshal(output, &task); err != nil { + task, err := parseScheduledTaskXML(output) + if err != nil { return false, err } - return task.Settings.Enabled, nil + // Task Scheduler 省略 Enabled 时使用 schema 默认值 true。 + return task.Settings.Enabled == nil || *task.Settings.Enabled, nil } return runValuePresent(defaultString(manifest.StartupValueName, "AgentDock")) } +func parseScheduledTaskXML(output []byte) (scheduledTaskXML, error) { + decoded, err := decodeScheduledTaskXML(output) + if err != nil { + return scheduledTaskXML{}, err + } + decoder := xml.NewDecoder(bytes.NewReader(decoded)) + // schtasks 会保留 UTF-16 声明;字节已在上一步转换为 UTF-8。 + decoder.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) { + if strings.EqualFold(strings.TrimSpace(charset), "utf-16") { + return input, nil + } + return nil, fmt.Errorf("不支持的计划任务 XML 编码:%s", charset) + } + var task scheduledTaskXML + if err := decoder.Decode(&task); err != nil { + return scheduledTaskXML{}, err + } + return task, nil +} + +func decodeScheduledTaskXML(output []byte) ([]byte, error) { + if len(output) < 2 { + return output, nil + } + littleEndian := output[0] == 0xff && output[1] == 0xfe + bigEndian := output[0] == 0xfe && output[1] == 0xff + if !littleEndian && !bigEndian { + return output, nil + } + payload := output[2:] + if len(payload)%2 != 0 { + return nil, errors.New("计划任务 XML 的 UTF-16 字节数无效") + } + codeUnits := make([]uint16, len(payload)/2) + for index := range codeUnits { + if littleEndian { + codeUnits[index] = uint16(payload[index*2]) | uint16(payload[index*2+1])<<8 + } else { + codeUnits[index] = uint16(payload[index*2])<<8 | uint16(payload[index*2+1]) + } + } + return []byte(string(utf16.Decode(codeUnits))), nil +} + func runScheduledTaskCommand(ctx context.Context, args ...string) error { output, err := exec.CommandContext(ctx, "schtasks.exe", args...).CombinedOutput() if err != nil { diff --git a/internal/desktopruntime/service_startup_windows_test.go b/internal/desktopruntime/service_startup_windows_test.go new file mode 100644 index 00000000..34963df1 --- /dev/null +++ b/internal/desktopruntime/service_startup_windows_test.go @@ -0,0 +1,43 @@ +//go:build windows + +package desktopruntime + +import ( + "testing" + "unicode/utf16" +) + +func TestParseScheduledTaskXMLAcceptsUTF16LE(t *testing.T) { + runes := utf16.Encode([]rune(`true`)) + data := []byte{0xff, 0xfe} + for _, value := range runes { + data = append(data, byte(value), byte(value>>8)) + } + 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 { + t.Fatalf("parseScheduledTaskXML() error = %v", err) + } + if task.Settings.Enabled == nil || *task.Settings.Enabled { + t.Fatal("scheduled task should be disabled") + } +} + +func TestParseScheduledTaskXMLUsesEnabledDefault(t *testing.T) { + task, err := parseScheduledTaskXML([]byte(``)) + if err != nil { + t.Fatalf("parseScheduledTaskXML() error = %v", err) + } + if task.Settings.Enabled != nil { + t.Fatal("scheduled task should preserve the missing Enabled element") + } +} diff --git a/internal/desktopruntime/tunnel_state_windows.go b/internal/desktopruntime/tunnel_state_windows.go index 84accaf2..214c94c5 100644 --- a/internal/desktopruntime/tunnel_state_windows.go +++ b/internal/desktopruntime/tunnel_state_windows.go @@ -137,7 +137,8 @@ func normalizeHTTPSOrigin(value string) (string, error) { if err != nil || !parsed.IsAbs() || parsed.Scheme != "https" || parsed.Host == "" { return "", fmt.Errorf("公网地址必须是完整 HTTPS Origin:%s", value) } - if parsed.User != nil || (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { + path := strings.TrimRight(parsed.EscapedPath(), "/") + if parsed.User != nil || (path != "" && !strings.EqualFold(path, "/mcp")) || parsed.RawQuery != "" || parsed.Fragment != "" { return "", fmt.Errorf("公网地址不能包含路径、查询参数、片段或用户信息:%s", value) } return "https://" + parsed.Host, nil diff --git a/internal/desktopruntime/tunnel_state_windows_test.go b/internal/desktopruntime/tunnel_state_windows_test.go new file mode 100644 index 00000000..bec8b39f --- /dev/null +++ b/internal/desktopruntime/tunnel_state_windows_test.go @@ -0,0 +1,28 @@ +//go:build windows + +package desktopruntime + +import "testing" + +func TestNormalizeHTTPSOriginAcceptsMCPURL(t *testing.T) { + for _, input := range []string{ + "https://yc.188166.top:18443", + "https://yc.188166.top:18443/", + "https://yc.188166.top:18443/mcp", + "https://yc.188166.top:18443/MCP/", + } { + origin, err := normalizeHTTPSOrigin(input) + if err != nil { + t.Fatalf("normalizeHTTPSOrigin(%q) error = %v", input, err) + } + if origin != "https://yc.188166.top:18443" { + t.Fatalf("normalizeHTTPSOrigin(%q) = %q", input, origin) + } + } +} + +func TestNormalizeHTTPSOriginRejectsOtherPaths(t *testing.T) { + if _, err := normalizeHTTPSOrigin("https://yc.188166.top:18443/oauth/token"); err == nil { + t.Fatal("normalizeHTTPSOrigin() should reject non-MCP paths") + } +} diff --git a/scripts/install/uninstall-windows.ps1 b/scripts/install/uninstall-windows.ps1 index 05d2d9e3..5afea260 100644 --- a/scripts/install/uninstall-windows.ps1 +++ b/scripts/install/uninstall-windows.ps1 @@ -169,6 +169,7 @@ foreach ($name in @( 'auth-token.dpapi', 'oauth-password.dpapi', 'oauth-token-secret.dpapi', + 'oauth-access-token-ttl.txt', 'server-url.txt', 'named-server-url.txt', 'control-panel-settings.json', diff --git a/scripts/test/install_test.go b/scripts/test/install_test.go index 75d41ae1..082b5cd0 100644 --- a/scripts/test/install_test.go +++ b/scripts/test/install_test.go @@ -540,6 +540,7 @@ func TestWindowsUninstallerCleansManagedTunnelState(t *testing.T) { "'control-panel-settings.json'", "'oauth-password.dpapi'", "'oauth-token-secret.dpapi'", + "'oauth-access-token-ttl.txt'", "'cloudflared-token.dpapi'", "'cloudflared.out.log'", "'cloudflared.err.log'",