my harness for niri
1import { randomBytes } from "crypto"
2import path from "path"
3import {
4 DEFAULT_COMMAND_TIMEOUT_MS,
5 DEFAULT_FILE_TIMEOUT_MS,
6 IMAGE_MAX_BYTES,
7 IMAGE_ROOT,
8 normalizeTimeoutMs,
9 resolveMaxLines,
10} from "./config.js"
11import { runRaw } from "./shell.js"
12import type { EditResult, ImageToolPayload, ModelImageInput } from "./types.js"
13
14/**
15 * Run a shell command.
16 * Output is capped at `maxLines` lines (default 150, 40 for known-verbose commands).
17 * When truncated, a "[truncated]" header is prepended so niri knows lines were dropped.
18 * Pass `maxLines: 0` to disable truncation entirely.
19 *
20 * @param command - Shell command to execute.
21 * @param maxLines - Optional line cap override (`0` disables truncation).
22 * @param timeoutMs - Optional command timeout in milliseconds.
23 * @returns Cleaned command output, potentially truncated with a header.
24 */
25export async function runCommand(command: string, maxLines?: number, timeoutMs?: number): Promise<string> {
26 const cap = resolveMaxLines(command, maxLines)
27 const raw = await runRaw(command, {
28 timeoutMs: normalizeTimeoutMs(timeoutMs, DEFAULT_COMMAND_TIMEOUT_MS),
29 })
30
31 if (cap === 0) return raw
32
33 const lines = raw.split("\n")
34 if (lines.length <= cap) return raw
35
36 const kept = lines.slice(-cap)
37 return `[truncated — showing last ${cap} of ${lines.length} lines]\n${kept.join("\n")}`
38}
39
40function normalizeImagePath(filePath: string): string {
41 const raw = String(filePath ?? "").trim()
42 if (!raw) throw new Error("image path is required")
43 const normalized = path.posix.normalize(raw)
44 if (!(normalized === IMAGE_ROOT || normalized.startsWith(`${IMAGE_ROOT}/`))) {
45 throw new Error(`image path must be inside ${IMAGE_ROOT}`)
46 }
47 return normalized
48}
49
50/**
51 * Read an image from the container and encode it as a data URL for multimodal input.
52 * Only paths inside the configured IMAGE_ROOT are allowed.
53 *
54 * @param filePath - Absolute image path inside the allowed image root.
55 * @param timeoutMs - Optional read timeout in milliseconds.
56 * @returns Parsed image payload suitable for model multimodal input.
57 * @throws If validation, decoding, or path checks fail.
58 */
59export async function readImageForModel(filePath: string, timeoutMs?: number): Promise<ModelImageInput> {
60 const safePath = normalizeImagePath(filePath)
61 const opTimeoutMs = normalizeTimeoutMs(timeoutMs, DEFAULT_FILE_TIMEOUT_MS)
62
63 const py = [
64 "import base64, imghdr, json, mimetypes, os, stat, sys, warnings",
65 "warnings.filterwarnings('ignore', category=DeprecationWarning)",
66 "path = sys.argv[1]",
67 "max_bytes = int(sys.argv[2])",
68 "def out(obj):",
69 " print(json.dumps(obj, ensure_ascii=False))",
70 "try:",
71 " st = os.stat(path)",
72 "except FileNotFoundError:",
73 " out({'ok': False, 'message': f'file not found: {path}'})",
74 " raise SystemExit(0)",
75 "except Exception as e:",
76 " out({'ok': False, 'message': f'could not stat {path}: {e}'})",
77 " raise SystemExit(0)",
78 "if not stat.S_ISREG(st.st_mode):",
79 " out({'ok': False, 'message': f'not a regular file: {path}'})",
80 " raise SystemExit(0)",
81 "if st.st_size <= 0:",
82 " out({'ok': False, 'message': f'file is empty: {path}'})",
83 " raise SystemExit(0)",
84 "if st.st_size > max_bytes:",
85 " out({'ok': False, 'message': f'file too large: {st.st_size} bytes (max {max_bytes})'})",
86 " raise SystemExit(0)",
87 "with open(path, 'rb') as f:",
88 " data = f.read()",
89 "kind = imghdr.what(None, data)",
90 "mime_map = {",
91 " 'jpeg': 'image/jpeg',",
92 " 'png': 'image/png',",
93 " 'gif': 'image/gif',",
94 " 'webp': 'image/webp',",
95 " 'bmp': 'image/bmp',",
96 " 'tiff': 'image/tiff',",
97 "}",
98 "mime = mime_map.get(kind)",
99 "if not mime:",
100 " guessed, _ = mimetypes.guess_type(path)",
101 " if guessed and guessed.startswith('image/'):",
102 " mime = guessed",
103 "if not mime:",
104 " out({'ok': False, 'message': f'unsupported image type: {path}'})",
105 " raise SystemExit(0)",
106 "encoded = base64.b64encode(data).decode('ascii')",
107 "out({",
108 " 'ok': True,",
109 " 'path': path,",
110 " 'mime': mime,",
111 " 'bytes': len(data),",
112 " 'data_url': 'data:' + mime + ';base64,' + encoded,",
113 "})",
114 ].join("\n")
115
116 const raw = await runRaw(
117 `python3 -c ${shellQuote(py)} ${shellQuote(safePath)} ${shellQuote(String(IMAGE_MAX_BYTES))}`,
118 { timeoutMs: opTimeoutMs },
119 )
120
121 let parsed: ImageToolPayload
122
123 try {
124 parsed = JSON.parse(raw.trim()) as ImageToolPayload
125 } catch {
126 throw new Error(`image_tool failed for ${safePath}: ${raw.trim() || "unknown error"}`)
127 }
128
129 if (!parsed?.ok) {
130 throw new Error(parsed?.message ?? `image_tool failed for ${safePath}`)
131 }
132
133 const bytes = parsed.bytes
134 if (!parsed.path || !parsed.mime || !parsed.data_url || typeof bytes !== "number" || !Number.isFinite(bytes)) {
135 throw new Error(`image_tool returned invalid payload for ${safePath}`)
136 }
137
138 return {
139 path: parsed.path,
140 mime: parsed.mime,
141 bytes,
142 dataUrl: parsed.data_url,
143 }
144}
145
146/**
147 * Escape a string for safe interpolation into a POSIX shell command.
148 */
149function shellQuote(str: string): string {
150 return "'" + str.replace(/'/g, "'\\''") + "'"
151}
152
153/**
154 * Read a file from the container with optional line-range selection.
155 * Returns content with a metadata header showing the line range and total line count.
156 * Defaults to lines 1–100 when no range is given.
157 *
158 * @param filePath - Path to the file inside the container.
159 * @param startLine - Inclusive 1-based start line.
160 * @param endLine - Optional inclusive 1-based end line.
161 * @param timeoutMs - Optional read timeout in milliseconds.
162 * @returns Header plus selected file content.
163 */
164export async function readFile(filePath: string, startLine = 1, endLine?: number, timeoutMs?: number): Promise<string> {
165 const start = Math.max(1, Math.trunc(Number(startLine)) || 1)
166 const end = endLine !== undefined ? Math.max(start, Math.trunc(Number(endLine)) || start) : undefined
167 const opTimeoutMs = normalizeTimeoutMs(timeoutMs, DEFAULT_FILE_TIMEOUT_MS)
168
169 if (!Number.isFinite(start) || (end !== undefined && !Number.isFinite(end))) {
170 throw new Error(`readFile: invalid line range (${startLine}, ${endLine})`)
171 }
172
173 const quoted = shellQuote(filePath)
174
175 const countRaw = await runRaw(`wc -l < ${quoted} 2>/dev/null || echo 0`, { timeoutMs: opTimeoutMs })
176 const totalLines = parseInt(countRaw.trim(), 10) || 0
177
178 const effectiveEnd = end ?? Math.min(start + 99, totalLines > 0 ? totalLines : start + 99)
179
180 const content = await runRaw(`sed -n '${start},${effectiveEnd}p' ${quoted} 2>&1`, { timeoutMs: opTimeoutMs })
181
182 const rangeStr = `lines ${start}–${effectiveEnd}`
183 const totalStr = totalLines > 0 ? ` of ${totalLines} total` : ""
184 const header = `[${filePath} ${rangeStr}${totalStr}]\n`
185
186 return header + content
187}
188
189/**
190 * Edit a file by replacing an exact snippet of text.
191 * old_text must appear exactly once; errors clearly if 0 or >1 matches.
192 *
193 * Uses an in-container python transform so we do not stream huge file bodies
194 * through the PTY (which is fragile on very large files).
195 *
196 * @param filePath - File path to edit.
197 * @param oldText - Exact text to replace (must match exactly once).
198 * @param newText - Replacement text.
199 * @param timeoutMs - Optional operation timeout in milliseconds.
200 * @returns Structured edit result describing success/failure.
201 */
202export async function editFile(filePath: string, oldText: string, newText: string, timeoutMs?: number): Promise<EditResult> {
203 if (oldText.length === 0) {
204 return { ok: false, message: "old_text must not be empty" }
205 }
206
207 const opTimeoutMs = normalizeTimeoutMs(timeoutMs, DEFAULT_FILE_TIMEOUT_MS)
208
209 const payload = Buffer.from(
210 JSON.stringify({ path: filePath, old_text: oldText, new_text: newText }),
211 "utf8",
212 ).toString("base64")
213 const payloadLines = payload.match(/.{1,76}/g)?.join("\n") ?? payload
214 const heredocToken = `NIRI_EDIT_${randomBytes(8).toString("hex").toUpperCase()}`
215
216 const py = [
217 "import base64, json, sys",
218 "def out(obj):",
219 " print(json.dumps(obj, ensure_ascii=False))",
220 "payload = json.loads(base64.b64decode(sys.stdin.read()).decode('utf-8'))",
221 "path = payload.get('path', '')",
222 "old = payload.get('old_text', '')",
223 "new = payload.get('new_text', '')",
224 "if old == '':",
225 " out({'ok': False, 'message': 'old_text must not be empty'})",
226 " raise SystemExit(0)",
227 "try:",
228 " with open(path, 'r', encoding='utf-8') as f:",
229 " content = f.read()",
230 "except FileNotFoundError:",
231 " out({'ok': False, 'message': f'could not read {path}: file not found'})",
232 " raise SystemExit(0)",
233 "except Exception as e:",
234 " out({'ok': False, 'message': f'could not read {path}: {e}'})",
235 " raise SystemExit(0)",
236 "count = content.count(old)",
237 "if count == 0:",
238 " out({'ok': False, 'message': f'old_text not found in {path}'})",
239 " raise SystemExit(0)",
240 "if count > 1:",
241 " out({'ok': False, 'message': f'old_text found {count} times in {path} — must be unique'})",
242 " raise SystemExit(0)",
243 "updated = content.replace(old, new, 1)",
244 "try:",
245 " with open(path, 'w', encoding='utf-8') as f:",
246 " f.write(updated)",
247 "except Exception as e:",
248 " out({'ok': False, 'message': f'could not write {path}: {e}'})",
249 " raise SystemExit(0)",
250 "out({'ok': True})",
251 ].join("\n")
252
253 const raw = await runRaw(
254 `python3 -c ${shellQuote(py)} << '${heredocToken}'\n${payloadLines}\n${heredocToken}`,
255 { timeoutMs: opTimeoutMs },
256 )
257
258 let parsed: { ok?: boolean; message?: string } | null = null
259 try {
260 parsed = JSON.parse(raw.trim()) as { ok?: boolean; message?: string }
261 } catch {
262 return { ok: false, message: `edit failed for ${filePath}: ${raw.trim() || "unknown error"}` }
263 }
264
265 if (!parsed.ok) {
266 return { ok: false, message: parsed.message ?? `edit failed for ${filePath}` }
267 }
268
269 const linesDelta = newText.split("\n").length - oldText.split("\n").length
270 const sign = linesDelta >= 0 ? "+" : ""
271 return {
272 ok: true,
273 message: `edited ${filePath} (${sign}${linesDelta} lines)`,
274 }
275}