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: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
/*.test
/agentdock.killed*

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

# Python 辅助脚本的本地缓存不得进入 Skill 源码和发布包。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。

## 镜像版本

Expand Down
6 changes: 6 additions & 0 deletions desktop/windows/control-panel/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="端口" VerticalAlignment="Center" Margin="0,0,0,10" />
<TextBox x:Name="PortTextBox" Grid.Column="1" Width="130" HorizontalAlignment="Left" Margin="0,0,0,10" />
Expand All @@ -185,6 +186,11 @@
<TextBox x:Name="BrowserCdpUrlTextBox" Grid.Row="5" Grid.Column="1" Margin="0,0,0,10" ToolTip="可选,例如 http://127.0.0.1:9222 或 ws://.../devtools/browser/..." />
<TextBlock Text="自动复用" Grid.Row="6" VerticalAlignment="Center" Margin="0,0,0,10" />
<CheckBox x:Name="BrowserReuseExistingCdpCheckBox" Grid.Row="6" Grid.Column="1" Content="自动发现并复用唯一已有 CDP;发现多个时不自动选择" VerticalAlignment="Center" Margin="0,0,0,10" />
<TextBlock Text="OAuth Token TTL" Grid.Row="7" VerticalAlignment="Center" Margin="0,0,0,10" />
<StackPanel Grid.Row="7" Grid.Column="1" Margin="0,0,0,10">
<TextBox x:Name="OAuthAccessTokenTtlTextBox" ToolTip="例如 1h、30d、never;留空时继承 AGENTDOCK_OAUTH_ACCESS_TOKEN_TTL,未设置则使用默认 1h" />
<TextBlock Text="支持 1h、30d、never 等格式;留空表示不固定,由环境变量或默认 1h 决定。" Foreground="#667085" Margin="0,4,0,0" TextWrapping="Wrap" />
</StackPanel>
</Grid>
</GroupBox>

Expand Down
2 changes: 2 additions & 0 deletions desktop/windows/control-panel/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions desktop/windows/control-panel/Models/RuntimeModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
1 change: 1 addition & 0 deletions desktop/windows/control-panel/Services/RuntimeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()}",
Expand Down
32 changes: 29 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
13 changes: 13 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions internal/desktopruntime/config_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"io"
"net/url"
"strings"

agentconfig "github.com/uvwt/agentdock/internal/config"
)

// ConfigUpdateRequest 是桌面端保存日常运行设置时使用的结构化请求。
Expand All @@ -17,6 +19,7 @@ type ConfigUpdateRequest struct {
LogLevel string
NexusEndpoint string
NexusTokenFile string
OAuthAccessTokenTTL string
BrowserEnabled bool
BrowserCDPURL string
BrowserReuseExistingCDP bool
Expand All @@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -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 == "" {
Expand Down
13 changes: 13 additions & 0 deletions internal/desktopruntime/config_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions internal/desktopruntime/config_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 17 additions & 8 deletions internal/desktopruntime/service_environment_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strings"
"unsafe"

agentconfig "github.com/uvwt/agentdock/internal/config"
"golang.org/x/sys/windows"
)

Expand Down Expand Up @@ -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"`
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
51 changes: 51 additions & 0 deletions internal/desktopruntime/service_environment_windows_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
15 changes: 15 additions & 0 deletions internal/desktopruntime/service_startup_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ func TestParseScheduledTaskXMLAcceptsUTF16LE(t *testing.T) {
}
}

func TestParseScheduledTaskXMLAcceptsUTF16BE(t *testing.T) {
runes := utf16.Encode([]rune(`<?xml version="1.0" encoding="UTF-16"?><Task><Settings><Enabled>true</Enabled></Settings></Task>`))
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(`<Task><Settings><Enabled>false</Enabled></Settings></Task>`))
if err != nil {
Expand Down
Loading
Loading