From 87dedf43e1987145395eb560ae0556763e19158b Mon Sep 17 00:00:00 2001 From: KevinZonda <33132228+KevinZonda@users.noreply.github.com> Date: Tue, 12 May 2026 17:28:48 +0800 Subject: [PATCH 1/2] Update `/clear` command documentation and functionality - Enhanced the `/clear` command description in both English and Chinese documentation to clarify its effects on backend session binding for private and group chats. - Updated the response messages in the command processor to reflect the new terminology regarding backend sessions. - Added a new test case to verify the `/clear` command's behavior in private chat sessions. - Updated `.gitignore` to include `.vscode` directory. --- .gitignore | 1 + book/en/how-to/use-builtin-commands.md | 10 ++++- book/zh/how-to/use-builtin-commands.md | 10 ++++- .../connector/processor_builtin_command.go | 12 +++--- .../processor_builtin_command_test.go | 39 ++++++++++++++++++- prompts/connector/help.md.tmpl | 2 +- 6 files changed, 63 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index c374ae34..f153d1cb 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ coverage.out .memory codex-home .tmp/ +.vscode diff --git a/book/en/how-to/use-builtin-commands.md b/book/en/how-to/use-builtin-commands.md index 7935c339..2bd18e6f 100644 --- a/book/en/how-to/use-builtin-commands.md +++ b/book/en/how-to/use-builtin-commands.md @@ -23,13 +23,19 @@ Shows a status card with: ## `/clear` -Resets the current `chat` scene session. The next message starts a fresh conversation with no prior context. +Clears the backend session binding for the current conversation. The next message starts a fresh conversation on a new backend session with no prior context. ``` /clear ``` -> Only affects `chat` scenes. `work` scenes are thread-scoped and reset naturally when the thread ends. +When to use: + +- **Direct messages**: clears the DM's backend session binding. +- **Group chats with `chat` scene enabled**: clears the chat-scene backend session binding for the group. +- After switching providers (for example, migrating from codex to opencode): the old backend session id cannot be resumed by the new provider — `/clear` unbinds it so the next message starts a fresh session. + +> Does not affect `work` scene threads. Each `work` thread has its own session; to rebind one, use `/session ` inside that thread. ## `/stop` diff --git a/book/zh/how-to/use-builtin-commands.md b/book/zh/how-to/use-builtin-commands.md index 393eefac..dc99b58c 100644 --- a/book/zh/how-to/use-builtin-commands.md +++ b/book/zh/how-to/use-builtin-commands.md @@ -23,13 +23,19 @@ Alice 提供多个斜杠命令,这些命令绕过 LLM,由连接器直接处 ## `/clear` -重置当前 `chat` 场景的 session。下一条消息将以全新对话开始,不带有之前的上下文。 +清空当前会话的后端 session 绑定。下一条消息会以全新对话开始,并在后端开新的 session,不带有之前的上下文。 ``` /clear ``` -> 仅影响 `chat` 场景。`work` 场景是基于话题的,话题结束时自然重置。 +适用场景: + +- **私聊**:直接清空 DM 的后端 session 绑定。 +- **群聊 `chat` 模式**:清空当前群聊 `chat` 场景的后端 session 绑定。 +- 切换 provider(例如从 codex 迁移到 opencode)后,旧的后端 session id 在新 provider 上无法 resume——用 `/clear` 解绑即可让下一条消息开新 session。 + +> 不影响 `work` 场景的话题。`work` 是按话题独立的,需要重绑请在该话题里使用 `/session `。 ## `/stop` diff --git a/internal/connector/processor_builtin_command.go b/internal/connector/processor_builtin_command.go index 6c8b865a..0824f9df 100644 --- a/internal/connector/processor_builtin_command.go +++ b/internal/connector/processor_builtin_command.go @@ -268,16 +268,18 @@ func forceDirectReplyJob(job Job) Job { } func (p *Processor) processClearCommand(ctx context.Context, job Job) JobProcessState { - reply := "当前只支持在群聊的 `chat` 模式下使用 `/clear`。" helpCfg := p.runtimeSnapshot().helpConfig + var reply string switch { - case !isGroupChatType(job.ChatType): - reply = "当前不是群聊会话,`/clear` 仅用于群聊 `chat` 模式。" - case !helpCfg.chatEnabled: + case isGroupChatType(job.ChatType) && !helpCfg.chatEnabled: reply = "当前群未启用 `chat` 模式,`/clear` 不会切换上下文。" default: _, _ = p.resetChatSceneSession(job.ReceiveIDType, job.ReceiveID) - reply = "当前群聊的 `chat` 上下文已经清空。后续普通消息会进入新的 Codex session。" + if isGroupChatType(job.ChatType) { + reply = "当前群聊的 `chat` 上下文已经清空。后续普通消息会进入新的后端 session。" + } else { + reply = "当前会话的上下文已经清空。下一条消息会进入新的后端 session。" + } } replyJob := job diff --git a/internal/connector/processor_builtin_command_test.go b/internal/connector/processor_builtin_command_test.go index 9a88dde3..b8801c66 100644 --- a/internal/connector/processor_builtin_command_test.go +++ b/internal/connector/processor_builtin_command_test.go @@ -140,7 +140,7 @@ func TestProcessor_ClearCommand_RotatesGroupChatSceneSession(t *testing.T) { if !strings.Contains(sender.replyMarkdownTexts[0], "已经清空") { t.Fatalf("unexpected clear reply: %q", sender.replyMarkdownTexts[0]) } - if !strings.Contains(sender.replyMarkdownTexts[0], "新的 Codex session") { + if !strings.Contains(sender.replyMarkdownTexts[0], "新的后端 session") { t.Fatalf("unexpected clear reply: %q", sender.replyMarkdownTexts[0]) } @@ -153,6 +153,43 @@ func TestProcessor_ClearCommand_RotatesGroupChatSceneSession(t *testing.T) { } } +func TestProcessor_ClearCommand_ResetsPrivateChatSession(t *testing.T) { + llmStub := &llmCallCountingStub{} + sender := &senderStub{} + processor := NewProcessor(llmStub, sender, "failed", "thinking") + processor.SetBuiltinHelpConfig(configForGroupScenesTest()) + + baseSessionKey := restoreChatSceneKey("chat_id", "oc_dm") + processor.setThreadID(baseSessionKey, "thread_old") + + state := processor.ProcessJobState(context.Background(), Job{ + ReceiveID: "oc_dm", + ReceiveIDType: "chat_id", + ChatType: "p2p", + SourceMessageID: "om_clear_dm", + SessionKey: "chat_id:oc_dm|message:om_clear_dm", + Text: "/clear", + }) + if state != JobProcessCompleted { + t.Fatalf("expected completed state, got %s", state) + } + if llmStub.calls != 0 { + t.Fatalf("expected clear command to bypass llm, got %d llm calls", llmStub.calls) + } + if sender.replyRichMarkdownCalls != 1 || sender.replyRichMarkdownDirectCalls != 1 { + t.Fatalf("expected one direct rich markdown reply, got rich=%d direct=%d", sender.replyRichMarkdownCalls, sender.replyRichMarkdownDirectCalls) + } + if !strings.Contains(sender.replyMarkdownTexts[0], "已经清空") { + t.Fatalf("unexpected clear reply: %q", sender.replyMarkdownTexts[0]) + } + if !strings.Contains(sender.replyMarkdownTexts[0], "新的后端 session") { + t.Fatalf("unexpected clear reply: %q", sender.replyMarkdownTexts[0]) + } + if threadID := processor.getThreadID(baseSessionKey); threadID != "" { + t.Fatalf("expected cleared private chat session to start without thread id, got %q", threadID) + } +} + func TestProcessor_StopCommand_BypassesLLMAndReplies(t *testing.T) { llmStub := &llmCallCountingStub{} sender := &senderStub{} diff --git a/prompts/connector/help.md.tmpl b/prompts/connector/help.md.tmpl index 986ec5ef..e0142fad 100644 --- a/prompts/connector/help.md.tmpl +++ b/prompts/connector/help.md.tmpl @@ -6,7 +6,7 @@ - `/status` 显示当前会话下 token 统计、活跃自动化任务、当前目标状态,以及 backend/session/cwd。 - `/clear` - 仅在群聊 `chat` 模式下可用;切换到新的群聊会话,相当于清空当前上下文。 + 清空当前会话的后端 session 绑定;私聊和群聊 `chat` 模式均可使用。下一条消息会开新的后端 session。 - `/stop` 停止当前 session 正在运行的回复,但保留现有 session;后续新指令会在当前 session 上继续。 - `/session [instruction]` From f1b02b3f7106917e1b1936f83f43118f48c60379 Mon Sep 17 00:00:00 2001 From: Li Zhihao Date: Wed, 13 May 2026 14:37:34 +0800 Subject: [PATCH 2/2] refactor(runtimeapi): replace TCP HTTP ports with Unix domain sockets for runtime API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the internal runtime API transport from TCP loopback ports to Unix domain sockets. Each bot uses its own socket at $ALICE_HOME/runtime.sock, eliminating port conflicts on multi-instance machines. The HTTP protocol stay; only the transport layer changes. - Config: runtime_http_addr → runtime_socket, default "runtime.sock" - Server: net.Listen("unix", path) + os.Chmod 0700; stale socket cleanup - Client: unified Unix socket transport via DialContext (no HTTP fallback) - Remove port auto-increment logic (incrementHostPort, etc.) - Add 13 socket-specific tests (healthz, auth, permissions, cleanup, etc.) Co-Authored-By: Claude Opus 4.7 --- SECURITY.md | 2 +- book/en/development/architecture.md | 2 +- book/en/explanation/runtime-api-design.md | 4 +- book/en/how-to/troubleshoot.md | 4 +- book/en/reference/configuration.md | 8 +- book/zh/development/architecture.md | 2 +- book/zh/explanation/runtime-api-design.md | 4 +- book/zh/how-to/troubleshoot.md | 4 +- book/zh/reference/configuration.md | 8 +- cmd/connector/root.go | 2 +- cmd/connector/runtime_root.go | 6 +- config.example.yaml | 12 +- internal/bootstrap/config_reload.go | 4 +- internal/bootstrap/config_reload_test.go | 6 +- internal/bootstrap/connector_runtime.go | 2 +- .../bootstrap/connector_runtime_builder.go | 8 +- internal/config/config.go | 8 +- internal/config/config_load.go | 6 +- internal/config/config_normalize.go | 2 +- internal/config/config_validate.go | 2 +- internal/config/multibot.go | 6 +- internal/config/multibot_normalize.go | 2 +- internal/config/multibot_runtime.go | 66 +--- internal/config/multibot_test.go | 16 +- internal/runtimeapi/client.go | 20 +- internal/runtimeapi/client_test.go | 289 ++++++++++++++++++ internal/runtimeapi/message_dispatch_test.go | 19 +- internal/runtimeapi/permissions_test.go | 21 +- internal/runtimeapi/server.go | 29 +- internal/runtimeapi/server_test.go | 26 +- internal/runtimeapi/types.go | 12 +- 31 files changed, 449 insertions(+), 153 deletions(-) create mode 100644 internal/runtimeapi/client_test.go diff --git a/SECURITY.md b/SECURITY.md index 4b7cfbee..5ede2bd0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,5 +22,5 @@ Security concerns include but are not limited to: - Never commit `config.yaml` containing real credentials - Use `log_level: debug` only temporarily for troubleshooting — debug logs may contain rendered prompts -- Keep `runtime_http_addr` bound to `127.0.0.1` unless you know what you're doing +- The runtime API listens on a Unix domain socket by default — no network exposure - Rotate your `runtime_http_token` periodically if exposed diff --git a/book/en/development/architecture.md b/book/en/development/architecture.md index ef52bc03..2ecae1b2 100644 --- a/book/en/development/architecture.md +++ b/book/en/development/architecture.md @@ -273,7 +273,7 @@ Important keys: - `group_scenes.chat`, `group_scenes.work` - `private_scenes.chat`, `private_scenes.work` - `permissions` -- `runtime_http_addr` +- `runtime_socket` - `workspace_dir`, `prompt_dir`, `codex_home` Behavior worth calling out: diff --git a/book/en/explanation/runtime-api-design.md b/book/en/explanation/runtime-api-design.md index 2d461533..ebbc62dd 100644 --- a/book/en/explanation/runtime-api-design.md +++ b/book/en/explanation/runtime-api-design.md @@ -10,7 +10,7 @@ Alice runs bundled skills as subprocess scripts. These scripts need to interact ### 1. Local-Only by Default -The API binds to `127.0.0.1` (configurable via `runtime_http_addr`). It is not designed to be exposed to the network. If you need remote access, use a reverse proxy or SSH tunnel — but this is not the intended use case. +The API listens on a Unix domain socket (configurable via `runtime_socket`). It is not designed to be exposed to the network. If you need remote access, use a reverse proxy or SSH tunnel — but this is not the intended use case. ### 2. Bearer Token Auth @@ -59,7 +59,7 @@ Both accept `multipart/form-data` with an optional `caption` field. Skills don't need to know the API address or token. Alice injects them: ```bash -ALICE_RUNTIME_API_BASE_URL="http://127.0.0.1:7331" +ALICE_RUNTIME_API_BASE_URL="unix:///home/user/.alice/runtime.sock" ALICE_RUNTIME_API_TOKEN="" ALICE_RUNTIME_BIN="/usr/local/bin/alice" ``` diff --git a/book/en/how-to/troubleshoot.md b/book/en/how-to/troubleshoot.md index 90f46b80..21b08c25 100644 --- a/book/en/how-to/troubleshoot.md +++ b/book/en/how-to/troubleshoot.md @@ -54,11 +54,11 @@ permissions: **Check API connectivity:** ```bash # From the machine running Alice -curl -s -H "Authorization: Bearer " http://127.0.0.1:7331/healthz +curl -s --unix-socket ~/.alice/runtime.sock -H "Authorization: Bearer " http://unix/healthz # Should return {"status":"ok"} ``` -The runtime HTTP API binds to the address in `runtime_http_addr` (default `127.0.0.1:7331`). Multi-bot setups auto-increment the port. +The runtime API listens on a Unix domain socket. The socket path defaults to `$ALICE_HOME/runtime.sock`. ## Configuration changes don't apply diff --git a/book/en/reference/configuration.md b/book/en/reference/configuration.md index 31a1182f..081e9123 100644 --- a/book/en/reference/configuration.md +++ b/book/en/reference/configuration.md @@ -512,16 +512,16 @@ Additional session scope: `"per_message"` — each DM with `#work` creates a fre --- -### Runtime HTTP API +### Runtime API -#### `runtime_http_addr` +#### `runtime_socket` | Field | Value | |-------|-------| | Type | `string` | -| Default | `"127.0.0.1:7331"` | +| Default | `"runtime.sock"` | -Listen address for the runtime HTTP API. Multi-bot setups auto-increment the port (`7332`, `7333`, ...). +Unix domain socket path for the runtime API, resolved relative to `alice_home`. Each bot has its own socket under its own `alice_home`, so no port conflicts. #### `runtime_http_token` diff --git a/book/zh/development/architecture.md b/book/zh/development/architecture.md index 645513d4..3d4c7fcb 100644 --- a/book/zh/development/architecture.md +++ b/book/zh/development/architecture.md @@ -273,7 +273,7 @@ Alice 暴露一个本地认证的 Runtime API,面向 bundled skill 和薄运 - `group_scenes.chat`、`group_scenes.work` - `private_scenes.chat`、`private_scenes.work` - `permissions` -- `runtime_http_addr` +- `runtime_socket` - `workspace_dir`、`prompt_dir`、`codex_home` 值得注意的行为: diff --git a/book/zh/explanation/runtime-api-design.md b/book/zh/explanation/runtime-api-design.md index 8e6204c2..b968459f 100644 --- a/book/zh/explanation/runtime-api-design.md +++ b/book/zh/explanation/runtime-api-design.md @@ -10,7 +10,7 @@ Alice 将 bundled skill 作为子进程脚本运行。这些脚本需要与运 ### 1. 默认仅本地访问 -API 绑定到 `127.0.0.1`(可通过 `runtime_http_addr` 配置)。它并非设计为对外暴露。如果需要远程访问,请使用反向代理或 SSH 隧道 — 但这并非预期用例。 +API 监听在 Unix 域套接字上(可通过 `runtime_socket` 配置)。它并非设计为对外暴露。如果需要远程访问,请使用反向代理或 SSH 隧道 — 但这并非预期用例。 ### 2. Bearer Token 认证 @@ -59,7 +59,7 @@ Runtime API **没有**纯文本发送端点。为什么? Skill 无需知道 API 地址或 token。Alice 注入它们: ```bash -ALICE_RUNTIME_API_BASE_URL="http://127.0.0.1:7331" +ALICE_RUNTIME_API_BASE_URL="unix:///home/user/.alice/runtime.sock" ALICE_RUNTIME_API_TOKEN="" ALICE_RUNTIME_BIN="/usr/local/bin/alice" ``` diff --git a/book/zh/how-to/troubleshoot.md b/book/zh/how-to/troubleshoot.md index 5acbd9f9..7b168cef 100644 --- a/book/zh/how-to/troubleshoot.md +++ b/book/zh/how-to/troubleshoot.md @@ -54,11 +54,11 @@ permissions: **检查 API 连通性:** ```bash # 在运行 Alice 的机器上执行 -curl -s -H "Authorization: Bearer " http://127.0.0.1:7331/healthz +curl -s --unix-socket ~/.alice/runtime.sock -H "Authorization: Bearer " http://unix/healthz # 应返回 {"status":"ok"} ``` -Runtime HTTP API 绑定在 `runtime_http_addr` 指定的地址(默认 `127.0.0.1:7331`)。多 bot 设置会自动递增端口。 +Runtime API 监听在 Unix 域套接字上,默认路径为 `$ALICE_HOME/runtime.sock`。 ## 配置更改不生效 diff --git a/book/zh/reference/configuration.md b/book/zh/reference/configuration.md index a53eb65b..ba252fb0 100644 --- a/book/zh/reference/configuration.md +++ b/book/zh/reference/configuration.md @@ -512,16 +512,16 @@ Reaction 反馈的飞书表情名称(如 `"OK"`、`"WINK"`、`"THUMBSUP"`) --- -### Runtime HTTP API +### Runtime API -#### `runtime_http_addr` +#### `runtime_socket` | 字段 | 值 | |-------|-------| | 类型 | `string` | -| 默认值 | `"127.0.0.1:7331"` | +| 默认值 | `"runtime.sock"` | -Runtime HTTP API 的监听地址。多 bot 设置会自动递增端口(`7332`、`7333`……)。 +Runtime API 的 Unix 域套接字路径,相对于 `alice_home` 解析。每个 bot 在其自己的 `alice_home` 下拥有独立的套接字,因此不会发生端口冲突。 #### `runtime_http_token` diff --git a/cmd/connector/root.go b/cmd/connector/root.go index 10b6b336..87330c31 100644 --- a/cmd/connector/root.go +++ b/cmd/connector/root.go @@ -430,7 +430,7 @@ func runConnector(configPath, pidFilePath string, pidFileExplicit bool, runtimeO } logging.Infof("automation engine enabled bot=%s state_file=%s", built.Config.BotID, built.AutomationStatePath) if built.RuntimeAPI != nil { - logging.Infof("runtime http api enabled bot=%s addr=%s", built.Config.BotID, built.RuntimeAPIBaseURL) + logging.Infof("runtime api enabled bot=%s socket=%s", built.Config.BotID, built.RuntimeAPISocket) } } if runtimeOnly { diff --git a/cmd/connector/runtime_root.go b/cmd/connector/runtime_root.go index 0550825b..c1105f70 100644 --- a/cmd/connector/runtime_root.go +++ b/cmd/connector/runtime_root.go @@ -41,11 +41,11 @@ func withRuntimeClient( } func loadRuntimeClient() (*runtimeapi.Client, sessionctx.SessionContext, error) { - baseURL := strings.TrimSpace(os.Getenv(runtimeapi.EnvBaseURL)) - if baseURL == "" { + addr := strings.TrimSpace(os.Getenv(runtimeapi.EnvBaseURL)) + if addr == "" { return nil, sessionctx.SessionContext{}, fmt.Errorf("missing %s", runtimeapi.EnvBaseURL) } - client := runtimeapi.NewClient(baseURL, os.Getenv(runtimeapi.EnvToken)) + client := runtimeapi.NewClient(addr, os.Getenv(runtimeapi.EnvToken)) if client == nil || !client.IsEnabled() { return nil, sessionctx.SessionContext{}, fmt.Errorf("runtime api client is unavailable") } diff --git a/config.example.yaml b/config.example.yaml index 3ea61aaf..08509701 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -261,11 +261,13 @@ bots: no_reply_token: "" create_feishu_thread: true - # ── Runtime HTTP API ─────────────────────────────────────────────────── - # Alice exposes a local HTTP API for bundled skills and automation tasks. - # Listen address for the runtime HTTP API. - # Default: 127.0.0.1:7331. Multi-bot auto-increments the port (7332, 7333, ...). - runtime_http_addr: "" + # ── Runtime API ──────────────────────────────────────────────────────── + # Alice exposes a local API over a Unix domain socket for bundled skills + # and automation tasks. + # Socket path (relative to alice_home, or absolute). Each bot gets its own + # socket under its own alice_home, so no port conflicts. + # Default: runtime.sock (resolved as $ALICE_HOME/runtime.sock). + runtime_socket: "" # Bearer token for API authentication. Auto-generated if empty. # Set explicitly for cross-process calls or manual debugging. # Example: runtime_http_token: "my-static-token" diff --git a/internal/bootstrap/config_reload.go b/internal/bootstrap/config_reload.go index 6389560c..e7ccc280 100644 --- a/internal/bootstrap/config_reload.go +++ b/internal/bootstrap/config_reload.go @@ -101,8 +101,8 @@ func diffRestartRequiredFields(current, next config.Config) []string { if current.FeishuBaseURL != next.FeishuBaseURL { changed = append(changed, "feishu_base_url") } - if current.RuntimeHTTPAddr != next.RuntimeHTTPAddr { - changed = append(changed, "runtime_http_addr") + if current.RuntimeSocket != next.RuntimeSocket { + changed = append(changed, "runtime_socket") } if current.RuntimeHTTPToken != next.RuntimeHTTPToken { changed = append(changed, "runtime_http_token") diff --git a/internal/bootstrap/config_reload_test.go b/internal/bootstrap/config_reload_test.go index 971c0985..ca16d95a 100644 --- a/internal/bootstrap/config_reload_test.go +++ b/internal/bootstrap/config_reload_test.go @@ -13,7 +13,7 @@ func TestDiffRestartRequiredFields(t *testing.T) { FeishuAppID: "cli_a", FeishuAppSecret: "sec_a", FeishuBaseURL: "https://open.feishu.cn", - RuntimeHTTPAddr: "127.0.0.1:7331", + RuntimeSocket: "/tmp/alice-test.sock", RuntimeHTTPToken: "token_a", WorkspaceDir: "/workspace/a", PromptDir: "prompts", @@ -25,7 +25,7 @@ func TestDiffRestartRequiredFields(t *testing.T) { next := current next.TriggerMode = "prefix" next.TriggerPrefix = "!alice" - next.RuntimeHTTPAddr = "127.0.0.1:7332" + next.RuntimeSocket = "/tmp/alice-test-2.sock" next.QueueCapacity = 512 next.WorkerConcurrency = 3 next.AuthStatusTimeoutSecs = 20 @@ -36,7 +36,7 @@ func TestDiffRestartRequiredFields(t *testing.T) { "auth_status_timeout_secs", "queue_capacity", "runtime_api_shutdown_timeout_secs", - "runtime_http_addr", + "runtime_socket", "worker_concurrency", } if !reflect.DeepEqual(got, want) { diff --git a/internal/bootstrap/connector_runtime.go b/internal/bootstrap/connector_runtime.go index 3c75467f..a0d9879a 100644 --- a/internal/bootstrap/connector_runtime.go +++ b/internal/bootstrap/connector_runtime.go @@ -23,7 +23,7 @@ type ConnectorRuntime struct { Processor *connector.Processor AutomationEngine *automation.Engine RuntimeAPI *runtimeapi.Server - RuntimeAPIBaseURL string + RuntimeAPISocket string RuntimeAPIToken string AutomationStatePath string SessionStatePath string diff --git a/internal/bootstrap/connector_runtime_builder.go b/internal/bootstrap/connector_runtime_builder.go index e7302bab..acbe9a90 100644 --- a/internal/bootstrap/connector_runtime_builder.go +++ b/internal/bootstrap/connector_runtime_builder.go @@ -94,7 +94,7 @@ func (b *connectorRuntimeBuilder) Build() (*ConnectorRuntime, error) { Processor: b.processor, AutomationEngine: b.automationEngine, RuntimeAPI: b.apiServer, - RuntimeAPIBaseURL: runtimeapi.BaseURL(b.cfg.RuntimeHTTPAddr), + RuntimeAPISocket: runtimeapi.BaseURL(b.cfg.RuntimeSocket), RuntimeAPIToken: b.apiToken, AutomationStatePath: b.paths.automationStatePath, SessionStatePath: b.paths.sessionStatePath, @@ -148,7 +148,7 @@ func (b *connectorRuntimeBuilder) buildProcessor() error { workDisable := b.cfg.GroupScenes.Work.DisableIdentityHints != nil && *b.cfg.GroupScenes.Work.DisableIdentityHints processor.SetSceneIdentityHints(chatDisable, workDisable) processor.SetRuntimeAPI( - runtimeapi.BaseURL(b.cfg.RuntimeHTTPAddr), + runtimeapi.BaseURL(b.cfg.RuntimeSocket), b.resolveRuntimeAPIToken(), ResolveRuntimeBinary(b.cfg.WorkspaceDir), ) @@ -184,7 +184,7 @@ func (b *connectorRuntimeBuilder) buildAutomationEngine() error { automationEngine.SetUserTaskTimeout(b.cfg.AutomationTaskTimeout) automationEngine.SetLLMRunner(b.backend) automationEngine.SetRunEnv(map[string]string{ - runtimeapi.EnvBaseURL: runtimeapi.BaseURL(b.cfg.RuntimeHTTPAddr), + runtimeapi.EnvBaseURL: runtimeapi.BaseURL(b.cfg.RuntimeSocket), runtimeapi.EnvToken: b.resolveRuntimeAPIToken(), runtimeapi.EnvBin: ResolveRuntimeBinary(b.cfg.WorkspaceDir), }) @@ -219,7 +219,7 @@ func (b *connectorRuntimeBuilder) buildAutomationEngine() error { func (b *connectorRuntimeBuilder) buildRuntimeAPI() { b.apiServer = runtimeapi.NewServer( - b.cfg.RuntimeHTTPAddr, + b.cfg.RuntimeSocket, b.resolveRuntimeAPIToken(), b.sender, b.automationStore, diff --git a/internal/config/config.go b/internal/config/config.go index 33ce210f..a9beada2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -42,8 +42,8 @@ const DefaultImmediateFeedbackMode = ImmediateFeedbackModeReaction // DefaultImmediateFeedbackReaction is the default reaction emoji for immediate feedback. const DefaultImmediateFeedbackReaction = "OK" -// DefaultRuntimeHTTPAddr is the default runtime HTTP listen address. -const DefaultRuntimeHTTPAddr = "127.0.0.1:7331" +// DefaultRuntimeSocket is the default runtime API Unix socket filename, resolved relative to AliceHome. +const DefaultRuntimeSocket = "runtime.sock" // DefaultWorkerConcurrency is the default worker pool size. const DefaultWorkerConcurrency = 3 @@ -143,7 +143,7 @@ type BotConfig struct { LLMProfiles map[string]LLMProfileConfig `mapstructure:"llm_profiles"` GroupScenes *GroupScenesConfig `mapstructure:"group_scenes"` PrivateScenes *GroupScenesConfig `mapstructure:"private_scenes"` - RuntimeHTTPAddr string `mapstructure:"runtime_http_addr"` + RuntimeSocket string `mapstructure:"runtime_socket"` RuntimeHTTPToken string `mapstructure:"runtime_http_token"` FailureMessage string `mapstructure:"failure_message"` ThinkingMessage string `mapstructure:"thinking_message"` @@ -188,7 +188,7 @@ type Config struct { CodexEnv map[string]string `mapstructure:"env"` CodexHome string `mapstructure:"codex_home"` - RuntimeHTTPAddr string `mapstructure:"runtime_http_addr"` + RuntimeSocket string `mapstructure:"runtime_socket"` RuntimeHTTPToken string `mapstructure:"runtime_http_token"` FailureMessage string `mapstructure:"failure_message"` ThinkingMessage string `mapstructure:"thinking_message"` diff --git a/internal/config/config_load.go b/internal/config/config_load.go index f9adcedf..862863a4 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -57,7 +57,7 @@ func setBotDefaults(v *viper.Viper) { } } -func setCommonConfigDefaults(v *viper.Viper, prefix string, includeRuntimeHTTPAddr bool) { +func setCommonConfigDefaults(v *viper.Viper, prefix string, includeRuntimeSocket bool) { if v == nil { return } @@ -67,8 +67,8 @@ func setCommonConfigDefaults(v *viper.Viper, prefix string, includeRuntimeHTTPAd v.SetDefault(configKey(prefix, "trigger_prefix"), "") v.SetDefault(configKey(prefix, "immediate_feedback_mode"), DefaultImmediateFeedbackMode) v.SetDefault(configKey(prefix, "immediate_feedback_reaction"), DefaultImmediateFeedbackReaction) - if includeRuntimeHTTPAddr { - v.SetDefault(configKey(prefix, "runtime_http_addr"), DefaultRuntimeHTTPAddr) + if includeRuntimeSocket { + v.SetDefault(configKey(prefix, "runtime_socket"), DefaultRuntimeSocket) } v.SetDefault(configKey(prefix, "runtime_http_token"), "") v.SetDefault(configKey(prefix, "failure_message"), "暂时不可用,请稍后重试。") diff --git a/internal/config/config_normalize.go b/internal/config/config_normalize.go index 7be9f484..21417dca 100644 --- a/internal/config/config_normalize.go +++ b/internal/config/config_normalize.go @@ -15,7 +15,7 @@ func normalizeLoadedConfig(cfg Config, rootEnv map[string]string) Config { cfg.GroupScenes = normalizeGroupScenes(cfg.GroupScenes) cfg.PrivateScenes = normalizePrivateScenes(cfg.PrivateScenes) cfg.CodexEnv = normalizeEnvMap(rootEnv) - cfg.RuntimeHTTPAddr = strings.TrimSpace(cfg.RuntimeHTTPAddr) + cfg.RuntimeSocket = strings.TrimSpace(cfg.RuntimeSocket) cfg.RuntimeHTTPToken = strings.TrimSpace(cfg.RuntimeHTTPToken) cfg.FailureMessage = strings.TrimSpace(cfg.FailureMessage) cfg.ThinkingMessage = strings.TrimSpace(cfg.ThinkingMessage) diff --git a/internal/config/config_validate.go b/internal/config/config_validate.go index c75f18c4..a6b4d99d 100644 --- a/internal/config/config_validate.go +++ b/internal/config/config_validate.go @@ -40,7 +40,7 @@ func validatePureMultiBotRootConfig(v *viper.Viper) error { "kimi_command", "kimi_timeout_secs", "kimi_prompt_prefix", - "runtime_http_addr", + "runtime_socket", "runtime_http_token", "failure_message", "thinking_message", diff --git a/internal/config/multibot.go b/internal/config/multibot.go index f4938fa3..e5e69498 100644 --- a/internal/config/multibot.go +++ b/internal/config/multibot.go @@ -59,14 +59,14 @@ func finalizeConfig(cfg Config, requireCredentials bool) (Config, error) { cfg.ImmediateFeedbackReaction = DefaultImmediateFeedbackReaction } cfg.CodexEnv = applyDefaultCodexEnv(cfg.CodexEnv) - if cfg.RuntimeHTTPAddr == "" { - cfg.RuntimeHTTPAddr = DefaultRuntimeHTTPAddr - } if cfg.AliceHome == "" { cfg.AliceHome = AliceHomeDir() } else { cfg.AliceHome = ResolveAliceHomeDir(cfg.AliceHome) } + if cfg.RuntimeSocket == "" { + cfg.RuntimeSocket = filepath.Join(cfg.AliceHome, DefaultRuntimeSocket) + } if cfg.WorkspaceDir == "" { cfg.WorkspaceDir = WorkspaceDirForAliceHome(cfg.AliceHome) } else { diff --git a/internal/config/multibot_normalize.go b/internal/config/multibot_normalize.go index 902e3599..061e7467 100644 --- a/internal/config/multibot_normalize.go +++ b/internal/config/multibot_normalize.go @@ -29,7 +29,7 @@ func normalizeBots(in map[string]BotConfig) map[string]BotConfig { normalized := normalizePrivateScenes(*bot.PrivateScenes) bot.PrivateScenes = &normalized } - bot.RuntimeHTTPAddr = strings.TrimSpace(bot.RuntimeHTTPAddr) + bot.RuntimeSocket = strings.TrimSpace(bot.RuntimeSocket) bot.RuntimeHTTPToken = strings.TrimSpace(bot.RuntimeHTTPToken) bot.FailureMessage = strings.TrimSpace(bot.FailureMessage) bot.ThinkingMessage = strings.TrimSpace(bot.ThinkingMessage) diff --git a/internal/config/multibot_runtime.go b/internal/config/multibot_runtime.go index ae674658..ffd68641 100644 --- a/internal/config/multibot_runtime.go +++ b/internal/config/multibot_runtime.go @@ -3,7 +3,6 @@ package config import ( "errors" "fmt" - "net" "path/filepath" "sort" "strings" @@ -29,9 +28,6 @@ func (cfg Config) RuntimeConfigs() ([]Config, error) { } runtimes = append(runtimes, runtime) } - if err := validateUniqueRuntimeHTTPAddrs(runtimes); err != nil { - return nil, err - } return runtimes, nil } @@ -69,17 +65,17 @@ func orderBotIDs(bots map[string]BotConfig) []string { func (cfg Config) deriveBotRuntimeConfig(botID string, bot BotConfig, index int) (Config, error) { runtime := Config{ - BotID: strings.TrimSpace(botID), - LogLevel: cfg.LogLevel, - LogFile: cfg.LogFile, - LogMaxSizeMB: cfg.LogMaxSizeMB, - LogMaxBackups: cfg.LogMaxBackups, - LogMaxAgeDays: cfg.LogMaxAgeDays, - LogCompress: cfg.LogCompress, - CodexEnv: map[string]string{}, - LLMProfiles: map[string]LLMProfileConfig{}, - Permissions: normalizeBotPermissions(BotPermissionsConfig{}), - RuntimeHTTPAddr: "", + BotID: strings.TrimSpace(botID), + LogLevel: cfg.LogLevel, + LogFile: cfg.LogFile, + LogMaxSizeMB: cfg.LogMaxSizeMB, + LogMaxBackups: cfg.LogMaxBackups, + LogMaxAgeDays: cfg.LogMaxAgeDays, + LogCompress: cfg.LogCompress, + CodexEnv: map[string]string{}, + LLMProfiles: map[string]LLMProfileConfig{}, + Permissions: normalizeBotPermissions(BotPermissionsConfig{}), + RuntimeSocket: "", } var err error if bot.Name != "" { @@ -102,14 +98,11 @@ func (cfg Config) deriveBotRuntimeConfig(botID string, bot BotConfig, index int) if bot.PrivateScenes != nil { runtime.PrivateScenes = *bot.PrivateScenes } - runtime.RuntimeHTTPAddr, err = deriveBotRuntimeHTTPAddr(bot, index) - if err != nil { - return Config{}, fmt.Errorf("bots.%s: derive runtime_http_addr failed: %w", runtime.BotID, err) - } runtime.RuntimeHTTPToken = bot.RuntimeHTTPToken runtime.FailureMessage = bot.FailureMessage runtime.ThinkingMessage = bot.ThinkingMessage runtime.AliceHome = deriveBotAliceHome(bot, runtime.BotID) + runtime.RuntimeSocket = deriveBotRuntimeSocket(bot, runtime.AliceHome) runtime.WorkspaceDir = deriveBotWorkspaceDir(bot, runtime.AliceHome) runtime.PromptDir = deriveBotPromptDir(bot, runtime.AliceHome) runtime.CodexHome = deriveBotCodexHome(bot, runtime.AliceHome) @@ -173,38 +166,11 @@ func deriveBotSoulPath(bot BotConfig, aliceHome string) string { return SoulPathForAliceHome(aliceHome) } -func deriveBotRuntimeHTTPAddr(bot BotConfig, index int) (string, error) { - if bot.RuntimeHTTPAddr != "" { - return strings.TrimSpace(bot.RuntimeHTTPAddr), nil - } - return incrementHostPort(DefaultRuntimeHTTPAddr, index) -} - -func incrementHostPort(addr string, delta int) (string, error) { - host, portStr, err := net.SplitHostPort(strings.TrimSpace(addr)) - if err != nil { - return "", err - } - basePort := 0 - if _, err := fmt.Sscanf(portStr, "%d", &basePort); err != nil { - return "", err - } - return net.JoinHostPort(host, fmt.Sprintf("%d", basePort+delta)), nil -} - -func validateUniqueRuntimeHTTPAddrs(runtimes []Config) error { - seen := make(map[string]string, len(runtimes)) - for _, runtime := range runtimes { - addr := strings.TrimSpace(runtime.RuntimeHTTPAddr) - if addr == "" { - continue - } - if existing, ok := seen[addr]; ok { - return fmt.Errorf("runtime_http_addr %q is duplicated between bots %q and %q", addr, existing, runtime.BotID) - } - seen[addr] = runtime.BotID +func deriveBotRuntimeSocket(bot BotConfig, aliceHome string) string { + if bot.RuntimeSocket != "" { + return strings.TrimSpace(bot.RuntimeSocket) } - return nil + return filepath.Join(aliceHome, DefaultRuntimeSocket) } func mergeLLMProfiles(base, override map[string]LLMProfileConfig) map[string]LLMProfileConfig { diff --git a/internal/config/multibot_test.go b/internal/config/multibot_test.go index 143668eb..a08f0f65 100644 --- a/internal/config/multibot_test.go +++ b/internal/config/multibot_test.go @@ -49,8 +49,9 @@ bots: if chat.CodexHome != filepath.Join(base, ".codex") { t.Fatalf("unexpected chat codex_home: %q", chat.CodexHome) } - if chat.RuntimeHTTPAddr != "127.0.0.1:7331" { - t.Fatalf("unexpected chat runtime_http_addr: %q", chat.RuntimeHTTPAddr) + wantChatSocket := filepath.Join(chat.AliceHome, DefaultRuntimeSocket) + if chat.RuntimeSocket != wantChatSocket { + t.Fatalf("unexpected chat runtime_socket: %q (want %q)", chat.RuntimeSocket, wantChatSocket) } work, err := cfg.RuntimeConfigForBot("work") @@ -63,8 +64,9 @@ bots: if work.CodexHome != filepath.Join(base, ".codex") { t.Fatalf("unexpected work codex_home: %q", work.CodexHome) } - if work.RuntimeHTTPAddr != "127.0.0.1:7332" { - t.Fatalf("unexpected work runtime_http_addr: %q", work.RuntimeHTTPAddr) + wantWorkSocket := filepath.Join(work.AliceHome, DefaultRuntimeSocket) + if work.RuntimeSocket != wantWorkSocket { + t.Fatalf("unexpected work runtime_socket: %q (want %q)", work.RuntimeSocket, wantWorkSocket) } } @@ -81,7 +83,7 @@ bots: prompt_dir: "`+filepath.Join(base, "custom-prompts")+`" codex_home: "`+filepath.Join(base, "custom-codex")+`" soul_path: "souls/chat.md" - runtime_http_addr: "127.0.0.1:7441" + runtime_socket: "/tmp/test-runtime.sock" `) cfg, err := LoadFromFile(cfgPath) if err != nil { @@ -107,8 +109,8 @@ bots: if runtime.SoulPath != filepath.Join(runtime.AliceHome, "souls/chat.md") { t.Fatalf("unexpected soul_path: %q", runtime.SoulPath) } - if runtime.RuntimeHTTPAddr != "127.0.0.1:7441" { - t.Fatalf("unexpected runtime_http_addr: %q", runtime.RuntimeHTTPAddr) + if runtime.RuntimeSocket != "/tmp/test-runtime.sock" { + t.Fatalf("unexpected runtime_socket: %q", runtime.RuntimeSocket) } } diff --git a/internal/runtimeapi/client.go b/internal/runtimeapi/client.go index 49b009cd..fa3e650a 100644 --- a/internal/runtimeapi/client.go +++ b/internal/runtimeapi/client.go @@ -3,6 +3,7 @@ package runtimeapi import ( "context" "fmt" + "net" "net/http" "strings" @@ -15,14 +16,25 @@ type Client struct { http *resty.Client } -func NewClient(baseURL, token string) *Client { - baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") - if baseURL == "" { +func NewClient(socketPath, token string) *Client { + socketPath = strings.TrimSpace(socketPath) + if socketPath == "" { return nil } + // Strip unix:// prefix if present. + socketPath = strings.TrimPrefix(socketPath, "unix://") + + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", socketPath) + }, + } httpClient := resty.New(). - SetBaseURL(baseURL). + SetTransport(transport). + SetBaseURL("http://unix"). // placeholder host; DialContext overrides it SetHeader("Accept", "application/json") + if token = strings.TrimSpace(token); token != "" { httpClient.SetAuthToken(token) } diff --git a/internal/runtimeapi/client_test.go b/internal/runtimeapi/client_test.go new file mode 100644 index 00000000..f7faf058 --- /dev/null +++ b/internal/runtimeapi/client_test.go @@ -0,0 +1,289 @@ +package runtimeapi + +import ( + "context" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Alice-space/alice/internal/automation" + "github.com/Alice-space/alice/internal/config" + "github.com/Alice-space/alice/internal/sessionctx" +) + +// shortSocketDir creates a temp directory with a deliberately short path to +// stay under the macOS Unix socket path limit (104 bytes). +func shortSocketDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "a") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + return dir +} + +// startServer starts an already-constructed Server on its configured socket +// and waits for the socket to be ready. Returns the cancel function to shut it down. +func startServer(t *testing.T, server *Server, socketPath string) context.CancelFunc { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + + errCh := make(chan error, 1) + go func() { + errCh <- server.Run(ctx) + }() + + for i := 0; i < 100; i++ { + if fi, err := os.Stat(socketPath); err == nil { + if fi.Mode()&os.ModeSocket != 0 { + return cancel + } + } + select { + case err := <-errCh: + cancel() + t.Fatalf("server exited before socket was ready: %v", err) + default: + } + time.Sleep(10 * time.Millisecond) + } + cancel() + t.Fatalf("timed out waiting for Unix socket at %s", socketPath) + return nil +} + +// startTestServer starts a runtime API server on a Unix socket and waits for +// the socket to be ready. Returns the cancel function to shut it down. +func startTestServer(t *testing.T, socketPath string, token string, store *automation.Store) context.CancelFunc { + t.Helper() + server := NewServer(socketPath, token, nil, store, config.Config{}) + return startServer(t, server, socketPath) +} + +func startTestServerWithCfg(t *testing.T, socketPath string, token string, store *automation.Store, cfg config.Config) context.CancelFunc { + t.Helper() + server := NewServer(socketPath, token, nil, store, cfg) + return startServer(t, server, socketPath) +} + +// newTestClient creates a client connected to a Unix socket, using the +// unix:// prefix to match production usage. +func newTestClient(t *testing.T, socketPath, token string) *Client { + t.Helper() + client := NewClient("unix://"+socketPath, token) + if client == nil || !client.IsEnabled() { + t.Fatal("client should be enabled for unix socket path") + } + return client +} + +func TestNewClient_SocketTransport(t *testing.T) { + client := NewClient("unix:///tmp/test.sock", "tok") + if client == nil || !client.IsEnabled() { + t.Fatal("client should be enabled for unix socket path") + } +} + +func TestNewClient_BarePath(t *testing.T) { + client := NewClient("/tmp/test.sock", "tok") + if client == nil || !client.IsEnabled() { + t.Fatal("client should be enabled for bare socket path") + } +} + +func TestNewClient_EmptyPathReturnsNil(t *testing.T) { + client := NewClient("", "") + if client != nil { + t.Fatal("client should be nil for empty path") + } +} + +func TestUnixSocketE2E_Healthz(t *testing.T) { + socketPath := filepath.Join(shortSocketDir(t), "s") + cancel := startTestServer(t, socketPath, "test-token", nil) + defer cancel() + + client := newTestClient(t, socketPath, "test-token") + result, err := client.do(context.Background(), + sessionctx.SessionContext{}, + http.MethodGet, "/healthz", nil, "", nil, + ) + if err != nil { + t.Fatalf("healthz failed: %v", err) + } + if status, ok := result["status"].(string); !ok || status != "ok" { + t.Fatalf("unexpected healthz response: %#v", result) + } +} + +func TestUnixSocketE2E_GoalCRUD(t *testing.T) { + socketDir := shortSocketDir(t) + socketPath := filepath.Join(socketDir, "s") + store := automation.NewStore(filepath.Join(socketDir, "automation.db")) + + cancel := startTestServer(t, socketPath, "test-token", store) + defer cancel() + + client := newTestClient(t, socketPath, "test-token") + + workSession := sessionctx.SessionContext{ + ReceiveIDType: "chat_id", + ReceiveID: "oc_test", + ActorOpenID: "ou_test", + ChatType: "group", + SessionKey: "chat_id:oc_test|work:om_work_seed", + SourceMessageID: "om_msg", + } + + result, err := client.CreateGoal(context.Background(), workSession, CreateGoalRequest{ + Objective: "test socket goal", + DeadlineIn: "1h", + }) + if err != nil { + t.Fatalf("create goal failed: %v", err) + } + goal, _ := result["goal"].(map[string]any) + if goal == nil { + t.Fatalf("unexpected create goal response: %#v", result) + } + + getResult, err := client.GetGoal(context.Background(), workSession) + if err != nil { + t.Fatalf("get goal failed: %v", err) + } + if getGoal, _ := getResult["goal"].(map[string]any); getGoal == nil { + t.Fatalf("expected goal in get response: %#v", getResult) + } +} + +func TestUnixSocketE2E_AuthRejected(t *testing.T) { + socketPath := filepath.Join(shortSocketDir(t), "s") + cancel := startTestServer(t, socketPath, "correct-token", nil) + defer cancel() + + badClient := newTestClient(t, socketPath, "wrong-token") + _, err := badClient.do(context.Background(), + sessionctx.SessionContext{}, + http.MethodGet, "/healthz", nil, "", nil, + ) + if err == nil { + t.Fatal("expected auth error with wrong token, got nil") + } + if !strings.Contains(strings.ToLower(err.Error()), "unauthorized") { + t.Fatalf("expected 401 error, got: %v", err) + } +} + +func TestUnixSocketE2E_StaleSocketCleanup(t *testing.T) { + socketPath := filepath.Join(shortSocketDir(t), "s") + + if err := os.WriteFile(socketPath, []byte("stale"), 0644); err != nil { + t.Fatalf("failed to create fake stale file: %v", err) + } + + cancel := startTestServer(t, socketPath, "tok", nil) + defer cancel() + + fi, err := os.Stat(socketPath) + if err != nil { + t.Fatalf("socket not found after server start: %v", err) + } + if fi.Mode()&os.ModeSocket == 0 { + t.Fatal("stale regular file was not replaced with a Unix socket") + } +} + +func TestUnixSocketE2E_SocketPermissions(t *testing.T) { + socketPath := filepath.Join(shortSocketDir(t), "s") + cancel := startTestServer(t, socketPath, "tok", nil) + defer cancel() + + fi, err := os.Stat(socketPath) + if err != nil { + t.Fatalf("socket not found: %v", err) + } + if fi.Mode()&os.ModeSocket == 0 { + t.Fatal("file is not a Unix socket") + } + if fi.Mode().Perm() != 0700 { + t.Fatalf("expected socket permissions 0700, got %04o", fi.Mode().Perm()) + } +} + +func TestUnixSocket_ListenErrorOnInvalidPath(t *testing.T) { + socketPath := "/nonexistent/path/should/not/exist/runtime.sock" + server := NewServer(socketPath, "tok", nil, nil, config.Config{}) + + err := server.Run(context.Background()) + if err == nil { + t.Fatal("expected error for invalid socket path, got nil") + } + t.Logf("error (expected): %v", err) +} + +func TestUnixSocketE2E_ClientWithBareServer(t *testing.T) { + socketPath := filepath.Join(shortSocketDir(t), "s") + + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) + }) + + ln, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("net.Listen: %v", err) + } + defer ln.Close() + go http.Serve(ln, mux) + + client := newTestClient(t, socketPath, "") + result, err := client.do(context.Background(), + sessionctx.SessionContext{}, + http.MethodGet, "/healthz", nil, "", nil, + ) + if err != nil { + t.Fatalf("healthz via Unix socket failed: %v", err) + } + if status, ok := result["status"].(string); !ok || status != "ok" { + t.Fatalf("unexpected response: %#v", result) + } +} + +func TestUnixSocketE2E_ShutdownCleanup(t *testing.T) { + socketPath := filepath.Join(shortSocketDir(t), "s") + cancel := startTestServer(t, socketPath, "tok", nil) + cancel() + time.Sleep(100 * time.Millisecond) + + if _, err := os.Stat(socketPath); err == nil { + t.Fatal("socket file should be removed on shutdown") + } +} + +func TestBaseURL_SocketPath(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + {name: "absolute path", path: "/home/user/.alice/runtime.sock", want: "unix:///home/user/.alice/runtime.sock"}, + {name: "has unix already", path: "unix:///tmp/s", want: "unix:///tmp/s"}, + {name: "empty", path: "", want: ""}, + {name: "whitespace", path: " ", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BaseURL(tt.path) + if got != tt.want { + t.Fatalf("BaseURL(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} diff --git a/internal/runtimeapi/message_dispatch_test.go b/internal/runtimeapi/message_dispatch_test.go index 0b30e41e..33ef1e30 100644 --- a/internal/runtimeapi/message_dispatch_test.go +++ b/internal/runtimeapi/message_dispatch_test.go @@ -2,7 +2,6 @@ package runtimeapi import ( "context" - "net/http/httptest" "path/filepath" "testing" @@ -83,10 +82,11 @@ func (s *runtimeMessageSenderStub) ReplyFileDirect(context.Context, string, stri func TestRuntimeAPI_SendImagePathDoesNotRequireResourceRoot(t *testing.T) { sender := &runtimeMessageSenderStub{} - server := NewServer("", "test-token", sender, nil, config.Config{}) - httpServer := httptest.NewServer(server.engine) - defer httpServer.Close() - client := NewClient(httpServer.URL, "test-token") + socketPath := filepath.Join(shortSocketDir(t), "s") + server := NewServer(socketPath, "test-token", sender, nil, config.Config{}) + cancel := startServer(t, server, socketPath) + defer cancel() + client := newTestClient(t, socketPath, "test-token") path := filepath.Join(t.TempDir(), "image.png") result, err := client.SendImage(t.Context(), sessionctx.SessionContext{ @@ -110,10 +110,11 @@ func TestRuntimeAPI_SendImagePathDoesNotRequireResourceRoot(t *testing.T) { func TestRuntimeAPI_SendFilePathDoesNotRequireResourceRoot(t *testing.T) { sender := &runtimeMessageSenderStub{} - server := NewServer("", "test-token", sender, nil, config.Config{}) - httpServer := httptest.NewServer(server.engine) - defer httpServer.Close() - client := NewClient(httpServer.URL, "test-token") + socketPath := filepath.Join(shortSocketDir(t), "s") + server := NewServer(socketPath, "test-token", sender, nil, config.Config{}) + cancel := startServer(t, server, socketPath) + defer cancel() + client := newTestClient(t, socketPath, "test-token") path := filepath.Join(t.TempDir(), "report.pdf") result, err := client.SendFile(t.Context(), sessionctx.SessionContext{ diff --git a/internal/runtimeapi/permissions_test.go b/internal/runtimeapi/permissions_test.go index 5bbb6228..8388d95d 100644 --- a/internal/runtimeapi/permissions_test.go +++ b/internal/runtimeapi/permissions_test.go @@ -1,7 +1,7 @@ package runtimeapi import ( - "net/http/httptest" + "path/filepath" "strings" "testing" @@ -12,14 +12,15 @@ import ( func TestRuntimeAPI_MessagePermissionDenied(t *testing.T) { enabled := false - server := NewServer("", "test-token", nil, nil, config.Config{ + socketPath := filepath.Join(shortSocketDir(t), "s") + server := NewServer(socketPath, "test-token", nil, nil, config.Config{ Permissions: config.BotPermissionsConfig{ RuntimeMessage: &enabled, }, }) - httpServer := httptest.NewServer(server.engine) - defer httpServer.Close() - client := NewClient(httpServer.URL, "test-token") + cancel := startServer(t, server, socketPath) + defer cancel() + client := newTestClient(t, socketPath, "test-token") _, err := client.SendImage(t.Context(), sessionctx.SessionContext{ ReceiveIDType: "chat_id", @@ -33,14 +34,16 @@ func TestRuntimeAPI_MessagePermissionDenied(t *testing.T) { func TestRuntimeAPI_AutomationPermissionDenied(t *testing.T) { enabled := false - server := NewServer("", "test-token", nil, automation.NewStore(t.TempDir()+"/automation.db"), config.Config{ + socketDir := shortSocketDir(t) + socketPath := filepath.Join(socketDir, "s") + server := NewServer(socketPath, "test-token", nil, automation.NewStore(filepath.Join(socketDir, "automation.db")), config.Config{ Permissions: config.BotPermissionsConfig{ RuntimeAutomation: &enabled, }, }) - httpServer := httptest.NewServer(server.engine) - defer httpServer.Close() - client := NewClient(httpServer.URL, "test-token") + cancel := startServer(t, server, socketPath) + defer cancel() + client := newTestClient(t, socketPath, "test-token") _, err := client.CreateTask(t.Context(), sessionctx.SessionContext{ ReceiveIDType: "chat_id", diff --git a/internal/runtimeapi/server.go b/internal/runtimeapi/server.go index 23bb2f13..a4151aaf 100644 --- a/internal/runtimeapi/server.go +++ b/internal/runtimeapi/server.go @@ -3,7 +3,9 @@ package runtimeapi import ( "context" "errors" + "net" "net/http" + "os" "strconv" "strings" "sync" @@ -29,7 +31,7 @@ const runtimeAPIMaxListLimit = 200 const runtimeAPIAuthRateLimit = 120 type Server struct { - addr string + socketPath string token string shutdownTimeout time.Duration sender Sender @@ -54,7 +56,7 @@ type GoalExecutor interface { } func NewServer( - addr, token string, + socketPath, token string, sender Sender, automationStore *automation.Store, cfg config.Config, @@ -65,7 +67,7 @@ func NewServer( engine.Use(gin.Recovery()) srv := &Server{ - addr: strings.TrimSpace(addr), + socketPath: strings.TrimSpace(socketPath), token: strings.TrimSpace(token), shutdownTimeout: runtimeAPIShutdownTimeout(cfg), sender: sender, @@ -111,15 +113,26 @@ func (s *Server) Run(ctx context.Context) error { } go s.authLimiter.RunCleanup(ctx, time.Minute) + // Remove stale socket file from a previous unclean shutdown. + _ = os.Remove(s.socketPath) + + ln, err := net.Listen("unix", s.socketPath) + if err != nil { + return err + } + if err := os.Chmod(s.socketPath, 0700); err != nil { + ln.Close() + return err + } + s.httpSrv = &http.Server{ - Addr: s.addr, Handler: s.engine, ReadHeaderTimeout: 10 * time.Second, MaxHeaderBytes: 1 << 20, } errCh := make(chan error, 1) go func() { - err := s.httpSrv.ListenAndServe() + err := s.httpSrv.Serve(ln) if errors.Is(err, http.ErrServerClosed) { err = nil } @@ -130,7 +143,11 @@ func (s *Server) Run(ctx context.Context) error { case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), s.shutdownTimeout) defer cancel() - return s.httpSrv.Shutdown(shutdownCtx) + if shutErr := s.httpSrv.Shutdown(shutdownCtx); shutErr != nil { + return shutErr + } + _ = os.Remove(s.socketPath) + return nil case err := <-errCh: return err } diff --git a/internal/runtimeapi/server_test.go b/internal/runtimeapi/server_test.go index b69d8467..e53ad32f 100644 --- a/internal/runtimeapi/server_test.go +++ b/internal/runtimeapi/server_test.go @@ -1,7 +1,7 @@ package runtimeapi import ( - "net/http/httptest" + "path/filepath" "strings" "testing" "time" @@ -334,11 +334,13 @@ func TestApplyTaskPatch_CanChangeStatus(t *testing.T) { } func TestAutomationTaskGet_EnforcesScopeIsolation(t *testing.T) { - store := automation.NewStore(t.TempDir() + "/automation.db") - server := NewServer("", "test-token", nil, store, config.Config{}) - httpServer := httptest.NewServer(server.engine) - defer httpServer.Close() - client := NewClient(httpServer.URL, "test-token") + socketDir := shortSocketDir(t) + socketPath := filepath.Join(socketDir, "s") + store := automation.NewStore(filepath.Join(socketDir, "automation.db")) + server := NewServer(socketPath, "test-token", nil, store, config.Config{}) + cancel := startServer(t, server, socketPath) + defer cancel() + client := newTestClient(t, socketPath, "test-token") session1 := sessionctx.SessionContext{ ReceiveIDType: "chat_id", @@ -409,11 +411,13 @@ func TestAutomationTaskGet_EnforcesScopeIsolation(t *testing.T) { } func TestGoalCreate_RejectsNonWorkSession(t *testing.T) { - store := automation.NewStore(t.TempDir() + "/automation.db") - server := NewServer("", "test-token", nil, store, config.Config{}) - httpServer := httptest.NewServer(server.engine) - defer httpServer.Close() - client := NewClient(httpServer.URL, "test-token") + socketDir := shortSocketDir(t) + socketPath := filepath.Join(socketDir, "s") + store := automation.NewStore(filepath.Join(socketDir, "automation.db")) + server := NewServer(socketPath, "test-token", nil, store, config.Config{}) + cancel := startServer(t, server, socketPath) + defer cancel() + client := newTestClient(t, socketPath, "test-token") _, err := client.CreateGoal(t.Context(), sessionctx.SessionContext{ ReceiveIDType: "chat_id", diff --git a/internal/runtimeapi/types.go b/internal/runtimeapi/types.go index 322a42b0..bf51df5d 100644 --- a/internal/runtimeapi/types.go +++ b/internal/runtimeapi/types.go @@ -53,13 +53,13 @@ type CreateGoalRequest struct { DeadlineIn string `json:"deadline_in,omitempty"` } -func BaseURL(addr string) string { - addr = strings.TrimSpace(addr) - if addr == "" { +func BaseURL(socketPath string) string { + socketPath = strings.TrimSpace(socketPath) + if socketPath == "" { return "" } - if strings.Contains(addr, "://") { - return addr + if strings.Contains(socketPath, "://") { + return socketPath } - return "http://" + addr + return "unix://" + socketPath }