142 lines
6.2 KiB
Python
142 lines
6.2 KiB
Python
"""Per-session ``KIMI_CODE_HOME`` builder that injects Omnigent hooks.
|
|
|
|
Kimi Code reads a single ``config.toml`` at ``$KIMI_CODE_HOME/config.toml``
|
|
(default ``~/.kimi-code``) and stores its auth (``oauth/`` + ``credentials/``)
|
|
relative to the same home — there is no project-level merge for the ``hooks``
|
|
array. To gate a session's tools without mutating the user's global config, the
|
|
runner points the launched ``kimi`` process at a session-scoped home that:
|
|
|
|
- symlinks every entry of the user's global home (oauth, credentials,
|
|
sessions, …) so login / providers / history keep working, and
|
|
- carries a ``config.toml`` that is the user's config text with two Omnigent
|
|
``[[hooks]]`` appended — a ``PreToolUse`` deny-gate and a ``PermissionRequest``
|
|
read-only surface, both dispatched to :mod:`omnigent.kimi_native_hook`.
|
|
|
|
Appending as text (rather than parsing + re-emitting TOML) keeps the user's
|
|
config byte-for-byte and needs no TOML writer: a trailing ``[[hooks]]`` table
|
|
array is always valid regardless of what section preceded it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import os
|
|
import shlex
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
#: Env var Kimi Code reads to locate its data dir (config.toml + oauth + …).
|
|
KIMI_CODE_HOME_ENV_VAR = "KIMI_CODE_HOME"
|
|
_CONFIG_FILE = "config.toml"
|
|
|
|
|
|
def resolve_user_kimi_home() -> Path:
|
|
"""Return the user's global Kimi Code home.
|
|
|
|
Mirrors kimi's own ``resolveKimiHome``: ``$KIMI_CODE_HOME`` when set, else
|
|
``~/.kimi-code``.
|
|
|
|
:returns: The resolved home path (may not exist if the user never ran kimi).
|
|
"""
|
|
env = os.environ.get(KIMI_CODE_HOME_ENV_VAR)
|
|
if env:
|
|
return Path(env)
|
|
return Path.home() / ".kimi-code"
|
|
|
|
|
|
def render_kimi_hooks_toml(*, bridge_dir: Path, python_executable: str | None = None) -> str:
|
|
"""Render the two Omnigent ``[[hooks]]`` entries as TOML text.
|
|
|
|
Both hooks dispatch to :mod:`omnigent.kimi_native_hook` with the bridge
|
|
dir baked into the command (no secrets on the command line — the hook reads
|
|
the server URL / auth / session id from the bridge's ``hook_config.json``).
|
|
|
|
:param bridge_dir: The kimi-native bridge dir the hook commands read.
|
|
:param python_executable: Interpreter to run the hook module; ``None`` uses
|
|
:data:`sys.executable` (the runner's interpreter, which has omnigent).
|
|
:returns: TOML text starting with a leading newline, safe to append.
|
|
"""
|
|
python = python_executable or sys.executable
|
|
# ``-I`` (isolated mode) is REQUIRED, not cosmetic: kimi runs the hook with
|
|
# ``cwd`` set to the session workspace, and ``python -m`` puts cwd on
|
|
# ``sys.path[0]``. A workspace that contains its own ``omnigent/`` directory
|
|
# (another checkout, a vendored copy) would otherwise shadow the installed
|
|
# package and the hook dies on ``ImportError`` before it can POST — so the
|
|
# approval card never publishes. ``-I`` drops cwd + PYTHONPATH + user-site
|
|
# from the path, importing only the interpreter's own omnigent. Mirrors
|
|
# claude-native's ``python -I -m omnigent.claude_native_hook``.
|
|
base = f"{shlex.quote(python)} -I -m omnigent.kimi_native_hook"
|
|
bridge = shlex.quote(str(bridge_dir))
|
|
pre = f"{base} evaluate-policy --bridge-dir {bridge}"
|
|
perm = f"{base} permission-request --bridge-dir {bridge}"
|
|
# No ``matcher`` → matches every tool. Commands are TOML basic strings;
|
|
# shlex.quote yields single-quoted POSIX tokens, which contain no double
|
|
# quotes or backslashes, so they embed in a "..." TOML string verbatim.
|
|
#
|
|
# ``timeout`` is required: kimi's DEFAULT_HOOK_TIMEOUT_SECONDS is 30s, which
|
|
# would kill the permission hook while it long-polls the web verdict (so the
|
|
# injected Approve/Deny keystroke never lands) and could sever a slow policy
|
|
# evaluate. Pin both to kimi's 600s ceiling — the longest the human may take
|
|
# to answer the card — after which kimi's own TUI prompt stands.
|
|
return (
|
|
"\n"
|
|
"# --- Omnigent native hooks (auto-generated; do not edit) ---\n"
|
|
"[[hooks]]\n"
|
|
'event = "PreToolUse"\n'
|
|
f'command = "{pre}"\n'
|
|
"timeout = 600\n"
|
|
"\n"
|
|
"[[hooks]]\n"
|
|
'event = "PermissionRequest"\n'
|
|
f'command = "{perm}"\n'
|
|
"timeout = 600\n"
|
|
)
|
|
|
|
|
|
def build_kimi_session_home(
|
|
session_home: Path,
|
|
*,
|
|
bridge_dir: Path,
|
|
python_executable: str | None = None,
|
|
) -> dict[str, str]:
|
|
"""Materialize a session-scoped ``KIMI_CODE_HOME`` with Omnigent hooks.
|
|
|
|
Symlinks every entry of the user's global kimi home (except
|
|
``config.toml``) into *session_home*, then writes a ``config.toml`` that is
|
|
the user's config plus the Omnigent hooks. Best-effort and idempotent:
|
|
re-running rewrites ``config.toml`` and leaves existing symlinks in place.
|
|
|
|
:param session_home: Directory to use as the session's ``KIMI_CODE_HOME``.
|
|
:param bridge_dir: The kimi-native bridge dir the hook commands read.
|
|
:param python_executable: Interpreter for the hook commands (see
|
|
:func:`render_kimi_hooks_toml`).
|
|
:returns: ``{"KIMI_CODE_HOME": str(session_home)}`` to merge into the
|
|
launched kimi process env.
|
|
"""
|
|
session_home.mkdir(parents=True, exist_ok=True)
|
|
with contextlib.suppress(OSError):
|
|
os.chmod(session_home, 0o700)
|
|
|
|
user_home = resolve_user_kimi_home()
|
|
base_config = ""
|
|
if user_home.is_dir():
|
|
for entry in user_home.iterdir():
|
|
if entry.name == _CONFIG_FILE:
|
|
# config.toml is materialized fresh below (user content + hooks).
|
|
continue
|
|
link = session_home / entry.name
|
|
if link.exists() or link.is_symlink():
|
|
continue
|
|
with contextlib.suppress(OSError):
|
|
link.symlink_to(entry)
|
|
with contextlib.suppress(OSError):
|
|
base_config = (user_home / _CONFIG_FILE).read_text(encoding="utf-8")
|
|
|
|
hooks = render_kimi_hooks_toml(bridge_dir=bridge_dir, python_executable=python_executable)
|
|
# Ensure a clean separation if the user's config has no trailing newline.
|
|
if base_config and not base_config.endswith("\n"):
|
|
base_config += "\n"
|
|
(session_home / _CONFIG_FILE).write_text(base_config + hooks, encoding="utf-8")
|
|
|
|
return {KIMI_CODE_HOME_ENV_VAR: str(session_home)}
|