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
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// Claude Code runtime-permission adapter (0777).
|
|
2
|
+
//
|
|
3
|
+
// THE SEAM, live-verified against claude 2.1.233 (never docs-trusted, per 0521):
|
|
4
|
+
// `--permission-prompt-tool mcp__<server>__<tool>` names an MCP tool the CLI
|
|
5
|
+
// calls INSTEAD of showing its own interactive prompt. The flag is undocumented
|
|
6
|
+
// in `claude --help` (a bogus flag errors with "unknown option"; this one is
|
|
7
|
+
// accepted), so it was proven by running it:
|
|
8
|
+
//
|
|
9
|
+
// • the tool receives `{ tool_name, input, tool_use_id }` — e.g.
|
|
10
|
+
// `{tool_name:"Bash", input:{command:"touch created.txt", description:…}}`;
|
|
11
|
+
// • it must answer with ONE text content block holding JSON:
|
|
12
|
+
// `{"behavior":"allow","updatedInput":{…}}` or
|
|
13
|
+
// `{"behavior":"deny","message":"…"}`;
|
|
14
|
+
// • the CLI BLOCKS on that call — a deliberately slow answer held a real run
|
|
15
|
+
// for 150s and it still executed the tool afterwards, so a card can wait on
|
|
16
|
+
// a human for minutes, not seconds;
|
|
17
|
+
// • a deny really stops the tool (the file was never created) and the message
|
|
18
|
+
// is handed to the model verbatim.
|
|
19
|
+
//
|
|
20
|
+
// This means claude needs NO new transport: the existing argv path (runCli,
|
|
21
|
+
// resume, streaming, activity feed) is untouched, and permissions ride an MCP
|
|
22
|
+
// server the daemon serves on loopback for the life of the run. Also verified:
|
|
23
|
+
// claude accepts an `http`-type server in `--mcp-config` with an Authorization
|
|
24
|
+
// header, so the daemon serves the tool IN-PROCESS — no bridge subprocess, and
|
|
25
|
+
// the run's hilos callbacks are directly in scope.
|
|
26
|
+
//
|
|
27
|
+
// `--mcp-config` ADDS to the user's own servers (only `--strict-mcp-config`
|
|
28
|
+
// would replace them), so gating a run never costs the operator their own MCP
|
|
29
|
+
// setup.
|
|
30
|
+
|
|
31
|
+
import http from "node:http";
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import os from "node:os";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import { randomBytes } from "node:crypto";
|
|
36
|
+
import {
|
|
37
|
+
DEFAULT_PERMISSION_POLL_MS,
|
|
38
|
+
DEFAULT_PERMISSION_TIMEOUT_MS,
|
|
39
|
+
resolveHilosPermissionReply,
|
|
40
|
+
} from "./permission-gate.mjs";
|
|
41
|
+
|
|
42
|
+
/** The MCP server key and tool name the daemon serves. Together they form the
|
|
43
|
+
* `mcp__<server>__<tool>` identifier claude's flag takes. */
|
|
44
|
+
export const CLAUDE_PERMISSION_SERVER = "hilos_permissions";
|
|
45
|
+
export const CLAUDE_PERMISSION_TOOL = "ask";
|
|
46
|
+
export const CLAUDE_PERMISSION_TOOL_ID = `mcp__${CLAUDE_PERMISSION_SERVER}__${CLAUDE_PERMISSION_TOOL}`;
|
|
47
|
+
|
|
48
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
49
|
+
|
|
50
|
+
/** How long an ask waits for claude's real session id before standing in the
|
|
51
|
+
* run's own. Claude's init frame lands in well under a second in practice. */
|
|
52
|
+
const DEFAULT_SESSION_ID_WAIT_MS = 10_000;
|
|
53
|
+
|
|
54
|
+
/** Tool names whose input names a file rather than a command. */
|
|
55
|
+
const FILE_INPUT_KEYS = ["file_path", "path", "notebook_path"];
|
|
56
|
+
|
|
57
|
+
function isObject(value) {
|
|
58
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Shape claude's permission-prompt payload into the SAME vendor-neutral request
|
|
63
|
+
* the opencode/ACP card flow already produces, so the card, its audit row, and
|
|
64
|
+
* the decision vocabulary are identical across vendors.
|
|
65
|
+
*/
|
|
66
|
+
export function normalizeClaudePermissionRequest(args, { sessionId = "", fallbackId = "0" } = {}) {
|
|
67
|
+
const toolName = typeof args?.tool_name === "string" ? args.tool_name : "tool";
|
|
68
|
+
const input = isObject(args?.input) ? args.input : {};
|
|
69
|
+
const vendorRequestId =
|
|
70
|
+
typeof args?.tool_use_id === "string" && args.tool_use_id
|
|
71
|
+
? args.tool_use_id
|
|
72
|
+
: `claude_${fallbackId}`;
|
|
73
|
+
const command = typeof input.command === "string" ? input.command : null;
|
|
74
|
+
const filePath = FILE_INPUT_KEYS.map((key) => input[key]).find(
|
|
75
|
+
(value) => typeof value === "string" && value,
|
|
76
|
+
);
|
|
77
|
+
const resources = command ? [command] : filePath ? [filePath] : [];
|
|
78
|
+
// The card's one-line title: the command or file is what a human is actually
|
|
79
|
+
// deciding about, so it leads; the tool name is the fallback.
|
|
80
|
+
const title = command || filePath || toolName;
|
|
81
|
+
return {
|
|
82
|
+
vendor: "claude_code",
|
|
83
|
+
vendorRequestId,
|
|
84
|
+
sessionId: String(sessionId || ""),
|
|
85
|
+
action: toolName,
|
|
86
|
+
resources,
|
|
87
|
+
suggestedSave: undefined,
|
|
88
|
+
metadata: {
|
|
89
|
+
title,
|
|
90
|
+
...(command ? { command } : {}),
|
|
91
|
+
...(filePath ? { path: filePath } : {}),
|
|
92
|
+
transport: "mcp-permission-prompt",
|
|
93
|
+
},
|
|
94
|
+
source: { type: "tool", name: toolName },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Turn a hilos reply into claude's permission-prompt result.
|
|
100
|
+
*
|
|
101
|
+
* "once" and "always" both allow THIS call — the durable card already records
|
|
102
|
+
* which one a human chose, and claude's own allowlist is deliberately not
|
|
103
|
+
* widened from here: persisting an always-allow into the CLI's config would put
|
|
104
|
+
* an approval outside the audited substrate.
|
|
105
|
+
*/
|
|
106
|
+
/**
|
|
107
|
+
* @param {string} reply
|
|
108
|
+
* @param {unknown} input
|
|
109
|
+
* @param {{ outcome?: string, byPolicy?: boolean, policyReason?: string | null }} [opts]
|
|
110
|
+
*/
|
|
111
|
+
export function claudePermissionResult(
|
|
112
|
+
reply,
|
|
113
|
+
input,
|
|
114
|
+
{ outcome = "decided", byPolicy = false, policyReason = null } = {},
|
|
115
|
+
) {
|
|
116
|
+
if (reply === "once" || reply === "always") {
|
|
117
|
+
return { behavior: "allow", updatedInput: isObject(input) ? input : {} };
|
|
118
|
+
}
|
|
119
|
+
// 0813 — a workspace RULE refused this, not a person. Saying "a person
|
|
120
|
+
// declined" would be false, and it would also waste the one thing a standing
|
|
121
|
+
// rule can give the model that a one-off rejection cannot: the reason, which
|
|
122
|
+
// is the difference between a wall and an instruction. Only claude's seam
|
|
123
|
+
// carries a message at all; ACP and codex get their own bare rejection, so
|
|
124
|
+
// nothing here may imply the model was told when it was not.
|
|
125
|
+
//
|
|
126
|
+
// Provenance and reason are separate inputs because a rule need not carry a
|
|
127
|
+
// reason: deriving "policy" from "a reason exists" would send a reasonless
|
|
128
|
+
// rule's refusal back as a person's, which is the precise lie this avoids.
|
|
129
|
+
const reason = typeof policyReason === "string" ? policyReason.trim() : "";
|
|
130
|
+
const message =
|
|
131
|
+
outcome === "timeout"
|
|
132
|
+
? "hilos: nobody answered this permission request in time, so it was declined. Don't retry it — say what you needed it for and stop."
|
|
133
|
+
: outcome === "aborted"
|
|
134
|
+
? "hilos: the run was cancelled, so this was declined. Stop here."
|
|
135
|
+
: byPolicy
|
|
136
|
+
? `hilos: your workspace's permission rules do not allow this.${reason ? ` ${reason}` : ""} Don't retry it — work within that rule, or say what you need.`
|
|
137
|
+
: "hilos: a person in the room declined this. Don't retry it — continue with what you can do without it, or explain what you need.";
|
|
138
|
+
return { behavior: "deny", message };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Does this run get the claude permission gate?
|
|
143
|
+
*
|
|
144
|
+
* The same rule the other vendors use: the workspace must have granted runtime
|
|
145
|
+
* permissions, and a run explicitly configured to never ask (the `skip` tier's
|
|
146
|
+
* bypass flags) has nothing to gate — hilos must be the one answering asks, so
|
|
147
|
+
* a run that answers its own is left exactly as the operator configured it.
|
|
148
|
+
*
|
|
149
|
+
* @param {{ vendor?: string, runtimePermissions?: boolean, codeArgs?: string[] }} o
|
|
150
|
+
*/
|
|
151
|
+
export function shouldGateClaudePermissions({ vendor, runtimePermissions, codeArgs = [] }) {
|
|
152
|
+
if (vendor !== "claude_code" || runtimePermissions !== true) return false;
|
|
153
|
+
const args = Array.isArray(codeArgs) ? codeArgs : [];
|
|
154
|
+
if (args.includes("--dangerously-skip-permissions")) return false;
|
|
155
|
+
// `--permission-mode bypassPermissions` is the same escape hatch by another
|
|
156
|
+
// spelling, and an operator who already named their own prompt tool owns it.
|
|
157
|
+
const modeIdx = args.indexOf("--permission-mode");
|
|
158
|
+
if (modeIdx >= 0 && args[modeIdx + 1] === "bypassPermissions") return false;
|
|
159
|
+
if (args.includes("--permission-prompt-tool")) return false;
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The argv the gate appends to a claude code run. */
|
|
164
|
+
export function claudePermissionArgs({ configPath, toolId = CLAUDE_PERMISSION_TOOL_ID }) {
|
|
165
|
+
return ["--mcp-config", configPath, "--permission-prompt-tool", toolId];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The flags to strip when a CLI turns out not to know them (compat retry). */
|
|
169
|
+
export const CLAUDE_PERMISSION_FLAGS = ["--mcp-config", "--permission-prompt-tool"];
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Serve the permission tool on loopback for the life of one run.
|
|
173
|
+
*
|
|
174
|
+
* Bound to 127.0.0.1 on an ephemeral port with a random bearer token, and the
|
|
175
|
+
* generated config file is written 0600 and deleted on close — the token never
|
|
176
|
+
* appears in argv (where any local `ps` would read it).
|
|
177
|
+
*
|
|
178
|
+
* @param {{
|
|
179
|
+
* requestPermission: (request: object, context: object) => Promise<unknown>,
|
|
180
|
+
* getPermissionDecision: (handle: unknown, context: object) => Promise<unknown>,
|
|
181
|
+
* sessionId?: string,
|
|
182
|
+
* resolveSessionId?: () => string | null | undefined,
|
|
183
|
+
* sessionIdWaitMs?: number,
|
|
184
|
+
* timeoutMs?: number,
|
|
185
|
+
* pollIntervalMs?: number,
|
|
186
|
+
* signal?: AbortSignal,
|
|
187
|
+
* log?: { error?: (message: string) => void },
|
|
188
|
+
* dir?: string,
|
|
189
|
+
* }} options
|
|
190
|
+
* @returns {Promise<{ configPath: string, toolId: string, url: string, port: number,
|
|
191
|
+
* args: string[], setSessionId: (value: string) => void, pending: () => number,
|
|
192
|
+
* close: () => Promise<void> }>}
|
|
193
|
+
*/
|
|
194
|
+
export async function startClaudePermissionServer({
|
|
195
|
+
requestPermission,
|
|
196
|
+
getPermissionDecision,
|
|
197
|
+
sessionId = "",
|
|
198
|
+
resolveSessionId,
|
|
199
|
+
sessionIdWaitMs = DEFAULT_SESSION_ID_WAIT_MS,
|
|
200
|
+
timeoutMs = DEFAULT_PERMISSION_TIMEOUT_MS,
|
|
201
|
+
pollIntervalMs = DEFAULT_PERMISSION_POLL_MS,
|
|
202
|
+
signal,
|
|
203
|
+
log,
|
|
204
|
+
dir = os.tmpdir(),
|
|
205
|
+
}) {
|
|
206
|
+
if (typeof requestPermission !== "function" || typeof getPermissionDecision !== "function") {
|
|
207
|
+
throw new Error("claude permission server requires requestPermission and getPermissionDecision");
|
|
208
|
+
}
|
|
209
|
+
const token = randomBytes(24).toString("hex");
|
|
210
|
+
let seq = 0;
|
|
211
|
+
let inflight = 0;
|
|
212
|
+
let currentSessionId = String(sessionId || "");
|
|
213
|
+
// hilos's server REJECTS a permission request with an empty vendorSessionId,
|
|
214
|
+
// so a fresh run (no session to resume) must not ask with one — every card
|
|
215
|
+
// would fail before it existed. This is the run's own correlation id, used
|
|
216
|
+
// only if claude never tells us its real one.
|
|
217
|
+
const fallbackSessionId = `hilos-run-${randomBytes(8).toString("hex")}`;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The session id to stamp on an ask.
|
|
221
|
+
*
|
|
222
|
+
* On a fresh run claude only reveals its session id in the init frame of its
|
|
223
|
+
* stream, which normally lands well before the first tool call — but "well
|
|
224
|
+
* before" is luck, not a guarantee, so an ask that arrives first WAITS a
|
|
225
|
+
* bounded moment for the real id rather than racing it. If it never comes
|
|
226
|
+
* (a server without post_progress runs the CLI unstreamed, so there is no
|
|
227
|
+
* init frame at all), the run's own id stands in: a card a human can decide
|
|
228
|
+
* beats an ask that dies as an invalid request.
|
|
229
|
+
*/
|
|
230
|
+
async function sessionIdForAsk() {
|
|
231
|
+
const pull = () => {
|
|
232
|
+
if (currentSessionId) return currentSessionId;
|
|
233
|
+
try {
|
|
234
|
+
const value = resolveSessionId?.();
|
|
235
|
+
if (typeof value === "string" && value) {
|
|
236
|
+
currentSessionId = value;
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
/* a session-id lookup must never break the gate */
|
|
241
|
+
}
|
|
242
|
+
return "";
|
|
243
|
+
};
|
|
244
|
+
const found = pull();
|
|
245
|
+
if (found) return found;
|
|
246
|
+
// Nothing could arrive later (an unstreamed run supplies no resolver at
|
|
247
|
+
// all), so waiting would only delay a card nobody is going to improve.
|
|
248
|
+
if (typeof resolveSessionId !== "function") return fallbackSessionId;
|
|
249
|
+
const deadline = Date.now() + Math.max(0, sessionIdWaitMs);
|
|
250
|
+
while (Date.now() < deadline) {
|
|
251
|
+
if (signal?.aborted) break;
|
|
252
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
253
|
+
const later = pull();
|
|
254
|
+
if (later) return later;
|
|
255
|
+
}
|
|
256
|
+
log?.error?.(
|
|
257
|
+
`claude permission: no CLI session id yet; asking as ${fallbackSessionId} so the card still reaches the room`,
|
|
258
|
+
);
|
|
259
|
+
return fallbackSessionId;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const server = http.createServer((req, res) => {
|
|
263
|
+
if (req.headers.authorization !== `Bearer ${token}`) {
|
|
264
|
+
res.writeHead(401).end();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (req.method !== "POST") {
|
|
268
|
+
// No SSE lane: claude probes GET once, accepts the refusal, and uses POST.
|
|
269
|
+
res.writeHead(405).end();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
let body = "";
|
|
273
|
+
let tooBig = false;
|
|
274
|
+
req.on("data", (chunk) => {
|
|
275
|
+
body += chunk;
|
|
276
|
+
if (body.length > 1_000_000) {
|
|
277
|
+
tooBig = true;
|
|
278
|
+
req.destroy();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
req.on("end", () => {
|
|
282
|
+
if (tooBig) return;
|
|
283
|
+
void handle(body, res);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
async function handle(body, res) {
|
|
288
|
+
let msg;
|
|
289
|
+
try {
|
|
290
|
+
msg = JSON.parse(body);
|
|
291
|
+
} catch {
|
|
292
|
+
res.writeHead(400).end();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const reply = (result) => {
|
|
296
|
+
if (res.writableEnded) return;
|
|
297
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
298
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result }));
|
|
299
|
+
};
|
|
300
|
+
// Notifications carry no id and expect no body.
|
|
301
|
+
if (msg?.id === undefined) {
|
|
302
|
+
res.writeHead(202).end();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (msg.method === "initialize") {
|
|
306
|
+
const requested =
|
|
307
|
+
typeof msg.params?.protocolVersion === "string" ? msg.params.protocolVersion : null;
|
|
308
|
+
reply({
|
|
309
|
+
protocolVersion: requested || PROTOCOL_VERSION,
|
|
310
|
+
capabilities: { tools: {} },
|
|
311
|
+
serverInfo: { name: "hilos-permissions", version: "1.0.0" },
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (msg.method === "tools/list") {
|
|
316
|
+
reply({
|
|
317
|
+
tools: [
|
|
318
|
+
{
|
|
319
|
+
name: CLAUDE_PERMISSION_TOOL,
|
|
320
|
+
description:
|
|
321
|
+
"Ask the people in this hilos room to approve a tool call. Blocks until someone decides.",
|
|
322
|
+
inputSchema: {
|
|
323
|
+
type: "object",
|
|
324
|
+
properties: {
|
|
325
|
+
tool_name: { type: "string" },
|
|
326
|
+
input: { type: "object" },
|
|
327
|
+
tool_use_id: { type: "string" },
|
|
328
|
+
},
|
|
329
|
+
required: ["tool_name", "input"],
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (msg.method === "tools/call") {
|
|
337
|
+
if (msg.params?.name !== CLAUDE_PERMISSION_TOOL) {
|
|
338
|
+
reply({
|
|
339
|
+
content: [{ type: "text", text: JSON.stringify({ behavior: "deny", message: "hilos: unknown tool" }) }],
|
|
340
|
+
isError: true,
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const args = isObject(msg.params?.arguments) ? msg.params.arguments : {};
|
|
345
|
+
const request = normalizeClaudePermissionRequest(args, {
|
|
346
|
+
sessionId: await sessionIdForAsk(),
|
|
347
|
+
fallbackId: String(++seq),
|
|
348
|
+
});
|
|
349
|
+
inflight++;
|
|
350
|
+
let outcome = "transport-error";
|
|
351
|
+
// Set only when a workspace RULE refused this ask (0813); a person's
|
|
352
|
+
// rejection leaves these alone and keeps the human wording. Provenance is
|
|
353
|
+
// tracked apart from the reason because a rule may carry no reason.
|
|
354
|
+
let byPolicy = false;
|
|
355
|
+
let policyReason = null;
|
|
356
|
+
let decisionReply = "reject";
|
|
357
|
+
try {
|
|
358
|
+
const resolved = await resolveHilosPermissionReply({
|
|
359
|
+
request,
|
|
360
|
+
requestPermission,
|
|
361
|
+
getPermissionDecision,
|
|
362
|
+
signal,
|
|
363
|
+
timeoutMs,
|
|
364
|
+
pollIntervalMs,
|
|
365
|
+
log,
|
|
366
|
+
label: "claude permission",
|
|
367
|
+
});
|
|
368
|
+
decisionReply = resolved.reply;
|
|
369
|
+
outcome = resolved.outcome;
|
|
370
|
+
byPolicy = resolved.byPolicy === true;
|
|
371
|
+
policyReason = resolved.policyReason ?? null;
|
|
372
|
+
} finally {
|
|
373
|
+
inflight--;
|
|
374
|
+
}
|
|
375
|
+
// A rejection is never an MCP error: an isError result makes claude retry
|
|
376
|
+
// or improvise around the block. A plain deny result is the refusal the
|
|
377
|
+
// CLI is built to respect.
|
|
378
|
+
reply({
|
|
379
|
+
content: [
|
|
380
|
+
{ type: "text", text: JSON.stringify(
|
|
381
|
+
claudePermissionResult(decisionReply, args.input, {
|
|
382
|
+
outcome,
|
|
383
|
+
byPolicy,
|
|
384
|
+
policyReason,
|
|
385
|
+
}),
|
|
386
|
+
) },
|
|
387
|
+
],
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
reply({});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
await new Promise((resolve, reject) => {
|
|
395
|
+
server.once("error", reject);
|
|
396
|
+
server.listen(0, "127.0.0.1", () => {
|
|
397
|
+
server.removeListener("error", reject);
|
|
398
|
+
resolve(undefined);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
const { port } = /** @type {{ port: number }} */ (server.address());
|
|
402
|
+
const url = `http://127.0.0.1:${port}/mcp`;
|
|
403
|
+
const configPath = path.join(
|
|
404
|
+
dir,
|
|
405
|
+
`hilos-permission-${process.pid}-${randomBytes(6).toString("hex")}.json`,
|
|
406
|
+
);
|
|
407
|
+
fs.writeFileSync(
|
|
408
|
+
configPath,
|
|
409
|
+
JSON.stringify({
|
|
410
|
+
mcpServers: {
|
|
411
|
+
[CLAUDE_PERMISSION_SERVER]: { type: "http", url, headers: { Authorization: `Bearer ${token}` } },
|
|
412
|
+
},
|
|
413
|
+
}),
|
|
414
|
+
{ mode: 0o600 },
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
configPath,
|
|
419
|
+
toolId: CLAUDE_PERMISSION_TOOL_ID,
|
|
420
|
+
url,
|
|
421
|
+
port,
|
|
422
|
+
args: claudePermissionArgs({ configPath }),
|
|
423
|
+
/** Let the caller stamp the CLI's real session id onto later cards. */
|
|
424
|
+
setSessionId(value) {
|
|
425
|
+
if (typeof value === "string" && value) currentSessionId = value;
|
|
426
|
+
},
|
|
427
|
+
pending: () => inflight,
|
|
428
|
+
async close() {
|
|
429
|
+
try {
|
|
430
|
+
fs.unlinkSync(configPath);
|
|
431
|
+
} catch {
|
|
432
|
+
/* a leftover temp config must never fail a run */
|
|
433
|
+
}
|
|
434
|
+
// Drop keep-alive sockets first: server.close() waits for open
|
|
435
|
+
// connections, and a CLI that exited without closing its MCP socket
|
|
436
|
+
// would otherwise hang the run's teardown forever.
|
|
437
|
+
try {
|
|
438
|
+
server.closeAllConnections?.();
|
|
439
|
+
} catch {
|
|
440
|
+
/* older runtimes simply do not have it */
|
|
441
|
+
}
|
|
442
|
+
await new Promise((resolve) => server.close(() => resolve(undefined)));
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -175,6 +175,10 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
|
|
|
175
175
|
* hilos-owned var (HILOS_*) is stripped from it regardless, and `PWD` is pinned
|
|
176
176
|
* to `cwd` when one is set (0615). Omit to inherit the daemon's environment
|
|
177
177
|
* minus HILOS_* (the safe default).
|
|
178
|
+
* @property {() => (void | Promise<void>)} [onPermissionGateDropped] - awaited
|
|
179
|
+
* BEFORE the ungated compat retry is spawned when a CLI rejects the 0777
|
|
180
|
+
* permission flags (0785), so the caller can warn its room while the run can
|
|
181
|
+
* still be stopped. A throw here never fails the run.
|
|
178
182
|
*/
|
|
179
183
|
|
|
180
184
|
/**
|
|
@@ -328,6 +332,37 @@ function runCliOnce(opts) {
|
|
|
328
332
|
// inside runCli (not a wrapper) so every call site, present and future, gets it.
|
|
329
333
|
const UNKNOWN_TRUST_RE = /unknown option '--trust'/;
|
|
330
334
|
|
|
335
|
+
// Compat retry (0777): the permission gate adds `--permission-prompt-tool` (and
|
|
336
|
+
// the `--mcp-config` that serves it) to claude runs. The flag is real but
|
|
337
|
+
// undocumented on 2.1.233, so a Claude Code old enough not to know it would
|
|
338
|
+
// reject the whole command. Rather than fail a run over a gate the CLI cannot
|
|
339
|
+
// honor, strip BOTH flags (and their values) and retry once — the run then
|
|
340
|
+
// behaves exactly as it did before 0777. It is logged as a lost gate, not
|
|
341
|
+
// silently swallowed: the caller sees the first attempt's stderr in the run.
|
|
342
|
+
//
|
|
343
|
+
// 0785: a daemon console line is not the room. Before the ungated retry is
|
|
344
|
+
// SPAWNED, `onPermissionGateDropped` gives the caller — which owns the channel
|
|
345
|
+
// and the thread — its chance to tell the people watching, so the warning
|
|
346
|
+
// arrives while the run can still be stopped instead of after every ungated
|
|
347
|
+
// command has already executed. The result is also stamped
|
|
348
|
+
// `permissionGateDropped:true` as a backstop for callers with no hook.
|
|
349
|
+
const UNKNOWN_PERMISSION_FLAG_RE =
|
|
350
|
+
/unknown option '(--permission-prompt-tool|--mcp-config)'/;
|
|
351
|
+
const PERMISSION_GATE_FLAGS = new Set(["--permission-prompt-tool", "--mcp-config"]);
|
|
352
|
+
|
|
353
|
+
/** Drop each flag AND the value that follows it. */
|
|
354
|
+
function stripFlagPairs(args, flags) {
|
|
355
|
+
const out = [];
|
|
356
|
+
for (let i = 0; i < args.length; i++) {
|
|
357
|
+
if (flags.has(args[i])) {
|
|
358
|
+
i++; // skip its value
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
out.push(args[i]);
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
|
|
331
366
|
/** @param {RunCliOptions} opts */
|
|
332
367
|
export async function runCli(opts) {
|
|
333
368
|
const first = await runCliOnce(opts);
|
|
@@ -340,5 +375,26 @@ export async function runCli(opts) {
|
|
|
340
375
|
) {
|
|
341
376
|
return runCliOnce({ ...opts, args: args.filter((a) => a !== "--trust") });
|
|
342
377
|
}
|
|
378
|
+
if (
|
|
379
|
+
first.status !== 0 &&
|
|
380
|
+
!first.aborted &&
|
|
381
|
+
args.includes("--permission-prompt-tool") &&
|
|
382
|
+
UNKNOWN_PERMISSION_FLAG_RE.test(first.stderr || "")
|
|
383
|
+
) {
|
|
384
|
+
console.log(
|
|
385
|
+
" code → this CLI doesn't support --permission-prompt-tool; retrying WITHOUT the hilos permission gate",
|
|
386
|
+
);
|
|
387
|
+
// Warn FIRST, then run. The hook is bounded by its owner; a failure here is
|
|
388
|
+
// not a reason to fail a run that is about to happen anyway.
|
|
389
|
+
if (typeof opts?.onPermissionGateDropped === "function") {
|
|
390
|
+
try {
|
|
391
|
+
await opts.onPermissionGateDropped();
|
|
392
|
+
} catch {
|
|
393
|
+
/* telling the room must never break the run */
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const retried = await runCliOnce({ ...opts, args: stripFlagPairs(args, PERMISSION_GATE_FLAGS) });
|
|
397
|
+
return { ...retried, permissionGateDropped: true };
|
|
398
|
+
}
|
|
343
399
|
return first;
|
|
344
400
|
}
|