kaiten-cli 0.3.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kaiten_cli/__init__.py +5 -0
- kaiten_cli/__main__.py +9 -0
- kaiten_cli/agent_gateway.py +577 -0
- kaiten_cli/app.py +1636 -0
- kaiten_cli/completion.py +570 -0
- kaiten_cli/discovery.py +39 -0
- kaiten_cli/errors.py +79 -0
- kaiten_cli/i18n.py +92 -0
- kaiten_cli/locales/ru.json +2967 -0
- kaiten_cli/localized_click.py +160 -0
- kaiten_cli/models.py +202 -0
- kaiten_cli/profiles.py +316 -0
- kaiten_cli/registry/__init__.py +260 -0
- kaiten_cli/registry/addons.py +392 -0
- kaiten_cli/registry/audit_and_analytics.py +451 -0
- kaiten_cli/registry/automation_schema.py +44 -0
- kaiten_cli/registry/automation_schemas.json +7417 -0
- kaiten_cli/registry/automations.py +342 -0
- kaiten_cli/registry/base.py +62 -0
- kaiten_cli/registry/blockers.py +320 -0
- kaiten_cli/registry/boards.py +520 -0
- kaiten_cli/registry/card_relations.py +376 -0
- kaiten_cli/registry/card_types.py +363 -0
- kaiten_cli/registry/cards.py +1329 -0
- kaiten_cli/registry/charts.py +539 -0
- kaiten_cli/registry/checklists.py +600 -0
- kaiten_cli/registry/columns.py +558 -0
- kaiten_cli/registry/comments.py +224 -0
- kaiten_cli/registry/custom_directories.py +980 -0
- kaiten_cli/registry/custom_properties.py +1460 -0
- kaiten_cli/registry/dashboards.py +564 -0
- kaiten_cli/registry/documents.py +761 -0
- kaiten_cli/registry/external_links.py +128 -0
- kaiten_cli/registry/files.py +738 -0
- kaiten_cli/registry/github_addon.py +613 -0
- kaiten_cli/registry/iterations.py +397 -0
- kaiten_cli/registry/lanes.py +205 -0
- kaiten_cli/registry/live_contracts.py +361 -0
- kaiten_cli/registry/members.py +336 -0
- kaiten_cli/registry/module_docs.py +108 -0
- kaiten_cli/registry/projects.py +369 -0
- kaiten_cli/registry/query.py +129 -0
- kaiten_cli/registry/restrictions.py +249 -0
- kaiten_cli/registry/roles_and_groups.py +1102 -0
- kaiten_cli/registry/scim.py +263 -0
- kaiten_cli/registry/service_desk.py +1270 -0
- kaiten_cli/registry/snapshot.py +187 -0
- kaiten_cli/registry/spaces.py +272 -0
- kaiten_cli/registry/subscribers.py +168 -0
- kaiten_cli/registry/tags.py +193 -0
- kaiten_cli/registry/time_logs.py +353 -0
- kaiten_cli/registry/tree.py +352 -0
- kaiten_cli/registry/utilities.py +287 -0
- kaiten_cli/registry/webhooks.py +265 -0
- kaiten_cli/runtime/__init__.py +1 -0
- kaiten_cli/runtime/automation_validation.py +115 -0
- kaiten_cli/runtime/behaviors.py +787 -0
- kaiten_cli/runtime/cache.py +733 -0
- kaiten_cli/runtime/client.py +387 -0
- kaiten_cli/runtime/endpoints.py +59 -0
- kaiten_cli/runtime/executor.py +269 -0
- kaiten_cli/runtime/fs_security.py +168 -0
- kaiten_cli/runtime/input.py +310 -0
- kaiten_cli/runtime/output.py +59 -0
- kaiten_cli/runtime/public_validation.py +35 -0
- kaiten_cli/runtime/snapshot_build.py +527 -0
- kaiten_cli/runtime/snapshot_common.py +363 -0
- kaiten_cli/runtime/snapshot_query.py +306 -0
- kaiten_cli/runtime/snapshot_store.py +1192 -0
- kaiten_cli/runtime/snapshots.py +62 -0
- kaiten_cli/runtime/sqlite_errors.py +23 -0
- kaiten_cli/runtime/support/__init__.py +1 -0
- kaiten_cli/runtime/support/addons.py +953 -0
- kaiten_cli/runtime/support/audit.py +70 -0
- kaiten_cli/runtime/support/batch.py +234 -0
- kaiten_cli/runtime/support/card_move_url.py +213 -0
- kaiten_cli/runtime/support/cards.py +174 -0
- kaiten_cli/runtime/support/checklists.py +21 -0
- kaiten_cli/runtime/support/company_users.py +117 -0
- kaiten_cli/runtime/support/dashboards.py +35 -0
- kaiten_cli/runtime/support/documents.py +175 -0
- kaiten_cli/runtime/support/files.py +561 -0
- kaiten_cli/runtime/support/markdown_export.py +525 -0
- kaiten_cli/runtime/support/pagination.py +167 -0
- kaiten_cli/runtime/support/projects.py +66 -0
- kaiten_cli/runtime/support/relations.py +86 -0
- kaiten_cli/runtime/support/restrictions.py +62 -0
- kaiten_cli/runtime/support/spaces.py +20 -0
- kaiten_cli/runtime/support/time_logs.py +61 -0
- kaiten_cli/runtime/support/tree.py +140 -0
- kaiten_cli/runtime/support/tree_sharing.py +447 -0
- kaiten_cli/runtime/trace.py +500 -0
- kaiten_cli/runtime/transforms.py +128 -0
- kaiten_cli/schema_docs.py +22 -0
- kaiten_cli/update_check.py +519 -0
- kaiten_cli-0.3.3.dist-info/METADATA +1110 -0
- kaiten_cli-0.3.3.dist-info/RECORD +99 -0
- kaiten_cli-0.3.3.dist-info/WHEEL +4 -0
- kaiten_cli-0.3.3.dist-info/entry_points.txt +3 -0
kaiten_cli/__init__.py
ADDED
kaiten_cli/__main__.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""HTTP gateway that exposes kaiten-cli as a chat agent endpoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import hmac
|
|
8
|
+
import ipaddress
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from http import HTTPStatus
|
|
19
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Callable
|
|
22
|
+
|
|
23
|
+
from kaiten_cli import __version__
|
|
24
|
+
from kaiten_cli.app import _agent_help_payload
|
|
25
|
+
|
|
26
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
27
|
+
DEFAULT_PORT = 8787
|
|
28
|
+
DEFAULT_TIMEOUT_SECONDS = 180
|
|
29
|
+
DEFAULT_REASONING_EFFORT = "low"
|
|
30
|
+
DEFAULT_CODEX_BIN = "codex"
|
|
31
|
+
MAX_REQUEST_BODY_BYTES = 1024 * 1024
|
|
32
|
+
MAX_CONCURRENT_CODEX_EXECUTIONS = 2
|
|
33
|
+
REQUEST_READ_TIMEOUT_SECONDS = 10.0
|
|
34
|
+
SKILL_NAMES = ("kaiten-cli-heavy-data", "kaiten-cli-metrics")
|
|
35
|
+
|
|
36
|
+
LOGGER = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# Keep child credentials and process settings deliberately narrow. In particular,
|
|
39
|
+
# KAITEN_LIVE and arbitrary secrets from the gateway environment must not reach Codex.
|
|
40
|
+
CODEX_ENV_ALLOWLIST = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"ALL_PROXY",
|
|
43
|
+
"APPDATA",
|
|
44
|
+
"CODEX_HOME",
|
|
45
|
+
"COMSPEC",
|
|
46
|
+
"HOME",
|
|
47
|
+
"HTTPS_PROXY",
|
|
48
|
+
"HTTP_PROXY",
|
|
49
|
+
"KAITEN_CLI_CONFIG_PATH",
|
|
50
|
+
"KAITEN_CLI_LOCALE",
|
|
51
|
+
"KAITEN_DOMAIN",
|
|
52
|
+
"KAITEN_TOKEN",
|
|
53
|
+
"LANG",
|
|
54
|
+
"LC_ALL",
|
|
55
|
+
"LC_CTYPE",
|
|
56
|
+
"LOCALAPPDATA",
|
|
57
|
+
"LOGNAME",
|
|
58
|
+
"NO_PROXY",
|
|
59
|
+
"OPENAI_API_KEY",
|
|
60
|
+
"OPENAI_BASE_URL",
|
|
61
|
+
"OPENAI_ORG_ID",
|
|
62
|
+
"OPENAI_PROJECT_ID",
|
|
63
|
+
"PATH",
|
|
64
|
+
"PATHEXT",
|
|
65
|
+
"REQUESTS_CA_BUNDLE",
|
|
66
|
+
"SHELL",
|
|
67
|
+
"SSL_CERT_DIR",
|
|
68
|
+
"SSL_CERT_FILE",
|
|
69
|
+
"SYSTEMROOT",
|
|
70
|
+
"TEMP",
|
|
71
|
+
"TMP",
|
|
72
|
+
"TMPDIR",
|
|
73
|
+
"USER",
|
|
74
|
+
"USERPROFILE",
|
|
75
|
+
"XDG_CACHE_HOME",
|
|
76
|
+
"XDG_CONFIG_HOME",
|
|
77
|
+
"XDG_DATA_HOME",
|
|
78
|
+
"all_proxy",
|
|
79
|
+
"http_proxy",
|
|
80
|
+
"https_proxy",
|
|
81
|
+
"no_proxy",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
SENSITIVE_CHILD_ENV_KEYS = frozenset(
|
|
85
|
+
{
|
|
86
|
+
"ALL_PROXY",
|
|
87
|
+
"HTTPS_PROXY",
|
|
88
|
+
"HTTP_PROXY",
|
|
89
|
+
"KAITEN_TOKEN",
|
|
90
|
+
"OPENAI_API_KEY",
|
|
91
|
+
"OPENAI_ORG_ID",
|
|
92
|
+
"OPENAI_PROJECT_ID",
|
|
93
|
+
"all_proxy",
|
|
94
|
+
"http_proxy",
|
|
95
|
+
"https_proxy",
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
SYSTEM_PROMPT = """You are Kaiten Agent, an assistant embedded into Kaiten.
|
|
100
|
+
Use kaiten-cli as the primary tool surface for Kaiten data.
|
|
101
|
+
Start with discovery commands before heavy reads:
|
|
102
|
+
- kaiten agent-help
|
|
103
|
+
- kaiten search-tools "<query>"
|
|
104
|
+
- kaiten describe <tool>
|
|
105
|
+
- kaiten examples <tool>
|
|
106
|
+
|
|
107
|
+
Prefer machine-readable, narrow, and bulk workflows:
|
|
108
|
+
- use --json for tool output
|
|
109
|
+
- use --compact and --fields to reduce payload
|
|
110
|
+
- prefer cards list-all, batch commands, snapshots, and query commands over per-card loops
|
|
111
|
+
- inspect cache/runtime stats before widening expensive reads
|
|
112
|
+
- reuse local scripts that are available in the configured workdir or extra directories
|
|
113
|
+
|
|
114
|
+
This gateway is always read-only. Never mutate Kaiten; explain that writes must be run
|
|
115
|
+
outside this gateway when a user asks for a write operation.
|
|
116
|
+
Answer in the user's language unless they request otherwise.
|
|
117
|
+
Select the CLI locale from the current conversation language: ru for Russian, en
|
|
118
|
+
for English or other languages. Pass --locale ru or --locale en BEFORE the command
|
|
119
|
+
on every kaiten invocation, including discovery and JSON calls. Change it when
|
|
120
|
+
the user requests a language change; explicit --locale overrides the environment.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
SKILL_FALLBACKS = {
|
|
124
|
+
"kaiten-cli-heavy-data": (
|
|
125
|
+
"Use bulk commands, snapshots, compact fields, and cache-aware workflows. "
|
|
126
|
+
"Avoid one CLI process or API request per card when a batch command exists."
|
|
127
|
+
),
|
|
128
|
+
"kaiten-cli-metrics": (
|
|
129
|
+
"For metrics, start from topology, bulk card fields, snapshots/query metrics, "
|
|
130
|
+
"space activity, chart tools, and card-location-history batch-get only when needed."
|
|
131
|
+
),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class AgentGatewayConfig:
|
|
137
|
+
host: str = DEFAULT_HOST
|
|
138
|
+
port: int = DEFAULT_PORT
|
|
139
|
+
codex_bin: str = DEFAULT_CODEX_BIN
|
|
140
|
+
codex_model: str | None = None
|
|
141
|
+
reasoning_effort: str = DEFAULT_REASONING_EFFORT
|
|
142
|
+
workdir: Path = Path.cwd()
|
|
143
|
+
extra_dirs: tuple[Path, ...] = ()
|
|
144
|
+
skip_git_repo_check: bool = False
|
|
145
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS
|
|
146
|
+
bearer_token: str | None = None
|
|
147
|
+
repo_root: Path = Path.cwd()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def default_repo_root() -> Path:
|
|
151
|
+
return Path(__file__).resolve().parents[2]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _json_dumps(value: Any) -> str:
|
|
155
|
+
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _safe_read_text(path: Path) -> str | None:
|
|
159
|
+
try:
|
|
160
|
+
return path.read_text(encoding="utf-8")
|
|
161
|
+
except OSError:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def load_skill_texts(repo_root: Path) -> dict[str, str]:
|
|
166
|
+
skills: dict[str, str] = {}
|
|
167
|
+
for skill_name in SKILL_NAMES:
|
|
168
|
+
skill_path = repo_root / "skills" / skill_name / "SKILL.md"
|
|
169
|
+
skills[skill_name] = _safe_read_text(skill_path) or SKILL_FALLBACKS[skill_name]
|
|
170
|
+
return skills
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def build_prompt(payload: dict[str, Any], *, repo_root: Path | None = None) -> str:
|
|
174
|
+
repo = repo_root or default_repo_root()
|
|
175
|
+
messages = payload.get("messages") if isinstance(payload.get("messages"), list) else []
|
|
176
|
+
context = payload.get("context") if isinstance(payload.get("context"), dict) else {}
|
|
177
|
+
endpoint_instructions = str(payload.get("instructions") or "").strip()
|
|
178
|
+
|
|
179
|
+
parts = [
|
|
180
|
+
SYSTEM_PROMPT.strip(),
|
|
181
|
+
"",
|
|
182
|
+
"Endpoint instructions from Kaiten:",
|
|
183
|
+
endpoint_instructions or "(none)",
|
|
184
|
+
"",
|
|
185
|
+
"kaiten agent-help JSON:",
|
|
186
|
+
_json_dumps(_agent_help_payload()),
|
|
187
|
+
"",
|
|
188
|
+
"Bundled skills:",
|
|
189
|
+
_json_dumps(load_skill_texts(repo)),
|
|
190
|
+
"",
|
|
191
|
+
"Kaiten runtime context:",
|
|
192
|
+
_json_dumps(context),
|
|
193
|
+
"",
|
|
194
|
+
"Conversation messages:",
|
|
195
|
+
_json_dumps(messages),
|
|
196
|
+
"",
|
|
197
|
+
"Return a concise final answer only. Mention exact commands when they materially help.",
|
|
198
|
+
]
|
|
199
|
+
return "\n".join(parts)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def build_codex_command(config: AgentGatewayConfig, last_message_path: Path) -> list[str]:
|
|
203
|
+
command = [
|
|
204
|
+
config.codex_bin,
|
|
205
|
+
"exec",
|
|
206
|
+
"--sandbox",
|
|
207
|
+
"read-only",
|
|
208
|
+
"--ephemeral",
|
|
209
|
+
"--ignore-user-config",
|
|
210
|
+
"--ignore-rules",
|
|
211
|
+
"--cd",
|
|
212
|
+
str(config.workdir),
|
|
213
|
+
"--output-last-message",
|
|
214
|
+
str(last_message_path),
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
for extra_dir in config.extra_dirs:
|
|
218
|
+
command.extend(["--add-dir", str(extra_dir)])
|
|
219
|
+
|
|
220
|
+
if config.skip_git_repo_check:
|
|
221
|
+
command.append("--skip-git-repo-check")
|
|
222
|
+
|
|
223
|
+
if config.codex_model:
|
|
224
|
+
command.extend(["--model", config.codex_model])
|
|
225
|
+
|
|
226
|
+
if config.reasoning_effort:
|
|
227
|
+
command.extend(["-c", f'model_reasoning_effort="{config.reasoning_effort}"'])
|
|
228
|
+
|
|
229
|
+
command.append("-")
|
|
230
|
+
return command
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _codex_environment(environ: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
234
|
+
source = os.environ if environ is None else environ
|
|
235
|
+
child_env = {key: source[key] for key in CODEX_ENV_ALLOWLIST if key in source}
|
|
236
|
+
child_env["KAITEN_CLI_READ_ONLY"] = "1"
|
|
237
|
+
child_env["KAITEN_CLI_STORAGE_READ_ONLY"] = "1"
|
|
238
|
+
return child_env
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _redact_child_secrets(text: str, child_env: Mapping[str, str]) -> str:
|
|
242
|
+
redacted = text
|
|
243
|
+
for key in SENSITIVE_CHILD_ENV_KEYS:
|
|
244
|
+
value = child_env.get(key)
|
|
245
|
+
if value:
|
|
246
|
+
redacted = redacted.replace(value, "[REDACTED]")
|
|
247
|
+
return redacted
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _run_process_group(
|
|
251
|
+
command: list[str],
|
|
252
|
+
*,
|
|
253
|
+
input: str,
|
|
254
|
+
text: bool,
|
|
255
|
+
capture_output: bool,
|
|
256
|
+
env: Mapping[str, str],
|
|
257
|
+
timeout: int,
|
|
258
|
+
check: bool,
|
|
259
|
+
) -> subprocess.CompletedProcess[str]:
|
|
260
|
+
"""Run Codex in its own process group so timeouts terminate normal descendants."""
|
|
261
|
+
|
|
262
|
+
popen_kwargs: dict[str, Any] = {
|
|
263
|
+
"stdin": subprocess.PIPE,
|
|
264
|
+
"stdout": subprocess.PIPE if capture_output else None,
|
|
265
|
+
"stderr": subprocess.PIPE if capture_output else None,
|
|
266
|
+
"text": text,
|
|
267
|
+
"env": dict(env),
|
|
268
|
+
}
|
|
269
|
+
if os.name == "posix":
|
|
270
|
+
popen_kwargs["start_new_session"] = True
|
|
271
|
+
elif os.name == "nt": # pragma: no cover - Windows-specific containment
|
|
272
|
+
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
273
|
+
|
|
274
|
+
process = subprocess.Popen(command, **popen_kwargs)
|
|
275
|
+
try:
|
|
276
|
+
stdout, stderr = process.communicate(input=input, timeout=timeout)
|
|
277
|
+
except subprocess.TimeoutExpired as error:
|
|
278
|
+
if os.name == "posix":
|
|
279
|
+
with contextlib.suppress(ProcessLookupError):
|
|
280
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
281
|
+
elif os.name == "nt": # pragma: no cover - Windows-specific containment
|
|
282
|
+
subprocess.run(
|
|
283
|
+
["taskkill", "/F", "/T", "/PID", str(process.pid)],
|
|
284
|
+
capture_output=True,
|
|
285
|
+
check=False,
|
|
286
|
+
)
|
|
287
|
+
else: # pragma: no cover - defensive platform fallback
|
|
288
|
+
process.kill()
|
|
289
|
+
process.communicate()
|
|
290
|
+
raise subprocess.TimeoutExpired(
|
|
291
|
+
command,
|
|
292
|
+
timeout,
|
|
293
|
+
output=error.output,
|
|
294
|
+
stderr=error.stderr,
|
|
295
|
+
) from error
|
|
296
|
+
|
|
297
|
+
completed = subprocess.CompletedProcess(command, process.returncode, stdout, stderr)
|
|
298
|
+
if check and completed.returncode:
|
|
299
|
+
raise subprocess.CalledProcessError(
|
|
300
|
+
completed.returncode,
|
|
301
|
+
command,
|
|
302
|
+
output=stdout,
|
|
303
|
+
stderr=stderr,
|
|
304
|
+
)
|
|
305
|
+
return completed
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def run_codex(
|
|
309
|
+
prompt: str,
|
|
310
|
+
config: AgentGatewayConfig,
|
|
311
|
+
*,
|
|
312
|
+
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
|
|
313
|
+
) -> str:
|
|
314
|
+
child_env = _codex_environment()
|
|
315
|
+
effective_runner = runner or _run_process_group
|
|
316
|
+
with tempfile.TemporaryDirectory(prefix="kaiten-agent-") as tmpdir:
|
|
317
|
+
last_message_path = Path(tmpdir) / "last-message.txt"
|
|
318
|
+
result = effective_runner(
|
|
319
|
+
build_codex_command(config, last_message_path),
|
|
320
|
+
input=prompt,
|
|
321
|
+
text=True,
|
|
322
|
+
capture_output=True,
|
|
323
|
+
env=child_env,
|
|
324
|
+
timeout=config.timeout_seconds,
|
|
325
|
+
check=False,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
if result.returncode != 0:
|
|
329
|
+
detail = (result.stderr or result.stdout or "").strip()
|
|
330
|
+
safe_detail = _redact_child_secrets(detail[-4000:], child_env)
|
|
331
|
+
raise RuntimeError(safe_detail or f"codex exited with {result.returncode}")
|
|
332
|
+
|
|
333
|
+
output = _safe_read_text(last_message_path)
|
|
334
|
+
if output and output.strip():
|
|
335
|
+
return _redact_child_secrets(output.strip(), child_env)
|
|
336
|
+
|
|
337
|
+
return _redact_child_secrets((result.stdout or "").strip(), child_env)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def chat_response(content: str) -> dict[str, Any]:
|
|
341
|
+
return {
|
|
342
|
+
"message": {
|
|
343
|
+
"role": "assistant",
|
|
344
|
+
"content": content,
|
|
345
|
+
},
|
|
346
|
+
"tool_runs": [
|
|
347
|
+
{
|
|
348
|
+
"type": "codex_exec",
|
|
349
|
+
"sandbox": "read-only",
|
|
350
|
+
}
|
|
351
|
+
],
|
|
352
|
+
"artifacts": [],
|
|
353
|
+
"usage": {
|
|
354
|
+
"provider": "codex_exec",
|
|
355
|
+
},
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class RequestBodyTooLarge(ValueError):
|
|
360
|
+
"""Raised when an HTTP request exceeds the gateway's fixed body limit."""
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _read_json(handler: BaseHTTPRequestHandler) -> dict[str, Any]:
|
|
364
|
+
if handler.headers.get("Transfer-Encoding"):
|
|
365
|
+
raise ValueError("Transfer-Encoding request bodies are not supported")
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
content_length = int(handler.headers.get("Content-Length") or 0)
|
|
369
|
+
except ValueError as error:
|
|
370
|
+
raise ValueError("Content-Length must be an integer") from error
|
|
371
|
+
|
|
372
|
+
if content_length < 0:
|
|
373
|
+
raise ValueError("Content-Length must not be negative")
|
|
374
|
+
if content_length > MAX_REQUEST_BODY_BYTES:
|
|
375
|
+
raise RequestBodyTooLarge(f"Request body exceeds the {MAX_REQUEST_BODY_BYTES}-byte limit")
|
|
376
|
+
if handler.headers.get_content_type() != "application/json":
|
|
377
|
+
raise ValueError("Content-Type must be application/json")
|
|
378
|
+
if content_length <= 0:
|
|
379
|
+
raise ValueError("JSON request body is required")
|
|
380
|
+
|
|
381
|
+
raw = handler.rfile.read(content_length)
|
|
382
|
+
data = json.loads(raw.decode("utf-8"))
|
|
383
|
+
if not isinstance(data, dict):
|
|
384
|
+
raise ValueError("JSON body must be an object")
|
|
385
|
+
return data
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _send_json(
|
|
389
|
+
handler: BaseHTTPRequestHandler, status: HTTPStatus, payload: dict[str, Any]
|
|
390
|
+
) -> None:
|
|
391
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
392
|
+
handler.send_response(status.value)
|
|
393
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
394
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
395
|
+
handler.end_headers()
|
|
396
|
+
handler.wfile.write(body)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _authorized(handler: BaseHTTPRequestHandler, config: AgentGatewayConfig) -> bool:
|
|
400
|
+
if not config.bearer_token:
|
|
401
|
+
return True
|
|
402
|
+
supplied = handler.headers.get("Authorization") or ""
|
|
403
|
+
expected = f"Bearer {config.bearer_token}"
|
|
404
|
+
return hmac.compare_digest(supplied.encode("utf-8"), expected.encode("utf-8"))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _is_loopback_host(host: str) -> bool:
|
|
408
|
+
normalized = host.strip().lower()
|
|
409
|
+
if normalized == "localhost":
|
|
410
|
+
return True
|
|
411
|
+
if normalized.startswith("[") and normalized.endswith("]"):
|
|
412
|
+
normalized = normalized[1:-1]
|
|
413
|
+
try:
|
|
414
|
+
return ipaddress.ip_address(normalized).is_loopback
|
|
415
|
+
except ValueError:
|
|
416
|
+
return False
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def validate_gateway_config(config: AgentGatewayConfig) -> None:
|
|
420
|
+
if not _is_loopback_host(config.host) and not config.bearer_token:
|
|
421
|
+
raise ValueError("A bearer token is required when binding to a non-loopback host")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def make_handler(config: AgentGatewayConfig) -> type[BaseHTTPRequestHandler]:
|
|
425
|
+
validate_gateway_config(config)
|
|
426
|
+
codex_slots = threading.BoundedSemaphore(MAX_CONCURRENT_CODEX_EXECUTIONS)
|
|
427
|
+
|
|
428
|
+
class AgentGatewayHandler(BaseHTTPRequestHandler):
|
|
429
|
+
server_version = "kaiten-agent-gateway"
|
|
430
|
+
|
|
431
|
+
def setup(self) -> None:
|
|
432
|
+
super().setup()
|
|
433
|
+
self.connection.settimeout(REQUEST_READ_TIMEOUT_SECONDS)
|
|
434
|
+
|
|
435
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
|
436
|
+
return
|
|
437
|
+
|
|
438
|
+
def _guard_auth(self) -> bool:
|
|
439
|
+
if _authorized(self, config):
|
|
440
|
+
return True
|
|
441
|
+
_send_json(self, HTTPStatus.UNAUTHORIZED, {"message": "Unauthorized"})
|
|
442
|
+
return False
|
|
443
|
+
|
|
444
|
+
def do_GET(self) -> None: # noqa: N802
|
|
445
|
+
if not self._guard_auth():
|
|
446
|
+
return
|
|
447
|
+
if self.path != "/health":
|
|
448
|
+
_send_json(self, HTTPStatus.NOT_FOUND, {"message": "Not found"})
|
|
449
|
+
return
|
|
450
|
+
_send_json(
|
|
451
|
+
self,
|
|
452
|
+
HTTPStatus.OK,
|
|
453
|
+
{
|
|
454
|
+
"ok": True,
|
|
455
|
+
"name": "kaiten-agent-gateway",
|
|
456
|
+
"version": __version__,
|
|
457
|
+
"capabilities": ["health", "chat", "kaiten-cli", "codex-exec"],
|
|
458
|
+
},
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def do_POST(self) -> None: # noqa: N802
|
|
462
|
+
if not self._guard_auth():
|
|
463
|
+
return
|
|
464
|
+
if self.path != "/v1/chat":
|
|
465
|
+
_send_json(self, HTTPStatus.NOT_FOUND, {"message": "Not found"})
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
payload = _read_json(self)
|
|
470
|
+
prompt = build_prompt(payload, repo_root=config.repo_root)
|
|
471
|
+
except RequestBodyTooLarge as error:
|
|
472
|
+
self.close_connection = True
|
|
473
|
+
_send_json(self, HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"message": str(error)})
|
|
474
|
+
return
|
|
475
|
+
except TimeoutError:
|
|
476
|
+
self.close_connection = True
|
|
477
|
+
_send_json(self, HTTPStatus.REQUEST_TIMEOUT, {"message": "Request body timed out"})
|
|
478
|
+
return
|
|
479
|
+
except json.JSONDecodeError as error:
|
|
480
|
+
_send_json(self, HTTPStatus.BAD_REQUEST, {"message": f"Invalid JSON: {error}"})
|
|
481
|
+
return
|
|
482
|
+
except ValueError as error:
|
|
483
|
+
_send_json(self, HTTPStatus.BAD_REQUEST, {"message": str(error)})
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
if not codex_slots.acquire(blocking=False):
|
|
487
|
+
_send_json(
|
|
488
|
+
self,
|
|
489
|
+
HTTPStatus.TOO_MANY_REQUESTS,
|
|
490
|
+
{"message": "Too many concurrent agent requests"},
|
|
491
|
+
)
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
content = run_codex(prompt, config)
|
|
496
|
+
except subprocess.TimeoutExpired:
|
|
497
|
+
LOGGER.warning("Codex execution timed out after %s seconds", config.timeout_seconds)
|
|
498
|
+
_send_json(self, HTTPStatus.GATEWAY_TIMEOUT, {"message": "Agent request timed out"})
|
|
499
|
+
return
|
|
500
|
+
except Exception as error: # pragma: no cover - behavior is tested via the handler
|
|
501
|
+
LOGGER.error("Codex execution failed (%s)", type(error).__name__)
|
|
502
|
+
_send_json(self, HTTPStatus.BAD_GATEWAY, {"message": "Agent execution failed"})
|
|
503
|
+
return
|
|
504
|
+
finally:
|
|
505
|
+
codex_slots.release()
|
|
506
|
+
|
|
507
|
+
if not content:
|
|
508
|
+
_send_json(
|
|
509
|
+
self, HTTPStatus.BAD_GATEWAY, {"message": "Codex returned an empty response"}
|
|
510
|
+
)
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
_send_json(self, HTTPStatus.OK, chat_response(content))
|
|
514
|
+
|
|
515
|
+
return AgentGatewayHandler
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def config_from_args(args: argparse.Namespace) -> AgentGatewayConfig:
|
|
519
|
+
repo_root = Path(
|
|
520
|
+
args.repo_root or os.environ.get("KAITEN_AGENT_REPO_ROOT") or default_repo_root()
|
|
521
|
+
)
|
|
522
|
+
workdir = Path(args.workdir or os.environ.get("KAITEN_AGENT_WORKDIR") or repo_root)
|
|
523
|
+
extra_dir_values = list(args.add_dir or [])
|
|
524
|
+
env_extra_dirs = os.environ.get("KAITEN_AGENT_EXTRA_DIRS")
|
|
525
|
+
if env_extra_dirs:
|
|
526
|
+
extra_dir_values.extend(env_extra_dirs.split(os.pathsep))
|
|
527
|
+
|
|
528
|
+
return AgentGatewayConfig(
|
|
529
|
+
host=args.host,
|
|
530
|
+
port=args.port,
|
|
531
|
+
codex_bin=args.codex_bin or os.environ.get("KAITEN_AGENT_CODEX_BIN") or DEFAULT_CODEX_BIN,
|
|
532
|
+
codex_model=args.codex_model or os.environ.get("KAITEN_AGENT_CODEX_MODEL") or None,
|
|
533
|
+
reasoning_effort=(
|
|
534
|
+
args.reasoning_effort
|
|
535
|
+
or os.environ.get("KAITEN_AGENT_CODEX_REASONING_EFFORT")
|
|
536
|
+
or DEFAULT_REASONING_EFFORT
|
|
537
|
+
),
|
|
538
|
+
workdir=workdir.resolve(),
|
|
539
|
+
extra_dirs=tuple(Path(value).expanduser().resolve() for value in extra_dir_values if value),
|
|
540
|
+
skip_git_repo_check=args.skip_git_repo_check,
|
|
541
|
+
timeout_seconds=int(
|
|
542
|
+
args.timeout_seconds
|
|
543
|
+
or os.environ.get("KAITEN_AGENT_TIMEOUT_SECONDS")
|
|
544
|
+
or DEFAULT_TIMEOUT_SECONDS
|
|
545
|
+
),
|
|
546
|
+
bearer_token=args.bearer_token or os.environ.get("KAITEN_AGENT_BEARER_TOKEN") or None,
|
|
547
|
+
repo_root=repo_root.resolve(),
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
552
|
+
parser = argparse.ArgumentParser(description="Run a local Kaiten Agent HTTP gateway.")
|
|
553
|
+
parser.add_argument("--host", default=os.environ.get("KAITEN_AGENT_HOST", DEFAULT_HOST))
|
|
554
|
+
parser.add_argument(
|
|
555
|
+
"--port", type=int, default=int(os.environ.get("KAITEN_AGENT_PORT", DEFAULT_PORT))
|
|
556
|
+
)
|
|
557
|
+
parser.add_argument("--codex-bin", default=None)
|
|
558
|
+
parser.add_argument("--codex-model", default=None)
|
|
559
|
+
parser.add_argument("--reasoning-effort", default=None)
|
|
560
|
+
parser.add_argument("--workdir", default=None)
|
|
561
|
+
parser.add_argument("--add-dir", action="append", default=None)
|
|
562
|
+
parser.add_argument("--skip-git-repo-check", action="store_true")
|
|
563
|
+
parser.add_argument("--repo-root", default=None)
|
|
564
|
+
parser.add_argument("--timeout-seconds", type=int, default=None)
|
|
565
|
+
parser.add_argument("--bearer-token", default=None)
|
|
566
|
+
return parser.parse_args(argv)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def main(argv: list[str] | None = None) -> None:
|
|
570
|
+
config = config_from_args(parse_args(argv))
|
|
571
|
+
server = ThreadingHTTPServer((config.host, config.port), make_handler(config))
|
|
572
|
+
print(f"kaiten-agent-gateway listening on http://{config.host}:{config.port}", flush=True)
|
|
573
|
+
server.serve_forever()
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
if __name__ == "__main__":
|
|
577
|
+
main()
|