@rubytech/create-sitedesk-code 0.1.507 → 0.1.509

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.
Files changed (87) hide show
  1. package/dist/index.js +53 -0
  2. package/package.json +2 -2
  3. package/payload/platform/config/brand.json +12 -0
  4. package/payload/platform/docs/superpowers/plans/2026-07-26-task-1926-portal-push-concurrency-guard.md +482 -0
  5. package/payload/platform/docs/superpowers/specs/2026-07-26-task-1926-portal-push-concurrency-guard-design.md +200 -0
  6. package/payload/platform/plugins/admin/PLUGIN.md +10 -5
  7. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +96 -13
  8. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +13 -0
  9. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +68 -28
  10. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +271 -29
  11. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +5 -4
  12. package/payload/platform/plugins/docs/references/admin-ui.md +95 -12
  13. package/payload/platform/plugins/memory/PLUGIN.md +6 -3
  14. package/payload/platform/scripts/__tests__/check-brand-cache.test.sh +231 -0
  15. package/payload/platform/scripts/__tests__/provision-honours-disabled-agents.test.sh +84 -0
  16. package/payload/platform/scripts/check-brand-cache.sh +138 -0
  17. package/payload/platform/scripts/check-risk-class.mjs +7 -2
  18. package/payload/platform/scripts/check-specialist-tool-surface.mjs +5 -2
  19. package/payload/platform/scripts/lib/canonical-tool-names.mjs +7 -4
  20. package/payload/platform/scripts/lib/provision-account-dir.sh +43 -0
  21. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +1 -1
  22. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -1
  23. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +56 -3
  24. package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -1
  25. package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts +19 -5
  26. package/payload/platform/services/claude-session-manager/dist/tool-surface.d.ts.map +1 -1
  27. package/payload/platform/services/claude-session-manager/dist/tool-surface.js +50 -11
  28. package/payload/platform/services/claude-session-manager/dist/tool-surface.js.map +1 -1
  29. package/payload/server/maxy-edge.js +15 -10
  30. package/payload/server/public/activity.html +5 -5
  31. package/payload/server/public/agents.html +4 -4
  32. package/payload/server/public/assets/{AdminLoginScreens-YT7jsh_H.js → AdminLoginScreens-8Jk7hnWH.js} +1 -1
  33. package/payload/server/public/assets/{AdminShell-Ckht9hmU.js → AdminShell-BMPEWNZk.js} +1 -1
  34. package/payload/server/public/assets/{activity-DH3Wgc3O.js → activity-ByWYF5dC.js} +1 -1
  35. package/payload/server/public/assets/admin-CnUWbKcx.js +1 -0
  36. package/payload/server/public/assets/agents-C0rTfrYd.js +1 -0
  37. package/payload/server/public/assets/{browser-Bym8QhfA.js → browser-4tTaem8N.js} +1 -1
  38. package/payload/server/public/assets/{calendar-CFnufJOy.js → calendar-BEjnkKG1.js} +1 -1
  39. package/payload/server/public/assets/chat-DBgzjDIE.js +1 -0
  40. package/payload/server/public/assets/chevron-left-DqYv3oFh.js +1 -0
  41. package/payload/server/public/assets/chevron-right-CQfPGsFb.js +1 -0
  42. package/payload/server/public/assets/clock-Dn6FHB51.js +1 -0
  43. package/payload/server/public/assets/data-D_e6Vdfd.js +1 -0
  44. package/payload/server/public/assets/{file-text-zch0E1GJ.js → file-text-DMKw3nIk.js} +1 -1
  45. package/payload/server/public/assets/{graph-BavHLzpV.js → graph-C3vW8lvw.js} +1 -1
  46. package/payload/server/public/assets/{graph-labels-CBwh4g-6.js → graph-labels-BZgd0L6n.js} +1 -1
  47. package/payload/server/public/assets/{maximize-2-bTn_Ozpi.js → maximize-2-la3IBA3M.js} +1 -1
  48. package/payload/server/public/assets/{operator-a4IOwf62.js → operator-RRRdqoRQ.js} +1 -1
  49. package/payload/server/public/assets/{page-3T2mE63b.js → page-BWFHRIAH.js} +1 -1
  50. package/payload/server/public/assets/{page-D6M1CmPe.js → page-DC11gesX.js} +1 -1
  51. package/payload/server/public/assets/{public-B3y_YU1G.js → public-DQbbLHQN.js} +1 -1
  52. package/payload/server/public/assets/{rotate-ccw-BGvzNB1b.js → rotate-ccw-OQD5si8N.js} +1 -1
  53. package/payload/server/public/assets/{routines-C7Dr16am.js → routines-asrBGZUR.js} +1 -1
  54. package/payload/server/public/assets/{skills-DFeb3XO3.js → skills-D4ECwoxa.js} +1 -1
  55. package/payload/server/public/assets/{tasks-CSfnCKJe.js → tasks-U3QdbUsO.js} +1 -1
  56. package/payload/server/public/assets/{time-entry-format-BgZqOOqG.js → time-entry-format-BgdTKgYr.js} +1 -1
  57. package/payload/server/public/assets/{triangle-alert-DBJ7xz_t.js → triangle-alert-Ck_3VhT9.js} +1 -1
  58. package/payload/server/public/assets/{useCopyFeedback-BFOujMl-.js → useCopyFeedback-BdUwyNpa.js} +1 -1
  59. package/payload/server/public/assets/useSubAccountSwitcher-CJKjEPNN.js +9 -0
  60. package/payload/server/public/assets/useSubAccountSwitcher-DOWQXDT8.css +1 -0
  61. package/payload/server/public/assets/{useVoiceRecorder-BP6CpYQD.js → useVoiceRecorder-ChnBrzbZ.js} +1 -1
  62. package/payload/server/public/assets/{wrench-DdaRhfzS.js → wrench-Cpee3C3J.js} +1 -1
  63. package/payload/server/public/brand/fonts/inter-600.woff2 +0 -0
  64. package/payload/server/public/brand/fonts/inter-700.woff2 +0 -0
  65. package/payload/server/public/brand/fonts/inter-800.woff2 +0 -0
  66. package/payload/server/public/brand-defaults.css +22 -1
  67. package/payload/server/public/browser.html +4 -4
  68. package/payload/server/public/calendar.html +7 -7
  69. package/payload/server/public/chat.html +13 -13
  70. package/payload/server/public/data.html +11 -11
  71. package/payload/server/public/graph.html +9 -9
  72. package/payload/server/public/index.html +14 -14
  73. package/payload/server/public/operator.html +14 -14
  74. package/payload/server/public/public.html +13 -13
  75. package/payload/server/public/routines.html +6 -6
  76. package/payload/server/public/skills.html +5 -5
  77. package/payload/server/public/tasks.html +6 -6
  78. package/payload/server/server.js +1139 -427
  79. package/payload/server/public/assets/admin-DetvulAu.js +0 -1
  80. package/payload/server/public/assets/agents-X_IvDHLZ.js +0 -1
  81. package/payload/server/public/assets/chat-wrckyIpw.js +0 -1
  82. package/payload/server/public/assets/chevron-left-Dtk3jEK5.js +0 -1
  83. package/payload/server/public/assets/chevron-right-Dih6IZ8B.js +0 -1
  84. package/payload/server/public/assets/clock-iJCrrmWM.js +0 -1
  85. package/payload/server/public/assets/data-D76--TYs.js +0 -1
  86. package/payload/server/public/assets/useSubAccountSwitcher-0PfRrN6i.js +0 -9
  87. package/payload/server/public/assets/useSubAccountSwitcher-BkQ5tA8A.css +0 -1
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env bash
2
+ # Tests for check-brand-cache.sh (Task 2019). Fixture-driven: builds a throwaway
3
+ # install dir + config dir per case and stubs the fetch command, because the real
4
+ # one crosses a Cloudflare edge that no test can hold still. sha256 and the
5
+ # ingress parse are the real code paths — those are what this asserts.
6
+ set -uo pipefail
7
+
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ CHECK="$SCRIPT_DIR/../check-brand-cache.sh"
10
+
11
+ PASS=0
12
+ FAIL=0
13
+
14
+ run_case() {
15
+ local name="$1"; shift
16
+ echo ""
17
+ echo "=== CASE: $name ==="
18
+ if "$@"; then
19
+ PASS=$((PASS + 1)); echo "PASS: $name"
20
+ else
21
+ FAIL=$((FAIL + 1)); echo "FAIL: $name"
22
+ fi
23
+ }
24
+
25
+ # Build install dir with two brand files and brand-defaults.css. Echoes its path.
26
+ mk_install() {
27
+ local d
28
+ d="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-install.XXXXXX")"
29
+ mkdir -p "$d/server/public/brand"
30
+ printf 'lime-logo-bytes' > "$d/server/public/brand/logo.png"
31
+ printf 'lime-icon-bytes' > "$d/server/public/brand/icon.png"
32
+ printf ':root{--brand:#c3f53c}' > "$d/server/public/brand-defaults.css"
33
+ echo "$d"
34
+ }
35
+
36
+ # Build a config dir whose cloudflared/config.yml has the given ingress hosts.
37
+ mk_config_with_hosts() {
38
+ local d; d="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-config.XXXXXX")"
39
+ mkdir -p "$d/cloudflared"
40
+ { echo "ingress:"; for h in "$@"; do echo " - hostname: $h"; done; echo " - service: http_status:404"; } \
41
+ > "$d/cloudflared/config.yml"
42
+ echo "$d"
43
+ }
44
+
45
+ mk_config_no_tunnel() {
46
+ local d; d="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-config.XXXXXX")"
47
+ echo "$d"
48
+ }
49
+
50
+ # A config dir with no cloudflared ingress but an alias-domains.json, the
51
+ # second source resolvePublicHostname reads.
52
+ mk_config_alias_only() {
53
+ local d; d="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-config.XXXXXX")"
54
+ printf '["public.realagent.app"]' > "$d/alias-domains.json"
55
+ echo "$d"
56
+ }
57
+
58
+ # Stub curl. $1 = dir whose files it serves as the "edge", keyed by basename.
59
+ # Writes headers to the -D path and the body to the -o path, exit 0.
60
+ mk_curl_stub() {
61
+ local serve_dir="$1" stub
62
+ stub="$(mktemp "${TMPDIR:-/tmp}/brand-cache-curl.XXXXXX")"
63
+ cat > "$stub" <<STUB
64
+ #!/usr/bin/env bash
65
+ hdr=""; out=""; url=""
66
+ while [ \$# -gt 0 ]; do
67
+ case "\$1" in
68
+ -D) hdr="\$2"; shift 2 ;;
69
+ -o) out="\$2"; shift 2 ;;
70
+ -*) shift ;;
71
+ *) url="\$1"; shift ;;
72
+ esac
73
+ done
74
+ name="\${url##*/}"
75
+ printf 'HTTP/2 200\r\ncf-cache-status: HIT\r\nage: 5352\r\n\r\n' > "\$hdr"
76
+ cat "$serve_dir/\$name" > "\$out"
77
+ STUB
78
+ chmod +x "$stub"
79
+ echo "$stub"
80
+ }
81
+
82
+ case_clean_install_reports_clean() {
83
+ local i c stub edge out rc
84
+ i="$(mk_install)"; c="$(mk_config_with_hosts operations.realagent.network)"
85
+ # Edge serves exactly what is on disk.
86
+ edge="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-edge.XXXXXX")"
87
+ cp "$i/server/public/brand/"* "$edge/"
88
+ cp "$i/server/public/brand-defaults.css" "$edge/"
89
+ stub="$(mk_curl_stub "$edge")"
90
+ out="$(BRAND_CACHE_CURL="$stub" "$CHECK" --install-dir "$i" --config-dir "$c" 2>/dev/null)"
91
+ rc=$?
92
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
93
+ # files=2 pinned as an integer: a \d+ match also accepts files=0, and a
94
+ # hostname resolving to nothing would then read as a pass.
95
+ echo "$out" | grep -qx "\[brand-cache\] clean host=operations.realagent.network files=2" \
96
+ || { echo " no clean line with files=2; got: $out"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
97
+ rm -rf "$i" "$c" "$edge" "$stub"; return 0
98
+ }
99
+
100
+ case_stale_edge_reports_stale_on_stderr() {
101
+ local i c stub edge err rc
102
+ i="$(mk_install)"; c="$(mk_config_with_hosts operations.realagent.network)"
103
+ edge="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-edge.XXXXXX")"
104
+ cp "$i/server/public/brand/"* "$edge/"
105
+ cp "$i/server/public/brand-defaults.css" "$edge/"
106
+ # The measured symptom: the edge holds the retired mark, disk holds the new one.
107
+ printf 'black-square-bytes' > "$edge/logo.png"
108
+ stub="$(mk_curl_stub "$edge")"
109
+ err="$(BRAND_CACHE_CURL="$stub" "$CHECK" --install-dir "$i" --config-dir "$c" 2>&1 >/dev/null)"
110
+ rc=$?
111
+ [ "$rc" -eq 1 ] || { echo " expected exit 1, got $rc"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
112
+ echo "$err" | grep -qE "^\[brand-cache\] STALE host=operations\.realagent\.network path=/brand/logo\.png edge=[0-9a-f]{7} disk=[0-9a-f]{7} cf-cache-status=HIT age=5352$" \
113
+ || { echo " no STALE line for /brand/logo.png; got: $err"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
114
+ rm -rf "$i" "$c" "$edge" "$stub"; return 0
115
+ }
116
+
117
+ case_no_tunnel_reports_skipped() {
118
+ local i c out rc
119
+ i="$(mk_install)"; c="$(mk_config_no_tunnel)"
120
+ out="$("$CHECK" --install-dir "$i" --config-dir "$c" 2>/dev/null)"
121
+ rc=$?
122
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; rm -rf "$i" "$c"; return 1; }
123
+ echo "$out" | grep -qx "\[brand-cache\] skipped host=none reason=no-tunnel-ingress" \
124
+ || { echo " no skipped line; got: $out"; rm -rf "$i" "$c"; return 1; }
125
+ rm -rf "$i" "$c"; return 0
126
+ }
127
+
128
+ case_localhost_only_ingress_is_no_tunnel() {
129
+ local i c out rc
130
+ i="$(mk_install)"; c="$(mk_config_with_hosts localhost muvin.local)"
131
+ out="$("$CHECK" --install-dir "$i" --config-dir "$c" 2>/dev/null)"
132
+ rc=$?
133
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; rm -rf "$i" "$c"; return 1; }
134
+ echo "$out" | grep -qx "\[brand-cache\] skipped host=none reason=no-tunnel-ingress" \
135
+ || { echo " localhost/.local counted as ingress; got: $out"; rm -rf "$i" "$c"; return 1; }
136
+ rm -rf "$i" "$c"; return 0
137
+ }
138
+
139
+ case_unfetchable_asset_is_not_silently_clean() {
140
+ local i c stub err rc
141
+ i="$(mk_install)"; c="$(mk_config_with_hosts operations.realagent.network)"
142
+ # Stub that answers 502 with an empty body for every path.
143
+ stub="$(mktemp "${TMPDIR:-/tmp}/brand-cache-curl.XXXXXX")"
144
+ cat > "$stub" <<'STUB'
145
+ #!/usr/bin/env bash
146
+ hdr=""; out=""
147
+ while [ $# -gt 0 ]; do
148
+ case "$1" in
149
+ -D) hdr="$2"; shift 2 ;;
150
+ -o) out="$2"; shift 2 ;;
151
+ -*) shift ;;
152
+ *) shift ;;
153
+ esac
154
+ done
155
+ printf 'HTTP/2 502\r\n\r\n' > "$hdr"
156
+ : > "$out"
157
+ STUB
158
+ chmod +x "$stub"
159
+ err="$(BRAND_CACHE_CURL="$stub" "$CHECK" --install-dir "$i" --config-dir "$c" 2>&1 >/dev/null)"
160
+ rc=$?
161
+ [ "$rc" -eq 1 ] || { echo " expected exit 1, got $rc"; rm -rf "$i" "$c" "$stub"; return 1; }
162
+ echo "$err" | grep -qE "^\[brand-cache\] UNREACHABLE host=operations\.realagent\.network path=/brand/logo\.png http=502$" \
163
+ || { echo " no UNREACHABLE line; got: $err"; rm -rf "$i" "$c" "$stub"; return 1; }
164
+ rm -rf "$i" "$c" "$stub"; return 0
165
+ }
166
+
167
+ case_no_assets_is_a_named_skip_not_a_clean() {
168
+ local i c out rc
169
+ i="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-install.XXXXXX")"; mkdir -p "$i/server/public"
170
+ c="$(mk_config_with_hosts operations.realagent.network)"
171
+ # bash 3.2 (macOS) errors on "${PATHS[@]}" for an empty array under `set -u`,
172
+ # so this also pins that the loop is never entered with nothing to compare.
173
+ out="$(/bin/bash "$CHECK" --install-dir "$i" --config-dir "$c" 2>&1)"
174
+ rc=$?
175
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc; got: $out"; rm -rf "$i" "$c"; return 1; }
176
+ echo "$out" | grep -qx "\[brand-cache\] skipped host=operations.realagent.network reason=no-brand-assets" \
177
+ || { echo " no named skip for an empty asset list; got: $out"; rm -rf "$i" "$c"; return 1; }
178
+ # files=0 must never be emitted: the task's own pinning exists so an empty
179
+ # comparison cannot read as a pass.
180
+ echo "$out" | grep -q "files=0" \
181
+ && { echo " emitted files=0, which reads as a pass"; rm -rf "$i" "$c"; return 1; }
182
+ rm -rf "$i" "$c"; return 0
183
+ }
184
+
185
+ case_alias_only_install_is_checked_not_skipped() {
186
+ local i c stub edge out rc
187
+ i="$(mk_install)"; c="$(mk_config_alias_only)"
188
+ edge="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-edge.XXXXXX")"
189
+ cp "$i/server/public/brand/"* "$edge/"
190
+ stub="$(mk_curl_stub "$edge")"
191
+ out="$(BRAND_CACHE_CURL="$stub" "$CHECK" --install-dir "$i" --config-dir "$c" 2>/dev/null)"
192
+ rc=$?
193
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
194
+ # An install with an alias edge and no ingress row does have an edge that can
195
+ # diverge, so reporting `skipped` there would read as nothing-to-check on the
196
+ # one state the skip wording promises it is not.
197
+ echo "$out" | grep -qx "\[brand-cache\] clean host=public.realagent.app files=2" \
198
+ || { echo " alias-only install was not checked; got: $out"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
199
+ rm -rf "$i" "$c" "$edge" "$stub"; return 0
200
+ }
201
+
202
+ case_brand_defaults_css_is_not_fetched() {
203
+ local i c stub edge out rc
204
+ i="$(mk_install)"; c="$(mk_config_with_hosts admin.realagent.network public.realagent.network)"
205
+ edge="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-edge.XXXXXX")"
206
+ cp "$i/server/public/brand/"* "$edge/"
207
+ # No brand-defaults.css at the edge. The gate at server/index.ts:1311-1322
208
+ # exempts /brand/ but not /brand-defaults.css, so through an admin-gated host
209
+ # that path answers with a login page at status 200 — hashing that against
210
+ # the stylesheet would be a permanent false STALE with no cache involved.
211
+ stub="$(mk_curl_stub "$edge")"
212
+ out="$(BRAND_CACHE_CURL="$stub" "$CHECK" --install-dir "$i" --config-dir "$c" 2>/dev/null)"
213
+ rc=$?
214
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
215
+ echo "$out" | grep -qx "\[brand-cache\] clean host=admin.realagent.network files=2" \
216
+ || { echo " expected only the two /brand/ files; got: $out"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
217
+ rm -rf "$i" "$c" "$edge" "$stub"; return 0
218
+ }
219
+
220
+ run_case "clean install reports clean with a pinned file count" case_clean_install_reports_clean
221
+ run_case "stale edge reports STALE on stderr and exits 1" case_stale_edge_reports_stale_on_stderr
222
+ run_case "no tunnel ingress reports skipped and exits 0" case_no_tunnel_reports_skipped
223
+ run_case "localhost/.local-only ingress counts as no tunnel" case_localhost_only_ingress_is_no_tunnel
224
+ run_case "an unfetchable asset is reported, never counted clean" case_unfetchable_asset_is_not_silently_clean
225
+ run_case "an empty asset list is a named skip, never files=0" case_no_assets_is_a_named_skip_not_a_clean
226
+ run_case "an alias-only install is checked, not skipped" case_alias_only_install_is_checked_not_skipped
227
+ run_case "brand-defaults.css is not fetched through a gated host" case_brand_defaults_css_is_not_fetched
228
+
229
+ echo ""
230
+ echo "PASS=$PASS FAIL=$FAIL"
231
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ # Task 1996 — an upgrade must not re-deliver an agent the operator switched off.
3
+ #
4
+ # provision_account_dir wipes every core specialist and re-copies it from the
5
+ # bundled templates on every run. Without this, disabling an agent lapses on the
6
+ # next publish: the file reappears in the dispatchable directory, nothing logs,
7
+ # and the card still reads "disabled". That is the no-event failure the task
8
+ # names, so it is pinned here.
9
+ set -uo pipefail
10
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ PLAT_SCRIPTS="$SCRIPT_DIR/.."
12
+ PROJECT_DIR="$(cd "$PLAT_SCRIPTS/.." && pwd)"
13
+ TEMPLATES_DIR="$PROJECT_DIR/templates"
14
+ PASS=0; FAIL=0; FAILED=()
15
+ assert_absent() { if [ ! -e "$1" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2: expected absent [$1]"); fi; }
16
+ assert_present() { if [ -e "$1" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$2: expected present [$1]"); fi; }
17
+ assert_eq() { if [ "$1" = "$2" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); FAILED+=("$3: expected [$2] got [$1]"); fi; }
18
+
19
+ . "$PLAT_SCRIPTS/lib/provision-account-dir.sh"
20
+ export TEMPLATES_DIR PROJECT_DIR
21
+ export SCRIPT_DIR="$PLAT_SCRIPTS"
22
+ export USERS_FILE="/nonexistent/users.json"
23
+
24
+ # A real shipped specialist, so the test exercises the same wipe-and-recopy loop
25
+ # a publish runs rather than a synthetic filename.
26
+ DISABLED_AGENT="coding-assistant.md"
27
+
28
+ T1=$(mktemp -d); A1="$T1/acct"
29
+ provision_account_dir "$A1" "dddddddd-dddd-dddd-dddd-dddddddddddd" "house" >/dev/null 2>&1
30
+ assert_present "$A1/specialists/agents/$DISABLED_AGENT" "baseline-agent-delivered"
31
+
32
+ # The operator disables it: the file moves to quarantine and the store names it.
33
+ mkdir -p "$A1/specialists/agents-disabled"
34
+ mv "$A1/specialists/agents/$DISABLED_AGENT" "$A1/specialists/agents-disabled/$DISABLED_AGENT"
35
+ printf '{"disabled":["%s"]}\n' "$DISABLED_AGENT" > "$A1/agents-disabled.json"
36
+
37
+ # The upgrade runs again.
38
+ OUT="$(provision_account_dir "$A1" "dddddddd-dddd-dddd-dddd-dddddddddddd" "house" 2>&1)"
39
+
40
+ assert_absent "$A1/specialists/agents/$DISABLED_AGENT" "disabled-agent-not-redelivered"
41
+ assert_present "$A1/specialists/agents-disabled/$DISABLED_AGENT" "quarantined-copy-survives-upgrade"
42
+ # Every other core specialist is still delivered: the guard removes what the
43
+ # operator named, not the whole set.
44
+ assert_present "$A1/specialists/agents/librarian.md" "other-agents-still-delivered"
45
+ # The removal is reported, so an operator reading the publish output can see
46
+ # which agents provisioning deliberately withheld.
47
+ assert_eq "$(printf '%s' "$OUT" | grep -c "agents-withheld=1")" "1" "withheld-count-logged"
48
+ rm -rf "$T1"
49
+
50
+ # No store, no behaviour change: every core specialist is delivered.
51
+ T2=$(mktemp -d); A2="$T2/acct"
52
+ provision_account_dir "$A2" "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" "house" >/dev/null 2>&1
53
+ assert_present "$A2/specialists/agents/$DISABLED_AGENT" "no-store-delivers-everything"
54
+ rm -rf "$T2"
55
+
56
+ # A malformed store withholds nothing rather than withholding everything: an
57
+ # unreadable file is not evidence that the operator disabled an agent.
58
+ T3=$(mktemp -d); A3="$T3/acct"
59
+ provision_account_dir "$A3" "ffffffff-ffff-ffff-ffff-ffffffffffff" "house" >/dev/null 2>&1
60
+ printf '{ not json\n' > "$A3/agents-disabled.json"
61
+ OUT3="$(provision_account_dir "$A3" "ffffffff-ffff-ffff-ffff-ffffffffffff" "house" 2>&1)"
62
+ assert_present "$A3/specialists/agents/$DISABLED_AGENT" "malformed-store-withholds-nothing"
63
+ assert_eq "$(printf '%s' "$OUT3" | grep -c 'agents-disabled-store-unreadable')" "1" "malformed-store-reported"
64
+ rm -rf "$T3"
65
+
66
+ # Review finding: the withhold used `rm -f` unconditionally. In the drift state
67
+ # the reconcile check exists to catch — store names it, file is live, quarantine
68
+ # empty — that deletes the only copy of a premium `--` agent or an operator-edited
69
+ # override, because the replace loop skips `*--*` and the copy loop only restores
70
+ # templates. Withholding must move, never destroy.
71
+ T4=$(mktemp -d); A4="$T4/acct"
72
+ provision_account_dir "$A4" "aaaa1111-aaaa-1111-aaaa-111111111111" "house" >/dev/null 2>&1
73
+ mkdir -p "$A4/specialists/agents"
74
+ printf -- '---\nname: quoter\n---\n\nOperator edited this premium agent.\n' > "$A4/specialists/agents/sitedesk--quoter.md"
75
+ printf '{"disabled":["sitedesk--quoter.md"]}\n' > "$A4/agents-disabled.json"
76
+ provision_account_dir "$A4" "aaaa1111-aaaa-1111-aaaa-111111111111" "house" >/dev/null 2>&1
77
+
78
+ assert_absent "$A4/specialists/agents/sitedesk--quoter.md" "premium-withheld-from-dispatch"
79
+ assert_present "$A4/specialists/agents-disabled/sitedesk--quoter.md" "premium-preserved-not-destroyed"
80
+ assert_eq "$(grep -c 'Operator edited this premium agent' "$A4/specialists/agents-disabled/sitedesk--quoter.md" 2>/dev/null || echo 0)" "1" "premium-content-intact"
81
+ rm -rf "$T4"
82
+
83
+ echo "PASS=$PASS FAIL=$FAIL"
84
+ if [ "$FAIL" -gt 0 ]; then printf '%s\n' "${FAILED[@]}"; exit 1; fi
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env bash
2
+ # check-brand-cache.sh — reconcile the bytes the public hostname returns
3
+ # against the bytes on disk (Task 2019).
4
+ #
5
+ # The failure this exists for emits no event at either end. The installer
6
+ # writes the new brand bytes and logs a successful upgrade; the origin serves
7
+ # them correctly to anyone asking it directly; Cloudflare's edge serves the
8
+ # pre-upgrade ones and reports `HIT` to nobody looking. There is no action to
9
+ # log and re-running the upgrade does not surface it, so only a reconciliation
10
+ # of served bytes against on-disk bytes reveals the state. That is the
11
+ # condition behind the 2026-07-26 report, where the regenerated Real Agent logo
12
+ # was correct on disk and invisible through the hostname for four hours.
13
+ #
14
+ # $ check-brand-cache.sh --install-dir <dir> --config-dir <dir>
15
+ #
16
+ # stdout, exit 0: [brand-cache] clean host=<h> files=<n>
17
+ # stdout, exit 0: [brand-cache] skipped host=none reason=no-tunnel-ingress
18
+ # stderr, exit 1: [brand-cache] STALE host=<h> path=<p> edge=<sha7> disk=<sha7> cf-cache-status=<s> age=<n>
19
+ # stderr, exit 1: [brand-cache] UNREACHABLE host=<h> path=<p> http=<code>
20
+ #
21
+ # No ingress hostname and no alias domain means no edge and therefore no
22
+ # possible divergence, so that is a skip, not a pass — a caller must never read
23
+ # the two as the same.
24
+ #
25
+ # Scope: the files under server/public/brand/ only. See the PATHS block below.
26
+ #
27
+ # BRAND_CACHE_CURL overrides the fetch command (tests stub it).
28
+ set -uo pipefail
29
+
30
+ INSTALL_DIR=""
31
+ CONFIG_DIR=""
32
+ while [ $# -gt 0 ]; do
33
+ case "$1" in
34
+ --install-dir) INSTALL_DIR="$2"; shift 2 ;;
35
+ --config-dir) CONFIG_DIR="$2"; shift 2 ;;
36
+ *) echo "[brand-cache] unknown argument: $1" >&2; exit 2 ;;
37
+ esac
38
+ done
39
+ if [ -z "$INSTALL_DIR" ] || [ -z "$CONFIG_DIR" ]; then
40
+ echo "[brand-cache] usage: check-brand-cache.sh --install-dir <dir> --config-dir <dir>" >&2
41
+ exit 2
42
+ fi
43
+
44
+ CURL="${BRAND_CACHE_CURL:-curl}"
45
+
46
+ # sha256sum is GNU; darwin ships shasum. Both print "<digest> <path>".
47
+ sha256() {
48
+ if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
49
+ else shasum -a 256 "$1" | awk '{print $1}'; fi
50
+ }
51
+
52
+ # The hostname to probe, read from the same two sources and with the same
53
+ # exclusions as platform/plugins/admin/mcp/src/lib/public-hostname.ts:
54
+ # cloudflared/config.yml first, then alias-domains.json. First non-excluded
55
+ # wins within each source.
56
+ #
57
+ # No apex tiebreak, and no attempt to pick the *public* host over the admin
58
+ # one: only /brand/ assets are fetched, and the admin gate exempts /brand/ on
59
+ # every host class (server/index.ts:1311-1322), so any ingress host serves them
60
+ # identically. The alias source is read because an install whose edge is an
61
+ # alias domain with no ingress row does have an edge that can diverge, and
62
+ # reporting `skipped` there would read as nothing-to-check.
63
+ resolve_host() {
64
+ local cfg="$CONFIG_DIR/cloudflared/config.yml" alias="$CONFIG_DIR/alias-domains.json" h
65
+ if [ -f "$cfg" ]; then
66
+ h="$(sed -n 's/^[[:space:]]*-[[:space:]]*hostname:[[:space:]]*\([^[:space:]]*\).*/\1/p' "$cfg" \
67
+ | grep -v '^localhost$' | grep -v '\.local$' | head -1)"
68
+ if [ -n "$h" ]; then echo "$h"; return 0; fi
69
+ fi
70
+ [ -f "$alias" ] || return 0
71
+ tr ',' '\n' < "$alias" | sed -n 's/.*"\([^"]*\)".*/\1/p' \
72
+ | grep -v '^localhost$' | grep -v '\.local$' | head -1
73
+ }
74
+
75
+ HOST="$(resolve_host)"
76
+ if [ -z "$HOST" ]; then
77
+ echo "[brand-cache] skipped host=none reason=no-tunnel-ingress"
78
+ exit 0
79
+ fi
80
+
81
+ # /brand/ only. /brand-defaults.css is NOT exempt from the admin gate
82
+ # (server/index.ts:1311-1322 lists /favicon.ico, /assets/, /brand/,
83
+ # /api/calendar/free-busy and the PWA paths, and nothing else), so through an
84
+ # admin-gated or operator host it answers with a login page at status 200
85
+ # (index.ts:1379). Hashing that against the stylesheet on disk would be a
86
+ # permanent STALE with no cache involved, which would both bury a real
87
+ # divergence and make the clean line unreachable. Covering it, and the page
88
+ # shells, needs a resolved public hostname — Task 2026.
89
+ PUBLIC_DIR="$INSTALL_DIR/server/public"
90
+ PATHS=()
91
+ if [ -d "$PUBLIC_DIR/brand" ]; then
92
+ while IFS= read -r f; do PATHS+=("/brand/$(basename "$f")"); done \
93
+ < <(find "$PUBLIC_DIR/brand" -maxdepth 1 -type f | sort)
94
+ fi
95
+
96
+ # Nothing to compare is a skip with its own reason, not a clean run. Emitting
97
+ # `files=0` here would read as a pass on an install whose payload never
98
+ # deployed, and that is the dead signal the file count is pinned against.
99
+ # Also the guard for bash 3.2 (darwin), where "${PATHS[@]}" on an empty array
100
+ # is an unbound-variable error under `set -u`.
101
+ if [ "${#PATHS[@]}" -eq 0 ]; then
102
+ echo "[brand-cache] skipped host=$HOST reason=no-brand-assets"
103
+ exit 0
104
+ fi
105
+
106
+ TMP="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache.XXXXXX")"
107
+ trap 'rm -rf "$TMP"' EXIT
108
+
109
+ FAILED=0
110
+ CHECKED=0
111
+ for p in "${PATHS[@]}"; do
112
+ body="$TMP/body"; hdr="$TMP/hdr"
113
+ "$CURL" -sS -L -D "$hdr" -o "$body" "https://$HOST$p" >/dev/null 2>&1
114
+ code="$(awk 'tolower($0) ~ /^http\// {c=$2} END{print c+0}' "$hdr" 2>/dev/null)"
115
+ if [ "$code" != "200" ]; then
116
+ echo "[brand-cache] UNREACHABLE host=$HOST path=$p http=$code" >&2
117
+ FAILED=1
118
+ continue
119
+ fi
120
+ edge="$(sha256 "$body")"
121
+ disk="$(sha256 "$PUBLIC_DIR$p")"
122
+ if [ "$edge" != "$disk" ]; then
123
+ # tail -1, not head -1: -L means $hdr can hold several header blocks, and
124
+ # the cache state that matters is the one on the response the bytes came
125
+ # from. head -1 would name a redirect hop's cache state instead, which is
126
+ # the field an operator reads to tell this fault from an origin fault.
127
+ status="$(awk 'tolower($1) == "cf-cache-status:" {print $2}' "$hdr" | tr -d '\r' | tail -1)"
128
+ age="$(awk 'tolower($1) == "age:" {print $2}' "$hdr" | tr -d '\r' | tail -1)"
129
+ echo "[brand-cache] STALE host=$HOST path=$p edge=${edge:0:7} disk=${disk:0:7} cf-cache-status=${status:-none} age=${age:-0}" >&2
130
+ FAILED=1
131
+ continue
132
+ fi
133
+ CHECKED=$((CHECKED + 1))
134
+ done
135
+
136
+ [ "$FAILED" -eq 1 ] && exit 1
137
+ echo "[brand-cache] clean host=$HOST files=$CHECKED"
138
+ exit 0
@@ -92,13 +92,18 @@ function collectPluginDirs() {
92
92
  }
