hilos-agent 0.9.1 → 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 +34 -14
- package/bin/hilos-agent.mjs +3 -1
- package/package.json +1 -1
- package/src/argv.mjs +61 -0
- package/src/hook.mjs +429 -61
- package/src/mcp-loopback.mjs +142 -0
- package/src/mcp.mjs +2 -2
- package/src/progress-emitter.mjs +5 -2
- package/src/reply-bridge.mjs +412 -49
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Turn-scoped loopback transport for a resumed Codex session (0854).
|
|
2
|
+
//
|
|
3
|
+
// Codex's remote MCP auth is normally an environment variable. The daemon
|
|
4
|
+
// deliberately strips HILOS_* secrets from coding children, and handing a
|
|
5
|
+
// persistent workspace token to a model-visible shell would undo that safety.
|
|
6
|
+
// Instead, keep the token in the daemon process and expose a random loopback
|
|
7
|
+
// URL only for the lifetime of one continuation. Codex can use every tool its
|
|
8
|
+
// agent identity is authorized for; it never receives the bearer credential.
|
|
9
|
+
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { DAEMON_CLIENT } from "./mcp.mjs";
|
|
13
|
+
|
|
14
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
const FORWARDED_RESPONSE_HEADERS = [
|
|
16
|
+
"content-type",
|
|
17
|
+
"mcp-session-id",
|
|
18
|
+
"mcp-protocol-version",
|
|
19
|
+
"www-authenticate",
|
|
20
|
+
];
|
|
21
|
+
const FORWARDED_REQUEST_HEADERS = [
|
|
22
|
+
"content-type",
|
|
23
|
+
"accept",
|
|
24
|
+
"mcp-session-id",
|
|
25
|
+
"mcp-protocol-version",
|
|
26
|
+
"mcp-method",
|
|
27
|
+
"mcp-name",
|
|
28
|
+
];
|
|
29
|
+
const MAX_HEADER_LENGTH = 4096;
|
|
30
|
+
|
|
31
|
+
function readBody(request) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
let size = 0;
|
|
34
|
+
const chunks = [];
|
|
35
|
+
request.on("data", (chunk) => {
|
|
36
|
+
size += chunk.length;
|
|
37
|
+
if (size > MAX_BODY_BYTES) {
|
|
38
|
+
reject(new Error("request body too large"));
|
|
39
|
+
request.destroy();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
chunks.push(chunk);
|
|
43
|
+
});
|
|
44
|
+
request.on("end", () => resolve(Buffer.concat(chunks)));
|
|
45
|
+
request.on("error", reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Start an unguessable, loopback-only HTTP proxy to one authenticated hilos MCP
|
|
51
|
+
* endpoint. The caller must close it in finally after the coding turn.
|
|
52
|
+
* @param {{
|
|
53
|
+
* url?: string,
|
|
54
|
+
* token?: string,
|
|
55
|
+
* channelId?: string,
|
|
56
|
+
* fetchImpl?: typeof fetch,
|
|
57
|
+
* }} [options]
|
|
58
|
+
* @returns {Promise<{url: string, close: () => Promise<void>} | null>}
|
|
59
|
+
*/
|
|
60
|
+
export async function startHilosMcpLoopback({ url, token, channelId = "", fetchImpl = fetch } = {}) {
|
|
61
|
+
if (!url || !token) return null;
|
|
62
|
+
const nonce = randomBytes(24).toString("hex");
|
|
63
|
+
const path = `/mcp/${nonce}`;
|
|
64
|
+
const upstream = new URL(url);
|
|
65
|
+
// Preserve the room that caused this turn as MCP's effective context. This
|
|
66
|
+
// does not widen or narrow token access, but it keeps guest-room memory gates
|
|
67
|
+
// and other context-sensitive server policy active even when the model calls
|
|
68
|
+
// a workspace-scoped tool without a channelId argument.
|
|
69
|
+
if (channelId) upstream.searchParams.set("channelId", channelId);
|
|
70
|
+
const upstreamRequests = new Set();
|
|
71
|
+
|
|
72
|
+
const server = createServer(async (request, response) => {
|
|
73
|
+
if (request.url !== path || !["POST", "GET", "DELETE"].includes(request.method || "")) {
|
|
74
|
+
response.writeHead(404).end();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
upstreamRequests.add(controller);
|
|
79
|
+
const abortUpstream = () => controller.abort();
|
|
80
|
+
request.once("aborted", abortUpstream);
|
|
81
|
+
response.once("close", abortUpstream);
|
|
82
|
+
try {
|
|
83
|
+
const body = request.method === "POST" ? await readBody(request) : undefined;
|
|
84
|
+
const headers = {
|
|
85
|
+
authorization: `Bearer ${token}`,
|
|
86
|
+
// This short-lived proxy is part of the persistent daemon's return
|
|
87
|
+
// path. Marking it on-demand would temporarily relabel a live daemon
|
|
88
|
+
// and could suppress the offline/wakeup behavior after the turn.
|
|
89
|
+
"x-hilos-connection-mode": "daemon",
|
|
90
|
+
...(DAEMON_CLIENT ? { "x-hilos-client": DAEMON_CLIENT } : {}),
|
|
91
|
+
};
|
|
92
|
+
for (const name of FORWARDED_REQUEST_HEADERS) {
|
|
93
|
+
const value = request.headers[name];
|
|
94
|
+
if (typeof value === "string" && value.length <= MAX_HEADER_LENGTH) {
|
|
95
|
+
headers[name] = value;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const forwarded = await fetchImpl(upstream, {
|
|
99
|
+
method: request.method,
|
|
100
|
+
headers,
|
|
101
|
+
...(body ? { body } : {}),
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
});
|
|
104
|
+
for (const name of FORWARDED_RESPONSE_HEADERS) {
|
|
105
|
+
const value = forwarded.headers.get(name);
|
|
106
|
+
if (value) response.setHeader(name, value);
|
|
107
|
+
}
|
|
108
|
+
response.writeHead(forwarded.status);
|
|
109
|
+
response.end(Buffer.from(await forwarded.arrayBuffer()));
|
|
110
|
+
} catch {
|
|
111
|
+
if (!response.destroyed && !response.writableEnded) {
|
|
112
|
+
response.writeHead(502, { "content-type": "application/json" });
|
|
113
|
+
response.end(JSON.stringify({ error: "hilos MCP loopback unavailable" }));
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
upstreamRequests.delete(controller);
|
|
117
|
+
request.removeListener("aborted", abortUpstream);
|
|
118
|
+
response.removeListener("close", abortUpstream);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await new Promise((resolve, reject) => {
|
|
123
|
+
server.once("error", reject);
|
|
124
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
125
|
+
});
|
|
126
|
+
const address = server.address();
|
|
127
|
+
if (!address || typeof address === "string") {
|
|
128
|
+
server.close();
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
let closed = false;
|
|
132
|
+
return {
|
|
133
|
+
url: `http://127.0.0.1:${address.port}${path}`,
|
|
134
|
+
async close() {
|
|
135
|
+
if (closed) return;
|
|
136
|
+
closed = true;
|
|
137
|
+
for (const controller of upstreamRequests) controller.abort();
|
|
138
|
+
server.closeAllConnections?.();
|
|
139
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
package/src/mcp.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
|
|
5
5
|
// Best-effort host label so hilos can show "whose machine" this daemon runs on.
|
|
6
|
-
const
|
|
6
|
+
export const DAEMON_CLIENT = (() => {
|
|
7
7
|
try {
|
|
8
8
|
return `${os.userInfo().username}@${os.hostname()}`;
|
|
9
9
|
} catch {
|
|
@@ -21,7 +21,7 @@ export function makeClient({ url, token }) {
|
|
|
21
21
|
"content-type": "application/json",
|
|
22
22
|
authorization: `Bearer ${token}`,
|
|
23
23
|
"x-hilos-connection-mode": "daemon",
|
|
24
|
-
...(
|
|
24
|
+
...(DAEMON_CLIENT ? { "x-hilos-client": DAEMON_CLIENT } : {}),
|
|
25
25
|
},
|
|
26
26
|
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
|
|
27
27
|
});
|
package/src/progress-emitter.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
createUsageFold,
|
|
23
23
|
sanitizeText,
|
|
24
24
|
} from "./agent-events.mjs";
|
|
25
|
+
import { commandArgv } from "./argv.mjs";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Which parser/stream-flags a coding command wants, from its FIRST token.
|
|
@@ -34,8 +35,10 @@ import {
|
|
|
34
35
|
* @returns {'claude_code'|'codex'|'cursor'|'opencode'|'antigravity'|'hermes'|'unknown'}
|
|
35
36
|
*/
|
|
36
37
|
export function detectVendor(codingCmd) {
|
|
37
|
-
const first =
|
|
38
|
-
const base = (first.split(/[/\\]/).pop() || "")
|
|
38
|
+
const first = commandArgv(codingCmd)[0] || "";
|
|
39
|
+
const base = (first.split(/[/\\]/).pop() || "")
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/\.(?:exe|cmd|bat|com)$/i, "");
|
|
39
42
|
if (base === "claude" || base === "claude-code" || base === "claude_code") return "claude_code";
|
|
40
43
|
if (base === "codex") return "codex";
|
|
41
44
|
// `agent` is Cursor's canonical binary name since Jan 2026 (the installer
|