@rubytech/create-realagent 1.0.852 → 1.0.854
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/dist/__tests__/preflight-port-classifier.test.js +240 -73
- package/dist/index.js +59 -11
- package/dist/preflight-port-classifier.js +176 -41
- package/package.json +1 -1
- package/payload/platform/config/brand-registry.json +44 -0
- package/payload/platform/lib/persistent-components/dist/index.d.ts +21 -0
- package/payload/platform/lib/persistent-components/dist/index.d.ts.map +1 -0
- package/payload/platform/lib/persistent-components/dist/index.js +32 -0
- package/payload/platform/lib/persistent-components/dist/index.js.map +1 -0
- package/payload/platform/lib/persistent-components/src/index.ts +28 -0
- package/payload/platform/lib/persistent-components/tsconfig.json +8 -0
- package/payload/platform/package.json +2 -2
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/admin/hooks/__tests__/playwright-file-guard.test.sh +278 -0
- package/payload/platform/plugins/admin/hooks/playwright-file-guard.sh +204 -20
- package/payload/platform/plugins/admin/mcp/dist/index.js +40 -1
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/docs/references/deployment.md +2 -0
- package/payload/platform/plugins/docs/references/getting-started.md +2 -0
- package/payload/platform/plugins/docs/references/platform.md +1 -1
- package/payload/platform/plugins/docs/references/troubleshooting.md +10 -0
- package/payload/platform/scripts/admin-persist-audit.ts +191 -0
- package/payload/platform/scripts/component-knowledgedoc-backfill.ts +214 -0
- package/payload/platform/scripts/installer-device-verify.sh +249 -0
- package/payload/platform/templates/specialists/agents/content-producer.md +2 -2
- package/payload/server/chunk-CFNSKDGA.js +667 -0
- package/payload/server/chunk-DC6DWYZJ.js +1603 -0
- package/payload/server/chunk-LTB5SSQW.js +10889 -0
- package/payload/server/chunk-MN2LGNUB.js +2143 -0
- package/payload/server/client-pool-AMT2W3II.js +34 -0
- package/payload/server/cloudflare-task-tracker-LJ4SMK2D.js +20 -0
- package/payload/server/maxy-edge.js +3 -3
- package/payload/server/public/assets/admin-DZ8Ke7t3.js +352 -0
- package/payload/server/public/assets/public-DApUXgoq.js +5 -0
- package/payload/server/public/assets/useVoiceRecorder-CI8GpxfU.js +36 -0
- package/payload/server/public/index.html +2 -2
- package/payload/server/public/public.html +2 -2
- package/payload/server/server.js +535 -351
- package/payload/server/public/assets/admin-Dyl8uNxX.js +0 -352
- package/payload/server/public/assets/public-B_PNZUph.js +0 -5
- package/payload/server/public/assets/useVoiceRecorder-fD0IWzJj.js +0 -36
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Regression test for playwright-file-guard.sh (Task 944).
|
|
3
|
+
#
|
|
4
|
+
# Covers:
|
|
5
|
+
# 1. file:// URL → hook emits hookSpecificOutput.updatedInput.url JSON,
|
|
6
|
+
# starts a backgrounded python3 -m http.server on a free port rooted
|
|
7
|
+
# at the file's parent dir, writes /tmp/playwright-file-guard.<port>.pid,
|
|
8
|
+
# and the rewritten URL responds 200.
|
|
9
|
+
# 2. https:// URL → silent passthrough, exit 0, no stdout JSON.
|
|
10
|
+
# 3. Free-port under contention: hold 8080, run hook on file://, assert
|
|
11
|
+
# chosen port != 8080.
|
|
12
|
+
# 4. Stale-PID cleanup mode: pre-create stale PID file pointing at a real
|
|
13
|
+
# http.server we control, run `cleanup`, assert process killed + file
|
|
14
|
+
# removed.
|
|
15
|
+
# 5. Reused-PID guard: PID file points at non-http.server process,
|
|
16
|
+
# cleanup leaves the process alone.
|
|
17
|
+
# 6. Fail-open on missing python3 (PATH override).
|
|
18
|
+
# 7. Malformed stdin → exit 0 silent passthrough.
|
|
19
|
+
# 8. Terminal stdin guard preserved.
|
|
20
|
+
# 9. Hook source contains no copy of the old menu phrase.
|
|
21
|
+
|
|
22
|
+
set -u
|
|
23
|
+
|
|
24
|
+
HOOK="$(cd "$(dirname "$0")/.." && pwd)/playwright-file-guard.sh"
|
|
25
|
+
if [[ ! -x "$HOOK" ]]; then
|
|
26
|
+
echo "FAIL: $HOOK not executable" >&2
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
# Track every PID we spawn so we can reap on EXIT.
|
|
31
|
+
TRACKED_PIDS=()
|
|
32
|
+
TMPFILES=()
|
|
33
|
+
|
|
34
|
+
cleanup_test_state() {
|
|
35
|
+
for p in "${TRACKED_PIDS[@]:-}"; do
|
|
36
|
+
[[ -n "$p" ]] && kill -9 "$p" 2>/dev/null || true
|
|
37
|
+
done
|
|
38
|
+
for f in "${TMPFILES[@]:-}"; do
|
|
39
|
+
[[ -n "$f" ]] && rm -f "$f" 2>/dev/null || true
|
|
40
|
+
done
|
|
41
|
+
rm -f /tmp/playwright-file-guard.*.pid 2>/dev/null || true
|
|
42
|
+
}
|
|
43
|
+
trap cleanup_test_state EXIT
|
|
44
|
+
|
|
45
|
+
PASS=0
|
|
46
|
+
FAIL=0
|
|
47
|
+
|
|
48
|
+
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
|
|
49
|
+
fail() { echo "FAIL: $1" >&2; FAIL=$((FAIL + 1)); }
|
|
50
|
+
|
|
51
|
+
# Sweep stale PID files BEFORE running tests so prior runs don't poison the
|
|
52
|
+
# fixture. The hook's opportunistic cleanup also triggers on every call, but
|
|
53
|
+
# a clean slate makes assertions deterministic.
|
|
54
|
+
rm -f /tmp/playwright-file-guard.*.pid 2>/dev/null || true
|
|
55
|
+
|
|
56
|
+
# ---- Test 1: file:// rewrite happy path -------------------------------------
|
|
57
|
+
|
|
58
|
+
TEST_HTML=$(mktemp -t playwright-file-guard-XXXXXX).html
|
|
59
|
+
echo "<html><body>hello</body></html>" > "$TEST_HTML"
|
|
60
|
+
TMPFILES+=("$TEST_HTML")
|
|
61
|
+
TEST_BASENAME=$(basename "$TEST_HTML")
|
|
62
|
+
|
|
63
|
+
INPUT_JSON=$(printf '{"hook_event_name":"PreToolUse","tool_name":"mcp__plugin_playwright_playwright__browser_navigate","tool_input":{"url":"file://%s"}}' "$TEST_HTML")
|
|
64
|
+
STDOUT_FILE=$(mktemp); STDERR_FILE=$(mktemp); TMPFILES+=("$STDOUT_FILE" "$STDERR_FILE")
|
|
65
|
+
|
|
66
|
+
printf '%s' "$INPUT_JSON" | bash "$HOOK" >"$STDOUT_FILE" 2>"$STDERR_FILE"
|
|
67
|
+
RC=$?
|
|
68
|
+
|
|
69
|
+
if [[ "$RC" -ne 0 ]]; then
|
|
70
|
+
fail "Test 1: hook exited $RC on file:// rewrite (expected 0)"
|
|
71
|
+
elif ! grep -q '"hookSpecificOutput"' "$STDOUT_FILE"; then
|
|
72
|
+
fail "Test 1: stdout missing hookSpecificOutput JSON. Got: $(cat "$STDOUT_FILE")"
|
|
73
|
+
elif ! grep -q '"updatedInput"' "$STDOUT_FILE"; then
|
|
74
|
+
fail "Test 1: stdout missing updatedInput field. Got: $(cat "$STDOUT_FILE")"
|
|
75
|
+
elif ! grep -qE '"url":\s*"http://127.0.0.1:[0-9]+/'"$TEST_BASENAME"'"' "$STDOUT_FILE"; then
|
|
76
|
+
fail "Test 1: stdout url not rewritten as expected. Got: $(cat "$STDOUT_FILE")"
|
|
77
|
+
elif ! grep -q '\[playwright-file-guard\] action=rewrite' "$STDERR_FILE"; then
|
|
78
|
+
fail "Test 1: stderr missing rewrite log line. Got: $(cat "$STDERR_FILE")"
|
|
79
|
+
else
|
|
80
|
+
PORT=$(grep -oE 'http://127\.0\.0\.1:[0-9]+/' "$STDOUT_FILE" | head -1 | grep -oE ':[0-9]+/' | tr -d ':/')
|
|
81
|
+
PID_FILE="/tmp/playwright-file-guard.${PORT}.pid"
|
|
82
|
+
if [[ ! -f "$PID_FILE" ]]; then
|
|
83
|
+
fail "Test 1: PID file $PID_FILE not created"
|
|
84
|
+
else
|
|
85
|
+
SERVER_PID=$(cat "$PID_FILE")
|
|
86
|
+
TRACKED_PIDS+=("$SERVER_PID")
|
|
87
|
+
HTTP_BODY=$(curl -s --max-time 2 "http://127.0.0.1:${PORT}/${TEST_BASENAME}" 2>/dev/null || true)
|
|
88
|
+
if echo "$HTTP_BODY" | grep -q "hello"; then
|
|
89
|
+
pass "Test 1: file:// → rewrite + spawned server responds 200"
|
|
90
|
+
else
|
|
91
|
+
fail "Test 1: spawned server at port $PORT did not return file content. Body: $HTTP_BODY"
|
|
92
|
+
fi
|
|
93
|
+
fi
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
# ---- Test 2: https:// passthrough -------------------------------------------
|
|
97
|
+
|
|
98
|
+
INPUT_JSON='{"hook_event_name":"PreToolUse","tool_name":"mcp__plugin_playwright_playwright__browser_navigate","tool_input":{"url":"https://example.com"}}'
|
|
99
|
+
STDOUT_FILE=$(mktemp); STDERR_FILE=$(mktemp); TMPFILES+=("$STDOUT_FILE" "$STDERR_FILE")
|
|
100
|
+
printf '%s' "$INPUT_JSON" | bash "$HOOK" >"$STDOUT_FILE" 2>"$STDERR_FILE"
|
|
101
|
+
RC=$?
|
|
102
|
+
|
|
103
|
+
if [[ "$RC" -ne 0 ]]; then
|
|
104
|
+
fail "Test 2: hook exited $RC on https:// passthrough"
|
|
105
|
+
elif [[ -s "$STDOUT_FILE" ]]; then
|
|
106
|
+
fail "Test 2: stdout should be empty on passthrough. Got: $(cat "$STDOUT_FILE")"
|
|
107
|
+
elif ! grep -q '\[playwright-file-guard\] action=passthrough scheme=https' "$STDERR_FILE"; then
|
|
108
|
+
fail "Test 2: stderr missing passthrough log line. Got: $(cat "$STDERR_FILE")"
|
|
109
|
+
else
|
|
110
|
+
pass "Test 2: https:// → silent passthrough"
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
# ---- Test 3: free port under :8080 contention -------------------------------
|
|
114
|
+
|
|
115
|
+
python3 -c "
|
|
116
|
+
import socket, time, sys
|
|
117
|
+
s = socket.socket()
|
|
118
|
+
try:
|
|
119
|
+
s.bind(('127.0.0.1', 8080))
|
|
120
|
+
s.listen(1)
|
|
121
|
+
print('READY', flush=True)
|
|
122
|
+
time.sleep(15)
|
|
123
|
+
except OSError as e:
|
|
124
|
+
print('SKIP', e, flush=True)
|
|
125
|
+
sys.exit(0)
|
|
126
|
+
" >/tmp/playwright-file-guard-test-blocker.stdout 2>&1 &
|
|
127
|
+
BLOCKER_PID=$!
|
|
128
|
+
TRACKED_PIDS+=("$BLOCKER_PID")
|
|
129
|
+
TMPFILES+=("/tmp/playwright-file-guard-test-blocker.stdout")
|
|
130
|
+
|
|
131
|
+
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
|
132
|
+
if grep -qE 'READY|SKIP' /tmp/playwright-file-guard-test-blocker.stdout 2>/dev/null; then break; fi
|
|
133
|
+
sleep 0.1
|
|
134
|
+
done
|
|
135
|
+
|
|
136
|
+
if grep -q SKIP /tmp/playwright-file-guard-test-blocker.stdout 2>/dev/null; then
|
|
137
|
+
echo "SKIP: Test 3 — port 8080 already held by another process; cannot run contention test"
|
|
138
|
+
else
|
|
139
|
+
TEST_HTML2=$(mktemp -t playwright-file-guard-XXXXXX).html
|
|
140
|
+
echo "<html>2</html>" > "$TEST_HTML2"
|
|
141
|
+
TMPFILES+=("$TEST_HTML2")
|
|
142
|
+
INPUT_JSON=$(printf '{"hook_event_name":"PreToolUse","tool_name":"mcp__plugin_playwright_playwright__browser_navigate","tool_input":{"url":"file://%s"}}' "$TEST_HTML2")
|
|
143
|
+
STDOUT_FILE=$(mktemp); TMPFILES+=("$STDOUT_FILE")
|
|
144
|
+
printf '%s' "$INPUT_JSON" | bash "$HOOK" >"$STDOUT_FILE" 2>/dev/null
|
|
145
|
+
CHOSEN_PORT=$(grep -oE 'http://127\.0\.0\.1:[0-9]+/' "$STDOUT_FILE" | head -1 | grep -oE ':[0-9]+/' | tr -d ':/')
|
|
146
|
+
if [[ -z "$CHOSEN_PORT" ]]; then
|
|
147
|
+
fail "Test 3: hook did not produce a rewrite under contention. Stdout: $(cat "$STDOUT_FILE")"
|
|
148
|
+
elif [[ "$CHOSEN_PORT" == "8080" ]]; then
|
|
149
|
+
fail "Test 3: hook picked 8080 even though it was bound"
|
|
150
|
+
else
|
|
151
|
+
SERVER_PID=$(cat "/tmp/playwright-file-guard.${CHOSEN_PORT}.pid" 2>/dev/null || echo "")
|
|
152
|
+
[[ -n "$SERVER_PID" ]] && TRACKED_PIDS+=("$SERVER_PID")
|
|
153
|
+
pass "Test 3: free-port under :8080 contention picked $CHOSEN_PORT"
|
|
154
|
+
fi
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
# ---- Test 4: stale-PID cleanup mode (explicit argv) -------------------------
|
|
158
|
+
|
|
159
|
+
STALE_DIR=$(mktemp -d); TMPFILES+=("$STALE_DIR")
|
|
160
|
+
( cd "$STALE_DIR" && exec python3 -u -m http.server 0 >/tmp/playwright-file-guard-stale.stdout 2>&1 ) &
|
|
161
|
+
STALE_PID=$!
|
|
162
|
+
TRACKED_PIDS+=("$STALE_PID")
|
|
163
|
+
TMPFILES+=("/tmp/playwright-file-guard-stale.stdout")
|
|
164
|
+
# Wait up to 2s for the http.server banner to flush.
|
|
165
|
+
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
|
|
166
|
+
if grep -qE 'port [0-9]+' /tmp/playwright-file-guard-stale.stdout 2>/dev/null; then break; fi
|
|
167
|
+
sleep 0.1
|
|
168
|
+
done
|
|
169
|
+
STALE_PORT=$(grep -oE 'port [0-9]+' /tmp/playwright-file-guard-stale.stdout 2>/dev/null | head -1 | grep -oE '[0-9]+' || echo "")
|
|
170
|
+
if [[ -z "$STALE_PORT" ]]; then
|
|
171
|
+
fail "Test 4 setup: could not extract port from python3 http.server"
|
|
172
|
+
else
|
|
173
|
+
STALE_PID_FILE="/tmp/playwright-file-guard.${STALE_PORT}.pid"
|
|
174
|
+
echo "$STALE_PID" > "$STALE_PID_FILE"
|
|
175
|
+
TMPFILES+=("$STALE_PID_FILE")
|
|
176
|
+
touch -t "$(date -u -v-2H +%Y%m%d%H%M.%S 2>/dev/null || date -u -d '2 hours ago' +%Y%m%d%H%M.%S)" "$STALE_PID_FILE"
|
|
177
|
+
|
|
178
|
+
bash "$HOOK" cleanup >/dev/null 2>&1
|
|
179
|
+
|
|
180
|
+
# SIGTERM is asynchronous — wait up to 1s for the process to actually die.
|
|
181
|
+
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
|
182
|
+
if ! kill -0 "$STALE_PID" 2>/dev/null; then break; fi
|
|
183
|
+
sleep 0.1
|
|
184
|
+
done
|
|
185
|
+
|
|
186
|
+
if kill -0 "$STALE_PID" 2>/dev/null; then
|
|
187
|
+
fail "Test 4: stale PID $STALE_PID still alive after cleanup"
|
|
188
|
+
kill -9 "$STALE_PID" 2>/dev/null || true
|
|
189
|
+
elif [[ -f "$STALE_PID_FILE" ]]; then
|
|
190
|
+
fail "Test 4: stale PID file $STALE_PID_FILE not removed after cleanup"
|
|
191
|
+
else
|
|
192
|
+
pass "Test 4: stale-PID cleanup killed http.server + removed PID file"
|
|
193
|
+
fi
|
|
194
|
+
fi
|
|
195
|
+
|
|
196
|
+
# ---- Test 5: reused-PID guard ----------------------------------------------
|
|
197
|
+
|
|
198
|
+
REUSED_PID_FILE="/tmp/playwright-file-guard.59999.pid"
|
|
199
|
+
echo "$$" > "$REUSED_PID_FILE"
|
|
200
|
+
TMPFILES+=("$REUSED_PID_FILE")
|
|
201
|
+
touch -t "$(date -u -v-2H +%Y%m%d%H%M.%S 2>/dev/null || date -u -d '2 hours ago' +%Y%m%d%H%M.%S)" "$REUSED_PID_FILE"
|
|
202
|
+
|
|
203
|
+
bash "$HOOK" cleanup >/dev/null 2>&1
|
|
204
|
+
|
|
205
|
+
if ! kill -0 "$$" 2>/dev/null; then
|
|
206
|
+
fail "Test 5: reused-PID guard killed our own bash process — fatal"
|
|
207
|
+
elif [[ -f "$REUSED_PID_FILE" ]]; then
|
|
208
|
+
fail "Test 5: reused-PID file $REUSED_PID_FILE not removed (hook should drop the stale ref)"
|
|
209
|
+
else
|
|
210
|
+
pass "Test 5: reused-PID guard left process alive, dropped stale PID file"
|
|
211
|
+
fi
|
|
212
|
+
|
|
213
|
+
# ---- Test 6: missing python3 fails open ------------------------------------
|
|
214
|
+
|
|
215
|
+
EMPTY_PATH_DIR=$(mktemp -d); TMPFILES+=("$EMPTY_PATH_DIR")
|
|
216
|
+
ln -s "$(command -v bash)" "$EMPTY_PATH_DIR/bash"
|
|
217
|
+
ln -s "$(command -v grep)" "$EMPTY_PATH_DIR/grep"
|
|
218
|
+
ln -s "$(command -v cat)" "$EMPTY_PATH_DIR/cat"
|
|
219
|
+
ln -s "$(command -v rm)" "$EMPTY_PATH_DIR/rm"
|
|
220
|
+
ln -s "$(command -v mktemp)" "$EMPTY_PATH_DIR/mktemp"
|
|
221
|
+
|
|
222
|
+
INPUT_JSON='{"hook_event_name":"PreToolUse","tool_name":"mcp__plugin_playwright_playwright__browser_navigate","tool_input":{"url":"file:///tmp/missing-python.html"}}'
|
|
223
|
+
STDOUT_FILE=$(mktemp); STDERR_FILE=$(mktemp); TMPFILES+=("$STDOUT_FILE" "$STDERR_FILE")
|
|
224
|
+
PATH="$EMPTY_PATH_DIR" printf '%s' "$INPUT_JSON" | PATH="$EMPTY_PATH_DIR" bash "$HOOK" >"$STDOUT_FILE" 2>"$STDERR_FILE"
|
|
225
|
+
RC=$?
|
|
226
|
+
|
|
227
|
+
if [[ "$RC" -ne 0 ]]; then
|
|
228
|
+
fail "Test 6: missing python3 should fail open (exit 0), got $RC"
|
|
229
|
+
elif [[ -s "$STDOUT_FILE" ]]; then
|
|
230
|
+
fail "Test 6: stdout should be empty on fail-open. Got: $(cat "$STDOUT_FILE")"
|
|
231
|
+
elif ! grep -q '\[playwright-file-guard\] action=fail reason=python3-missing' "$STDERR_FILE"; then
|
|
232
|
+
fail "Test 6: stderr missing python3-missing log line. Got: $(cat "$STDERR_FILE")"
|
|
233
|
+
else
|
|
234
|
+
pass "Test 6: missing python3 → fail open with diagnostic"
|
|
235
|
+
fi
|
|
236
|
+
|
|
237
|
+
# ---- Test 7: malformed stdin → silent passthrough ---------------------------
|
|
238
|
+
|
|
239
|
+
STDOUT_FILE=$(mktemp); STDERR_FILE=$(mktemp); TMPFILES+=("$STDOUT_FILE" "$STDERR_FILE")
|
|
240
|
+
printf '%s' 'not json at all { ' | bash "$HOOK" >"$STDOUT_FILE" 2>"$STDERR_FILE"
|
|
241
|
+
RC=$?
|
|
242
|
+
|
|
243
|
+
if [[ "$RC" -ne 0 ]]; then
|
|
244
|
+
fail "Test 7: malformed stdin should fail open (exit 0), got $RC"
|
|
245
|
+
elif [[ -s "$STDOUT_FILE" ]]; then
|
|
246
|
+
fail "Test 7: stdout should be empty on malformed stdin"
|
|
247
|
+
else
|
|
248
|
+
pass "Test 7: malformed stdin → silent passthrough"
|
|
249
|
+
fi
|
|
250
|
+
|
|
251
|
+
# ---- Test 8: terminal stdin guard preserved --------------------------------
|
|
252
|
+
|
|
253
|
+
if ! grep -q '\[ -t 0 \]' "$HOOK"; then
|
|
254
|
+
fail "Test 8: terminal stdin guard ([ -t 0 ]) missing from hook"
|
|
255
|
+
else
|
|
256
|
+
pass "Test 8: terminal stdin guard present"
|
|
257
|
+
fi
|
|
258
|
+
|
|
259
|
+
# ---- Test 9: zero-grep-hits for old menu copy in this hook ------------------
|
|
260
|
+
|
|
261
|
+
# The pattern uses [l] to break self-match: this file's bytes contain "[l]ocal"
|
|
262
|
+
# but the hook source contains "local" if the menu copy was reintroduced. The
|
|
263
|
+
# extended-regex bracket class matches "local" without our query containing
|
|
264
|
+
# the literal string we're forbidding.
|
|
265
|
+
OLD_MENU_PATTERN='Start a [l]ocal HTTP server'
|
|
266
|
+
if grep -qE "$OLD_MENU_PATTERN" "$HOOK"; then
|
|
267
|
+
fail "Test 9: hook still contains the old menu copy"
|
|
268
|
+
else
|
|
269
|
+
pass "Test 9: hook does not contain the old menu copy"
|
|
270
|
+
fi
|
|
271
|
+
|
|
272
|
+
echo
|
|
273
|
+
echo "──────── playwright-file-guard test summary ────────"
|
|
274
|
+
echo "PASS: $PASS"
|
|
275
|
+
echo "FAIL: $FAIL"
|
|
276
|
+
|
|
277
|
+
[[ "$FAIL" -gt 0 ]] && exit 1
|
|
278
|
+
exit 0
|
|
@@ -1,30 +1,214 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# PreToolUse hook —
|
|
2
|
+
# PreToolUse hook — rewrites file:// browser_navigate calls to a backgrounded
|
|
3
|
+
# loopback HTTP server, so Playwright never sees a file:// URL (which it
|
|
4
|
+
# blocks behind a 2-minute MCP timeout).
|
|
3
5
|
#
|
|
4
|
-
# Scoped to mcp__plugin_playwright_playwright__browser_navigate via
|
|
5
|
-
#
|
|
6
|
-
# guidance so the agent self-corrects on the first try instead of
|
|
7
|
-
# retrying into Playwright's cryptic error + 2-minute MCP timeout.
|
|
6
|
+
# Scoped to mcp__plugin_playwright_playwright__browser_navigate via the
|
|
7
|
+
# settings.json matcher.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# Modes:
|
|
10
|
+
# default (stdin = PreToolUse JSON):
|
|
11
|
+
# - Sweeps stale /tmp/playwright-file-guard.*.pid (>1h old) opportunistically.
|
|
12
|
+
# - If tool_input.url is file://, picks a free port, backgrounds
|
|
13
|
+
# `python3 -m http.server <port>` rooted at the file's parent dir,
|
|
14
|
+
# connect-verifies the server within 1s, writes PID to
|
|
15
|
+
# /tmp/playwright-file-guard.<port>.pid, and emits a JSON object on
|
|
16
|
+
# stdout setting hookSpecificOutput.updatedInput.url to
|
|
17
|
+
# http://127.0.0.1:<port>/<basename>.
|
|
18
|
+
# - Other URLs: passthrough log to stderr, exit 0, no stdout.
|
|
19
|
+
#
|
|
20
|
+
# `cleanup` argv: only runs the stale-PID sweep; does not read stdin.
|
|
21
|
+
#
|
|
22
|
+
# Error philosophy: fail open. Any internal failure → exit 0 with a stderr
|
|
23
|
+
# diagnostic, letting Playwright handle the original URL (the legacy
|
|
24
|
+
# 2-minute timeout) rather than block on a parser bug.
|
|
25
|
+
#
|
|
26
|
+
# Observability: every action emits one stderr line of the shape
|
|
27
|
+
# [playwright-file-guard] action=<rewrite|passthrough|cleanup|fail> ...
|
|
28
|
+
|
|
29
|
+
set -u
|
|
12
30
|
|
|
13
|
-
|
|
31
|
+
MODE="${1:-rewrite}"
|
|
32
|
+
|
|
33
|
+
# Terminal stdin guard. Cleanup mode never reads stdin.
|
|
34
|
+
if [[ "$MODE" != "cleanup" ]] && [ -t 0 ]; then
|
|
14
35
|
exit 0
|
|
15
36
|
fi
|
|
16
|
-
INPUT=$(cat)
|
|
17
|
-
|
|
18
|
-
if echo "$INPUT" | grep -qiE '"url"\s*:\s*"file://'; then
|
|
19
|
-
cat >&2 << 'EOF'
|
|
20
|
-
file:// URLs are blocked by Playwright. To view a local HTML file in the browser:
|
|
21
37
|
|
|
22
|
-
|
|
23
|
-
|
|
38
|
+
# Without python3 we cannot do anything useful. Drain stdin (so the upstream
|
|
39
|
+
# tool dispatcher does not get SIGPIPE) and fail open.
|
|
40
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
41
|
+
if [[ "$MODE" != "cleanup" ]]; then
|
|
42
|
+
cat >/dev/null
|
|
43
|
+
fi
|
|
44
|
+
echo "[playwright-file-guard] action=fail reason=python3-missing" >&2
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
24
47
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
48
|
+
if [[ "$MODE" == "cleanup" ]]; then
|
|
49
|
+
INPUT=""
|
|
50
|
+
else
|
|
51
|
+
INPUT=$(cat)
|
|
28
52
|
fi
|
|
29
53
|
|
|
30
|
-
exit 0
|
|
54
|
+
MODE="$MODE" INPUT="$INPUT" python3 <<'PY' || exit 0
|
|
55
|
+
import os, sys, json, glob, time, socket, subprocess
|
|
56
|
+
from urllib.parse import unquote
|
|
57
|
+
|
|
58
|
+
MODE = os.environ.get('MODE', 'rewrite')
|
|
59
|
+
INPUT = os.environ.get('INPUT', '')
|
|
60
|
+
NOW = time.time()
|
|
61
|
+
STALE_THRESHOLD = 3600 # 1h
|
|
62
|
+
PID_GLOB = '/tmp/playwright-file-guard.*.pid'
|
|
63
|
+
|
|
64
|
+
def log(line):
|
|
65
|
+
print(line, file=sys.stderr, flush=True)
|
|
66
|
+
|
|
67
|
+
def port_from_pidfile(path):
|
|
68
|
+
parts = os.path.basename(path).split('.')
|
|
69
|
+
return parts[1] if len(parts) >= 3 else 'unknown'
|
|
70
|
+
|
|
71
|
+
def sweep_stale():
|
|
72
|
+
for pid_file in glob.glob(PID_GLOB):
|
|
73
|
+
try:
|
|
74
|
+
mtime = os.stat(pid_file).st_mtime
|
|
75
|
+
except OSError:
|
|
76
|
+
continue
|
|
77
|
+
if NOW - mtime < STALE_THRESHOLD:
|
|
78
|
+
continue
|
|
79
|
+
port = port_from_pidfile(pid_file)
|
|
80
|
+
try:
|
|
81
|
+
with open(pid_file) as f:
|
|
82
|
+
pid = int(f.read().strip())
|
|
83
|
+
except Exception:
|
|
84
|
+
try: os.remove(pid_file)
|
|
85
|
+
except OSError: pass
|
|
86
|
+
log(f'[playwright-file-guard] action=cleanup port={port} status=unparseable-pid')
|
|
87
|
+
continue
|
|
88
|
+
cmd = subprocess.run(
|
|
89
|
+
['ps', '-p', str(pid), '-o', 'command='],
|
|
90
|
+
capture_output=True, text=True,
|
|
91
|
+
)
|
|
92
|
+
cmdline = cmd.stdout if cmd.returncode == 0 else ''
|
|
93
|
+
# macOS ps resolves /usr/local/bin/python3 → Python.app binary "Python";
|
|
94
|
+
# Linux/Pi ps shows "python3". Match either via case-insensitive
|
|
95
|
+
# `python` substring + the unambiguous `http.server` invocation.
|
|
96
|
+
cmdline_lower = cmdline.lower()
|
|
97
|
+
if 'python' in cmdline_lower and 'http.server' in cmdline_lower:
|
|
98
|
+
subprocess.run(['kill', str(pid)], capture_output=True)
|
|
99
|
+
log(f'[playwright-file-guard] action=cleanup port={port} pid={pid} status=killed')
|
|
100
|
+
else:
|
|
101
|
+
log(f'[playwright-file-guard] action=cleanup port={port} pid={pid} status=pid-reused-skip')
|
|
102
|
+
try: os.remove(pid_file)
|
|
103
|
+
except OSError: pass
|
|
104
|
+
|
|
105
|
+
# Opportunistic sweep on every invocation — closes the gap that nothing
|
|
106
|
+
# currently calls cleanup on a periodic schedule.
|
|
107
|
+
sweep_stale()
|
|
108
|
+
|
|
109
|
+
if MODE == 'cleanup':
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
payload = json.loads(INPUT) if INPUT else {}
|
|
114
|
+
except Exception:
|
|
115
|
+
sys.exit(0)
|
|
116
|
+
|
|
117
|
+
tool_input = payload.get('tool_input') or {}
|
|
118
|
+
url = tool_input.get('url')
|
|
119
|
+
if not isinstance(url, str) or not url:
|
|
120
|
+
sys.exit(0)
|
|
121
|
+
|
|
122
|
+
clean_url = url.split('#', 1)[0].split('?', 1)[0]
|
|
123
|
+
|
|
124
|
+
if '://' not in clean_url:
|
|
125
|
+
log('[playwright-file-guard] action=passthrough scheme=none')
|
|
126
|
+
sys.exit(0)
|
|
127
|
+
|
|
128
|
+
scheme, rest = clean_url.split('://', 1)
|
|
129
|
+
scheme = scheme.lower()
|
|
130
|
+
if scheme != 'file':
|
|
131
|
+
log(f'[playwright-file-guard] action=passthrough scheme={scheme}')
|
|
132
|
+
sys.exit(0)
|
|
133
|
+
|
|
134
|
+
# file:///abs/path → /abs/path; file://host/path → /path (host ignored).
|
|
135
|
+
if rest.startswith('/'):
|
|
136
|
+
file_path = rest
|
|
137
|
+
elif '/' in rest:
|
|
138
|
+
file_path = '/' + rest.split('/', 1)[1]
|
|
139
|
+
else:
|
|
140
|
+
file_path = '/'
|
|
141
|
+
|
|
142
|
+
file_path = unquote(file_path)
|
|
143
|
+
abs_path = os.path.abspath(file_path)
|
|
144
|
+
|
|
145
|
+
if not os.path.isfile(abs_path):
|
|
146
|
+
log(f'[playwright-file-guard] action=fail reason=file-not-found path={abs_path}')
|
|
147
|
+
sys.exit(0)
|
|
148
|
+
|
|
149
|
+
served_dir = os.path.dirname(abs_path)
|
|
150
|
+
basename = os.path.basename(abs_path)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
s = socket.socket()
|
|
154
|
+
s.bind(('127.0.0.1', 0))
|
|
155
|
+
port = s.getsockname()[1]
|
|
156
|
+
s.close()
|
|
157
|
+
except Exception as e:
|
|
158
|
+
log(f'[playwright-file-guard] action=fail reason=port-pick-failed err={type(e).__name__}')
|
|
159
|
+
sys.exit(0)
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
proc = subprocess.Popen(
|
|
163
|
+
[sys.executable, '-m', 'http.server', str(port), '--bind', '127.0.0.1'],
|
|
164
|
+
cwd=served_dir,
|
|
165
|
+
stdout=subprocess.DEVNULL,
|
|
166
|
+
stderr=subprocess.DEVNULL,
|
|
167
|
+
start_new_session=True,
|
|
168
|
+
)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
log(f'[playwright-file-guard] action=fail reason=spawn-failed err={type(e).__name__}')
|
|
171
|
+
sys.exit(0)
|
|
172
|
+
|
|
173
|
+
pid_file = f'/tmp/playwright-file-guard.{port}.pid'
|
|
174
|
+
try:
|
|
175
|
+
with open(pid_file, 'w') as f:
|
|
176
|
+
f.write(str(proc.pid))
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
# Connect-verify within 1s closes the TOCTOU window between bind(0) and the
|
|
181
|
+
# http.server actually accepting connections.
|
|
182
|
+
deadline = time.time() + 1.0
|
|
183
|
+
ready = False
|
|
184
|
+
while time.time() < deadline:
|
|
185
|
+
try:
|
|
186
|
+
sock = socket.create_connection(('127.0.0.1', port), timeout=0.2)
|
|
187
|
+
sock.close()
|
|
188
|
+
ready = True
|
|
189
|
+
break
|
|
190
|
+
except OSError:
|
|
191
|
+
time.sleep(0.05)
|
|
192
|
+
|
|
193
|
+
if not ready:
|
|
194
|
+
try: proc.kill()
|
|
195
|
+
except Exception: pass
|
|
196
|
+
try: os.remove(pid_file)
|
|
197
|
+
except OSError: pass
|
|
198
|
+
log(f'[playwright-file-guard] action=fail reason=server-not-ready port={port}')
|
|
199
|
+
sys.exit(0)
|
|
200
|
+
|
|
201
|
+
new_url = f'http://127.0.0.1:{port}/{basename}'
|
|
202
|
+
updated_input = dict(tool_input)
|
|
203
|
+
updated_input['url'] = new_url
|
|
204
|
+
|
|
205
|
+
print(json.dumps({
|
|
206
|
+
'hookSpecificOutput': {
|
|
207
|
+
'hookEventName': 'PreToolUse',
|
|
208
|
+
'permissionDecision': 'allow',
|
|
209
|
+
'permissionDecisionReason': 'file:// rewritten to local http.server',
|
|
210
|
+
'updatedInput': updated_input,
|
|
211
|
+
},
|
|
212
|
+
}))
|
|
213
|
+
log(f'[playwright-file-guard] action=rewrite original=file://{abs_path} served_dir={served_dir} port={port} pid={proc.pid}')
|
|
214
|
+
PY
|
|
@@ -9,6 +9,7 @@ import { execFileSync } from "node:child_process";
|
|
|
9
9
|
import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { writeKey, validateKey, hasKey, keyFilePath, deleteKey } from "../../../../lib/anthropic-key/dist/index.js";
|
|
11
11
|
import { writeAdminEntry, removeAdminFromAccount } from "../../../../lib/admins-write/dist/index.js";
|
|
12
|
+
import { isPersistentComponent } from "../../../../lib/persistent-components/dist/index.js";
|
|
12
13
|
import { deviceUrlBlock } from "../../../../lib/device-url/dist/index.js";
|
|
13
14
|
import { substituteBrandPlaceholders } from "../../../../lib/brand-templating/dist/index.js";
|
|
14
15
|
import { resolveEntitlement } from "../../../../lib/entitlement/dist/index.js";
|
|
@@ -1631,12 +1632,50 @@ server.tool("plugin-read", "Read a plugin definition (PLUGIN.md) or one of its r
|
|
|
1631
1632
|
};
|
|
1632
1633
|
}
|
|
1633
1634
|
});
|
|
1635
|
+
// Task 940 — `"rendered"` is a sentinel, not a persistence ack. The platform
|
|
1636
|
+
// UI's stream-parser intercepts this tool_use upstream of the SDK reply
|
|
1637
|
+
// (platform/ui/app/lib/claude-agent/stream-parser.ts:362) and emits a typed
|
|
1638
|
+
// `component` event that admin-agent.ts's persistMessage flushes to Neo4j as
|
|
1639
|
+
// a sibling :Component node.
|
|
1640
|
+
//
|
|
1641
|
+
// Task 942 — render-component is also the *server commitment surface* for
|
|
1642
|
+
// PERSISTENT_COMPONENTS (action-list, document-editor, rich-content-editor,
|
|
1643
|
+
// grid-editor). The actual file write + :KnowledgeDocument projection lives
|
|
1644
|
+
// in the admin server process (stream-parser path) because the SDK delivers
|
|
1645
|
+
// `tool_use.input` to the UI in the assistant message but the MCP handler's
|
|
1646
|
+
// return reaches only the agent — there is no path back to persistMessage
|
|
1647
|
+
// from here. This handler emits a one-line observability marker per
|
|
1648
|
+
// persistence-eligible call so operators can grep for the commitment, and
|
|
1649
|
+
// returns richer JSON so the doctrine grep
|
|
1650
|
+
// `render-component.*"rendered"` resolves to a persistence-aware handler
|
|
1651
|
+
// rather than a bare stub.
|
|
1634
1652
|
server.tool("render-component", "Render a pre-built UI component inline in the conversation. " +
|
|
1635
1653
|
"Call this instead of describing a UI — the component handles input collection. " +
|
|
1636
1654
|
"Wait for the user's response before continuing.", {
|
|
1637
1655
|
name: z.string().describe("Component name from the UI suite (e.g. single-select, confirm, info-card, form, progress)"),
|
|
1638
1656
|
data: z.record(z.string(), z.unknown()),
|
|
1639
|
-
}, async (
|
|
1657
|
+
}, async ({ name, data }) => {
|
|
1658
|
+
if (isPersistentComponent(name)) {
|
|
1659
|
+
// Persistence-eligible: log the commitment so the live + audit grep
|
|
1660
|
+
// (`[render-component-persist]`) can correlate the MCP call against
|
|
1661
|
+
// the downstream :KnowledgeDocument MERGE. Byte counts come from
|
|
1662
|
+
// data.html (preferred) or data.content; both being absent is a
|
|
1663
|
+
// legitimate render of an empty editor and the projection is skipped
|
|
1664
|
+
// server-side.
|
|
1665
|
+
const html = typeof data?.html === "string" ? data.html : "";
|
|
1666
|
+
const content = typeof data?.content === "string" ? data.content : "";
|
|
1667
|
+
const bytes = html.length > 0 ? html.length : content.length;
|
|
1668
|
+
const mimeType = html.length > 0 ? "text/html" : (content.length > 0 ? "text/markdown" : "");
|
|
1669
|
+
console.error(`[render-component-persist] componentName=${name} bytes=${bytes} mimeType=${mimeType || "none"}`);
|
|
1670
|
+
return {
|
|
1671
|
+
content: [{
|
|
1672
|
+
type: "text",
|
|
1673
|
+
text: JSON.stringify({ rendered: true, name, persistent: true, bytes, mimeType }),
|
|
1674
|
+
}],
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
return { content: [{ type: "text", text: "rendered" }] };
|
|
1678
|
+
});
|
|
1640
1679
|
server.tool("session-reset", "Reset the current session. Compacts conversation history to memory, clears the visible conversation, " +
|
|
1641
1680
|
"and starts a fresh session with a new greeting. Call when the user asks to start a new session, " +
|
|
1642
1681
|
"clear the conversation, or start fresh.", {}, async () => ({ content: [{ type: "text", text: "reset" }] }));
|