93
93
 
94
94
  /** Independent of tool-surface.ts on purpose: a gate that shares the parser it
95
- * is checking cannot detect that parser drifting. */
95
+ * is checking cannot detect that parser drifting.
96
+ *
97
+ * Task 1998 — `hidden:` entries carry the same name+riskClass shape as
98
+ * `tools:` entries and are served by the same MCP server, so a hidden tool
99
+ * can contradict its own name exactly as a visible one can. Scanning only
100
+ * `tools:` would leave eight declarations outside the gate. */
96
101
  function declaredTools(manifestPath) {
97
102
  const out = []
98
103
  let current = null
99
104
  let inTools = false
100
105
  for (const line of readFileSync(manifestPath, 'utf8').split(/\r?\n/)) {
101
- if (/^tools:\s*$/.test(line)) { inTools = true; continue }
106
+ if (/^(tools|hidden):\s*$/.test(line)) { inTools = true; current = null; continue }
102
107
  if (inTools && /^[A-Za-z_]/.test(line)) inTools = false
103
108
  if (!inTools) continue
104
109
  const name = line.match(/^\s+-\s+name:\s*(.+?)\s*$/)
@@ -88,8 +88,11 @@ function parsePluginManifest(rawManifest) {
88
88
  if (nameLine) { tools.push(nameLine[1]); continue; }
89
89
  }
90
90
  if (mode === 'hidden') {
91
- const item = line.match(/^\s+-\s+(.+?)\s*$/);
92
- if (item) hidden.push(item[1]);
91
+ // Task 1998 — hidden entries carry the same `- name:` + `riskClass:`
92
+ // shape as tools entries. Names only here; the gate checks membership
93
+ // of the `all` surface, not consequence.
94
+ const nameLine = line.match(/^\s+-\s+name:\s*(.+?)\s*$/);
95
+ if (nameLine) hidden.push(nameLine[1]);
93
96
  }
94
97
  }
95
98
  return { tools, hidden, hasTools };
@@ -36,9 +36,12 @@ function extractFrontmatter(md) {
36
36
  return md.slice(3, end)
37
37
  }
38
38
 
39
- // Collect `- name: <x>` entries under a bare `tools:` block and `- <x>` entries
40
- // under a bare `hidden:` block. Both contribute names a specialist may list in
41
- // its `tools:` frontmatter, so both belong to the live set.
39
+ // Collect `- name: <x>` entries under a bare `tools:` block and under a bare
40
+ // `hidden:` block. Both contribute names a specialist may list in its `tools:`
41
+ // frontmatter, so both belong to the live set. Task 1998 gave the hidden block
42
+ // the same name+riskClass shape as tools, so the two branches now read alike;
43
+ // this parser stays separate from tool-surface.ts on purpose, so a drift in one
44
+ // shows up as a diff rather than as agreement between a parser and itself.
42
45
  function parseToolNames(frontmatter) {
43
46
  const lines = frontmatter.split('\n')
44
47
  const names = []
@@ -57,7 +60,7 @@ function parseToolNames(frontmatter) {
57
60
  const m = line.match(/^\s+-\s+name:\s*(.+?)\s*$/)
58
61
  if (m) names.push(m[1])
59
62
  } else if (mode === 'hidden') {
60
- const m = line.match(/^\s+-\s+(.+?)\s*$/)
63
+ const m = line.match(/^\s+-\s+name:\s*(.+?)\s*$/)
61
64
  if (m) names.push(m[1])
62
65
  }
63
66
  }
@@ -251,6 +251,49 @@ SETTINGS_EOF
251
251
  [ -f "$specialist" ] && cp "$specialist" "$ACCOUNT_DIR/specialists/agents/$(basename "$specialist")"
252
252
  done
253
253
 
254
+ # Task 1996 — the copy loop above re-delivers every core specialist, which
255
+ # would silently undo a disable on the next publish. specialists/agents/ is
256
+ # the dispatchable set (spawn-context.ts reads it), so an agent the operator
257
+ # switched off is removed again here. The quarantined copy under
258
+ # specialists/agents-disabled/ is never touched: it is the operator's own
259
+ # file, and for a premium or edited agent it is the only copy.
260
+ #
261
+ # An unreadable store withholds NOTHING. A corrupt file is not evidence that
262
+ # an agent was disabled, and withholding the whole set on a parse error would
263
+ # turn a bad byte into a fleet-wide loss of specialists.
264
+ if [ -f "$ACCOUNT_DIR/agents-disabled.json" ]; then
265
+ local _disabled_names _withheld=0
266
+ if _disabled_names="$(python3 -c '
267
+ import json, sys
268
+ with open(sys.argv[1]) as fh:
269
+ names = json.load(fh)["disabled"]
270
+ if not isinstance(names, list):
271
+ raise SystemExit(1)
272
+ for n in names:
273
+ if isinstance(n, str) and n.endswith(".md") and "/" not in n and ".." not in n:
274
+ print(n)
275
+ ' "$ACCOUNT_DIR/agents-disabled.json" 2>/dev/null)"; then
276
+ local _name
277
+ while IFS= read -r _name; do
278
+ [ -n "$_name" ] || continue
279
+ if [ -e "$ACCOUNT_DIR/specialists/agents/$_name" ]; then
280
+ # MOVE, never delete. In the drift state the standing parity check
281
+ # exists to catch (store names it, file is live, quarantine empty) a
282
+ # delete would destroy the only copy of a premium `--` agent or an
283
+ # operator-edited override: the replace loop above skips `*--*` and
284
+ # the copy loop only restores templates, so nothing re-delivers it.
285
+ mkdir -p "$ACCOUNT_DIR/specialists/agents-disabled"
286
+ mv -f "$ACCOUNT_DIR/specialists/agents/$_name" \
287
+ "$ACCOUNT_DIR/specialists/agents-disabled/$_name"
288
+ _withheld=$((_withheld + 1))
289
+ fi
290
+ done <<< "$_disabled_names"
291
+ echo " specialists agents-withheld=$_withheld (disabled by operator)"
292
+ else
293
+ echo " specialists agents-disabled-store-unreadable — withholding nothing"
294
+ fi
295
+ fi
296
+
254
297
  rm -f "$ACCOUNT_DIR/.claude/agents/"*.md 2>/dev/null || true
255
298
 
256
299
  # SOUL.md — user-controlled. Only create if missing. Never overwrite.
@@ -15,7 +15,7 @@ export interface ReconcileResult extends ReconcileCounts {
15
15
  /** The single per-run summary line body. A non-zero count is the standing signal
16
16
  * that an agent authored folders the hard guards could not prevent. Pure
17
17
  * formatter so the emitter and its test share one wording. */
18
- export declare function formatReconcileLine(c: ReconcileCounts, homeMissing?: number, homeUnaudited?: number): string;
18
+ export declare function formatReconcileLine(c: ReconcileCounts, homeMissing?: number, homeUnaudited?: number, agentDrift?: number): string;
19
19
  /** Parse the fenced ```allowed-top-level block from an account's SCHEMA.md —
20
20
  * the same structured set fs-schema-guard.sh parses with awk. Returns the
21
21
  * entry names (blank lines dropped). An absent block yields an empty set. */
@@ -1 +1 @@
1
- {"version":3,"file":"account-dir-schema-reconcile.d.ts","sourceRoot":"","sources":["../src/account-dir-schema-reconcile.ts"],"names":[],"mappings":"AAsFA,MAAM,WAAW,eAAe;IAC9B,aAAa,EAAE,MAAM,CAAA;IACrB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,eAAgB,SAAQ,eAAe;IACtD;;;;;0CAKsC;IACtC,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;AAED;;+DAE+D;AAC/D,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,eAAe,EAAE,WAAW,SAAI,EAAE,aAAa,SAAI,GAAG,MAAM,CAGlG;AAED;;8EAE8E;AAC9E,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAcpE;AA+ED;;;8EAG8E;AAC9E,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,eAAe,CAoCpE;AAED,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAA;IACpB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;CAC/B;AAyBD;;;;;;;;;;;;;iCAaiC;AACjC,wBAAgB,YAAY,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CA+CtD;AAED,MAAM,WAAW,yBAAyB;IACxC,KAAK,IAAI,IAAI,CAAA;IACb,IAAI,IAAI,IAAI,CAAA;CACb;AAED;oDACoD;AACpD,wBAAgB,+BAA+B,CAC7C,IAAI,EAAE,aAAa,GAAG;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,GAC3C,yBAAyB,CAiB3B"}
1
+ {"version":3,"file":"account-dir-schema-reconcile.d.ts","sourceRoot":"","sources":["../src/account-dir-schema-reconcile.ts"],"names":[],"mappings":"AA2FA,MAAM,WAAW,eAAe;IAC9B,aAAa,EAAE,MAAM,CAAA;IACrB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,eAAgB,SAAQ,eAAe;IACtD;;;;;0CAKsC;IACtC,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;AAED;;+DAE+D;AAC/D,wBAAgB,mBAAmB,CACjC,CAAC,EAAE,eAAe,EAClB,WAAW,SAAI,EACf,aAAa,SAAI,EACjB,UAAU,SAAI,GACb,MAAM,CAGR;AAED;;8EAE8E;AAC9E,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAcpE;AA+ED;;;8EAG8E;AAC9E,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,eAAe,CAoCpE;AAED,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAA;IACpB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;CAC/B;AA0DD;;;;;;;;;;;;;iCAaiC;AACjC,wBAAgB,YAAY,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CA8DtD;AAED,MAAM,WAAW,yBAAyB;IACxC,KAAK,IAAI,IAAI,CAAA;IACb,IAAI,IAAI,IAAI,CAAA;CACb;AAED;oDACoD;AACpD,wBAAgB,+BAA+B,CAC7C,IAAI,EAAE,aAAa,GAAG;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,GAC3C,yBAAyB,CAiB3B"}