Skip to content
Open
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
24 changes: 23 additions & 1 deletion lib/KaimonGate/src/gate_protocol.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
27 changes: 16 additions & 11 deletions lib/KaimonGate/src/gate_serve.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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 :
Expand Down Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions lib/KaimonGate/src/gate_state.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
51 changes: 45 additions & 6 deletions lib/KaimonGate/test/src/test_integration.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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[]
9 changes: 7 additions & 2 deletions src/Kaimon.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 41 additions & 10 deletions src/gate_client_discovery.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -192,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[]

Expand Down Expand Up @@ -235,17 +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 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 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
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
# 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

Expand Down Expand Up @@ -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),
Expand Down
24 changes: 24 additions & 0 deletions test/Project.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,37 @@
[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"

[sources]
Kaimon = {path = ".."}

[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"
Loading