krnl-code 1.0.4__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.
- krnl_agent/__init__.py +9 -0
- krnl_agent/__main__.py +7 -0
- krnl_agent/agent_registry.py +95 -0
- krnl_agent/agent_selector.py +69 -0
- krnl_agent/audit_log.py +155 -0
- krnl_agent/background.py +94 -0
- krnl_agent/checkpoints.py +67 -0
- krnl_agent/ci.py +73 -0
- krnl_agent/cli.py +1458 -0
- krnl_agent/commands.py +42 -0
- krnl_agent/config.py +425 -0
- krnl_agent/context.py +352 -0
- krnl_agent/depaudit.py +63 -0
- krnl_agent/deploy.py +245 -0
- krnl_agent/doctor.py +106 -0
- krnl_agent/events.py +141 -0
- krnl_agent/gitignore.py +47 -0
- krnl_agent/graph.py +928 -0
- krnl_agent/guardrails.py +70 -0
- krnl_agent/headless.py +60 -0
- krnl_agent/history.py +49 -0
- krnl_agent/hooks.py +72 -0
- krnl_agent/ingest.py +129 -0
- krnl_agent/llm.py +456 -0
- krnl_agent/loop.py +779 -0
- krnl_agent/mcp_client.py +128 -0
- krnl_agent/memory.py +61 -0
- krnl_agent/modelrouter.py +151 -0
- krnl_agent/monitor.py +112 -0
- krnl_agent/notify.py +119 -0
- krnl_agent/parallel_executor.py +139 -0
- krnl_agent/permissions.py +128 -0
- krnl_agent/plugins.py +105 -0
- krnl_agent/pricing.py +85 -0
- krnl_agent/prompts.py +60 -0
- krnl_agent/repomap.py +133 -0
- krnl_agent/sandbox.py +69 -0
- krnl_agent/scaffold.py +167 -0
- krnl_agent/schedules.py +137 -0
- krnl_agent/secrets.py +100 -0
- krnl_agent/selfheal.py +87 -0
- krnl_agent/server.py +302 -0
- krnl_agent/sessions.py +258 -0
- krnl_agent/settings.py +59 -0
- krnl_agent/skills.py +73 -0
- krnl_agent/teams.py +38 -0
- krnl_agent/tool_schemas.py +431 -0
- krnl_agent/tools.py +694 -0
- krnl_agent/webtools.py +139 -0
- krnl_code-1.0.4.dist-info/METADATA +214 -0
- krnl_code-1.0.4.dist-info/RECORD +56 -0
- krnl_code-1.0.4.dist-info/WHEEL +5 -0
- krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
- krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
- krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
- krnl_code-1.0.4.dist-info/top_level.txt +1 -0
krnl_agent/context.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Context-window management and @file mention expansion.
|
|
2
|
+
|
|
3
|
+
- `estimate_tokens` — a cheap, dependency-free token estimate (~4 chars/token).
|
|
4
|
+
- `expand_mentions` — turns `@path/to/file` in a task into inlined file content.
|
|
5
|
+
- `compact_history` — when the conversation gets too big, trims the oldest tool
|
|
6
|
+
outputs (safe: it never breaks the assistant→tool message pairing) so the
|
|
7
|
+
request stays under the model's budget.
|
|
8
|
+
|
|
9
|
+
Phase 2 additions:
|
|
10
|
+
- `get_graph_aware_context` — uses the code knowledge graph to pull in relevant
|
|
11
|
+
code (callers, callees, parent classes) up to a configurable hop limit.
|
|
12
|
+
- `DifferentialContextTracker` — tracks what's been sent to the model this session
|
|
13
|
+
and sends only diffs on subsequent turns.
|
|
14
|
+
- `compact_with_summarization` — improves compaction by summarizing dropped content.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING, Optional
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from .tools import ToolContext
|
|
26
|
+
|
|
27
|
+
_MENTION_RE = re.compile(r"(?<!\w)@([A-Za-z0-9_./\\\-]+)")
|
|
28
|
+
_TRIM_NOTE = "[older tool output trimmed to save context]"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def estimate_tokens(messages: list[dict]) -> int:
|
|
32
|
+
chars = 0
|
|
33
|
+
for m in messages:
|
|
34
|
+
c = m.get("content")
|
|
35
|
+
if isinstance(c, str):
|
|
36
|
+
chars += len(c)
|
|
37
|
+
for tc in m.get("tool_calls") or []:
|
|
38
|
+
chars += len(str(tc.get("function", {}).get("arguments", "")))
|
|
39
|
+
return chars // 4
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def expand_mentions(task: str, ctx: "ToolContext") -> str:
|
|
43
|
+
"""Inline the contents of any @-mentioned files that exist in the workspace."""
|
|
44
|
+
seen: set[str] = set()
|
|
45
|
+
blocks: list[str] = []
|
|
46
|
+
for match in _MENTION_RE.finditer(task):
|
|
47
|
+
rel = match.group(1).strip(".,;:)")
|
|
48
|
+
if rel in seen:
|
|
49
|
+
continue
|
|
50
|
+
seen.add(rel)
|
|
51
|
+
try:
|
|
52
|
+
if ctx.is_ignored(rel):
|
|
53
|
+
continue
|
|
54
|
+
full = ctx.resolve(rel)
|
|
55
|
+
if not full.is_file():
|
|
56
|
+
continue
|
|
57
|
+
if full.stat().st_size > ctx.cfg.max_file_bytes:
|
|
58
|
+
blocks.append(f"--- @{rel} (too large to inline) ---")
|
|
59
|
+
continue
|
|
60
|
+
text = full.read_text(encoding="utf-8", errors="replace")
|
|
61
|
+
blocks.append(f"--- @{rel} ---\n{text}")
|
|
62
|
+
except Exception:
|
|
63
|
+
continue
|
|
64
|
+
if not blocks:
|
|
65
|
+
return task
|
|
66
|
+
return task + "\n\nReferenced files:\n" + "\n\n".join(blocks)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def compact_history(messages: list[dict], max_tokens: int, keep_recent: int = 6):
|
|
70
|
+
"""Return (messages, changed). Trims oldest tool outputs when over budget."""
|
|
71
|
+
if estimate_tokens(messages) <= max_tokens:
|
|
72
|
+
return messages, False
|
|
73
|
+
out = [dict(m) for m in messages]
|
|
74
|
+
# never touch the system message (0) or the most recent `keep_recent`
|
|
75
|
+
end = max(1, len(out) - keep_recent)
|
|
76
|
+
changed = False
|
|
77
|
+
for i in range(1, end):
|
|
78
|
+
if estimate_tokens(out) <= max_tokens:
|
|
79
|
+
break
|
|
80
|
+
m = out[i]
|
|
81
|
+
if m.get("role") == "tool" and m.get("content") != _TRIM_NOTE:
|
|
82
|
+
m["content"] = _TRIM_NOTE
|
|
83
|
+
changed = True
|
|
84
|
+
return out, changed
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_SUMMARY_SYS = (
|
|
88
|
+
"Summarize this coding-session transcript into a concise brief a developer "
|
|
89
|
+
"needs to continue: decisions made, files changed, current state, and what's "
|
|
90
|
+
"left. Be factual and specific; no fluff."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def summarize_history(client, messages: list[dict], keep_recent: int = 4):
|
|
95
|
+
"""Replace the middle of the conversation with an LLM-written summary.
|
|
96
|
+
Returns (new_messages, changed). Preserves assistant→tool message pairing.
|
|
97
|
+
"""
|
|
98
|
+
if len(messages) <= keep_recent + 2:
|
|
99
|
+
return messages, False
|
|
100
|
+
system = messages[0]
|
|
101
|
+
cut = len(messages) - keep_recent
|
|
102
|
+
while cut > 1 and messages[cut].get("role") == "tool":
|
|
103
|
+
cut -= 1
|
|
104
|
+
middle = messages[1:cut]
|
|
105
|
+
recent = messages[cut:]
|
|
106
|
+
if not middle:
|
|
107
|
+
return messages, False
|
|
108
|
+
|
|
109
|
+
lines = []
|
|
110
|
+
for m in middle:
|
|
111
|
+
role = m.get("role", "?")
|
|
112
|
+
content = m.get("content") or ""
|
|
113
|
+
if m.get("tool_calls"):
|
|
114
|
+
names = ", ".join(tc["function"]["name"] for tc in m["tool_calls"])
|
|
115
|
+
content = (content + f" [called: {names}]").strip()
|
|
116
|
+
lines.append(f"{role}: {content}")
|
|
117
|
+
transcript = "\n".join(lines)[:12000]
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
resp = client.chat(
|
|
121
|
+
[
|
|
122
|
+
{"role": "system", "content": _SUMMARY_SYS},
|
|
123
|
+
{"role": "user", "content": transcript},
|
|
124
|
+
],
|
|
125
|
+
None,
|
|
126
|
+
None,
|
|
127
|
+
False,
|
|
128
|
+
)
|
|
129
|
+
summary = resp.content
|
|
130
|
+
except Exception:
|
|
131
|
+
return messages, False
|
|
132
|
+
|
|
133
|
+
new = [system, {"role": "user", "content": "[Summary of earlier conversation]\n" + summary}]
|
|
134
|
+
new += recent
|
|
135
|
+
return new, True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# --------------------------------------------------------------------------- #
|
|
139
|
+
# Phase 2: Graph-Aware Context Selection
|
|
140
|
+
# --------------------------------------------------------------------------- #
|
|
141
|
+
@dataclass
|
|
142
|
+
class DifferentialContextTracker:
|
|
143
|
+
"""Tracks what content has been sent to the model to enable differential updates."""
|
|
144
|
+
sent_files: dict[str, str] = field(default_factory=dict) # file_path -> content_hash
|
|
145
|
+
sent_graph_nodes: set[str] = field(default_factory=set) # node_ids that have been sent
|
|
146
|
+
|
|
147
|
+
def _hash_content(self, content: str) -> str:
|
|
148
|
+
"""Create a hash of content for change detection."""
|
|
149
|
+
return hashlib.md5(content.encode("utf-8")).hexdigest()
|
|
150
|
+
|
|
151
|
+
def update_file(self, file_path: str, content: str) -> bool:
|
|
152
|
+
"""Update tracking for a file. Returns True if content changed."""
|
|
153
|
+
content_hash = self._hash_content(content)
|
|
154
|
+
if file_path in self.sent_files:
|
|
155
|
+
if self.sent_files[file_path] == content_hash:
|
|
156
|
+
return False # No change
|
|
157
|
+
self.sent_files[file_path] = content_hash
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
def add_graph_nodes(self, node_ids: list[str]) -> None:
|
|
161
|
+
"""Mark graph nodes as sent."""
|
|
162
|
+
self.sent_graph_nodes.update(node_ids)
|
|
163
|
+
|
|
164
|
+
def get_file_diff(self, file_path: str, content: str) -> Optional[str]:
|
|
165
|
+
"""Return content if file changed, None if unchanged."""
|
|
166
|
+
content_hash = self._hash_content(content)
|
|
167
|
+
if file_path in self.sent_files and self.sent_files[file_path] == content_hash:
|
|
168
|
+
return None # Unchanged
|
|
169
|
+
return content
|
|
170
|
+
|
|
171
|
+
def get_new_graph_nodes(self, node_ids: list[str]) -> list[str]:
|
|
172
|
+
"""Return node IDs that haven't been sent yet."""
|
|
173
|
+
return [nid for nid in node_ids if nid not in self.sent_graph_nodes]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_graph_aware_context(
|
|
177
|
+
task: str,
|
|
178
|
+
ctx: "ToolContext",
|
|
179
|
+
graph_manager,
|
|
180
|
+
hop_limit: int = 1,
|
|
181
|
+
tracker: Optional[DifferentialContextTracker] = None,
|
|
182
|
+
) -> str:
|
|
183
|
+
"""Use the code knowledge graph to pull in relevant code.
|
|
184
|
+
|
|
185
|
+
Given the current task/files-in-focus, use the Phase 1 graph to pull in
|
|
186
|
+
directly connected nodes (callers, callees, parent classes) up to a
|
|
187
|
+
configurable hop limit.
|
|
188
|
+
|
|
189
|
+
If tracker is provided, only returns new/changed content.
|
|
190
|
+
"""
|
|
191
|
+
if not graph_manager or not graph_manager.graph.node_by_id:
|
|
192
|
+
return "" # Graph not available or empty
|
|
193
|
+
|
|
194
|
+
# Extract @file mentions from task to find focus files
|
|
195
|
+
focus_files = set()
|
|
196
|
+
for match in _MENTION_RE.finditer(task):
|
|
197
|
+
rel = match.group(1).strip(".,;:)")
|
|
198
|
+
try:
|
|
199
|
+
if not ctx.is_ignored(rel):
|
|
200
|
+
full = ctx.resolve(rel)
|
|
201
|
+
if full.is_file():
|
|
202
|
+
focus_files.add(str(full))
|
|
203
|
+
except Exception:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
if not focus_files:
|
|
207
|
+
return "" # No focus files, no graph context needed
|
|
208
|
+
|
|
209
|
+
# Get graph nodes for focus files
|
|
210
|
+
focus_node_ids = set()
|
|
211
|
+
for file_path in focus_files:
|
|
212
|
+
nodes = graph_manager.graph.get_nodes_by_file(file_path)
|
|
213
|
+
focus_node_ids.update(n.id for n in nodes)
|
|
214
|
+
|
|
215
|
+
# Get neighbors within hop limit
|
|
216
|
+
relevant_node_ids = set(focus_node_ids)
|
|
217
|
+
current_level = set(focus_node_ids)
|
|
218
|
+
|
|
219
|
+
for _hop in range(hop_limit):
|
|
220
|
+
next_level = set()
|
|
221
|
+
for nid in current_level:
|
|
222
|
+
neighbors = graph_manager.graph.get_neighbors(nid, direction="out")
|
|
223
|
+
for neighbor in neighbors:
|
|
224
|
+
if neighbor.id not in relevant_node_ids:
|
|
225
|
+
relevant_node_ids.add(neighbor.id)
|
|
226
|
+
next_level.add(neighbor.id)
|
|
227
|
+
current_level = next_level
|
|
228
|
+
if not current_level:
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
# If using differential tracking, filter to new nodes
|
|
232
|
+
if tracker:
|
|
233
|
+
new_node_ids = tracker.get_new_graph_nodes(list(relevant_node_ids))
|
|
234
|
+
relevant_node_ids = set(new_node_ids)
|
|
235
|
+
|
|
236
|
+
# Collect file paths for relevant nodes
|
|
237
|
+
relevant_files = set()
|
|
238
|
+
for nid in relevant_node_ids:
|
|
239
|
+
node = graph_manager.graph.get_node(nid)
|
|
240
|
+
if node:
|
|
241
|
+
relevant_files.add(node.file_path)
|
|
242
|
+
|
|
243
|
+
# Read file contents (with differential tracking)
|
|
244
|
+
context_parts = []
|
|
245
|
+
for file_path in sorted(relevant_files):
|
|
246
|
+
try:
|
|
247
|
+
if ctx.is_ignored(file_path):
|
|
248
|
+
continue
|
|
249
|
+
full_path = Path(file_path)
|
|
250
|
+
if not full_path.is_file():
|
|
251
|
+
continue
|
|
252
|
+
if full_path.stat().st_size > ctx.cfg.max_file_bytes:
|
|
253
|
+
continue
|
|
254
|
+
content = full_path.read_text(encoding="utf-8", errors="replace")
|
|
255
|
+
|
|
256
|
+
# Differential tracking
|
|
257
|
+
if tracker:
|
|
258
|
+
diff_content = tracker.get_file_diff(file_path, content)
|
|
259
|
+
if diff_content is None:
|
|
260
|
+
continue # Unchanged, skip
|
|
261
|
+
content = diff_content
|
|
262
|
+
tracker.update_file(file_path, content)
|
|
263
|
+
|
|
264
|
+
context_parts.append(f"--- {file_path} ---\n{content}")
|
|
265
|
+
except Exception:
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
# Mark nodes as sent
|
|
269
|
+
if tracker:
|
|
270
|
+
tracker.add_graph_nodes(list(relevant_node_ids))
|
|
271
|
+
|
|
272
|
+
if not context_parts:
|
|
273
|
+
return ""
|
|
274
|
+
|
|
275
|
+
return "\n\n".join(context_parts)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def compact_with_summarization(
|
|
279
|
+
messages: list[dict],
|
|
280
|
+
max_tokens: int,
|
|
281
|
+
keep_recent: int = 6,
|
|
282
|
+
client=None,
|
|
283
|
+
) -> tuple[list[dict], bool]:
|
|
284
|
+
"""Compact history with LLM summarization of dropped content.
|
|
285
|
+
|
|
286
|
+
When compaction triggers, summarize dropped tool output/decisions rather
|
|
287
|
+
than naive oldest-first drop. This improves compaction quality at the
|
|
288
|
+
truncation boundary.
|
|
289
|
+
"""
|
|
290
|
+
if estimate_tokens(messages) <= max_tokens:
|
|
291
|
+
return messages, False
|
|
292
|
+
|
|
293
|
+
if not client:
|
|
294
|
+
# Fall back to simple compaction if no client available
|
|
295
|
+
return compact_history(messages, max_tokens, keep_recent)
|
|
296
|
+
|
|
297
|
+
out = [dict(m) for m in messages]
|
|
298
|
+
changed = False
|
|
299
|
+
|
|
300
|
+
# Find content to drop
|
|
301
|
+
to_summarize = []
|
|
302
|
+
keep_indices = {0, len(out) - 1} # Always keep system and last message
|
|
303
|
+
for i in range(len(out) - keep_recent, len(out)):
|
|
304
|
+
keep_indices.add(i)
|
|
305
|
+
|
|
306
|
+
for i in range(1, len(out) - 1):
|
|
307
|
+
if i in keep_indices:
|
|
308
|
+
continue
|
|
309
|
+
if estimate_tokens(out) <= max_tokens:
|
|
310
|
+
break
|
|
311
|
+
m = out[i]
|
|
312
|
+
if m.get("role") in ("user", "assistant"):
|
|
313
|
+
to_summarize.append((i, m))
|
|
314
|
+
out[i] = None # Mark for removal
|
|
315
|
+
changed = True
|
|
316
|
+
|
|
317
|
+
# Remove marked messages
|
|
318
|
+
out = [m for m in out if m is not None]
|
|
319
|
+
|
|
320
|
+
# Generate summary if we dropped content
|
|
321
|
+
if to_summarize and client:
|
|
322
|
+
try:
|
|
323
|
+
summary_text = "\n".join(
|
|
324
|
+
f"{m.get('role', '?')}: {m.get('content', '')[:500]}"
|
|
325
|
+
for _, m in to_summarize
|
|
326
|
+
)
|
|
327
|
+
summary_text = summary_text[:8000] # Limit summary input
|
|
328
|
+
|
|
329
|
+
resp = client.chat(
|
|
330
|
+
[
|
|
331
|
+
{
|
|
332
|
+
"role": "system",
|
|
333
|
+
"content": "Summarize this conversation excerpt concisely. Focus on: decisions made, files changed, errors encountered, and current state.",
|
|
334
|
+
},
|
|
335
|
+
{"role": "user", "content": summary_text},
|
|
336
|
+
],
|
|
337
|
+
None,
|
|
338
|
+
None,
|
|
339
|
+
False,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Insert summary after system message
|
|
343
|
+
summary_msg = {
|
|
344
|
+
"role": "user",
|
|
345
|
+
"content": f"[Summary of earlier conversation]\n{resp.content}",
|
|
346
|
+
}
|
|
347
|
+
out.insert(1, summary_msg)
|
|
348
|
+
except Exception:
|
|
349
|
+
# If summarization fails, just use simple compaction
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
return out, changed
|
krnl_agent/depaudit.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Dependency vulnerability audit.
|
|
2
|
+
|
|
3
|
+
Detects the project's package ecosystem(s) and runs the matching audit tool if it
|
|
4
|
+
is installed - `pip-audit` for Python, `npm audit` for Node, `cargo audit` for
|
|
5
|
+
Rust, `govulncheck` for Go. Each is best-effort: if the tool is missing we say so
|
|
6
|
+
and tell the agent how to install it, rather than failing. The agent can then act
|
|
7
|
+
on the findings (pin/upgrade versions) or surface them to the user.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
|
|
14
|
+
# (manifest filename, tool exe, audit command, install hint)
|
|
15
|
+
_ECOSYSTEMS = [
|
|
16
|
+
("requirements.txt", "pip-audit", "pip-audit -r requirements.txt --progress-spinner off",
|
|
17
|
+
"pip install pip-audit"),
|
|
18
|
+
("pyproject.toml", "pip-audit", "pip-audit --progress-spinner off", "pip install pip-audit"),
|
|
19
|
+
("package.json", "npm", "npm audit --omit=dev", "install Node.js / npm"),
|
|
20
|
+
("Cargo.toml", "cargo-audit", "cargo audit", "cargo install cargo-audit"),
|
|
21
|
+
("go.mod", "govulncheck", "govulncheck ./...", "go install golang.org/x/vuln/cmd/govulncheck@latest"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def audit(ctx, timeout: int = 180) -> tuple[bool, str]:
|
|
26
|
+
"""Run dependency audits for every detected ecosystem. Returns (clean, report)."""
|
|
27
|
+
root = ctx.root
|
|
28
|
+
detected = [(mf, exe, cmd, hint) for (mf, exe, cmd, hint) in _ECOSYSTEMS
|
|
29
|
+
if (root / mf).exists()]
|
|
30
|
+
if not detected:
|
|
31
|
+
return True, ("No recognized dependency manifest found "
|
|
32
|
+
"(requirements.txt, pyproject.toml, package.json, Cargo.toml, go.mod).")
|
|
33
|
+
|
|
34
|
+
blocks: list[str] = []
|
|
35
|
+
any_findings = False
|
|
36
|
+
seen_tools: set[str] = set()
|
|
37
|
+
for manifest, exe, cmd, hint in detected:
|
|
38
|
+
if exe in seen_tools:
|
|
39
|
+
continue
|
|
40
|
+
seen_tools.add(exe)
|
|
41
|
+
if not shutil.which(exe):
|
|
42
|
+
blocks.append(f"## {manifest}\n ⚠ '{exe}' not installed — `{hint}` to enable this audit.")
|
|
43
|
+
continue
|
|
44
|
+
try:
|
|
45
|
+
res = subprocess.run(cmd, cwd=root, shell=True, capture_output=True,
|
|
46
|
+
text=True, timeout=timeout)
|
|
47
|
+
except subprocess.TimeoutExpired:
|
|
48
|
+
blocks.append(f"## {manifest}\n ⚠ {exe} timed out after {timeout}s.")
|
|
49
|
+
continue
|
|
50
|
+
except Exception as e: # noqa: BLE001
|
|
51
|
+
blocks.append(f"## {manifest}\n ⚠ {exe} failed to run: {e}")
|
|
52
|
+
continue
|
|
53
|
+
out = (res.stdout + "\n" + res.stderr).strip()
|
|
54
|
+
if len(out) > 6000:
|
|
55
|
+
out = out[:6000] + "\n… (truncated)"
|
|
56
|
+
# Non-zero exit from these tools means vulnerabilities were found.
|
|
57
|
+
status = "VULNERABILITIES FOUND" if res.returncode != 0 else "clean"
|
|
58
|
+
if res.returncode != 0:
|
|
59
|
+
any_findings = True
|
|
60
|
+
blocks.append(f"## {manifest} ({exe}) — {status}\n{out or '(no output)'}")
|
|
61
|
+
|
|
62
|
+
report = "Dependency audit\n\n" + "\n\n".join(blocks)
|
|
63
|
+
return (not any_findings), report
|
krnl_agent/deploy.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Auto-deployment — one sentence to a live URL, across many providers.
|
|
2
|
+
|
|
3
|
+
A registry of deploy *targets*, each described declaratively: the CLI it needs, the
|
|
4
|
+
env var(s) that hold its credential, the headless deploy/rollback commands, the
|
|
5
|
+
config file(s) it expects, and whether it has a real free tier. The agent (or the
|
|
6
|
+
`deploy` / `ship` commands) uses this to:
|
|
7
|
+
|
|
8
|
+
* `readiness()` — which targets are usable right now (CLI installed + token set),
|
|
9
|
+
* `suggest()` — pick a sensible default target for a detected stack,
|
|
10
|
+
* `deploy_command()` / `rollback_command()` — build the exact shell command,
|
|
11
|
+
* `config_template()` — scaffold the platform config file.
|
|
12
|
+
|
|
13
|
+
Nothing here runs a command itself — building the command is separated from running
|
|
14
|
+
it so the loop can route execution through the normal approval + sandbox + audit
|
|
15
|
+
gates. Credentials are referenced by env-var name only and never read into strings.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
# kinds: edge | static | paas | container | cloud | db
|
|
24
|
+
@dataclass
|
|
25
|
+
class Target:
|
|
26
|
+
name: str
|
|
27
|
+
kind: str
|
|
28
|
+
cli: str # executable that must be installed
|
|
29
|
+
token_envs: list[str] # env vars holding the credential (any present = ok)
|
|
30
|
+
deploy_cmd: str # headless, non-interactive
|
|
31
|
+
rollback_cmd: str = "" # "" = no first-class rollback
|
|
32
|
+
config_files: list[str] = field(default_factory=list)
|
|
33
|
+
free_tier: bool = True
|
|
34
|
+
billable: bool = False # may create billable infra even on free plans
|
|
35
|
+
note: str = ""
|
|
36
|
+
health_supported: bool = True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Ordered roughly by agent-friendliness (one-command source->URL first).
|
|
40
|
+
TARGETS: dict[str, Target] = {
|
|
41
|
+
# --- Edge / serverless (true one-command, perpetual free tier) ----------- #
|
|
42
|
+
"cloudrun": Target(
|
|
43
|
+
"cloudrun", "cloud", "gcloud", ["GOOGLE_APPLICATION_CREDENTIALS"],
|
|
44
|
+
"gcloud run deploy {service} --source . --region {region} --quiet "
|
|
45
|
+
"--allow-unauthenticated --format json",
|
|
46
|
+
"gcloud run services update-traffic {service} --to-revisions {revision}=100 "
|
|
47
|
+
"--region {region} --quiet",
|
|
48
|
+
["Dockerfile (optional — buildpacks used otherwise)"],
|
|
49
|
+
free_tier=True, note="Always-free 2M req/mo; source->URL in one command."),
|
|
50
|
+
"cloudflare": Target(
|
|
51
|
+
"cloudflare", "edge", "wrangler", ["CLOUDFLARE_API_TOKEN"],
|
|
52
|
+
"npx wrangler deploy",
|
|
53
|
+
"npx wrangler rollback",
|
|
54
|
+
["wrangler.toml"],
|
|
55
|
+
free_tier=True, note="100k req/day free, no cold start, commercial OK."),
|
|
56
|
+
"cfpages": Target(
|
|
57
|
+
"cfpages", "static", "wrangler", ["CLOUDFLARE_API_TOKEN"],
|
|
58
|
+
"npx wrangler pages deploy {dist}",
|
|
59
|
+
"", ["wrangler.toml (optional)"],
|
|
60
|
+
free_tier=True, note="Static/JAMstack on Cloudflare Pages."),
|
|
61
|
+
# --- PaaS / static (low friction) --------------------------------------- #
|
|
62
|
+
"vercel": Target(
|
|
63
|
+
"vercel", "paas", "vercel", ["VERCEL_TOKEN"],
|
|
64
|
+
"vercel deploy --prod --yes --token $VERCEL_TOKEN",
|
|
65
|
+
"vercel rollback {deployment} --yes --token $VERCEL_TOKEN",
|
|
66
|
+
["vercel.json"],
|
|
67
|
+
free_tier=True, note="Hobby tier is non-commercial; great for Next.js/front-end."),
|
|
68
|
+
"netlify": Target(
|
|
69
|
+
"netlify", "static", "netlify", ["NETLIFY_AUTH_TOKEN"],
|
|
70
|
+
"netlify deploy --prod --dir {dist}",
|
|
71
|
+
"", ["netlify.toml"],
|
|
72
|
+
free_tier=True, note="Generous free static hosting."),
|
|
73
|
+
"fly": Target(
|
|
74
|
+
"fly", "container", "fly", ["FLY_API_TOKEN"],
|
|
75
|
+
"fly deploy --remote-only",
|
|
76
|
+
"fly releases rollback {version}",
|
|
77
|
+
["fly.toml"],
|
|
78
|
+
free_tier=False, billable=True, note="No free tier (trial only); needs a card."),
|
|
79
|
+
"railway": Target(
|
|
80
|
+
"railway", "paas", "railway", ["RAILWAY_TOKEN", "RAILWAY_API_TOKEN"],
|
|
81
|
+
"railway up --ci",
|
|
82
|
+
"", ["railway.json"],
|
|
83
|
+
free_tier=False, billable=True, note="Trial credit only (~$5)."),
|
|
84
|
+
"render": Target(
|
|
85
|
+
"render", "paas", "render", ["RENDER_API_KEY"],
|
|
86
|
+
"render deploys create {service} --confirm --wait --output json",
|
|
87
|
+
"render rollback {service} --confirm",
|
|
88
|
+
["render.yaml"],
|
|
89
|
+
free_tier=True, note="Free web service spins down when idle. Deploy hooks also work."),
|
|
90
|
+
# --- Containers / orchestration ----------------------------------------- #
|
|
91
|
+
"docker": Target(
|
|
92
|
+
"docker", "container", "docker", ["DOCKER_TOKEN", "DOCKER_PASSWORD"],
|
|
93
|
+
"docker buildx build --platform linux/amd64 -t {image} --push .",
|
|
94
|
+
"", ["Dockerfile", ".dockerignore"],
|
|
95
|
+
free_tier=True, note="Builds & pushes an image to a registry; pair with a runtime."),
|
|
96
|
+
"helm": Target(
|
|
97
|
+
"helm", "container", "helm", ["KUBECONFIG"],
|
|
98
|
+
"helm upgrade --install {release} {chart} -f values.yaml --atomic --timeout 5m",
|
|
99
|
+
"helm rollback {release} {revision}",
|
|
100
|
+
["Chart.yaml", "values.yaml"],
|
|
101
|
+
free_tier=True, billable=True,
|
|
102
|
+
note="--atomic auto-rolls-back on failed deploy (self-healing primitive)."),
|
|
103
|
+
"kubernetes": Target(
|
|
104
|
+
"kubernetes", "container", "kubectl", ["KUBECONFIG"],
|
|
105
|
+
"kubectl apply -f {manifest}",
|
|
106
|
+
"kubectl rollout undo deployment/{deployment}",
|
|
107
|
+
["*.yaml"],
|
|
108
|
+
free_tier=True, billable=True, note="Cluster costs apply."),
|
|
109
|
+
# --- Big cloud ---------------------------------------------------------- #
|
|
110
|
+
"aws-sam": Target(
|
|
111
|
+
"aws-sam", "cloud", "sam", ["AWS_ACCESS_KEY_ID"],
|
|
112
|
+
"sam deploy --no-confirm-changeset --resolve-s3 --capabilities CAPABILITY_IAM",
|
|
113
|
+
"", ["template.yaml", "samconfig.toml"],
|
|
114
|
+
free_tier=True, billable=True, note="Smoothest AWS path after first --guided run."),
|
|
115
|
+
"aws-apprunner": Target(
|
|
116
|
+
"aws-apprunner", "cloud", "aws", ["AWS_ACCESS_KEY_ID"],
|
|
117
|
+
"aws apprunner create-service --cli-input-json file://apprunner.json",
|
|
118
|
+
"", ["apprunner.json"],
|
|
119
|
+
free_tier=False, billable=True, note="Consumption-billed container service."),
|
|
120
|
+
"azure-aca": Target(
|
|
121
|
+
"azure-aca", "cloud", "az", ["AZURE_CLIENT_ID"],
|
|
122
|
+
"az containerapp up --name {service} --source .",
|
|
123
|
+
"", ["app.yaml (optional)"],
|
|
124
|
+
free_tier=True, billable=True, note="Always-free 2M req/mo; source->URL."),
|
|
125
|
+
# --- Databases (provisioned before the app) ----------------------------- #
|
|
126
|
+
"neon": Target(
|
|
127
|
+
"neon", "db", "neonctl", ["NEON_API_KEY"],
|
|
128
|
+
"neonctl projects create --name {name} --output json",
|
|
129
|
+
"", [], free_tier=True,
|
|
130
|
+
note="Serverless Postgres, instant branches, generous free tier."),
|
|
131
|
+
"supabase": Target(
|
|
132
|
+
"supabase", "db", "supabase", ["SUPABASE_ACCESS_TOKEN"],
|
|
133
|
+
"supabase db push",
|
|
134
|
+
"", ["supabase/config.toml"], free_tier=True,
|
|
135
|
+
note="Full backend: Postgres + auth + storage + edge functions."),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Default target preference order for an autodetected app (free + one-command first).
|
|
139
|
+
_DEFAULT_ORDER = ["cloudrun", "cloudflare", "vercel", "netlify", "render", "fly",
|
|
140
|
+
"railway", "azure-aca", "aws-sam", "helm"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def target(name: str) -> Target | None:
|
|
144
|
+
return TARGETS.get(name)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def has_token(t: Target) -> bool:
|
|
148
|
+
return any(os.getenv(e) for e in t.token_envs)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cli_installed(t: Target) -> bool:
|
|
152
|
+
return shutil.which(t.cli) is not None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def readiness() -> list[dict]:
|
|
156
|
+
"""One row per target: is the CLI installed, is a token set, is it usable now."""
|
|
157
|
+
rows = []
|
|
158
|
+
for t in TARGETS.values():
|
|
159
|
+
rows.append({
|
|
160
|
+
"name": t.name, "kind": t.kind, "cli": t.cli,
|
|
161
|
+
"cli_ok": cli_installed(t),
|
|
162
|
+
"token_env": " or ".join(t.token_envs),
|
|
163
|
+
"token_ok": has_token(t),
|
|
164
|
+
"ready": cli_installed(t) and has_token(t),
|
|
165
|
+
"free_tier": t.free_tier, "billable": t.billable, "note": t.note,
|
|
166
|
+
})
|
|
167
|
+
return rows
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def suggest(detected_stack: str = "", prefer_ready: bool = True) -> str | None:
|
|
171
|
+
"""Pick a default deploy target. Prefer one that's ready (CLI+token), else the
|
|
172
|
+
most agent-friendly free option for the order list."""
|
|
173
|
+
order = list(_DEFAULT_ORDER)
|
|
174
|
+
s = (detected_stack or "").lower()
|
|
175
|
+
if any(k in s for k in ("next", "react", "vite", "static", "astro", "vue")):
|
|
176
|
+
order = ["vercel", "netlify", "cloudflare", "cfpages"] + order
|
|
177
|
+
if prefer_ready:
|
|
178
|
+
for name in order:
|
|
179
|
+
t = TARGETS.get(name)
|
|
180
|
+
if t and cli_installed(t) and has_token(t):
|
|
181
|
+
return name
|
|
182
|
+
for name in order:
|
|
183
|
+
if name in TARGETS:
|
|
184
|
+
return name
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _fill(cmd: str, params: dict) -> str:
|
|
189
|
+
out = cmd
|
|
190
|
+
for k, v in (params or {}).items():
|
|
191
|
+
out = out.replace("{" + k + "}", str(v))
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def deploy_command(name: str, params: dict | None = None) -> tuple[bool, str]:
|
|
196
|
+
"""Return (ok, command-or-error). Does NOT run it."""
|
|
197
|
+
t = TARGETS.get(name)
|
|
198
|
+
if not t:
|
|
199
|
+
return False, f"Unknown deploy target '{name}'. Known: {', '.join(TARGETS)}"
|
|
200
|
+
if not cli_installed(t):
|
|
201
|
+
return False, f"'{t.cli}' CLI not installed for target {name}."
|
|
202
|
+
if not has_token(t):
|
|
203
|
+
return False, (f"No credential for {name}: set one of "
|
|
204
|
+
f"{', '.join(t.token_envs)} in the environment.")
|
|
205
|
+
return True, _fill(t.deploy_cmd, params or {})
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def rollback_command(name: str, params: dict | None = None) -> tuple[bool, str]:
|
|
209
|
+
t = TARGETS.get(name)
|
|
210
|
+
if not t:
|
|
211
|
+
return False, f"Unknown target '{name}'."
|
|
212
|
+
if not t.rollback_cmd:
|
|
213
|
+
return False, f"{name} has no first-class rollback command."
|
|
214
|
+
return True, _fill(t.rollback_cmd, params or {})
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---- config-file scaffolds (minimal, safe starting points) ----------------- #
|
|
218
|
+
_CONFIGS = {
|
|
219
|
+
"fly.toml": (
|
|
220
|
+
'app = "{app}"\nprimary_region = "{region}"\n\n'
|
|
221
|
+
'[build]\n\n[http_service]\n internal_port = {port}\n force_https = true\n'
|
|
222
|
+
' auto_stop_machines = true\n auto_start_machines = true\n min_machines_running = 0\n'),
|
|
223
|
+
"render.yaml": (
|
|
224
|
+
"services:\n - type: web\n name: {app}\n runtime: docker\n"
|
|
225
|
+
" plan: free\n healthCheckPath: /health\n"),
|
|
226
|
+
"vercel.json": '{\n "version": 2\n}\n',
|
|
227
|
+
"wrangler.toml": (
|
|
228
|
+
'name = "{app}"\nmain = "{entry}"\ncompatibility_date = "2024-01-01"\n'),
|
|
229
|
+
"Dockerfile.python": (
|
|
230
|
+
"FROM python:3.12-slim\nWORKDIR /app\nCOPY requirements.txt .\n"
|
|
231
|
+
"RUN pip install --no-cache-dir -r requirements.txt\nCOPY . .\n"
|
|
232
|
+
"EXPOSE {port}\nCMD [\"python\", \"{entry}\"]\n"),
|
|
233
|
+
"Dockerfile.node": (
|
|
234
|
+
"FROM node:20-slim\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev\n"
|
|
235
|
+
"COPY . .\nEXPOSE {port}\nCMD [\"node\", \"{entry}\"]\n"),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def config_template(kind: str, params: dict | None = None) -> str | None:
|
|
240
|
+
tpl = _CONFIGS.get(kind)
|
|
241
|
+
if tpl is None:
|
|
242
|
+
return None
|
|
243
|
+
p = {"app": "my-app", "region": "iad", "port": 8080, "entry": "main.py"}
|
|
244
|
+
p.update(params or {})
|
|
245
|
+
return _fill(tpl, p)
|