diff --git a/modules/claude-code/README.md b/modules/claude-code/README.md index a02e88f9..caaf1680 100644 --- a/modules/claude-code/README.md +++ b/modules/claude-code/README.md @@ -45,8 +45,8 @@ whenever the node's tool runtime collects the module's tools: | file | holds | |---|---| -| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's | -| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin | +| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh` (with a `timeout` of 660000 ms, mesh_wait's longest wait and a minute, so the agent's one-minute default does not cut a wait, issue 1871), and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's | +| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin, and `CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT` (660000) in `env` beside the operator's own, the mesh's key winning | | `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one | | `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in | diff --git a/modules/claude-code/cmd/claude-code/claude_code_test.go b/modules/claude-code/cmd/claude-code/claude_code_test.go index 021e0d56..e9d0db00 100644 --- a/modules/claude-code/cmd/claude-code/claude_code_test.go +++ b/modules/claude-code/cmd/claude-code/claude_code_test.go @@ -83,6 +83,20 @@ func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) { for k := range MarketplaceKeys() { delete(m, k) } + // The wait bound is new since the TypeScript (issue 1871), and is held below. + if env, ok := m["env"].(map[string]any); ok { + delete(env, "CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT") + if len(env) == 0 { + delete(m, "env") + } + } + } + if m, ok := a.(map[string]any); ok && file == "managed-mcp.json" { + if servers, ok := m["mcpServers"].(map[string]any); ok { + if entry, ok := servers["mesh"].(map[string]any); ok { + delete(entry, "timeout") + } + } } _ = json.Unmarshal([]byte(want[file]), &b) if !reflect.DeepEqual(a, b) { @@ -104,6 +118,21 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T) if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil { t.Fatalf("%v", mcp.MCPServers) } + // The entry bounds a call by mesh_wait's longest wait and the idle bound is laid over the operator's env + // (issue 1871): a session's wait is not cut at the agent's one-minute default. + if mcp.MCPServers["mesh"]["timeout"] != float64(660000) { + t.Errorf("the mesh entry's timeout is %v", mcp.MCPServers["mesh"]["timeout"]) + } + for _, held := range []map[string]any{nil, {"CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT": "1", "FOO": "bar"}} { + var m struct { + Env map[string]any `json:"env"` + } + got := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"env": held}}, nil, "/h", nil, Config{}) + _ = json.Unmarshal([]byte(got["managed-settings.json"]), &m) + if m.Env["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] != "660000" || (held != nil && m.Env["FOO"] != "bar") { + t.Errorf("env %v rendered as %v", held, m.Env) + } + } if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) { t.Fatal("rendering is not deterministic") } diff --git a/modules/claude-code/cmd/claude-code/render.go b/modules/claude-code/cmd/claude-code/render.go index 0ce3acde..53920864 100644 --- a/modules/claude-code/cmd/claude-code/render.go +++ b/modules/claude-code/cmd/claude-code/render.go @@ -22,6 +22,7 @@ import ( "encoding/json" "fmt" "regexp" + "strconv" "strings" ) @@ -98,6 +99,27 @@ func jsonFile(v any) string { return b.String() } +// MeshWaitBoundMillis is how long the agent may wait on the mesh's console for one call: mesh_wait's longest +// wait (600 s, mesh-tools node-tools console.WaitLongest) plus a minute's margin. The agent bounds an HTTP +// server's request by the largest of 60 s, the server entry's "timeout" and MCP_TIMEOUT, and a call that +// receives nothing for CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT (5 minutes by default) is aborted +// (code.claude.com/docs/en/mcp.md). Without these every mesh_wait ended "The operation timed out." at a +// minute, whatever its timeout (issue 1871). +const MeshWaitBoundMillis = 660000 + +// meshWaitEnv lays the idle bound into the environment the settings give every session, the mesh's key winning +// over the operator's, like the forge's keys. +func meshWaitEnv(managed map[string]any) { + env := map[string]any{} + if held, ok := managed["env"].(map[string]any); ok { + for k, v := range held { + env[k] = v + } + } + env["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] = strconv.Itoa(MeshWaitBoundMillis) + managed["env"] = env +} + // Render composes the three files. registered — what was registered through this module and applies // here — is laid over the servers the operator set in its settings; config is the rest of what was // registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections @@ -119,7 +141,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath servers[name] = entry } } - servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console} + servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console, "timeout": MeshWaitBoundMillis} // The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered // through this module's tools is laid over it (novox/hq issue 340). @@ -143,6 +165,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath managed[key] = entries } forgeEnv(managed, facts.ForgeCredentials) + meshWaitEnv(managed) if binding != nil && binding.Kind == "api-key" { managed["apiKeyHelper"] = helperPath }