hilos-agent 0.9.0 → 0.9.2

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.
package/src/argv.mjs ADDED
@@ -0,0 +1,61 @@
1
+ // Small shell-word parser for configured CLI commands. hilos never runs these
2
+ // strings through a shell; this preserves quoted paths/values while keeping the
3
+ // spawn boundary injection-safe. It intentionally supports words, single and
4
+ // double quotes, and backslash-escaped whitespace/quotes—not expansions, pipes,
5
+ // or redirects. Other backslashes stay literal so Windows and UNC paths work.
6
+
7
+ export function commandArgv(command) {
8
+ const input = String(command || "");
9
+ const out = [];
10
+ let word = "";
11
+ let quote = "";
12
+ let started = false;
13
+ for (let index = 0; index < input.length; index += 1) {
14
+ const char = input[index];
15
+ if (char === "\\" && quote !== "'") {
16
+ const next = input[index + 1];
17
+ const escapesQuote = next === '"' || (!quote && next === "'");
18
+ const escapesWhitespace = !quote && typeof next === "string" && /\s/.test(next);
19
+ if (escapesQuote || escapesWhitespace) {
20
+ word += next;
21
+ index += 1;
22
+ } else {
23
+ word += "\\";
24
+ }
25
+ started = true;
26
+ continue;
27
+ }
28
+ if (quote) {
29
+ if (char === quote) quote = "";
30
+ else word += char;
31
+ started = true;
32
+ continue;
33
+ }
34
+ if (char === "'" || char === '"') {
35
+ quote = char;
36
+ started = true;
37
+ continue;
38
+ }
39
+ if (/\s/.test(char)) {
40
+ if (started) {
41
+ out.push(word);
42
+ word = "";
43
+ started = false;
44
+ }
45
+ continue;
46
+ }
47
+ word += char;
48
+ started = true;
49
+ }
50
+ if (started) out.push(word);
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * Encode one literal argument for `commandArgv`'s small command grammar.
56
+ * Single-quoted spans preserve whitespace and backslashes verbatim; an
57
+ * embedded apostrophe is represented as an adjacent double-quoted span.
58
+ */
59
+ export function quoteCommandArg(value) {
60
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
61
+ }
@@ -0,0 +1,310 @@
1
+ // Message attachments → files the coding CLI can actually look at (0779).
2
+ //
3
+ // A designer pastes a screenshot and writes "fix this spacing". Before this,
4
+ // the daemon handed its CLI a plain `author: body` transcript and the image was
5
+ // gone — so the agent answered as if nothing had been sent, which reads as the
6
+ // agent being bad rather than the pipe being broken.
7
+ //
8
+ // 0780 put the attachments on the mention itself (`list_mentions` resolves them
9
+ // the same way `read_channel` and `get_thread` do, with a ONE-HOUR signed url).
10
+ // This module turns that into local files.
11
+ //
12
+ // Rules:
13
+ // - FETCH AT EXECUTION, not at enqueue. The url is signed for an hour, so a
14
+ // job that waits behind other work is usually still fine — and downloading
15
+ // at enqueue means a burst of image mentions holds every job's bytes on
16
+ // disk at once. One running job, one temp dir. The tradeoff is stated at
17
+ // fetchMentionImages.
18
+ // - NEVER fail the run. Every error path returns fewer files (or none) and
19
+ // the run proceeds on the transcript alone, which already names the file.
20
+ // - Images only. Neither CLI takes a PDF or a zip as a native input, and
21
+ // downloading one would just be a file nobody opens.
22
+ // - NEVER fetch an internal address. `metadata.attachments` is written by
23
+ // whoever sent the message, and this code runs on the USER'S OWN MACHINE —
24
+ // an unguarded fetch would let a workspace member read the daemon
25
+ // operator's localhost and hand the result to a model. See guardedFetch.
26
+ // - Node builtins only, dependency-free, `fetch` injectable — so this is
27
+ // unit-tested offline with a stubbed fetch, no network and no daemon.
28
+
29
+ import { lookup } from "node:dns/promises";
30
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
31
+ import { tmpdir } from "node:os";
32
+ import { join } from "node:path";
33
+
34
+ /** At most this many images per run — see lib/attachment-context.ts for why. */
35
+ export const MAX_IMAGES = 4;
36
+
37
+ /** Per-image byte ceiling (10MB). Over it the image is skipped, not truncated. */
38
+ export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
39
+
40
+ /** Whole-request deadline per image. A slow host must not eat the run. */
41
+ export const FETCH_TIMEOUT_MS = 20_000;
42
+
43
+ /** Redirect hops we follow ourselves, re-checking the host at every one. */
44
+ const MAX_REDIRECTS = 3;
45
+
46
+ /**
47
+ * Is a resolved IP internal/reserved? Loopback, RFC1918, CGNAT, link-local
48
+ * (which is where 169.254.169.254 cloud metadata lives), IPv6 loopback and
49
+ * unique-local, and IPv4-mapped IPv6. A deliberate twin of `isBlockedIp` in
50
+ * lib/ssrf.ts — the daemon ships as its own npm package and imports no app
51
+ * code; tests pin the two to the same answers.
52
+ */
53
+ export function isBlockedIp(ip) {
54
+ let h = String(ip || "").toLowerCase().replace(/^\[|\]$/g, "").split("%")[0];
55
+ const mapped = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
56
+ if (mapped) h = mapped[1];
57
+ if (h === "::" || h === "::1") return true;
58
+ if (h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd")) return true;
59
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
60
+ if (m) {
61
+ const a = Number(m[1]);
62
+ const b = Number(m[2]);
63
+ if (a === 0 || a === 10 || a === 127) return true;
64
+ if (a === 169 && b === 254) return true;
65
+ if (a === 172 && b >= 16 && b <= 31) return true;
66
+ if (a === 192 && b === 168) return true;
67
+ if (a === 100 && b >= 64 && b <= 127) return true;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ /** Obviously-internal hostname, by name or literal IP. No DNS. */
73
+ export function isBlockedHost(hostname) {
74
+ const h = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
75
+ if (h === "localhost" || h.endsWith(".localhost") || h.endsWith(".local") || h.endsWith(".internal")) {
76
+ return true;
77
+ }
78
+ return isBlockedIp(h);
79
+ }
80
+
81
+ /**
82
+ * May the daemon fetch this url? http(s) only, and no host that is — or
83
+ * resolves to — an internal address. Fails CLOSED: an unparseable url or a
84
+ * failed DNS lookup is a no.
85
+ * @param {string} url
86
+ * @param {{ lookupImpl?: any }} [opts]
87
+ */
88
+ export async function isFetchableUrl(url, opts = {}) {
89
+ let parsed;
90
+ try {
91
+ parsed = new URL(String(url));
92
+ } catch {
93
+ return false;
94
+ }
95
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
96
+ const host = parsed.hostname;
97
+ if (isBlockedHost(host)) return false;
98
+ // A literal IP already passed isBlockedIp above; resolving it again is noise.
99
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return true;
100
+ const lookupImpl = opts.lookupImpl || lookup;
101
+ try {
102
+ const addrs = await lookupImpl(host, { all: true });
103
+ if (!addrs || !addrs.length) return false;
104
+ return !addrs.some((a) => isBlockedIp(a.address));
105
+ } catch {
106
+ return false; // DNS rebinding protection is worth a false negative
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Fetch with the guards on: host-checked at every redirect hop (hence
112
+ * `redirect: "manual"` — following automatically would let a public url bounce
113
+ * to 169.254.169.254 behind our back), a whole-request timeout, and a streamed
114
+ * body with a running byte count so the cap holds even when `content-length` is
115
+ * absent or lying. `arrayBuffer()` would have materialized the whole response
116
+ * before any check could run.
117
+ *
118
+ * Returns a Buffer, or null for every failure — the caller treats null as "no
119
+ * image", never as a failed run.
120
+ * @param {string} url
121
+ * @param {{ fetchImpl?: any, lookupImpl?: any, maxBytes?: number, timeoutMs?: number }} [opts]
122
+ */
123
+ export async function guardedFetch(url, opts = {}) {
124
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
125
+ if (typeof fetchImpl !== "function") return null;
126
+ const maxBytes = opts.maxBytes ?? MAX_IMAGE_BYTES;
127
+ const controller = new AbortController();
128
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? FETCH_TIMEOUT_MS);
129
+ try {
130
+ let current = String(url);
131
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
132
+ if (!(await isFetchableUrl(current, opts))) return null;
133
+ const res = await fetchImpl(current, { signal: controller.signal, redirect: "manual" });
134
+ if (!res) return null;
135
+ const status = Number(res.status ?? 0);
136
+ if (status >= 300 && status < 400) {
137
+ const next = res.headers?.get?.("location");
138
+ if (!next) return null;
139
+ current = new URL(next, current).toString(); // re-checked at the top
140
+ continue;
141
+ }
142
+ if (!res.ok) return null;
143
+ const declared = Number(res.headers?.get?.("content-length") ?? "");
144
+ if (Number.isFinite(declared) && declared > maxBytes) return null;
145
+ if (!res.body || typeof res.body.getReader !== "function") return null;
146
+ const reader = res.body.getReader();
147
+ const chunks = [];
148
+ let total = 0;
149
+ for (;;) {
150
+ const { done, value } = await reader.read();
151
+ if (done) break;
152
+ if (!value) continue;
153
+ total += value.byteLength ?? value.length ?? 0;
154
+ if (total > maxBytes) {
155
+ await reader.cancel().catch(() => {});
156
+ return null;
157
+ }
158
+ chunks.push(Buffer.from(value));
159
+ }
160
+ return total ? Buffer.concat(chunks) : null;
161
+ }
162
+ return null; // too many redirects
163
+ } catch {
164
+ return null;
165
+ } finally {
166
+ clearTimeout(timer);
167
+ }
168
+ }
169
+
170
+ /** True when an attachment is an image a coding CLI can look at. */
171
+ export function isImageAttachment(a) {
172
+ return Boolean(a) && typeof a.type === "string" && a.type.toLowerCase().startsWith("image/");
173
+ }
174
+
175
+ /**
176
+ * The image attachments on a mention, in order, capped. Non-images are dropped
177
+ * deliberately (the transcript still names them).
178
+ * @param {any} message a list_mentions row
179
+ */
180
+ export function pickImageAttachments(message) {
181
+ const list = message && Array.isArray(message.attachments) ? message.attachments : [];
182
+ return list.filter((a) => isImageAttachment(a) && typeof a.url === "string" && a.url).slice(0, MAX_IMAGES);
183
+ }
184
+
185
+ /** A safe on-disk basename (never a path, never empty). */
186
+ export function safeImageFilename(name, index) {
187
+ const base = String(name ?? "").split(/[/\\]/).pop() || "";
188
+ const cleaned = base
189
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
190
+ .replace(/^[-.]+/, "")
191
+ .slice(0, 60);
192
+ return cleaned || `image-${index + 1}.png`;
193
+ }
194
+
195
+ /**
196
+ * Download a mention's images into a fresh temp dir. Returns the files (with
197
+ * absolute paths) plus the dir, which the caller removes with `cleanupImages`
198
+ * once the run is over.
199
+ *
200
+ * Called at EXECUTION time (run.mjs's safeHandle), not at enqueue, so at most
201
+ * one job's images sit on disk however long the queue gets. THE TRADEOFF: the
202
+ * url the server signed lives one hour, and the daemon cannot re-resolve one
203
+ * (no tool re-signs a single message's attachments), so a job that waits more
204
+ * than an hour behind other work loses its images and runs text-only. That is
205
+ * the right way to lose: the transcript still names the file, and the
206
+ * alternative was every queued job's bytes held on the user's disk at once.
207
+ *
208
+ * Returns `{ dir: null, files: [] }` when there is nothing to fetch or when
209
+ * everything failed — the caller treats that as "text-only", which is exactly
210
+ * the pre-0779 behavior.
211
+ *
212
+ * @param {any} message
213
+ * @param {{ fetchImpl?: any, lookupImpl?: any, log?: any }} [opts]
214
+ */
215
+ export async function fetchMentionImages(message, opts = {}) {
216
+ const picked = pickImageAttachments(message);
217
+ if (!picked.length) return { dir: null, files: [] };
218
+ let dir = null;
219
+ try {
220
+ dir = await mkdtemp(join(tmpdir(), "hilos-attach-"));
221
+ } catch {
222
+ return { dir: null, files: [] };
223
+ }
224
+ const files = [];
225
+ for (const [i, a] of picked.entries()) {
226
+ try {
227
+ const bytes = await guardedFetch(a.url, opts);
228
+ if (!bytes) continue;
229
+ const path = join(dir, `${i + 1}-${safeImageFilename(a.name, i)}`);
230
+ await writeFile(path, bytes);
231
+ files.push({ path, name: a.name || "attachment", type: a.type || "" });
232
+ } catch (e) {
233
+ opts.log?.error?.(`couldn't fetch an attachment: ${e?.message || e}`);
234
+ }
235
+ }
236
+ if (!files.length) {
237
+ await cleanupImages(dir);
238
+ return { dir: null, files: [] };
239
+ }
240
+ return { dir, files };
241
+ }
242
+
243
+ /** Remove a fetched-image temp dir. Best-effort and never throws. */
244
+ export async function cleanupImages(dir) {
245
+ if (!dir) return;
246
+ try {
247
+ await rm(dir, { recursive: true, force: true });
248
+ } catch {
249
+ /* a leftover temp dir is the OS's problem, never the run's */
250
+ }
251
+ }
252
+
253
+ const TOKEN_RE = /!\[([^\]]*)\]\(attachment:([^)\s]*)\)/g;
254
+
255
+ /**
256
+ * Replace every `![name](attachment:<id>)` pill token with the plain name. The
257
+ * daemon's models saw those raw tokens too — `fetchContext` builds its
258
+ * transcript straight from `body`, so an unresolvable `attachment:<uuid>`
259
+ * reached every chat reply and every routing decision.
260
+ */
261
+ export function stripAttachmentTokens(body) {
262
+ return String(body ?? "")
263
+ .replace(TOKEN_RE, (_m, alt) => (String(alt).trim() ? String(alt).trim() : "attachment"))
264
+ .replace(/[ \t]+\n/g, "\n")
265
+ .trim();
266
+ }
267
+
268
+ /**
269
+ * One transcript line's text: the body with pill tokens flattened, plus an
270
+ * honest `[attached: name (type)]` marker per attachment.
271
+ *
272
+ * TWIN of `renderAttachmentLine` in lib/attachment-context.ts. It takes the
273
+ * already-resolved `attachments` array that read_channel / get_thread /
274
+ * list_mentions return, where the server version takes raw `metadata`; the two
275
+ * are pinned to identical output by matching tests. Change one, change both.
276
+ */
277
+ export function renderAttachmentLine(body, attachments) {
278
+ const clean = stripAttachmentTokens(body);
279
+ const list = Array.isArray(attachments) ? attachments : [];
280
+ const markers = list
281
+ .map((a) => {
282
+ const name = (a && typeof a.name === "string" && a.name.trim()) || "attachment";
283
+ const type = a && typeof a.type === "string" ? a.type.trim() : "";
284
+ return type ? `[attached: ${name} (${type})]` : `[attached: ${name}]`;
285
+ })
286
+ .join(" ");
287
+ if (!markers) return clean;
288
+ return clean ? `${clean} ${markers}` : markers;
289
+ }
290
+
291
+ /**
292
+ * The prompt block that connects an image to the request. Without it the file
293
+ * is a path the agent has no reason to open — and even a CLI that took the
294
+ * image natively can't tell which of them the person meant.
295
+ *
296
+ * `readable: true` for a vendor with no image flag (Claude Code): the file is
297
+ * on disk and the agent opens it with its own file-read tool.
298
+ */
299
+ export function imagePromptNote(files, opts = {}) {
300
+ if (!files || !files.length) return "";
301
+ const lines = files.map((f) => `- ${f.path} (${f.name}${f.type ? `, ${f.type}` : ""})`);
302
+ const head =
303
+ files.length === 1
304
+ ? "The person attached this image to the request:"
305
+ : `The person attached these ${files.length} images to the request:`;
306
+ const tail = opts.readable
307
+ ? "Open each file to look at it before you start — it is part of the request, not decoration."
308
+ : "They are attached to this prompt — look at them before you start.";
309
+ return [head, ...lines, tail].join("\n");
310
+ }