@rubytech/create-realagent 1.0.616 → 1.0.618
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/package.json +1 -1
- package/payload/platform/config/brand.json +0 -4
- package/payload/platform/package-lock.json +1547 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -0
- package/payload/platform/plugins/admin/hooks/webfetch-preflight.mjs +363 -0
- package/payload/platform/plugins/admin/skills/stream-log-review/SKILL.md +4 -1
- package/payload/platform/plugins/cloudflare/PLUGIN.md +2 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-binding.test.ts +0 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js +158 -99
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts +103 -70
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +300 -529
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
- package/payload/platform/plugins/cloudflare/references/setup-guide.md +10 -13
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +18 -14
- package/payload/platform/plugins/docs/references/cloudflare.md +32 -49
- package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
- package/payload/platform/scripts/seed-neo4j.sh +12 -0
- package/payload/platform/templates/agents/admin/IDENTITY.md +2 -0
- package/payload/platform/templates/specialists/agents/personal-assistant.md +6 -6
- package/payload/server/public/assets/{admin-Df1liz4Y.js → admin-D7LRdkYB.js} +30 -30
- package/payload/server/public/index.html +1 -1
- package/payload/server/server.js +88 -23
- package/payload/platform/plugins/cloudflare/mcp/__tests__/brand-load.test.ts +0 -81
- package/payload/platform/plugins/cloudflare/mcp/__tests__/manifest-scope.test.ts +0 -65
- package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-0.test.ts +0 -70
- package/payload/platform/plugins/cloudflare/mcp/__tests__/verify-scenario-B.test.ts +0 -124
|
@@ -67,3 +67,4 @@ Tools are available via the `admin` MCP server.
|
|
|
67
67
|
|
|
68
68
|
- `hooks/pre-tool-use.sh` — enforces admin agent write boundaries
|
|
69
69
|
- `hooks/playwright-file-guard.sh` — intercepts file:// URLs with actionable guidance
|
|
70
|
+
- `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse hook for WebFetch — detects JS-SPA shells before Claude Code's
|
|
3
|
+
// internal extraction pipeline burns its 60-second axios timeout on an HTML
|
|
4
|
+
// shell that has no server-rendered content. WebFetch is HTTP-only — when it
|
|
5
|
+
// gets a body like `<div id="root"></div>` plus a single `<script>`, there
|
|
6
|
+
// is nothing to extract, and waiting 60s changes nothing.
|
|
7
|
+
//
|
|
8
|
+
// Wired in account_settings.json hooks.PreToolUse[matcher=WebFetch] (see
|
|
9
|
+
// platform/scripts/seed-neo4j.sh). Reads Claude Code hook protocol JSON on
|
|
10
|
+
// stdin: { tool_name, tool_input: { url, prompt } }.
|
|
11
|
+
//
|
|
12
|
+
// Outcomes:
|
|
13
|
+
// spa-shell → exit 2, stderr = WEBFETCH_CANNOT_READ_JS_SPA: <directive>
|
|
14
|
+
// Claude Code synthesises a tool_result with is_error=true
|
|
15
|
+
// and stderr as content, the agent surfaces a loud failure
|
|
16
|
+
// to the user (per IDENTITY.md "Tool Failure Discipline").
|
|
17
|
+
// ok → exit 0, allow the real WebFetch to run.
|
|
18
|
+
// inconclusive → exit 0, allow the real WebFetch to run. Anything that
|
|
19
|
+
// isn't a high-confidence positive shell detection passes
|
|
20
|
+
// through — fail-open is the only safe posture for a
|
|
21
|
+
// preflight gate. Blocking false positives would be worse
|
|
22
|
+
// than the 60-second stall this hook exists to prevent.
|
|
23
|
+
//
|
|
24
|
+
// Every branch writes one verdict line to
|
|
25
|
+
// ${ACCOUNT_DIR}/logs/webfetch-preflight.log so the decision trail is
|
|
26
|
+
// retrievable per-account without correlating across conversation logs.
|
|
27
|
+
//
|
|
28
|
+
// Cache: ${ACCOUNT_DIR}/cache/webfetch-preflight/<sha256(url)>.json with
|
|
29
|
+
// a 10-minute mtime TTL. The verdict is intrinsic to the URL's response
|
|
30
|
+
// shape, not to any conversation's context — account-scoped sharing
|
|
31
|
+
// eliminates duplicate probes when the same URL recurs across sessions.
|
|
32
|
+
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
import { mkdirSync, readFileSync, writeFileSync, statSync, appendFileSync } from "node:fs";
|
|
35
|
+
import { resolve, dirname } from "node:path";
|
|
36
|
+
|
|
37
|
+
const PROBE_TIMEOUT_MS = 2000;
|
|
38
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
39
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
40
|
+
const SHELL_BYTE_THRESHOLD = 5 * 1024;
|
|
41
|
+
const TEXT_BYTE_THRESHOLD = 20;
|
|
42
|
+
|
|
43
|
+
const accountDir = process.env.ACCOUNT_DIR || "";
|
|
44
|
+
const logPath = accountDir ? resolve(accountDir, "logs", "webfetch-preflight.log") : null;
|
|
45
|
+
|
|
46
|
+
function ts() {
|
|
47
|
+
return new Date().toISOString();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function logLine(line) {
|
|
51
|
+
if (!logPath) return;
|
|
52
|
+
try {
|
|
53
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
54
|
+
appendFileSync(logPath, `[${ts()}] ${line}\n`);
|
|
55
|
+
} catch {
|
|
56
|
+
// Logging failure must never block the tool. The verdict still emits to
|
|
57
|
+
// stderr where Claude Code's own hook-stderr capture will catch it.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function emitVerdict(fields, allowFlag) {
|
|
62
|
+
const line = `[webfetch-preflight] ${fields.join(" ")}`;
|
|
63
|
+
logLine(line);
|
|
64
|
+
// Stderr trail so a reader inspecting Claude Code's hook stderr capture
|
|
65
|
+
// sees the same verdict line (independent of the per-account log file).
|
|
66
|
+
process.stderr.write(line + "\n");
|
|
67
|
+
if (allowFlag === "block") {
|
|
68
|
+
// Caller adds the structured WEBFETCH_CANNOT_READ_JS_SPA message before
|
|
69
|
+
// exiting — this function only emits the diagnostic line.
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readStdin() {
|
|
76
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
77
|
+
const chunks = [];
|
|
78
|
+
let total = 0;
|
|
79
|
+
const limit = 64 * 1024;
|
|
80
|
+
process.stdin.on("data", (c) => {
|
|
81
|
+
chunks.push(c);
|
|
82
|
+
total += c.length;
|
|
83
|
+
if (total > limit) {
|
|
84
|
+
// Destroy the stream before rejecting so the `end` listener can't fire
|
|
85
|
+
// a no-op resolve on the rejected Promise (and so we don't allocate
|
|
86
|
+
// a large Buffer.concat over already-discarded chunks).
|
|
87
|
+
process.stdin.destroy();
|
|
88
|
+
rejectPromise(new Error("stdin-too-large"));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
process.stdin.on("end", () => resolvePromise(Buffer.concat(chunks).toString("utf8")));
|
|
92
|
+
process.stdin.on("error", rejectPromise);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readBodyCapped(res, maxBytes) {
|
|
97
|
+
return new Promise(async (resolvePromise, rejectPromise) => {
|
|
98
|
+
try {
|
|
99
|
+
const reader = res.body.getReader();
|
|
100
|
+
const chunks = [];
|
|
101
|
+
let total = 0;
|
|
102
|
+
while (true) {
|
|
103
|
+
const { value, done } = await reader.read();
|
|
104
|
+
if (done) break;
|
|
105
|
+
chunks.push(Buffer.from(value));
|
|
106
|
+
total += value.byteLength;
|
|
107
|
+
if (total >= maxBytes) {
|
|
108
|
+
reader.cancel().catch(() => {});
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
resolvePromise(Buffer.concat(chunks, Math.min(total, maxBytes)));
|
|
113
|
+
} catch (err) {
|
|
114
|
+
rejectPromise(err);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function detectShape(bodyStr) {
|
|
120
|
+
// Strip <script>, <style>, comments, then all tags. What remains is the
|
|
121
|
+
// user-visible text. <20 chars after this strip = the page renders no
|
|
122
|
+
// meaningful content server-side.
|
|
123
|
+
const stripped = bodyStr
|
|
124
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
|
|
125
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "")
|
|
126
|
+
.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, "")
|
|
127
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
128
|
+
.replace(/<[^>]+>/g, "")
|
|
129
|
+
.replace(/\s+/g, " ")
|
|
130
|
+
.trim();
|
|
131
|
+
|
|
132
|
+
const hasRootDiv =
|
|
133
|
+
/<div\s+id=["']root["']\s*>\s*<\/div>/i.test(bodyStr) ||
|
|
134
|
+
/<div\s+id=["']app["']\s*>\s*<\/div>/i.test(bodyStr) ||
|
|
135
|
+
/<div\s+id=["']__next["']\s*>\s*<\/div>/i.test(bodyStr);
|
|
136
|
+
|
|
137
|
+
// <body> contains only <script>/<link>/<meta> + whitespace + comments.
|
|
138
|
+
// Common pattern for Vite/CRA shells without a labelled root div.
|
|
139
|
+
const bodyMatch = bodyStr.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
|
|
140
|
+
let bodyOnlyScripts = false;
|
|
141
|
+
if (bodyMatch) {
|
|
142
|
+
const inner = bodyMatch[1].replace(/<!--[\s\S]*?-->/g, "");
|
|
143
|
+
const tagless = inner
|
|
144
|
+
.replace(/<(script|link|meta)\b[^>]*>[\s\S]*?<\/\1>/gi, "")
|
|
145
|
+
.replace(/<(script|link|meta)\b[^>]*\/?>/gi, "")
|
|
146
|
+
.trim();
|
|
147
|
+
bodyOnlyScripts = tagless.length === 0 && /<script\b/i.test(inner);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { textBytes: stripped.length, hasRootDiv, bodyOnlyScripts };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function classifyFetchError(err) {
|
|
154
|
+
const name = err && err.name ? err.name : "";
|
|
155
|
+
const code = err && err.cause && err.cause.code ? err.cause.code : err && err.code ? err.code : "";
|
|
156
|
+
if (name === "AbortError" || name === "TimeoutError") return "timeout";
|
|
157
|
+
if (code === "ENOTFOUND" || code === "EAI_AGAIN") return "dns-fail";
|
|
158
|
+
if (code === "ECONNREFUSED" || code === "ECONNRESET" || code === "EHOSTUNREACH") return "tcp-fail";
|
|
159
|
+
if (code === "CERT_HAS_EXPIRED" || code === "DEPTH_ZERO_SELF_SIGNED_CERT") return "tls-fail";
|
|
160
|
+
return `fetch-err-${code || name || "unknown"}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function main() {
|
|
164
|
+
let raw;
|
|
165
|
+
try {
|
|
166
|
+
raw = await readStdin();
|
|
167
|
+
} catch (err) {
|
|
168
|
+
emitVerdict([`verdict=inconclusive`, `reason=stdin-${err.message || "unknown"}`]);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let payload;
|
|
173
|
+
try {
|
|
174
|
+
payload = JSON.parse(raw);
|
|
175
|
+
} catch {
|
|
176
|
+
emitVerdict([`verdict=inconclusive`, `reason=stdin-not-json`]);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const toolInput = payload && typeof payload === "object" ? payload.tool_input : null;
|
|
181
|
+
const url = toolInput && typeof toolInput.url === "string" ? toolInput.url : "";
|
|
182
|
+
if (!url) {
|
|
183
|
+
emitVerdict([`verdict=inconclusive`, `reason=no-url-in-tool-input`]);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let parsed;
|
|
188
|
+
try {
|
|
189
|
+
parsed = new URL(url);
|
|
190
|
+
} catch {
|
|
191
|
+
emitVerdict([`verdict=inconclusive`, `reason=unparseable-url`, `url=${JSON.stringify(url)}`]);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
196
|
+
emitVerdict([
|
|
197
|
+
`verdict=inconclusive`,
|
|
198
|
+
`reason=disallowed-scheme`,
|
|
199
|
+
`scheme=${parsed.protocol.replace(":", "")}`,
|
|
200
|
+
]);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Cache key — strip fragment, normalise. Same URL across conversations
|
|
205
|
+
// hits the same cache entry; verdict is intrinsic to the response shape.
|
|
206
|
+
parsed.hash = "";
|
|
207
|
+
const cacheKey = parsed.toString();
|
|
208
|
+
const cacheHash = createHash("sha256").update(cacheKey).digest("hex").slice(0, 32);
|
|
209
|
+
const cacheDir = accountDir ? resolve(accountDir, "cache", "webfetch-preflight") : null;
|
|
210
|
+
const cachePath = cacheDir ? resolve(cacheDir, `${cacheHash}.json`) : null;
|
|
211
|
+
|
|
212
|
+
if (cachePath) {
|
|
213
|
+
try {
|
|
214
|
+
const stat = statSync(cachePath);
|
|
215
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
216
|
+
if (ageMs < CACHE_TTL_MS) {
|
|
217
|
+
const cached = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
218
|
+
if (cached && typeof cached.verdict === "string") {
|
|
219
|
+
const ageSec = Math.round(ageMs / 1000);
|
|
220
|
+
const fields = [
|
|
221
|
+
`verdict=${cached.verdict}`,
|
|
222
|
+
`body_bytes=${cached.body_bytes ?? 0}`,
|
|
223
|
+
`text_bytes=${cached.text_bytes ?? 0}`,
|
|
224
|
+
`cache=hit`,
|
|
225
|
+
`cache_age_s=${ageSec}`,
|
|
226
|
+
`url=${JSON.stringify(cacheKey)}`,
|
|
227
|
+
];
|
|
228
|
+
if (cached.reason) fields.push(`reason=${cached.reason}`);
|
|
229
|
+
if (cached.verdict === "spa-shell") {
|
|
230
|
+
emitVerdict(fields, "block");
|
|
231
|
+
const bytes = cached.body_bytes ?? 0;
|
|
232
|
+
process.stderr.write(buildShortCircuitMessage(cacheKey, bytes) + "\n");
|
|
233
|
+
process.exit(2);
|
|
234
|
+
}
|
|
235
|
+
emitVerdict(fields);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// Cache miss or unreadable — continue to live probe.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let res;
|
|
245
|
+
try {
|
|
246
|
+
res = await fetch(parsed.toString(), {
|
|
247
|
+
headers: { Accept: "text/html,application/xhtml+xml,*/*;q=0.5" },
|
|
248
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
249
|
+
redirect: "follow",
|
|
250
|
+
});
|
|
251
|
+
} catch (err) {
|
|
252
|
+
emitVerdict([
|
|
253
|
+
`verdict=inconclusive`,
|
|
254
|
+
`reason=${classifyFetchError(err)}`,
|
|
255
|
+
`url=${JSON.stringify(cacheKey)}`,
|
|
256
|
+
]);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (res.status >= 500) {
|
|
261
|
+
emitVerdict([
|
|
262
|
+
`verdict=inconclusive`,
|
|
263
|
+
`reason=http-${res.status}`,
|
|
264
|
+
`url=${JSON.stringify(cacheKey)}`,
|
|
265
|
+
]);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const contentType = (res.headers.get("content-type") || "").toLowerCase();
|
|
270
|
+
if (contentType && !contentType.includes("text/html") && !contentType.includes("application/xhtml")) {
|
|
271
|
+
emitVerdict([
|
|
272
|
+
`verdict=inconclusive`,
|
|
273
|
+
`reason=non-html-content-type`,
|
|
274
|
+
`ct=${JSON.stringify(contentType.slice(0, 60))}`,
|
|
275
|
+
`url=${JSON.stringify(cacheKey)}`,
|
|
276
|
+
]);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let body;
|
|
281
|
+
try {
|
|
282
|
+
body = await readBodyCapped(res, MAX_BODY_BYTES);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
emitVerdict([
|
|
285
|
+
`verdict=inconclusive`,
|
|
286
|
+
`reason=body-read-${classifyFetchError(err)}`,
|
|
287
|
+
`url=${JSON.stringify(cacheKey)}`,
|
|
288
|
+
]);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const bodyBytes = body.length;
|
|
293
|
+
const bodyStr = body.toString("utf8");
|
|
294
|
+
const { textBytes, hasRootDiv, bodyOnlyScripts } = detectShape(bodyStr);
|
|
295
|
+
|
|
296
|
+
const isShell =
|
|
297
|
+
bodyBytes <= SHELL_BYTE_THRESHOLD &&
|
|
298
|
+
textBytes < TEXT_BYTE_THRESHOLD &&
|
|
299
|
+
(hasRootDiv || bodyOnlyScripts);
|
|
300
|
+
|
|
301
|
+
const verdict = isShell ? "spa-shell" : "ok";
|
|
302
|
+
const reason = isShell ? "js-spa-no-server-content" : undefined;
|
|
303
|
+
|
|
304
|
+
if (cachePath && cacheDir) {
|
|
305
|
+
try {
|
|
306
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
307
|
+
writeFileSync(
|
|
308
|
+
cachePath,
|
|
309
|
+
JSON.stringify({
|
|
310
|
+
verdict,
|
|
311
|
+
body_bytes: bodyBytes,
|
|
312
|
+
text_bytes: textBytes,
|
|
313
|
+
reason,
|
|
314
|
+
url: cacheKey,
|
|
315
|
+
fetched_at: Date.now(),
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
} catch {
|
|
319
|
+
// Best-effort. A failed cache write means the next probe re-runs;
|
|
320
|
+
// not a correctness issue.
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const fields = [
|
|
325
|
+
`verdict=${verdict}`,
|
|
326
|
+
`body_bytes=${bodyBytes}`,
|
|
327
|
+
`text_bytes=${textBytes}`,
|
|
328
|
+
`cache=miss`,
|
|
329
|
+
`url=${JSON.stringify(cacheKey)}`,
|
|
330
|
+
];
|
|
331
|
+
if (isShell) {
|
|
332
|
+
fields.push(`has_root_div=${hasRootDiv}`, `body_only_scripts=${bodyOnlyScripts}`, `reason=${reason}`);
|
|
333
|
+
emitVerdict(fields, "block");
|
|
334
|
+
process.stderr.write(buildShortCircuitMessage(cacheKey, bodyBytes) + "\n");
|
|
335
|
+
process.exit(2);
|
|
336
|
+
}
|
|
337
|
+
emitVerdict(fields);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function buildShortCircuitMessage(url, bodyBytes) {
|
|
341
|
+
// Wording is the actual content the LLM sees as tool_result. Names the
|
|
342
|
+
// failure class (machine-readable prefix), the reason in plain English,
|
|
343
|
+
// and the two concrete user actions that unblock the task. The directive
|
|
344
|
+
// not to silently retry is explicit because the model's instinct is to
|
|
345
|
+
// try Playwright next — that hides the failure from the user.
|
|
346
|
+
return (
|
|
347
|
+
`WEBFETCH_CANNOT_READ_JS_SPA: The HTML response at ${url} is a JavaScript ` +
|
|
348
|
+
`single-page-application shell (${bodyBytes} bytes, no server-rendered text). ` +
|
|
349
|
+
`WebFetch does not execute JavaScript, so it has no content to extract. ` +
|
|
350
|
+
`Do NOT retry WebFetch on this URL and do NOT silently dispatch another tool ` +
|
|
351
|
+
`(Playwright, research-assistant, memory-search) to substitute — that hides ` +
|
|
352
|
+
`the failure from the user. Stop, tell the user plainly that you cannot ` +
|
|
353
|
+
`read this URL because it renders content with JavaScript, and ask them to ` +
|
|
354
|
+
`paste the relevant text or send a screenshot.`
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
main().catch((err) => {
|
|
359
|
+
// Top-level safety net — fail-open. A crash in the preflight must never
|
|
360
|
+
// block a tool dispatch; the worst case is we lose the optimisation and
|
|
361
|
+
// pay the existing 60s timeout cost.
|
|
362
|
+
emitVerdict([`verdict=inconclusive`, `reason=preflight-crash`, `err=${JSON.stringify(String(err).slice(0, 80))}`]);
|
|
363
|
+
});
|
|
@@ -52,9 +52,12 @@ The `conversationId` is visible in the admin UI and appears on every `[spawn]` /
|
|
|
52
52
|
- `[tool-wait]` lines every 5 s between `[tool-use]` and `[tool-result]` — distinguishes API silence from tool-internal stall.
|
|
53
53
|
- `[tool-wait-diag]` at 15 / 30 / 45 / 60 s — confirms DNS/TCP/HTTP health across a stall window (not just at the timeout moment).
|
|
54
54
|
- `[tool-wait-proc]` at 30 s — subprocess open FDs, sockets by TCP state, RSS.
|
|
55
|
-
- `[subproc-stderr]` — subprocess stderr
|
|
55
|
+
- `[subproc-stderr]` — line from the main Claude Code subprocess's stderr. Today the CLI is a bundled Bun binary that ignores Node's `NODE_DEBUG`, so this channel is normally silent — see `[subproc-debug-unavailable]` below. MCP server stderr lines arrive separately as `[mcp:<server>]`.
|
|
56
|
+
- `[subproc-stderr-tee-attached]` / `[subproc-stderr-tee-detached]` (Task 535) — lifecycle of the main-subprocess stderr tee. `bytes=0 lines=0` on detach means the tee worked but the subprocess emitted nothing. Absence of both markers next to a `[spawn]` means the tee infrastructure is broken — escalate.
|
|
57
|
+
- `[subproc-debug-unavailable]` (Task 535) — one line per spawn, `reason=bundled-bun-binary-ignores-node-debug`. This is the documented reason `[subproc-stderr]` lines are normally absent for the main subprocess. Treat its absence as a regression, not its presence as a problem.
|
|
56
58
|
- `[tool-failure-diag]` — one-shot probe on the failure path (Task 530); complements the mid-flight `[tool-wait-diag]` with end-of-wait network state.
|
|
57
59
|
- `[mcp-tee-attach]` / `[mcp-tee-skip]` / `[mcp:<server>]` — MCP server stderr routed into the stream log; a missing attach marker explains missing server diagnostics.
|
|
60
|
+
- `[tool-result] error=true output="WEBFETCH_CANNOT_READ_JS_SPA: …"` — a WebFetch dispatch was short-circuited by the SPA preflight hook (Task 536). The URL is a JS-SPA shell that WebFetch cannot extract content from. The agent should have responded to the user naming the failure and asking for a paste or screenshot; if instead an `[agent-dispatch]` (Playwright, research-assistant) follows immediately, the loud-failure guidance in IDENTITY.md did not land. The hook's per-invocation decision trail lives in `{accountDir}/logs/webfetch-preflight.log` (one `[webfetch-preflight] verdict=…` line per probe).
|
|
58
61
|
|
|
59
62
|
## Boundaries
|
|
60
63
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cloudflare
|
|
3
|
-
description: Cloudflare Tunnel setup and management —
|
|
3
|
+
description: Cloudflare Tunnel setup and management — bound account is the universe, agent owns it absolutely
|
|
4
4
|
tools:
|
|
5
5
|
- cloudflare-setup
|
|
6
6
|
- cf-add-zone
|
|
@@ -19,7 +19,7 @@ tools:
|
|
|
19
19
|
|
|
20
20
|
# Cloudflare Tunnel Setup
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
Each installation has its own Cloudflare account, owned by the user, accessed via `cert.pem` from `tunnel-login` (OAuth — the only auth path). The bound account is the entire universe of routable zones; the agent has absolute authority over it (add, delete, restructure). Anything on the account that doesn't belong to the user's current intended state is pollution — `cf-rebuild` deletes it, with `reason=no-intent` refusal when intent is unstated. `cloudflare-setup` is the onboarding orchestrator: a UI-driven state machine that surfaces what's on the account, asks the user to pick a domain, requires explicit cleanup confirmation for any pollution, then creates the tunnel. `cf-verify` is the non-mutating audit (account state + device state + default pollution view). The device records `account-binding.json` on first login so cert rotation under a different account is detected and refused — that's the only inherited identity check.
|
|
23
23
|
|
|
24
24
|
## When to activate
|
|
25
25
|
|