From 11c338d2fcd825b805e84d4004ed1389722a5aeb Mon Sep 17 00:00:00 2001 From: "FURRYSTATION\\kirby" Date: Sun, 21 Jun 2026 05:45:39 -0400 Subject: [PATCH 1/6] Update dependencies and TUI analytics tests --- test/Project.toml | 21 +++++++++++++++ test/tui_analytics_tests.jl | 52 +++++++++++++++++++++---------------- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/test/Project.toml b/test/Project.toml index d17b5db6..50696b3c 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,13 +1,34 @@ [deps] Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +DBInterface = "a10d1c49-ce27-4219-8d33-6db1a4562965" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" +InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" JET = "c3a54625-cd67-489e-a8e7-0a5a0ff4e31b" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +Kaimon = "d3856c55-31fd-4246-b7e8-380411123c01" +REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" +ReTest = "e0db7c4e-2690-44b9-bad6-7687da720f89" +SQLite = "0aa819cd-b072-5ff4-a722-6bc24af294d9" +Serialization = "9e88b42a-f829-5b0c-bbe9-9e923198166b" Supposition = "5a0628fe-1738-4658-9b6d-0b7605a9755b" +TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +Tachikoma = "468859d6-42d8-48b7-8ad9-1d312e0e3b0a" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +ZMQ = "c2297ded-f4af-51ae-bb23-16f91089e4e1" [compat] Aqua = "0.8" +DBInterface = "2.6.1" HTTP = "2" +InteractiveUtils = "1.10" JET = "0.11" +JSON = "1" +REPL = "1.10" +ReTest = "0.3.4" +SQLite = "1.6.1" +Serialization = "1.11.0" Supposition = "0.3.5" +TOML = "1.0" +Tachikoma = "2.1.0" +ZMQ = "1" diff --git a/test/tui_analytics_tests.jl b/test/tui_analytics_tests.jl index 2080dcb9..84c1b674 100644 --- a/test/tui_analytics_tests.jl +++ b/test/tui_analytics_tests.jl @@ -773,22 +773,12 @@ end end @testset "Analytics view never crashes on any terminal size" begin - @check function analytics_render_any_size( - w = Data.Integers(20, 300), - h = Data.Integers(5, 100), - ) - db_path = tempname() * ".db" - Database.init_db!(db_path) - try - m = Kaimon.KaimonModel(server_port = 19998) - m.db_initialized = true - m.server_started = true - m.activity_mode = :analytics - Kaimon.set_theme!(:kokaku) - - # Push a few results so there's data to render - for i = 1:3 - r = Kaimon.ToolCallResult( + db_path = tempname() * ".db" + Database.init_db!(db_path) + try + for _ = 1:3 + persist_tool_call!( + Kaimon.ToolCallResult( now(), "prop_test", "{}", @@ -796,19 +786,35 @@ end "10ms", true, "", - ) - persist_tool_call!(r) - end - Kaimon._refresh_analytics!(m; force = true) + ), + ) + end + analytics_fixture = ( + tool_summary = Database.get_tool_summary(), + error_hotspots = Database.get_error_hotspots(), + recent_execs = Database.get_tool_executions(; days = 1), + ) + Kaimon.set_theme!(:kokaku) + + @check max_examples=1000 function analytics_render_any_size( + w = Data.Integers(20, 300), + h = Data.Integers(5, 100), + ) + m = Kaimon.KaimonModel(server_port = 19998) + m.db_initialized = true + m.server_started = true + m.activity_mode = :analytics + m.analytics_cache = analytics_fixture + m.analytics_last_refresh = time() area = Tachikoma.Rect(0, 0, w, h) buf = Tachikoma.Buffer(area) Kaimon._view_analytics(m, area, buf) true # didn't throw - finally - Database.close_db!() - rm(db_path; force = true) end + finally + Database.close_db!() + rm(db_path; force = true) end end From 9e5cb35934cae3859cd5c3eb37122da9187f7249 Mon Sep 17 00:00:00 2001 From: "FURRYSTATION\\kirby" Date: Sun, 21 Jun 2026 05:53:40 -0400 Subject: [PATCH 2/6] adds windows TCP paths to test file --- test/gate_async_tests.jl | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/test/gate_async_tests.jl b/test/gate_async_tests.jl index beddbab7..2971d743 100644 --- a/test/gate_async_tests.jl +++ b/test/gate_async_tests.jl @@ -125,23 +125,47 @@ end # No sleep needed — sockets are already bound else session_id = "test-async-$(bytes2hex(rand(UInt8, 4)))" - Kaimon.KaimonGate._serve(name = "test", session_id = session_id, force = true, tools = [tool]) - # Give the gate a moment to bind its IPC sockets + if Sys.iswindows() + Kaimon.KaimonGate._serve( + name = "test", + session_id = session_id, + force = true, + tools = [tool], + mode = :tcp, + host = "127.0.0.1", + port = 0, + ) + else + Kaimon.KaimonGate._serve( + name = "test", + session_id = session_id, + force = true, + tools = [tool], + ) + end + # Give the gate a moment to bind its sockets sleep(0.15) end - sock_dir = Kaimon.KaimonGate.sock_dir() - rep_path = joinpath(sock_dir, "$session_id.sock") - pub_path = joinpath(sock_dir, "$session_id-stream.sock") + if Sys.iswindows() + rep_path = rstrip(ZMQ._get_last_endpoint(Kaimon.KaimonGate._GATE_SOCKET[]), '\0') + pub_path = Kaimon.KaimonGate._STREAM_ENDPOINT[] + else + sock_dir = Kaimon.KaimonGate.sock_dir() + rep_path = "ipc://" * joinpath(sock_dir, "$session_id.sock") + pub_path = "ipc://" * joinpath(sock_dir, "$session_id-stream.sock") + end ctx = Context() req = Socket(ctx, REQ) sub = Socket(ctx, SUB) req.rcvtimeo = 5_000 # ms sub.rcvtimeo = 5_000 + req.linger = 0 + sub.linger = 0 - connect(req, "ipc://$rep_path") - connect(sub, "ipc://$pub_path") + connect(req, rep_path) + connect(sub, pub_path) subscribe(sub, "") # subscribe to all topics sleep(0.1) # let SUB handshake with PUB before the tool runs From 37c6253381f13070493f89aad547a2d940c33e6e Mon Sep 17 00:00:00 2001 From: "FURRYSTATION\\kirby" Date: Sun, 21 Jun 2026 06:02:47 -0400 Subject: [PATCH 3/6] Uses Julia sleep() rather than unix system sleep --- test/tcp_stale_session_tests.jl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/tcp_stale_session_tests.jl b/test/tcp_stale_session_tests.jl index 1ed169f4..3cff4158 100644 --- a/test/tcp_stale_session_tests.jl +++ b/test/tcp_stale_session_tests.jl @@ -375,7 +375,8 @@ end """Return a PID that is guaranteed dead (a short process we wait on, then reap).""" function _dead_pid() - proc = open(`sleep 0.01`) + julia = joinpath(Sys.BINDIR, Base.julia_exename()) + proc = open(Cmd([julia, "-e", "sleep(0.01)"])) pid = Int(getpid(proc)) # getpid returns Int32 on some platforms; pid field is Int wait(proc) # reap — process is now gone (not a zombie) return pid From 4078291998ae5e5b98a45e56abefabc0252e1068 Mon Sep 17 00:00:00 2001 From: "FURRYSTATION\\kirby" Date: Sun, 21 Jun 2026 06:35:28 -0400 Subject: [PATCH 4/6] full test suite passing on Windows. - additional tcp path for tests - Scaled down number of open sockets in Windows. - fixes raw config paths by switching to JSON.json --- test/Project.toml | 3 +++ test/projects_config_tests.jl | 7 ++++--- test/request_channel_tests.jl | 21 ++++++++++++++------ test/service_endpoint_tests.jl | 29 ++++++++++++++++++---------- test/xpub_presence_tests.jl | 6 +++++- test/zmq_socket_concurrency_tests.jl | 14 ++++++++++---- 6 files changed, 56 insertions(+), 24 deletions(-) diff --git a/test/Project.toml b/test/Project.toml index 50696b3c..6a610a95 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -17,6 +17,9 @@ Tachikoma = "468859d6-42d8-48b7-8ad9-1d312e0e3b0a" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" ZMQ = "c2297ded-f4af-51ae-bb23-16f91089e4e1" +[sources] +Kaimon = {path = ".."} + [compat] Aqua = "0.8" DBInterface = "2.6.1" diff --git a/test/projects_config_tests.jl b/test/projects_config_tests.jl index 463795ac..13535590 100644 --- a/test/projects_config_tests.jl +++ b/test/projects_config_tests.jl @@ -1,6 +1,7 @@ # Tests for the project allow-list and the `allow_any_project` opt-in (#46). using ReTest +using JSON using Kaimon @testset "Project allow-list + allow_any_project (#46)" begin @@ -15,19 +16,19 @@ using Kaimon other = mktempdir(); write(joinpath(other, "Project.toml"), "name = \"Y\"\n") # Without the flag: only the listed, enabled project is allowed. - write(pjson, """{"projects":[{"project_path":"$allowed","enabled":true}]}""") + write(pjson, JSON.json(Dict("projects" => [Dict("project_path" => allowed, "enabled" => true)]))) @test Kaimon.projects_allow_any() == false @test Kaimon.is_project_allowed(allowed) @test !Kaimon.is_project_allowed(other) # With the flag: any path is allowed (the allow-list is bypassed). - write(pjson, """{"allow_any_project":true,"projects":[]}""") + write(pjson, JSON.json(Dict("allow_any_project" => true, "projects" => []))) @test Kaimon.projects_allow_any() == true @test Kaimon.is_project_allowed(other) @test Kaimon.is_project_allowed("/nonexistent/whatever") # Explicit false behaves like absent. - write(pjson, """{"allow_any_project":false,"projects":[]}""") + write(pjson, JSON.json(Dict("allow_any_project" => false, "projects" => []))) @test Kaimon.projects_allow_any() == false @test !Kaimon.is_project_allowed(other) finally diff --git a/test/request_channel_tests.jl b/test/request_channel_tests.jl index ae8f3b53..bdd5e093 100644 --- a/test/request_channel_tests.jl +++ b/test/request_channel_tests.jl @@ -30,18 +30,27 @@ using Kaimon shortbase = Sys.iswindows() ? tempdir() : "/tmp" mktempdir(shortbase) do dir ENV["XDG_CACHE_HOME"] = dir - sid = KG.serve(force = true) + if Sys.iswindows() + sid = KG.serve(force = true, mode = :tcp, host = "127.0.0.1", port = 0) + else + sid = KG.serve(force = true) + end @assert KG.PROTOCOL_VERSION == 2 mgr = K.ConnectionManager(; sock_dir = KG.sock_dir()) K.start!(mgr) conn = nothing - t0 = time() - while time() - t0 < 10 - cs = K.connected_sessions(mgr) - isempty(cs) || (conn = cs[1]; break) - sleep(0.2) + if Sys.iswindows() + sleep(0.15) # let gate bind its TCP sockets + conn = K.connect_tcp!(mgr, KG._TCP_HOST[], KG._TCP_PORT[]) + else + t0 = time() + while time() - t0 < 10 + cs = K.connected_sessions(mgr) + isempty(cs) || (conn = cs[1]; break) + sleep(0.2) + end end conn === nothing && error("client never connected to in-process gate") @assert conn.req_channel !== nothing "no request channel after connect" diff --git a/test/service_endpoint_tests.jl b/test/service_endpoint_tests.jl index 042058ee..a87ac3df 100644 --- a/test/service_endpoint_tests.jl +++ b/test/service_endpoint_tests.jl @@ -3,6 +3,19 @@ using Kaimon const ZMQt = Kaimon.ZMQ +# Windows has no ipc:// transport; use ephemeral TCP loopback like production. +function _bind_test_router!(router) + if Sys.iswindows() + ZMQt.bind(router, "tcp://127.0.0.1:0") + return rstrip(ZMQt._get_last_endpoint(router), '\0') + else + dir = mktempdir() + path = joinpath(dir, "svc-test-$(bytes2hex(rand(UInt8, 4))).sock") + ZMQt.bind(router, "ipc://$path") + return "ipc://$path" + end +end + @testset "Service endpoint (ROUTER concurrency rework)" begin @testset "_is_agent_turn gates only agent_run" begin @test Kaimon._is_agent_turn((type = :tool_call, tool_name = :agent_run, args = Dict())) @@ -12,14 +25,12 @@ const ZMQt = Kaimon.ZMQ end @testset "multipart framing round-trips REQ↔ROUTER" begin - # Hermetic: private context + temp ipc path, never touches the live service socket. - dir = mktempdir() - path = joinpath(dir, "svc-test.sock") + # Hermetic: private context + temp endpoint, never touches the live service socket. ctx = ZMQt.Context() router = ZMQt.Socket(ctx, ZMQt.ROUTER); router.rcvtimeo = 2000; router.linger = 0 - ZMQt.bind(router, "ipc://$path") + endpoint = _bind_test_router!(router) req = ZMQt.Socket(ctx, ZMQt.REQ); req.rcvtimeo = 2000; req.linger = 0 - ZMQt.connect(req, "ipc://$path") + ZMQt.connect(req, endpoint) try # REQ → ROUTER: arrives as [identity, empty-delimiter, payload] ZMQt.send(req, Vector{UInt8}("ping")) @@ -36,13 +47,11 @@ const ZMQt = Kaimon.ZMQ end @testset "two REQ clients are routed back by identity" begin - dir = mktempdir() - path = joinpath(dir, "svc-multi.sock") ctx = ZMQt.Context() router = ZMQt.Socket(ctx, ZMQt.ROUTER); router.rcvtimeo = 2000; router.linger = 0 - ZMQt.bind(router, "ipc://$path") - a = ZMQt.Socket(ctx, ZMQt.REQ); a.rcvtimeo = 2000; a.linger = 0; ZMQt.connect(a, "ipc://$path") - b = ZMQt.Socket(ctx, ZMQt.REQ); b.rcvtimeo = 2000; b.linger = 0; ZMQt.connect(b, "ipc://$path") + endpoint = _bind_test_router!(router) + a = ZMQt.Socket(ctx, ZMQt.REQ); a.rcvtimeo = 2000; a.linger = 0; ZMQt.connect(a, endpoint) + b = ZMQt.Socket(ctx, ZMQt.REQ); b.rcvtimeo = 2000; b.linger = 0; ZMQt.connect(b, endpoint) try ZMQt.send(a, Vector{UInt8}("A")) ZMQt.send(b, Vector{UInt8}("B")) diff --git a/test/xpub_presence_tests.jl b/test/xpub_presence_tests.jl index dfdd2762..263cd005 100644 --- a/test/xpub_presence_tests.jl +++ b/test/xpub_presence_tests.jl @@ -31,7 +31,11 @@ using Kaimon KG.on_stream_subscribe(t -> push!(joined, t)) KG.on_stream_unsubscribe(t -> push!(left, t)) - KG.serve(force = true) + if Sys.iswindows() + KG.serve(force = true, mode = :tcp, host = "127.0.0.1", port = 0) + else + KG.serve(force = true) + end ep = KG._STREAM_ENDPOINT[] @assert !isempty(ep) "no stream endpoint" diff --git a/test/zmq_socket_concurrency_tests.jl b/test/zmq_socket_concurrency_tests.jl index e6191b34..efda7470 100644 --- a/test/zmq_socket_concurrency_tests.jl +++ b/test/zmq_socket_concurrency_tests.jl @@ -25,16 +25,20 @@ using Kaimon const ZMQ = Kaimon.ZMQ ctx = ZMQ.Context() N = max(Threads.nthreads(), 1) - PER = 1500 + # Windows exhausts socket handles faster than Unix (lower per-process limit). + # Enough iterations to hammer concurrent construction; not so many we hit EMFILE. + PER = Sys.iswindows() ? 200 : 1500 + gc_every = Sys.iswindows() ? 25 : 100 @sync for _t in 1:N Threads.@spawn for i in 1:PER s = Kaimon._zmq_socket(ctx, ZMQ.REQ) # the serialized constructor s.linger = 0 close(s) - (i % 100 == 0) && GC.gc(false) # release fds + clear weakrefs + (i % gc_every == 0) && GC.gc(false) # release fds + clear weakrefs end end GC.gc() + close(ctx) println("ZMQ_SOCKET_STRESS_OK total=", N * PER) """ path, io = mktemp() @@ -42,8 +46,10 @@ using Kaimon close(io) out = IOBuffer() - # -t 6 forces real concurrency in the child regardless of how the suite runs. - cmd = pipeline(`$(Base.julia_cmd()) --project=$proj -t 6 $path`; stdout = out, stderr = out) + # Force real concurrency in the child regardless of how the suite runs. + # Windows: fewer threads to stay under socket-handle limits. + threads = Sys.iswindows() ? 4 : 6 + cmd = pipeline(`$(Base.julia_cmd()) --project=$proj -t $threads $path`; stdout = out, stderr = out) proc = run(cmd; wait = false) wait(proc) output = String(take!(out)) From 7ae0b6b3c85e4374f297d892605ed8b783e84987 Mon Sep 17 00:00:00 2001 From: "FURRYSTATION\\kirby" Date: Sun, 21 Jun 2026 07:34:11 -0400 Subject: [PATCH 5/6] Fix Windows gate auto-discovery via localhost TCP metadata. Default serve() to TCP on Windows, write discoverable sock metadata for loopback gates, escape backslashes in session JSON, fix PID liveness checks, and align XDG_CACHE_HOME between gate and server. Co-authored-by: Cursor --- lib/KaimonGate/src/gate_protocol.jl | 24 +++++++++- lib/KaimonGate/src/gate_serve.jl | 27 ++++++----- lib/KaimonGate/src/gate_state.jl | 16 ++++++- lib/KaimonGate/test/src/test_integration.jl | 51 ++++++++++++++++++--- src/Kaimon.jl | 9 +++- src/gate_client_discovery.jl | 18 ++++---- 6 files changed, 114 insertions(+), 31 deletions(-) diff --git a/lib/KaimonGate/src/gate_protocol.jl b/lib/KaimonGate/src/gate_protocol.jl index 14c984f2..aea41c4c 100644 --- a/lib/KaimonGate/src/gate_protocol.jl +++ b/lib/KaimonGate/src/gate_protocol.jl @@ -4,10 +4,32 @@ # ── Metadata ────────────────────────────────────────────────────────────────── +function _json_escape_string(s::AbstractString) + io = IOBuffer() + print(io, '"') + for c in s + if c == '\\' + print(io, "\\\\") + elseif c == '"' + print(io, "\\\"") + elseif c == '\n' + print(io, "\\n") + elseif c == '\r' + print(io, "\\r") + elseif c == '\t' + print(io, "\\t") + else + print(io, c) + end + end + print(io, '"') + return String(take!(io)) +end + function _json_value(v) v isa Bool && return v ? "true" : "false" v isa Number && return string(v) - return "\"$v\"" + return _json_escape_string(string(v)) end function write_metadata( diff --git a/lib/KaimonGate/src/gate_serve.jl b/lib/KaimonGate/src/gate_serve.jl index 121a8f47..6c55b53d 100644 --- a/lib/KaimonGate/src/gate_serve.jl +++ b/lib/KaimonGate/src/gate_serve.jl @@ -26,8 +26,8 @@ to override the TTY check. serve(tools=tools, namespace="todo_dev") # branch A serve(tools=tools, namespace="todo_main") # branch B ``` -- `mode::Symbol`: Transport mode — `:ipc` (default, local Unix socket) or - `:tcp` (network-accessible, for remote debugging). +- `mode::Symbol`: Transport mode — `:ipc` (default on Unix, local Unix socket) or + `:tcp` (default on Windows; network-accessible, for remote debugging). - `host::String`: Bind address for TCP mode (default `"127.0.0.1"`, localhost only). Use `"0.0.0.0"` to accept connections from remote machines (no auth — use with care). - `port::Int`: Port for TCP mode (default `0` = ephemeral, ZMQ picks a free port). @@ -36,7 +36,9 @@ to override the TTY check. (default `true`). When `false`, the gate serves normally but writes no metadata file, so the Kaimon TUI / MCP server won't list it or import its tools — for embedded/private gates that clients reach via explicit endpoints (e.g. TachiRei atoms, reached on demand by id). - IPC only; TCP gates are never file-discovered (they're connected via `connect_tcp!`). + IPC only; TCP gates on remote hosts are connected via `connect_tcp!`. Localhost + TCP gates (e.g. on Windows) write metadata for file-based discovery when + `discoverable=true`. # Example ```julia @@ -52,7 +54,7 @@ KaimonGate.serve(mode=:tcp, port=9876, force=true) # Environment variables These override the keyword defaults when set: -- `KAIMON_GATE_MODE`: `"ipc"` or `"tcp"` (default: `"ipc"`) +- `KAIMON_GATE_MODE`: `"ipc"` or `"tcp"` (default: `"tcp"` on Windows, `"ipc"` elsewhere) - `KAIMON_GATE_HOST`: Bind address for TCP (default: `"127.0.0.1"`) - `KAIMON_GATE_PORT`: Port for TCP (default: `"0"` = ephemeral) - `KAIMON_GATE_STREAM_PORT`: PUB stream port for TCP (default: `"0"` = ephemeral). @@ -92,10 +94,15 @@ function serve(; :tcp elseif toml_mode == "tcp" || has_toml_port :tcp + elseif Sys.iswindows() + :tcp # no ipc:// transport (#41) else :ipc end end + if mode == :ipc && Sys.iswindows() + throw(ArgumentError("ipc:// transport is not available on Windows; omit mode or use mode=:tcp")) + end if host === nothing env_host = get(ENV, "KAIMON_GATE_HOST", "") host = !isempty(env_host) ? env_host : @@ -374,13 +381,11 @@ function _serve(; _STREAM_SOCKET[] = pub_socket _STREAM_ENDPOINT[] = stream_endpoint - # Write metadata file for session discovery (IPC only — TCP sessions - # are connected manually via connect_tcp! and don't use file-based discovery). - # `discoverable=false` serves the gate but does NOT advertise it in the discovery - # registry — for embedded/private gates that clients reach via explicit endpoints - # (the Kaimon TUI / MCP server won't list it or import its tools). - if mode != :tcp && discoverable - write_metadata(sid, name, endpoint, stream_endpoint; spawned_by, mode) + # Write metadata for session discovery. IPC always (when discoverable). + # Localhost TCP too (Windows has no ipc:// — this is how the TUI finds local gates). + # Remote TCP and tcp_gates.json poll markers are connected via connect_tcp! (#50). + if discoverable && (mode == :ipc || (mode == :tcp && _is_local_bind_host(host))) + write_metadata(sid, name, String(endpoint), String(stream_endpoint); spawned_by, mode) end # Register cleanup diff --git a/lib/KaimonGate/src/gate_state.jl b/lib/KaimonGate/src/gate_state.jl index 73e5e785..f9070523 100644 --- a/lib/KaimonGate/src/gate_state.jl +++ b/lib/KaimonGate/src/gate_state.jl @@ -74,13 +74,18 @@ function _gate_cache_dir() # gate auto-discovery whenever XDG_CACHE_HOME is set. Mirrors the #42 server # fix (1cf20a5) on the gate side — the half PR #45 patched in gate.jl that the # KaimonGate split didn't carry over. (#42, #45) - d = if Sys.iswindows() + # When XDG_CACHE_HOME is set (including in tests on Windows), honor it on all + # platforms; otherwise fall back to the OS-native default location. + xdg = get(ENV, "XDG_CACHE_HOME", "") + d = if !isempty(xdg) + joinpath(xdg, "kaimon") + elseif Sys.iswindows() joinpath( get(ENV, "LOCALAPPDATA", joinpath(homedir(), "AppData", "Local")), "Kaimon", ) else - joinpath(get(ENV, "XDG_CACHE_HOME", joinpath(homedir(), ".cache")), "kaimon") + joinpath(joinpath(homedir(), ".cache"), "kaimon") end mkpath(d) return d @@ -94,6 +99,13 @@ function sock_dir() return d end +"""Whether `host` is a loopback bind address (localhost TCP gates may be file-discovered).""" +function _is_local_bind_host(host::AbstractString) + h = lowercase(strip(host)) + return h in ("127.0.0.1", "::1", "localhost", "0.0.0.0", "") || + startswith(h, "127.") +end + """ _install_peek_report_override(session_id::String) diff --git a/lib/KaimonGate/test/src/test_integration.jl b/lib/KaimonGate/test/src/test_integration.jl index 81023e2c..b8fcd1a3 100644 --- a/lib/KaimonGate/test/src/test_integration.jl +++ b/lib/KaimonGate/test/src/test_integration.jl @@ -72,18 +72,28 @@ end end) session_id = "test-async-$(bytes2hex(rand(UInt8, 4)))" - KaimonGate._serve(name="test", session_id=session_id, force=true, tools=[tool]) + if Sys.iswindows() + KaimonGate._serve( + name = "test", + session_id = session_id, + force = true, + tools = [tool], + mode = :tcp, + host = "127.0.0.1", + port = 0, + ) + else + KaimonGate._serve(name = "test", session_id = session_id, force = true, tools = [tool]) + end sleep(0.15) ctx = ZMQ.Context() req = ZMQ.Socket(ctx, ZMQ.REQ) sub = ZMQ.Socket(ctx, ZMQ.SUB) - + if Sys.iswindows() - sock = KaimonGate._GATE_SOCKET[] - rep_path = rstrip(ZMQ._get_last_endpoint(sock), '\0') - pub_sock = KaimonGate._STREAM_SOCKET[] - pub_path = rstrip(ZMQ._get_last_endpoint(pub_sock), '\0') + rep_path = rstrip(ZMQ._get_last_endpoint(KaimonGate._GATE_SOCKET[]), '\0') + pub_path = KaimonGate._STREAM_ENDPOINT[] else sock_dir = KaimonGate.sock_dir() rep_path = "ipc://" * joinpath(sock_dir, "$session_id.sock") @@ -241,4 +251,33 @@ end end end +@testset "metadata JSON escapes backslashes" begin + @test KaimonGate._json_value(raw"K:\Dev\proj") == "\"K:\\\\Dev\\\\proj\"" + mktempdir() do tmp + old_xdg = get(ENV, "XDG_CACHE_HOME", nothing) + ENV["XDG_CACHE_HOME"] = tmp + try + sid = "json-escape-test" + meta_path = KaimonGate.write_metadata( + sid, "test", "tcp://127.0.0.1:1", "tcp://127.0.0.1:2"; mode = :tcp, + ) + content = read(meta_path, String) + proj = dirname(Base.active_project()) + if occursin('\\', proj) + @test occursin(replace(proj, "\\" => "\\\\"), content) + end + # Must be valid JSON (stdlib has no JSON dep; use a minimal round-trip check). + m = match(r"\"project_path\": \"(.*)\"", content) + @test m !== nothing + @test m.captures[1] == replace(proj, "\\" => "\\\\") + finally + if old_xdg === nothing + delete!(ENV, "XDG_CACHE_HOME") + else + ENV["XDG_CACHE_HOME"] = old_xdg + end + end + end +end + end # if !_RUNNING[] diff --git a/src/Kaimon.jl b/src/Kaimon.jl index 3de4198a..d61c7147 100644 --- a/src/Kaimon.jl +++ b/src/Kaimon.jl @@ -59,13 +59,18 @@ function kaimon_cache_dir() # Append "kaimon" under XDG_CACHE_HOME rather than using it verbatim, so we # get our own subdir instead of scattering kaimon.db/sock/sessions.json into # the shared cache root. Mirrors kaimon_config_dir's (correct) handling. (#42) - dir = if Sys.iswindows() + # When XDG_CACHE_HOME is set (including tests on Windows), honor it on all + # platforms so gate metadata and server discovery share the same sock dir. + xdg = get(ENV, "XDG_CACHE_HOME", "") + dir = if !isempty(xdg) + joinpath(xdg, "kaimon") + elseif Sys.iswindows() joinpath( get(ENV, "LOCALAPPDATA", joinpath(homedir(), "AppData", "Local")), "Kaimon", ) else - joinpath(get(ENV, "XDG_CACHE_HOME", joinpath(homedir(), ".cache")), "kaimon") + joinpath(joinpath(homedir(), ".cache"), "kaimon") end mkpath(dir) return dir diff --git a/src/gate_client_discovery.jl b/src/gate_client_discovery.jl index 23379b2a..7e094369 100644 --- a/src/gate_client_discovery.jl +++ b/src/gate_client_discovery.jl @@ -10,9 +10,12 @@ Cross-platform check whether a process with the given PID exists. Uses signal 0 (no actual signal sent) via libuv. """ -function _is_pid_alive(pid::Int) +function _is_pid_alive(pid::Integer) + pid = Int(pid) pid > 0 || return false ccall(:uv_kill, Cint, (Cint, Cint), pid, 0) == 0 || return false + # Windows has no `ps -o state=`; uv_kill(signal 0) is sufficient there. + Sys.iswindows() && return true # Zombie processes (defunct) still respond to signal 0. # Check the process state to filter them out. try @@ -235,15 +238,12 @@ function discover_sessions(mgr::ConnectionManager) # nothing changed. If the PID is different the process restarted: # don't skip so the watcher can replace the stale connection. session_mode = Symbol(get(meta, "mode", "ipc")) - # TCP gates are owned exclusively by _poll_tcp_gates!, which connects them - # via connect_tcp! with the auth token from tcp_gates.json. The file-watcher - # must NOT connect them: it has no token, so its connection is rejected with - # "Authentication required". And on a successful poll-connect the gate drops a - # mode=:tcp marker into sock_dir (for reconnect bookkeeping) — which the - # watcher would otherwise pick up and double-connect tokenless, the bad - # connection winning. So ignore TCP markers here entirely. (#50) + # tcp_gates.json poll bookkeeping (sid "tcp-host-port") is owned by + # _poll_tcp_gates! — skip to avoid tokenless double-connect (#50). + # Localhost TCP gates advertised by serve() (e.g. Windows) are discovered here. if session_mode == :tcp - continue + startswith(session_id, "tcp-") && continue + _is_local_host(_endpoint_host(get(meta, "endpoint", ""))) || continue end if session_mode != :tcp && haskey(known_id_pids, session_id) && known_id_pids[session_id] == pid continue From 59a9b9f35ce8212c4ea0f1c76be10dee52976251 Mon Sep 17 00:00:00 2001 From: Kahli Burke Date: Thu, 25 Jun 2026 22:35:46 -0700 Subject: [PATCH 6/6] fix(discovery): preserve #50 guarantees for localhost-TCP gate discovery The Windows localhost-TCP discovery path (PR #54) let discover_sessions file-discover serve()-advertised localhost gates. Harden it so it can't regress the #50 tokenless-double-connect class: - Apply the already-tracked/same-PID dedup to localhost TCP too (was gated !=:tcp), so a stable discovered gate isn't re-emitted as a 'restart' every cycle. - Resolve and attach the local auth token (env KAIMON_GATE_TOKEN / global config api key, mirroring connect_tcp!) for discovered localhost TCP gates, so an auth-required local gate authenticates instead of failing tokenless each cycle; ignored by a lax gate. Poll/reconnect markers (sid 'tcp-host-port') remain skipped, so the original #50 path is untouched. Discovery/TCP tests: 83 pass. --- src/gate_client_discovery.jl | 39 ++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/gate_client_discovery.jl b/src/gate_client_discovery.jl index 7e094369..4db0e90a 100644 --- a/src/gate_client_discovery.jl +++ b/src/gate_client_discovery.jl @@ -195,6 +195,27 @@ end # ── Socket Discovery ───────────────────────────────────────────────────────── +""" + _resolve_local_gate_token() -> String + +Auth token for connecting to a LOCALHOST gate discovered on disk: env +`KAIMON_GATE_TOKEN`, else the active security config's first api key (non-lax). +Mirrors `connect_tcp!`'s resolution. A localhost gate shares this machine's +config, so this authenticates an auth-required gate and is ignored by a lax one. +""" +function _resolve_local_gate_token() + tok = get(ENV, "KAIMON_GATE_TOKEN", "") + isempty(tok) || return tok + try + config = load_global_config() + if config.mode != :lax && !isempty(config.api_keys) + return first(config.api_keys) + end + catch + end + return "" +end + function discover_sessions(mgr::ConnectionManager) isdir(mgr.sock_dir) || return REPLConnection[] @@ -238,14 +259,19 @@ function discover_sessions(mgr::ConnectionManager) # nothing changed. If the PID is different the process restarted: # don't skip so the watcher can replace the stale connection. session_mode = Symbol(get(meta, "mode", "ipc")) - # tcp_gates.json poll bookkeeping (sid "tcp-host-port") is owned by - # _poll_tcp_gates! — skip to avoid tokenless double-connect (#50). - # Localhost TCP gates advertised by serve() (e.g. Windows) are discovered here. + # tcp_gates.json poll bookkeeping AND connect_tcp! reconnect markers both use + # sid "tcp-host-port" — those gates are owned by _poll_tcp_gates!/connect_tcp! + # (connected WITH a token), so the file-watcher must never touch them, or it + # double-connects tokenless and the bad connection wins (#50). Only + # serve()-advertised LOCALHOST gates (real uuid sid; e.g. Windows, no ipc://) + # are file-discovered here. if session_mode == :tcp startswith(session_id, "tcp-") && continue _is_local_host(_endpoint_host(get(meta, "endpoint", ""))) || continue end - if session_mode != :tcp && haskey(known_id_pids, session_id) && known_id_pids[session_id] == pid + # Already tracked with the same PID → nothing changed. Applies to localhost TCP + # too (a stable discovered gate must not be re-emitted as a "restart" each cycle). + if haskey(known_id_pids, session_id) && known_id_pids[session_id] == pid continue end @@ -298,6 +324,11 @@ function discover_sessions(mgr::ConnectionManager) pid = pid, spawned_by = get(meta, "spawned_by", "user"), server_pubkey = server_pubkey, + # A localhost TCP gate shares this machine's security config, so present the + # local token: it authenticates an auth-required gate and is ignored by a lax + # one — instead of failing tokenless every cycle. IPC gates are filesystem- + # scoped and need none. (Remote/poll TCP gates are skipped above.) + auth_token = session_mode == :tcp ? _resolve_local_gate_token() : "", ) # If this session_id was already known (PID changed = process restarted),