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/README.md +80 -16
- package/bin/hilos-agent.mjs +16 -6
- package/package.json +1 -1
- package/src/acp-session.mjs +69 -54
- package/src/agent-events.mjs +645 -45
- package/src/argv.mjs +61 -0
- package/src/attachments.mjs +310 -0
- package/src/claude-permissions.mjs +445 -0
- package/src/cli.mjs +56 -0
- package/src/codex-mcp-session.mjs +619 -0
- package/src/config.mjs +83 -7
- package/src/handler.mjs +914 -77
- package/src/hook.mjs +793 -108
- package/src/mcp-loopback.mjs +142 -0
- package/src/mcp.mjs +3 -2
- package/src/model-resolve.mjs +180 -11
- package/src/permission-gate.mjs +269 -0
- package/src/progress-emitter.mjs +100 -5
- package/src/queue.mjs +21 -5
- package/src/redact.mjs +11 -1
- package/src/reply-bridge.mjs +847 -0
- package/src/resume.mjs +48 -11
- package/src/run.mjs +130 -4
- package/src/transcript.mjs +153 -0
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 `` 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
|
+
}
|