@rubytech/create-maxy-code 0.1.492 → 0.1.493
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/plugins/admin/PLUGIN.md +1 -0
- package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +99 -0
- package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +108 -0
- package/payload/platform/plugins/admin/skills/agent-builder/references/agent-pattern.md +18 -6
- package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +11 -1
- package/payload/platform/plugins/cloudflare/bin/__tests__/portal-brand-css.test.sh +1 -1
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +22 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +111 -3
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +13 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +12 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +8 -0
- package/payload/platform/scripts/lib/provision-account-dir.sh +6 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +35 -6
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +62 -1
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +9 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.d.ts +80 -0
- package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.js +216 -0
- package/payload/platform/services/claude-session-manager/dist/skill-staleness-audit.js.map +1 -0
- package/payload/platform/templates/agents/admin/IDENTITY.md +5 -1
- package/payload/platform/templates/agents/passive/IDENTITY.md +2 -0
- package/payload/platform/templates/agents/public/IDENTITY.md +2 -0
- package/payload/platform/templates/specialists/agents/citation-auditor.md +1 -0
- package/payload/platform/templates/specialists/agents/coding-assistant.md +1 -0
- package/payload/platform/templates/specialists/agents/compiled-truth-rewriter.md +3 -0
- package/payload/platform/templates/specialists/agents/content-producer.md +1 -0
- package/payload/platform/templates/specialists/agents/data-manager.md +1 -0
- package/payload/platform/templates/specialists/agents/database-operator.md +1 -0
- package/payload/platform/templates/specialists/agents/librarian.md +1 -0
- package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -0
- package/payload/platform/templates/specialists/agents/project-manager.md +1 -0
- package/payload/platform/templates/specialists/agents/public-session-reviewer.md +1 -0
- package/payload/platform/templates/specialists/agents/research-assistant.md +3 -0
- package/payload/platform/templates/specialists/agents/typed-edge-classifier.md +1 -0
- package/payload/premium-plugins/writer-craft/agents/writer-craft--manuscript-reviewer.md +3 -0
- package/payload/server/public/assets/{chat-DWvYv9jq.js → chat-1SSmFsjd.js} +1 -1
- package/payload/server/public/assets/{operator-CldXXilj.js → operator-Cxq9TxaH.js} +1 -1
- package/payload/server/public/assets/{page-BIwdWXnv.js → page-DBHzC59_.js} +1 -1
- package/payload/server/public/assets/{public-BKQEQGPE.js → public-jIzvdjt_.js} +1 -1
- package/payload/server/public/chat.html +2 -2
- package/payload/server/public/operator.html +2 -2
- package/payload/server/public/public.html +2 -2
- package/payload/server/server.js +35 -26
package/package.json
CHANGED
|
@@ -188,6 +188,7 @@ Tools are available via the `admin` MCP server.
|
|
|
188
188
|
## Hooks
|
|
189
189
|
|
|
190
190
|
- `hooks/quote-render-gate.sh` — **PreToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** Makes the deterministic quote-render path the only route a client-facing quote PDF can reach a customer. Blocks (exit 2) a `browser-pdf-save` whose output path is a client-quote artefact (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) when no render receipt exists at `quoting/jobs/<jobId>/render-receipt.json` — the receipt `quote-render` writes only after its reconcile / margin-leak / link-liveness gates all pass. A hand-authored quote (the SiteDesk 2026-07-14 bypass: `Write` HTML → `browser-pdf-save`, `quote-render` never called, an internal back-test caveat naming another client folded into the free-text region) has no receipt, so the print is refused rather than delivered. **Block message:** `Blocked: this client quote was not produced by the quote-render tool, so its fixed layout and the reconcile, margin-leak and link-liveness gates never ran. Run quote-render for this job (it files the document and its receipt), then print the client HTML path it returns to PDF.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> detail=client quote PDF has no render receipt` (or `reason=bad-jobid` when the filename does not resolve to a job). Every non-quote PDF — brochures, invoices, the admin-scope internal view — and the deterministic path itself (receipt present) pass at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3`): `browser-pdf-save` is a general-purpose tool and the agent never controls this hook's stdin, so failing open opens no bypass while never bricking unrelated PDFs.
|
|
191
|
+
- `hooks/quote-render-pdf-conformance.sh` — **PostToolUse matcher=`mcp__plugin_browser_browser__browser-pdf-save`.** The PDF-time companion to the PreToolUse `quote-render-gate`: the HTML gates run inside `quote-render` before the receipt, but the two PDF-only conformance checks cannot, because the PDF does not exist until `browser-pdf-save` runs. For a gated client-quote PDF (`memory/users/<phone>/documents/quote-<jobId>.pdf` or `…-breakdown.pdf`) whose render receipt names the source HTML, it blocks (exit 2) when the printed PDF's `/URI` annotation count is below the source HTML's absolute-link count (a rasterised or flattened print that dropped links) or when an embedded `https://` link does not answer 200 (a Sign-Online link a pending redeploy would 404). The offline `/URI`-count check runs first and short-circuits, so a link-loss regression never depends on the network. **Block message:** `Blocked: the printed quote PDF is missing links the document declared, or an embedded link is not live (…). Do not send it. If a Sign-Online link is not live yet, deploy the signing page first; then re-render and re-print so the PDF carries every link the document declares.` **Log line** (stderr, on a block): `[quote-render] op=bypass jobId=<jobId> reason=pdf-link-conformance detail=<uri-count …|link <url> -> <code>>`. Every non-quote PDF, and any call with no receipt (the PreToolUse gate owns that block), passes at exit 0. **Fail-open** on an uninspectable call (tty / empty stdin / no `python3` / receipt or PDF absent). **Reach on upgrade:** `provision-account-dir.sh` writes this PostToolUse matcher only at account-provision time, so accounts provisioned before it landed keep a stale matcher and need a settings backfill (tracked as a follow-up task), the same reach limit the AskUserQuestion carrier gate hit.
|
|
191
192
|
- `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.
|
|
192
193
|
- `hooks/askuserquestion-investigate-gate.sh` — PreToolUse matcher=`AskUserQuestion`. Blocks the question (exit 2) when no read-only investigation tool has fired since the latest real user turn in the session JSONL. The structural fix for the failure class where the agent fabricates a menu before evidence-gathering (session `c085ec2c-46fb-4b73-8865-68cf85866ea8` 2026-05-22 — "change remote access password" → invented options "Admin PIN / Cloudflare tunnel / WiFi password" with zero prior tool_use; post-correction the agent immediately fired `remote-auth-status` → `ToolSearch` → `remote-auth-set-password`, proving it knew the moves). **Allowlist** (exact, with trailing `__<tool>` suffix-match for namespaced `mcp__plugin_<plugin>_<server>__<tool>` aliases): `ToolSearch`, `Grep`, `Glob`, `Read`, `LS`, `NotebookRead`, `Bash`, `WebFetch`, `WebSearch`, plus the read-only admin / memory MCP tools (`*-status`, `*-list`, `*-read`, `skill-find`, `memory-find-candidates`, `profile-read`, `conversation-list`, `memory-list-attachments`, `memory-read-attachment`). **Block message:** `Blocked: AskUserQuestion requires at least one investigation tool (ToolSearch, Grep, Read, *-list, *-status, *-read, skill-find, ...) earlier in this turn. Search the operator's literal phrase first.` **Log line** (stderr, one per call): `[ask-gate] decision=<allow|block> sessionId=<id8> seen=<csv|-> reason=<allowlist-hit|no-investigation|fail-open-no-transcript|fail-open-parse-error>`. **Fail-open** on missing transcript or parse error — nudges, never bricks the UI.
|
|
193
194
|
- `hooks/askuserquestion-channel-carrier-gate.sh` — PreToolUse matcher=`AskUserQuestion`, registered as a **second command on the same matcher** as the investigate-gate (either exit 2 blocks; the two guard different conditions). Blocks the question (exit 2) when the session has a native channel attached, because the Claude Code channel protocol carries only the two permission methods — there is no carrier for the `AskUserQuestion` elicitation, so on an rc-spawn channel (webchat/WhatsApp/Telegram) session the harness would block the turn awaiting a selection that can never arrive and the reader renders only delivered replies, wedging the turn (live SiteDesk incident session `1a085718` 2026-07-11: `/chat` stalled on the delivered "On it…" ack with no card). **Signal:** the rc-spawn writes the channel MCP registration as a config file in `os.tmpdir()` keyed by session id (`maxy-channel-<sid>.json` / `maxy-webchat-channel-<sid>.json` / `maxy-wa-channel-<sid>.json` / `maxy-telegram-channel-<sid>.json`, `sid` sanitized `[^A-Za-z0-9_-]→_` exactly as the writers do); its presence at tool-call time is the authoritative "channel attached to this session" marker. The hook resolves the tmpdir the way Node's `os.tmpdir()` does (`${TMPDIR:-/tmp}`, trailing slash stripped). Correct in both readings of the claude.ai/code scoping question: a Pi channel process sees the tmpfile (block); a claude.ai/code process on another host never sees the Pi tmpfile (fail-open → allow, that surface can answer the card). **Block message:** `Blocked: this is a native-channel session and AskUserQuestion cannot be delivered over a channel (the channel protocol carries no elicitation card, so the turn would wedge). Ask the same question as prose in your channel reply tool and wait for the operator's next message.` **Log line** (stderr, one per decisive call): `[ask-channel] decision=<allow|block> sessionId=<id8> channel=<attached|none> reason=<channel-attached|no-channel|fail-open-no-envelope|fail-open-no-session>`. **Fail-open** on no envelope, blank session id, or absent channel file — nudges, never bricks the UI. The complementary standing check (`interactive-wedge` in the session manager) catches any un-carried interactive tool_use that slips past this hook; it scans every account's project-slug (not just the boot slug), so a client sub-account channel wedge is covered too. **Reach on upgrade:** `provision-account-dir.sh` writes this matcher only at account-provision time, and the upgrade path re-provisions only the house account — so an account provisioned before this gate landed keeps a stale matcher and re-wedges (live SiteDesk recurrence session `d39a22b0`, account `2078cb54`, 2026-07-15). `setup-account.sh` runs `reconcile_all_accounts_askgate` (`platform/scripts/lib/account-settings-askgate.sh`) on every install to retrofit the carrier gate onto every existing account's `settings.json` — idempotent surgical jq patch of the `AskUserQuestion` matcher, one `[backfill-1683] file=<f> status=<absent|no-hooks|already-set|updated|rewrite-failed>` line per account, contents never echoed.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Regression test for quote-render-pdf-conformance.sh.
|
|
3
|
+
#
|
|
4
|
+
# PostToolUse hook on browser-pdf-save. For a gated client-quote PDF whose render
|
|
5
|
+
# receipt names the source HTML, it blocks when the PDF dropped links (its /URI
|
|
6
|
+
# count is below the HTML's absolute-link count) or when an embedded https link
|
|
7
|
+
# is not live. Everything else passes; it fails open on anything uninspectable.
|
|
8
|
+
#
|
|
9
|
+
# Covers (all offline-deterministic — the one network case points at a closed
|
|
10
|
+
# local port so the connection refusal is immediate and reproducible):
|
|
11
|
+
# A. non-quote PDF → ALLOWED (not gated)
|
|
12
|
+
# B. quote PDF, no receipt → ALLOWED (PreToolUse owns that block)
|
|
13
|
+
# C. quote PDF, HTML has no absolute links → ALLOWED (0 >= 0, no fetch)
|
|
14
|
+
# D. quote PDF, PDF /URI < HTML links → BLOCKED (link-loss, no fetch)
|
|
15
|
+
# E. quote PDF, /URI ok but an https link is dead → BLOCKED (link not live)
|
|
16
|
+
# F. quote PDF, /URI ok and the only link is 200 → not asserted (needs a live server)
|
|
17
|
+
# G. empty stdin → ALLOWED (fail-open)
|
|
18
|
+
# H. non-browser tool → ALLOWED (matcher defence-in-depth)
|
|
19
|
+
# I. a block emits the op=bypass fault line
|
|
20
|
+
|
|
21
|
+
set -u
|
|
22
|
+
|
|
23
|
+
HOOK="$(cd "$(dirname "$0")/.." && pwd)/quote-render-pdf-conformance.sh"
|
|
24
|
+
if [[ ! -x "$HOOK" ]]; then
|
|
25
|
+
echo "FAIL: $HOOK not executable" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
ACCT=$(mktemp -d)
|
|
30
|
+
cleanup() { rm -rf "$ACCT"; }
|
|
31
|
+
trap cleanup EXIT
|
|
32
|
+
|
|
33
|
+
DOCS="$ACCT/memory/users/44700900000/documents"
|
|
34
|
+
mkdir -p "$DOCS"
|
|
35
|
+
|
|
36
|
+
# Receipt + filed HTML for each scenario job. The receipt names the filed quote
|
|
37
|
+
# HTML; the hook reads its absolute links and compares to the PDF's /URI count.
|
|
38
|
+
mk_job() { # jobId, html_body, pdf_uri_repeat
|
|
39
|
+
local job="$1" body="$2" uris="$3"
|
|
40
|
+
mkdir -p "$ACCT/quoting/jobs/$job"
|
|
41
|
+
local html="$DOCS/quote-$job.html"
|
|
42
|
+
printf '%s' "$body" > "$html"
|
|
43
|
+
printf '{"jobId":"%s","filed":{"internal":"x","breakdown":"x","quote":"%s"}}' "$job" "$html" \
|
|
44
|
+
> "$ACCT/quoting/jobs/$job/render-receipt.json"
|
|
45
|
+
# A minimal PDF-ish file carrying `uris` copies of the /URI token.
|
|
46
|
+
{ printf '%%PDF-1.4\n'; local i=0; while [[ $i -lt $uris ]]; do printf '/URI (x)\n'; i=$((i+1)); done; } \
|
|
47
|
+
> "$DOCS/quote-$job.pdf"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# C: no absolute links, PDF has none → allow.
|
|
51
|
+
mk_job "clean" '<html><body><a href="#top">top</a><p>no links</p></body></html>' 0
|
|
52
|
+
# D: two https links, PDF dropped them (0 /URI) → block on count.
|
|
53
|
+
mk_job "dropped" '<html><body><a href="https://a.example/1">a</a><a href="https://b.example/2">b</a></body></html>' 0
|
|
54
|
+
# E: one https link, PDF kept a /URI, but the link points at a closed local port → block on liveness.
|
|
55
|
+
mk_job "dead" '<html><body><a href="https://127.0.0.1:1/never">sign</a></body></html>' 1
|
|
56
|
+
|
|
57
|
+
PASS=0
|
|
58
|
+
FAIL=0
|
|
59
|
+
|
|
60
|
+
run_case() {
|
|
61
|
+
local name="$1" stdin="$2" expected_exit="$3" actual_exit
|
|
62
|
+
( cd "$ACCT" && printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>/dev/null )
|
|
63
|
+
actual_exit=$?
|
|
64
|
+
if [[ "$actual_exit" -eq "$expected_exit" ]]; then
|
|
65
|
+
echo "PASS: $name (exit=$actual_exit)"; PASS=$((PASS + 1))
|
|
66
|
+
else
|
|
67
|
+
echo "FAIL: $name (expected exit=$expected_exit, got=$actual_exit)" >&2; FAIL=$((FAIL + 1))
|
|
68
|
+
fi
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
envelope() { printf '{"hook_event_name":"PostToolUse","tool_name":"%s","tool_input":{"path":"%s"}}' "$1" "$2"; }
|
|
72
|
+
BROWSER="mcp__plugin_browser_browser__browser-pdf-save"
|
|
73
|
+
|
|
74
|
+
run_case "A: non-quote PDF → ALLOWED" \
|
|
75
|
+
"$(envelope "$BROWSER" "$DOCS/brochure.pdf")" 0
|
|
76
|
+
run_case "B: quote PDF, no receipt → ALLOWED (PreToolUse owns it)" \
|
|
77
|
+
"$(envelope "$BROWSER" "$DOCS/quote-nojob.pdf")" 0
|
|
78
|
+
run_case "C: HTML has no absolute links → ALLOWED" \
|
|
79
|
+
"$(envelope "$BROWSER" "$DOCS/quote-clean.pdf")" 0
|
|
80
|
+
run_case "D: PDF /URI below HTML link count → BLOCKED" \
|
|
81
|
+
"$(envelope "$BROWSER" "$DOCS/quote-dropped.pdf")" 2
|
|
82
|
+
run_case "E: an embedded https link is not live → BLOCKED" \
|
|
83
|
+
"$(envelope "$BROWSER" "$DOCS/quote-dead.pdf")" 2
|
|
84
|
+
run_case "G: empty stdin → ALLOWED (fail-open)" \
|
|
85
|
+
"" 0
|
|
86
|
+
run_case "H: non-browser tool → ALLOWED" \
|
|
87
|
+
'{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"path":"'"$DOCS"'/quote-dropped.pdf"}}' 0
|
|
88
|
+
|
|
89
|
+
# I: a block emits the op=bypass fault line on stderr.
|
|
90
|
+
STDERR=$( cd "$ACCT" && printf '%s' "$(envelope "$BROWSER" "$DOCS/quote-dropped.pdf")" | bash "$HOOK" 2>&1 >/dev/null )
|
|
91
|
+
if printf '%s' "$STDERR" | grep -q '\[quote-render\] op=bypass jobId=dropped reason=pdf-link-conformance'; then
|
|
92
|
+
echo "PASS: I: block emits op=bypass jobId=dropped"; PASS=$((PASS + 1))
|
|
93
|
+
else
|
|
94
|
+
echo "FAIL: I: op=bypass line absent (got: $STDERR)" >&2; FAIL=$((FAIL + 1))
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
echo "──────── quote-render-pdf-conformance test summary ────────"
|
|
98
|
+
echo "PASS=$PASS FAIL=$FAIL"
|
|
99
|
+
[[ "$FAIL" -eq 0 ]]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# quote-render-pdf-conformance — PostToolUse gate on browser-pdf-save.
|
|
3
|
+
#
|
|
4
|
+
# The companion to the PreToolUse quote-render-gate: that gate checks the
|
|
5
|
+
# rendered HTML on the deterministic path (via the render receipt) before the
|
|
6
|
+
# PDF is printed; the HTML the tool gated cannot yet be a PDF at PreToolUse, so
|
|
7
|
+
# the two PDF-only conformance checks run here, once the PDF exists.
|
|
8
|
+
#
|
|
9
|
+
# For a gated client-quote PDF (memory/users/<phone>/documents/quote-<jobId>.pdf
|
|
10
|
+
# or ...-breakdown.pdf) whose render receipt names the source HTML, it asserts:
|
|
11
|
+
# 1. the PDF's /URI annotation count >= the source HTML's absolute-link count
|
|
12
|
+
# (a print that dropped links — a rasterised or flattened copy — carries
|
|
13
|
+
# fewer /URI than the HTML had anchors);
|
|
14
|
+
# 2. every embedded https:// anchor in the source HTML answers 200 (a pending
|
|
15
|
+
# redeploy that would 404 the Sign-Online link is caught before the PDF is
|
|
16
|
+
# sent).
|
|
17
|
+
# The /URI-count check is offline and runs first; the network fetch runs only
|
|
18
|
+
# when the count check passes, so a link-loss regression never depends on the
|
|
19
|
+
# network being up.
|
|
20
|
+
#
|
|
21
|
+
# Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on any
|
|
22
|
+
# uninspectable call (tty / empty stdin / no python3 / no receipt / PDF not
|
|
23
|
+
# written): the no-receipt case is already the PreToolUse gate's to block, and
|
|
24
|
+
# browser-pdf-save is general-purpose, so failing open here bricks no unrelated
|
|
25
|
+
# PDF. No task numbers or internal refs in any operator-visible string.
|
|
26
|
+
|
|
27
|
+
set -uo pipefail
|
|
28
|
+
|
|
29
|
+
if [ -t 0 ]; then exit 0; fi
|
|
30
|
+
INPUT=$(cat)
|
|
31
|
+
[ -z "$INPUT" ] && exit 0
|
|
32
|
+
|
|
33
|
+
RESULT=$(INPUT="$INPUT" BASE="$PWD" python3 - <<'PY' 2>/dev/null || true
|
|
34
|
+
import os, json, re, sys, urllib.request
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
d = json.loads(os.environ["INPUT"])
|
|
38
|
+
except Exception:
|
|
39
|
+
sys.exit(0) # uninspectable envelope -> fail open
|
|
40
|
+
|
|
41
|
+
tool = d.get("tool_name", "") or ""
|
|
42
|
+
if not tool.endswith("browser-pdf-save"):
|
|
43
|
+
sys.exit(0)
|
|
44
|
+
path = (d.get("tool_input", {}) or {}).get("path", "") or ""
|
|
45
|
+
if not path:
|
|
46
|
+
sys.exit(0)
|
|
47
|
+
norm = os.path.normpath(path)
|
|
48
|
+
m = re.search(r'(?:^|/)memory/users/[^/]+/documents/quote-(.+?)(-breakdown)?\.pdf$', norm)
|
|
49
|
+
if not m:
|
|
50
|
+
sys.exit(0) # not a gated client-quote artefact
|
|
51
|
+
jobid = m.group(1)
|
|
52
|
+
if "/" in jobid or ".." in jobid:
|
|
53
|
+
sys.exit(0) # the PreToolUse gate owns bad-jobid; here fail open
|
|
54
|
+
variant = m.group(2) # "-breakdown" or None
|
|
55
|
+
|
|
56
|
+
base = os.environ["BASE"]
|
|
57
|
+
receipt_path = os.path.join(base, "quoting", "jobs", jobid, "render-receipt.json")
|
|
58
|
+
try:
|
|
59
|
+
receipt = json.load(open(receipt_path))
|
|
60
|
+
except Exception:
|
|
61
|
+
sys.exit(0) # no receipt -> PreToolUse gate already blocks; fail open
|
|
62
|
+
|
|
63
|
+
filed = receipt.get("filed", {}) or {}
|
|
64
|
+
html_path = filed.get("breakdown" if variant else "quote", "")
|
|
65
|
+
if not html_path or not os.path.isfile(html_path) or not os.path.isfile(path):
|
|
66
|
+
sys.exit(0) # nothing to compare against -> fail open
|
|
67
|
+
|
|
68
|
+
html = open(html_path, encoding="utf-8", errors="replace").read()
|
|
69
|
+
# Anchors that become a /URI annotation in the printed PDF: absolute http(s) or
|
|
70
|
+
# mailto hrefs. Fragment and relative anchors do not, so they are not counted.
|
|
71
|
+
uri_anchors = re.findall(r'href="((?:https?|mailto):[^"]*)"', html, re.I)
|
|
72
|
+
https_anchors = [u for u in uri_anchors if u.lower().startswith("https:")]
|
|
73
|
+
|
|
74
|
+
# /URI occurrences in the printed PDF. This is the same primitive the e-sign
|
|
75
|
+
# stamp gate uses (grep -a -c '/URI'); on real browser-pdf-save output it was
|
|
76
|
+
# measured to carry one greppable /URI per surviving link (base PDFs counted 4
|
|
77
|
+
# to 6, one per anchor), so the count is a faithful per-link tally, not stream-
|
|
78
|
+
# compressed away. A rasterised or flattened reprint drops to a lower count.
|
|
79
|
+
uri_count = open(path, "rb").read().count(b"/URI")
|
|
80
|
+
if uri_count < len(uri_anchors):
|
|
81
|
+
print("BLOCK|%s|uri-count pdf=%d html_links=%d" % (jobid, uri_count, len(uri_anchors)))
|
|
82
|
+
sys.exit(0)
|
|
83
|
+
|
|
84
|
+
for u in https_anchors:
|
|
85
|
+
try:
|
|
86
|
+
with urllib.request.urlopen(u, timeout=6) as r:
|
|
87
|
+
code = r.status
|
|
88
|
+
except Exception as e:
|
|
89
|
+
code = getattr(e, "code", 0) or 0
|
|
90
|
+
if code != 200:
|
|
91
|
+
print("BLOCK|%s|link %s -> %s" % (jobid, u, code))
|
|
92
|
+
sys.exit(0)
|
|
93
|
+
|
|
94
|
+
sys.exit(0)
|
|
95
|
+
PY
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
case "$RESULT" in
|
|
99
|
+
BLOCK\|*)
|
|
100
|
+
rest=${RESULT#BLOCK|}
|
|
101
|
+
jobid=${rest%%|*}
|
|
102
|
+
detail=${rest#*|}
|
|
103
|
+
echo "[quote-render] op=bypass jobId=$jobid reason=pdf-link-conformance detail=$detail" >&2
|
|
104
|
+
echo "Blocked: the printed quote PDF is missing links the document declared, or an embedded link is not live ($detail). Do not send it. If a Sign-Online link is not live yet, deploy the signing page first; then re-render and re-print so the PDF carries every link the document declares." >&2
|
|
105
|
+
exit 2
|
|
106
|
+
;;
|
|
107
|
+
esac
|
|
108
|
+
exit 0
|
|
@@ -110,12 +110,24 @@ Two further clauses bind the Output contract itself. **Recency**, universal: a t
|
|
|
110
110
|
claim carries the date it was observed, and a date the agent cannot see is one it flags, never
|
|
111
111
|
one it infers. **Corroboration**, for agents whose deliverable is a pattern, a theme, a trend, a
|
|
112
112
|
signal: the agent names the independent sources behind it and does not report one as established
|
|
113
|
-
on a single occurrence.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
113
|
+
on a single occurrence.
|
|
114
|
+
|
|
115
|
+
Corroboration binds only that subset, and the subset cannot be read out of a prompt's text, so
|
|
116
|
+
every prompt declares it. The flat specialists carry `pattern_deliverable: true` or
|
|
117
|
+
`pattern_deliverable: false` as a frontmatter key; the role `IDENTITY.md` files, which have no
|
|
118
|
+
frontmatter and are injected raw, carry the same marker as a trailing HTML comment
|
|
119
|
+
`<!-- pattern_deliverable: true -->`. Absent is a gate failure, not a default. A prompt declaring
|
|
120
|
+
true carries this sentence, character for character, as a third paragraph of its `## Grounding`
|
|
121
|
+
section; a prompt declaring false does not carry it at all:
|
|
122
|
+
|
|
123
|
+
> Where what you report is a pattern, a theme, a trend, or a signal, name the independent occurrences behind it; one occurrence is a single observation you report as such, never a pattern you assert.
|
|
124
|
+
|
|
125
|
+
`agent-grounding-drift.test.ts` fails if any shipped prompt carries the provenance or
|
|
126
|
+
person-attribute sentence anything other than exactly once, if a prompt does not declare
|
|
127
|
+
`pattern_deliverable` exactly once, if a true-declaring prompt carries the corroboration sentence
|
|
128
|
+
anything other than exactly once, if a false-declaring prompt carries it at all, and if the
|
|
129
|
+
number of prompts it inspected is not the number it expects. The marker and the sentence cannot
|
|
130
|
+
drift apart, and a new agent that skips any of the three does not ship.
|
|
119
131
|
|
|
120
132
|
## Skills owned
|
|
121
133
|
|
|
@@ -477,7 +477,15 @@ Dispatch runs **only** inside a live operator session on the account's own devic
|
|
|
477
477
|
|
|
478
478
|
2. **For each unswept row, in order — stamp, send, then mark that one row swept:**
|
|
479
479
|
|
|
480
|
-
- **Stamp a per-signer copy from the persisted base — no browser, no network.** Dispatch must **never fetch the deployed page**: it may sit behind **Cloudflare Access**, so a headless fetch hits the Access login screen, not the document, and the PDF is garbage. The render-once base (§ 5) is what removes that dependency. Load `${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.pdf`, append an **Electronic Acceptance Certificate** page, and write a per-signer copy alongside it
|
|
480
|
+
- **Stamp a per-signer copy from the persisted base — no browser, no network.** Dispatch must **never fetch the deployed page**: it may sit behind **Cloudflare Access**, so a headless fetch hits the Access login screen, not the document, and the PDF is garbage. The render-once base (§ 5) is what removes that dependency. Load `${ACCOUNT_DIR}/e-sign/<DOC_REF>/base.pdf`, append an **Electronic Acceptance Certificate** page, and write a per-signer copy alongside it, all with a PDF library that loads the base and copies its pages through intact (**`pdf-lib`**, run as a small Node script). The stamp copies existing pages; it never renders them to an image. Rasterising the base (ghostscript to PNG, or any image-then-redraw rebuild) discards every link annotation and all selectable text, so the signer receives a flattened copy with dead links; it is prohibited whatever is or is not installed. A substitute for pdf-lib is allowed only if it imports existing PDF pages: pypdf, qpdf, or pdftk. A canvas-only generator such as reportlab cannot import a base and is not a substitute. Whatever the library, it adds no browser and no network call.
|
|
481
|
+
|
|
482
|
+
**Pre-flight: the page-copying library must resolve before any stamp runs.** A stamp attempted without it is the exact history this guards against, where a prior session met the missing library by rasterising instead of installing one:
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
node -e 'import("pdf-lib").then(()=>console.log("ok"),e=>{console.error("pdf-lib missing: npm i pdf-lib at the account directory root");process.exit(1)})'
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
On failure the one remediation is that install; a fallback that renders pages to images is never one. The stamp script itself:
|
|
481
489
|
|
|
482
490
|
```js
|
|
483
491
|
// stamp.mjs — node, offline. Reads the persisted base, appends a certificate page,
|
|
@@ -509,6 +517,8 @@ Dispatch runs **only** inside a live operator session on the account's own devic
|
|
|
509
517
|
|
|
510
518
|
The certificate fields come **from the D1 row**, not a re-fetch: `name`, `title` (an operator-defined field if present), `token`, `doc_ref`, `accepted_at`, the signing `method`, and the **verbatim intent/consent assertion** the signer ticked (§ 2). That makes the artifact self-contained evidence of who accepted, when, and on what stated basis.
|
|
511
519
|
|
|
520
|
+
**Post-stamp gate: prove fidelity before any send.** For both `base.pdf` and the stamped copy, count link annotations with `grep -a -c '/URI'` and measure page-1 text extent with ghostscript text extraction (`gs -q -dNOPAUSE -dBATCH -sDEVICE=txtwrite -dFirstPage=1 -dLastPage=1 -o - <file> | wc -c`). The stamped copy passes only when its `/URI` count and its page-1 text extent are each at least the base's. On either regression the stamp rasterised or otherwise flattened the base, so delete the stamped copy, skip both sends, and leave the row `swept = 0` so the next sweep re-surfaces it. This gate converts a link-stripped copy from a no-event failure (nothing logs until a signer clicks a dead link) into a rejection at stamp time. Record the base and stamped `/URI` counts alongside every dispatch, pass or fail, so a fidelity regression is a number rather than a complaint.
|
|
521
|
+
|
|
512
522
|
- **Send twice via `email-send`**, each carrying `doc_ref`, `name`, `token`, `accepted_at`, and the **stamped** PDF (`signed-<token>.pdf`) as an attachment:
|
|
513
523
|
- once to the **business owner's configured address** (read from configured business identity — never hard-code a recipient in this workflow);
|
|
514
524
|
- once to the **signer's captured `email`** — this copy is the **statutory record provided to the signer**, the evidentiary copy the legal basis (intro) relies on.
|
|
@@ -18,7 +18,7 @@ set -u
|
|
|
18
18
|
|
|
19
19
|
BIN_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
20
20
|
SCRIPT="$BIN_DIR/portal-brand-css.mjs"
|
|
21
|
-
TEMPLATE="$BIN_DIR/../skills/data-portal/template/
|
|
21
|
+
TEMPLATE="$BIN_DIR/../skills/data-portal/template/portal.css"
|
|
22
22
|
[ -f "$SCRIPT" ] || { echo "FAIL: $SCRIPT not found" >&2; exit 1; }
|
|
23
23
|
[ -f "$TEMPLATE" ] || { echo "FAIL: $TEMPLATE not found" >&2; exit 1; }
|
|
24
24
|
# Case 2 strips the :root block with perl and diffs what is left. Without perl
|
|
@@ -198,6 +198,28 @@ export async function pushAccount(opts) {
|
|
|
198
198
|
const current = Number(state[0]?.currentGeneration ?? 0)
|
|
199
199
|
const next = current + 1
|
|
200
200
|
|
|
201
|
+
// Crash safety (Task 1923). A stage that died mid-batch — a D1 500 on one
|
|
202
|
+
// INSERT — leaves rows at `next` while the pointer stays at `current`. Every
|
|
203
|
+
// later run recomputes the same `next`, and its first colliding INSERT then
|
|
204
|
+
// hits UNIQUE(accountId, relPath, generation), so the index freezes at the
|
|
205
|
+
// stale generation until an operator hand-clears the orphans. Clearing `next`
|
|
206
|
+
// before staging lets a partial stage self-heal on the next cycle. Guarded on
|
|
207
|
+
// a non-zero count so the DELETE never precedes the flip on a clean cycle and
|
|
208
|
+
// the log line always means a recovered wedge. The count comes from a SELECT
|
|
209
|
+
// because the D1 client returns rows, not the affected-row meta.
|
|
210
|
+
const orphan = await client.query(
|
|
211
|
+
'SELECT COUNT(*) AS n FROM directory WHERE accountId = ? AND generation = ?',
|
|
212
|
+
[accountId, next],
|
|
213
|
+
)
|
|
214
|
+
const orphanCount = Number(orphan[0]?.n ?? 0)
|
|
215
|
+
if (orphanCount > 0) {
|
|
216
|
+
await client.query('DELETE FROM directory WHERE accountId = ? AND generation = ?', [
|
|
217
|
+
accountId,
|
|
218
|
+
next,
|
|
219
|
+
])
|
|
220
|
+
log(`${TAG} op=stage-clear account=${accountId} generation=${next} cleared=${orphanCount}`)
|
|
221
|
+
}
|
|
222
|
+
|
|
201
223
|
for (const row of rows) {
|
|
202
224
|
await client.query(
|
|
203
225
|
'INSERT INTO directory (accountId, relPath, sizeBytes, modifiedAt, indexedAt, isDir, generation) ' +
|
|
@@ -309,13 +309,85 @@ function generationClient() {
|
|
|
309
309
|
return []
|
|
310
310
|
}
|
|
311
311
|
if (/DELETE FROM directory\b/i.test(sql)) {
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
312
|
+
const g = Number(params[1])
|
|
313
|
+
// Two DELETE shapes reach this client: the post-flip sweep removes
|
|
314
|
+
// every OTHER generation (`generation != ?`); the pre-stage clear
|
|
315
|
+
// (Task 1923) removes exactly THIS generation (`generation = ?`).
|
|
316
|
+
if (/generation\s*!=\s*\?/i.test(sql)) {
|
|
317
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation !== g) rows.splice(i, 1)
|
|
318
|
+
} else {
|
|
319
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation === g) rows.splice(i, 1)
|
|
315
320
|
}
|
|
316
321
|
return []
|
|
317
322
|
}
|
|
318
323
|
if (/SELECT COUNT/i.test(sql)) {
|
|
324
|
+
// The pre-stage orphan count (Task 1923) filters `generation = ?`; the
|
|
325
|
+
// verify count joins the pointer. Only the former binds a generation.
|
|
326
|
+
if (/generation\s*=\s*\?/i.test(sql)) {
|
|
327
|
+
const g = Number(params[1])
|
|
328
|
+
return [{ n: rows.filter((r) => r.generation === g).length }]
|
|
329
|
+
}
|
|
330
|
+
return [{ n: rows.filter((r) => r.generation === pointer).length }]
|
|
331
|
+
}
|
|
332
|
+
return []
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The wedge Task 1923 fixes: a stage that half-completed on a D1 500 leaves
|
|
338
|
+
// orphan rows at the NEXT generation while the pointer stays at current. This
|
|
339
|
+
// client models the one property that turns that into a permanent freeze — the
|
|
340
|
+
// D1 UNIQUE(accountId, relPath, generation) constraint — by throwing on a
|
|
341
|
+
// colliding INSERT, exactly as every later cycle's first re-stage did.
|
|
342
|
+
function wedgeableClient(seed: { pointer: number; current?: string[]; orphans: string[] }) {
|
|
343
|
+
const calls: { sql: string; params: unknown[] }[] = []
|
|
344
|
+
const rows: { accountId: string; relPath: string; generation: number }[] = []
|
|
345
|
+
let pointer: number | null = seed.pointer
|
|
346
|
+
const orphanGen = seed.pointer + 1
|
|
347
|
+
// The last good tree the pointer names, present in a real wedge alongside the
|
|
348
|
+
// orphans. Seeding it proves the pre-stage clear scopes to `next` and leaves
|
|
349
|
+
// the reader-visible current generation untouched.
|
|
350
|
+
for (const relPath of seed.current ?? []) rows.push({ accountId: 'acc-1', relPath, generation: seed.pointer })
|
|
351
|
+
for (const relPath of seed.orphans) rows.push({ accountId: 'acc-1', relPath, generation: orphanGen })
|
|
352
|
+
return {
|
|
353
|
+
calls,
|
|
354
|
+
rows,
|
|
355
|
+
pointerValue: () => pointer,
|
|
356
|
+
async query(sql: string, params: unknown[] = []) {
|
|
357
|
+
calls.push({ sql, params })
|
|
358
|
+
if (/SELECT\s+currentGeneration/i.test(sql)) {
|
|
359
|
+
return pointer === null ? [] : [{ currentGeneration: pointer }]
|
|
360
|
+
}
|
|
361
|
+
if (/INSERT INTO directory_state|UPDATE directory_state/i.test(sql)) {
|
|
362
|
+
pointer = Number(params[1] ?? params[0])
|
|
363
|
+
return []
|
|
364
|
+
}
|
|
365
|
+
if (/INSERT INTO directory \(/i.test(sql)) {
|
|
366
|
+
const accountId = String(params[0])
|
|
367
|
+
const relPath = String(params[1])
|
|
368
|
+
const generation = Number(params[6])
|
|
369
|
+
if (rows.some((r) => r.accountId === accountId && r.relPath === relPath && r.generation === generation)) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
'd1 query failed: HTTP 500 — UNIQUE constraint failed: directory.accountId, directory.relPath, directory.generation',
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
rows.push({ accountId, relPath, generation })
|
|
375
|
+
return []
|
|
376
|
+
}
|
|
377
|
+
if (/DELETE FROM directory\b/i.test(sql)) {
|
|
378
|
+
const g = Number(params[1])
|
|
379
|
+
if (/generation\s*!=\s*\?/i.test(sql)) {
|
|
380
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation !== g) rows.splice(i, 1)
|
|
381
|
+
} else {
|
|
382
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation === g) rows.splice(i, 1)
|
|
383
|
+
}
|
|
384
|
+
return []
|
|
385
|
+
}
|
|
386
|
+
if (/SELECT COUNT/i.test(sql)) {
|
|
387
|
+
if (/generation\s*=\s*\?/i.test(sql)) {
|
|
388
|
+
const g = Number(params[1])
|
|
389
|
+
return [{ n: rows.filter((r) => r.generation === g).length }]
|
|
390
|
+
}
|
|
319
391
|
return [{ n: rows.filter((r) => r.generation === pointer).length }]
|
|
320
392
|
}
|
|
321
393
|
return []
|
|
@@ -323,6 +395,42 @@ function generationClient() {
|
|
|
323
395
|
}
|
|
324
396
|
}
|
|
325
397
|
|
|
398
|
+
describe('a partial stage self-heals (Task 1923)', () => {
|
|
399
|
+
it('clears orphans at the next generation, flips onto it, and logs stage-clear', async () => {
|
|
400
|
+
// Pointer at 5 naming the last good tree; three orphan rows already at
|
|
401
|
+
// generation 6 for relPaths the current walk re-stages ('output' is the
|
|
402
|
+
// walk's first row, so the current code's first INSERT is the one that
|
|
403
|
+
// collides). Without the pre-stage clear that INSERT throws on the UNIQUE
|
|
404
|
+
// constraint and the push fails, which is what froze gls-data for ~6 h on
|
|
405
|
+
// 2026-07-22.
|
|
406
|
+
const c = wedgeableClient({
|
|
407
|
+
pointer: 5,
|
|
408
|
+
current: ['output', 'output/report.pdf', 'quotes', 'quotes/CR2969'],
|
|
409
|
+
orphans: ['output', 'output/report.pdf', 'quotes'],
|
|
410
|
+
})
|
|
411
|
+
const lines: string[] = []
|
|
412
|
+
const r = await push(c, { log: (l: string) => lines.push(l) })
|
|
413
|
+
|
|
414
|
+
expect(r.rows).toBe(7)
|
|
415
|
+
expect(c.pointerValue()).toBe(6)
|
|
416
|
+
// Nothing survives at a generation the pointer does not name.
|
|
417
|
+
expect(c.rows.every((row) => row.generation === 6)).toBe(true)
|
|
418
|
+
const clear = lines.find((l) => l.includes('op=stage-clear'))!
|
|
419
|
+
expect(clear).toContain('account=acc-1')
|
|
420
|
+
expect(clear).toContain('generation=6')
|
|
421
|
+
// Exactly the 3 orphans at generation 6, not the 4 current-tree rows the
|
|
422
|
+
// pointer names: the clear scopes to `next`, never to a reader-visible row.
|
|
423
|
+
expect(clear).toContain('cleared=3')
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('emits no stage-clear line on a clean cycle with no orphans', async () => {
|
|
427
|
+
const c = wedgeableClient({ pointer: 5, orphans: [] })
|
|
428
|
+
const lines: string[] = []
|
|
429
|
+
await push(c, { log: (l: string) => lines.push(l) })
|
|
430
|
+
expect(lines.some((l) => l.includes('op=stage-clear'))).toBe(false)
|
|
431
|
+
})
|
|
432
|
+
})
|
|
433
|
+
|
|
326
434
|
describe('the index replace is atomic', () => {
|
|
327
435
|
it('never issues a delete before the pointer flip', async () => {
|
|
328
436
|
// The delete-then-insert window this replaces was Task 1842: a client
|
|
@@ -60,6 +60,7 @@ const deadHead = async () => {
|
|
|
60
60
|
throw new Error('ECONNREFUSED')
|
|
61
61
|
}
|
|
62
62
|
const errHead = async () => ({ ok: false, status: 502 })
|
|
63
|
+
const missingHead = async () => ({ ok: false, status: 404 })
|
|
63
64
|
|
|
64
65
|
describe('processIndexedDownload', () => {
|
|
65
66
|
it('returns a signed url for an indexed file when the device answers', async () => {
|
|
@@ -87,6 +88,18 @@ describe('processIndexedDownload', () => {
|
|
|
87
88
|
expect(r.payload.error).toBe('device-offline')
|
|
88
89
|
})
|
|
89
90
|
|
|
91
|
+
// A live device that answers 404 means the indexed row is stale: the file was
|
|
92
|
+
// renamed or removed while the index was frozen. That is "refresh the list",
|
|
93
|
+
// not "the device is down", so it must not collapse into device-offline.
|
|
94
|
+
it('reports file-missing when a reachable device 404s the path', async () => {
|
|
95
|
+
const lines: string[] = []
|
|
96
|
+
const r = await processIndexedDownload('sess-a', PATH, makeEnv(), (l) => lines.push(l), 1000, missingHead as never)
|
|
97
|
+
expect(r.status).toBe(404)
|
|
98
|
+
expect(r.payload.error).toBe('file-missing')
|
|
99
|
+
expect(r.url).toBeUndefined()
|
|
100
|
+
expect(lines.some((l) => l.includes('result=file-missing'))).toBe(true)
|
|
101
|
+
})
|
|
102
|
+
|
|
90
103
|
it('404s a path that is not in the index, without contacting the device', async () => {
|
|
91
104
|
let touched = false
|
|
92
105
|
const spy = async () => {
|
package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts
CHANGED
|
@@ -171,6 +171,18 @@ export async function processIndexedDownload(
|
|
|
171
171
|
try {
|
|
172
172
|
const head = await fetchFn(url, { method: 'HEAD' })
|
|
173
173
|
if (!head.ok) {
|
|
174
|
+
if (head.status === 404) {
|
|
175
|
+
// The device answered, so it is not down: this row is stale. The file
|
|
176
|
+
// was renamed or removed while the index was frozen, and the device
|
|
177
|
+
// returns the same 404 for a path that is missing and one that is not
|
|
178
|
+
// exposed — so this cannot and must not try to tell those apart. The
|
|
179
|
+
// client's remedy is "refresh the list", not "wait for the device".
|
|
180
|
+
log(
|
|
181
|
+
`[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} ` +
|
|
182
|
+
`status=404 ms=${Date.now() - started} result=file-missing`,
|
|
183
|
+
)
|
|
184
|
+
return { status: 404, payload: { ok: false, error: 'file-missing' } }
|
|
185
|
+
}
|
|
174
186
|
log(
|
|
175
187
|
`[data-portal] op=fetch-through owner=${q(session.ownerId)} path=${q(relPath)} ` +
|
|
176
188
|
`status=${head.status} ms=${Date.now() - started} result=device-error`,
|
|
@@ -292,6 +292,14 @@
|
|
|
292
292
|
say('That file is on the device, which is not reachable right now. Try again shortly.');
|
|
293
293
|
return;
|
|
294
294
|
}
|
|
295
|
+
/* A reachable device that 404s a stale row sends error:'file-missing'.
|
|
296
|
+
The message says refresh, not wait. Keyed on the string, so the
|
|
297
|
+
edge's own not-found 404s (not-granted, absent, is-directory) carry
|
|
298
|
+
error:'not found' and keep the generic message below. */
|
|
299
|
+
if (res.d && res.d.error === 'file-missing') {
|
|
300
|
+
say('That file is no longer available under this name. Refresh the list and try again.');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
295
303
|
if (!res.d.ok || !res.d.url) {
|
|
296
304
|
say('That file could not be opened.');
|
|
297
305
|
return;
|
|
@@ -128,6 +128,12 @@ provision_account_dir() {
|
|
|
128
128
|
{ "type": "command", "command": "bash $HOOKS_PATH/post-tool-use-agent.sh" }
|
|
129
129
|
]
|
|
130
130
|
},
|
|
131
|
+
{
|
|
132
|
+
"matcher": "mcp__plugin_browser_browser__browser-pdf-save",
|
|
133
|
+
"hooks": [
|
|
134
|
+
{ "type": "command", "command": "bash $HOOKS_PATH/quote-render-pdf-conformance.sh" }
|
|
135
|
+
]
|
|
136
|
+
},
|
|
131
137
|
{
|
|
132
138
|
"matcher": "mcp__.*__.*-(export|import)-parse$",
|
|
133
139
|
"hooks": [
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"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"}
|
|
@@ -2071,6 +2071,10 @@ export function buildHttpApp(deps) {
|
|
|
2071
2071
|
}
|
|
2072
2072
|
const byte = body.byte;
|
|
2073
2073
|
const trigger = typeof body.trigger === 'string' ? body.trigger : 'manual';
|
|
2074
|
+
// Task 1920 — the stop button sets escalate:true so that when ESC proves
|
|
2075
|
+
// ineffective (turn blocked in a tool/subagent call) the manager escalates
|
|
2076
|
+
// once to Ctrl-C. Absent/false keeps the ESC-only re-send path.
|
|
2077
|
+
const escalate = body.escalate === true;
|
|
2074
2078
|
const intId = `${sessionId.slice(0, 8)}-${start}`;
|
|
2075
2079
|
deps.logger(`[webchat-interrupt] op=interrupt-requested intId=${intId} sessionId=${sessionId.slice(0, 8)} trigger=${trigger}`);
|
|
2076
2080
|
const result = writeInterruptToPty(sessionId, byte, deps.logger);
|
|
@@ -2115,6 +2119,11 @@ export function buildHttpApp(deps) {
|
|
|
2115
2119
|
let lastSendAt = sentAt;
|
|
2116
2120
|
let escSends = 1; // the initial ESC already written above
|
|
2117
2121
|
let canResend = true;
|
|
2122
|
+
// Task 1920 — the one-shot escalation guard. Seed it from the initial byte:
|
|
2123
|
+
// if the pre-loop write above already sent Ctrl-C (byte='ctrl-c'), the loop
|
|
2124
|
+
// must not send a second one. The invariant is "exactly one Ctrl-C ever per
|
|
2125
|
+
// call" because a second SIGINT can exit the CLI and kill the session.
|
|
2126
|
+
let ctrlCSent = byte === 'ctrl-c';
|
|
2118
2127
|
while (Date.now() - sentAt < stopMs) {
|
|
2119
2128
|
const st = deps.watcher.getBySessionId(sessionId)?.status;
|
|
2120
2129
|
if (st !== 'busy') {
|
|
@@ -2122,14 +2131,34 @@ export function buildHttpApp(deps) {
|
|
|
2122
2131
|
break;
|
|
2123
2132
|
}
|
|
2124
2133
|
if (canResend && Date.now() - lastSendAt >= reescalateMs) {
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2134
|
+
if (escalate) {
|
|
2135
|
+
// Task 1920 — ESC ran for a full sub-window while still busy, so it is
|
|
2136
|
+
// ineffective (turn blocked in a tool/subagent call). Escalate once to
|
|
2137
|
+
// Ctrl-C (SIGINT reaches the harness mid-tool-call); never a second
|
|
2138
|
+
// time — a second SIGINT can exit the CLI and kill the session. After
|
|
2139
|
+
// the one Ctrl-C, stop sending and just poll for busy-clear.
|
|
2140
|
+
if (!ctrlCSent) {
|
|
2141
|
+
const esc = writeInterruptToPty(sessionId, 'ctrl-c', deps.logger);
|
|
2142
|
+
lastSendAt = Date.now();
|
|
2143
|
+
if (esc.ok) {
|
|
2144
|
+
ctrlCSent = true;
|
|
2145
|
+
deps.logger(`[webchat-interrupt] op=interrupt-escalated intId=${intId} mechanism=ctrl-c sends=${escSends + 1}`);
|
|
2146
|
+
}
|
|
2147
|
+
else {
|
|
2148
|
+
canResend = false; // child vanished mid-interrupt; the next status read exits the loop
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2130
2151
|
}
|
|
2131
2152
|
else {
|
|
2132
|
-
|
|
2153
|
+
const re = writeInterruptToPty(sessionId, byte, deps.logger);
|
|
2154
|
+
lastSendAt = Date.now();
|
|
2155
|
+
if (re.ok) {
|
|
2156
|
+
escSends += 1;
|
|
2157
|
+
deps.logger(`[webchat-interrupt] op=interrupt-reescalated intId=${intId} sends=${escSends}`);
|
|
2158
|
+
}
|
|
2159
|
+
else {
|
|
2160
|
+
canResend = false; // child vanished mid-interrupt; the next status read exits the loop
|
|
2161
|
+
}
|
|
2133
2162
|
}
|
|
2134
2163
|
}
|
|
2135
2164
|
await new Promise((r) => setTimeout(r, 120));
|