Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
/*.test
/agentdock.killed*

# Windows WPF 控制面板的本地构建中间文件。
/desktop/windows/control-panel/obj/

# Python 辅助脚本的本地缓存不得进入 Skill 源码和发布包。
__pycache__/
*.py[cod]
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

## 镜像版本

| 镜像标签 | 用途 |
Expand Down
3 changes: 2 additions & 1 deletion desktop/windows/control-panel/Services/RuntimeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
19 changes: 19 additions & 0 deletions internal/desktopruntime/service_environment_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
56 changes: 52 additions & 4 deletions internal/desktopruntime/service_startup_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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"`
}

Expand Down Expand Up @@ -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 {
Expand Down
43 changes: 43 additions & 0 deletions internal/desktopruntime/service_startup_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//go:build windows

package desktopruntime

import (
"testing"
"unicode/utf16"
)

func TestParseScheduledTaskXMLAcceptsUTF16LE(t *testing.T) {
runes := utf16.Encode([]rune(`<?xml version="1.0" encoding="UTF-16"?><Task><Settings><Enabled>true</Enabled></Settings></Task>`))
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(`<Task><Settings><Enabled>false</Enabled></Settings></Task>`))
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(`<Task><Settings></Settings></Task>`))
if err != nil {
t.Fatalf("parseScheduledTaskXML() error = %v", err)
}
if task.Settings.Enabled != nil {
t.Fatal("scheduled task should preserve the missing Enabled element")
}
}
3 changes: 2 additions & 1 deletion internal/desktopruntime/tunnel_state_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions internal/desktopruntime/tunnel_state_windows_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions scripts/install/uninstall-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions scripts/test/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
Loading