@rubytech/create-sitedesk-code 0.1.512 → 0.1.513

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 (24) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/docs/superpowers/plans/2026-07-27-task-2028-declared-file-write-deny.md +303 -0
  3. package/payload/platform/docs/superpowers/specs/2026-07-27-task-2028-declared-file-write-deny-design.md +126 -0
  4. package/payload/platform/lib/account-schema-regions/dist/index.d.ts +5 -0
  5. package/payload/platform/lib/account-schema-regions/dist/index.d.ts.map +1 -1
  6. package/payload/platform/lib/account-schema-regions/dist/index.js +5 -0
  7. package/payload/platform/lib/account-schema-regions/dist/index.js.map +1 -1
  8. package/payload/platform/lib/account-schema-regions/src/index.ts +5 -0
  9. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh +14 -0
  10. package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +38 -0
  11. package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-post.sh +11 -0
  12. package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +25 -1
  13. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +6 -0
  14. package/payload/platform/plugins/cloudflare/PLUGIN.md +1 -1
  15. package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +1 -1
  16. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
  17. package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +26 -0
  18. package/payload/platform/scripts/lib/account-schema-owned-dirs.py +40 -4
  19. package/payload/platform/scripts/logs-read.sh +47 -24
  20. package/payload/platform/scripts/logs-read.test.sh +73 -31
  21. package/payload/platform/scripts/provision-worktree.sh +80 -0
  22. package/payload/server/{chunk-2WAXM5N2.js → chunk-S6HYTIU3.js} +2 -3
  23. package/payload/server/maxy-edge.js +1 -1
  24. package/payload/server/server.js +89 -41
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: cloudflare
3
3
  description: Cloudflare operations — tunnel setup/reset, DNS, Pages hosting, D1 data capture, and dashboard guidance. Zero agent-facing MCP tools; every operation is the agent invoking `cloudflared` or `wrangler` directly via Bash, calling the Cloudflare API with a reused per-scope narrow token, or quoting a dashboard click-path the operator performs themselves.
4
- account-owned-files: [{"file": "data-portal.json", "description": "The account's data-portal folder index, written by bin/portal-index-push.mjs on every push."}]
4
+ account-owned-files: [{"file": "data-portal.json", "writtenBy": "agent", "description": "The account's portal config and folder index. Authored by the agent under the data-portal skill, which is also the only path that changes it; bin/portal-index-push.mjs reads it on every push."}]
5
5
  tools: []
6
6
  mcp-manifest: skip
7
7
  ---
@@ -78,7 +78,7 @@ export function resolveExposedDirs(schemaText, exposeFolders = []) {
78
78
  collisions: /** @type {string[]} */ ([]),
79
79
  exposed: /** @type {string[]} */ ([]),
80
80
  }
81
- // Fail CLOSED. fs-schema-guard.sh:76-78 fails open on a missing schema so an
81
+ // Fail CLOSED. fs-schema-guard.sh:77-80 fails open on a missing schema so an
82
82
  // unseeded legacy account is not write-blocked; a read surface facing a
83
83
  // client must do the opposite. Do not "fix" this to match the guard.
84
84
  if (typeof schemaText !== 'string' || schemaText.length === 0) return empty
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: scheduling
3
3
  description: "Calendar and scheduling — create events, manage appointments, set up recurring triggers."
4
- account-owned-files: [{"file": "calendar-availability.json", "description": "The booking page's availability config, written by the publish-availability script and read by the live booking page."}]
4
+ account-owned-files: [{"file": "calendar-availability.json", "writtenBy": "agent", "description": "The booking page's availability config. Authored by the agent under the calendar-site skill, which is also the only path that changes it; the publish-availability script reads it to refresh the live booking page."}]
5
5
  tools:
6
6
  - name: schedule-event
7
7
  publicAllowlist: false
@@ -395,5 +395,31 @@ for _n in e-sign data-portal.json calendar-availability.json wa-channel-bindings
395
395
  assert_grep "$_n" "$ALLOWED_SHIP" "ship-allow-$_n"
396
396
  done
397
397
 
398
+ # --- declared-files fence: one name<TAB>plugin line per declared FILE -------
399
+ # The allowed-top-level fence is a layout list: it says a name may exist at the
400
+ # account root, not that an agent may author it. The write guard needs the file
401
+ # set apart from the bucket set, plus the owning plugin to name in its block
402
+ # message, so the merge emits it as its own fence.
403
+ DECL_SHIP="$(awk '/^```declared-files$/{f=1;next} /^```$/{f=0} f' "$A_SHIP/SCHEMA.md")"
404
+ for _n in wa-channel-bindings.json telegram-channel-bindings.json webchat-channel-bindings.json \
405
+ canonical-webchat-session.json session-titles.json agents-disabled.json; do
406
+ assert_grep "$(printf '%s\tadmin' "$_n")" "$DECL_SHIP" "ship-decl-$_n"
407
+ done
408
+ # An agent-written declaration (writtenBy: agent) stays OUT of the fence: the
409
+ # calendar-site and data-portal skills author these two files with Write, so
410
+ # denying them would break the only path that creates them. They remain in the
411
+ # allowed set (asserted above), so the reconcile still never calls them strays.
412
+ assert_nogrep "data-portal.json" "$DECL_SHIP" "ship-decl-excludes-agent-written-portal"
413
+ assert_nogrep "calendar-availability.json" "$DECL_SHIP" "ship-decl-excludes-agent-written-availability"
414
+ # A declared DIR never enters the file fence.
415
+ assert_nogrep "e-sign" "$DECL_SHIP" "ship-decl-excludes-dirs"
416
+ assert_grep "declared-files:start" "$(cat "$A_SHIP/SCHEMA.md")" "ship-decl-marker"
417
+ # Idempotent: a second merge leaves the file byte-identical.
418
+ B_SHIP="$(shasum "$A_SHIP/SCHEMA.md" | awk '{print $1}')"
419
+ merge_owned_dirs_into_schema "$A_SHIP" >/dev/null
420
+ assert_eq "$(shasum "$A_SHIP/SCHEMA.md" | awk '{print $1}')" "$B_SHIP" "ship-decl-idempotent"
421
+ # A brand whose payload declares no owned file emits no region at all.
422
+ assert_nogrep "declared-files:start" "$(cat "$A_MX/SCHEMA.md")" "mx-no-decl-region"
423
+
398
424
  echo "PASS=$PASS FAIL=$FAIL"
399
425
  if [ "$FAIL" -ne 0 ]; then printf '%s\n' "${FAILED[@]}"; exit 1; fi
@@ -8,6 +8,13 @@ by kind:
8
8
  account-owned-dirs: [{"dir": "quoting", "description": "..."}]
9
9
  account-owned-files: [{"file": "data-portal.json", "description": "..."}]
10
10
 
11
+ A file entry may add "writtenBy": "agent" to say the agent authors it under a
12
+ skill's instruction rather than platform code writing it whole. Such a file is
13
+ still declared — the reconcile must not call it a stray — but it is left out of
14
+ the declared-files fence the write guard denies on, because denying it would
15
+ block the only path that creates it. Absent or any other value means platform
16
+ code is the writer.
17
+
11
18
  The owned-entry set for a brand is the union declared by every platform plugin
12
19
  ($PROJECT_DIR/plugins/*/PLUGIN.md) and every premium sub-plugin of a bundle the
13
20
  brand ships ($PREMIUM_ROOT/<bundle>/plugins/*/PLUGIN.md, gated by brand.json
@@ -30,6 +37,8 @@ MARK_START = "<!-- plugin-owned-dirs:start -->"
30
37
  MARK_END = "<!-- plugin-owned-dirs:end -->"
31
38
  ONT_START = "<!-- ontology-buckets:start -->"
32
39
  ONT_END = "<!-- ontology-buckets:end -->"
40
+ DECL_START = "<!-- declared-files:start -->"
41
+ DECL_END = "<!-- declared-files:end -->"
33
42
 
34
43
  # Base top-level names shipped in the account template plus `.quarantine`. This
35
44
  # set is prune-protection only: a reconcile never *adds* a missing seed entry,
@@ -109,7 +118,9 @@ def _read_vertical(project_dir):
109
118
 
110
119
 
111
120
  def _parse_plugin_md(path):
112
- """Return (name, [ {name, kind, description} ]) from a PLUGIN.md, or (None, []).
121
+ """Return (name, [ {name, kind, description, writtenBy} ]) from a PLUGIN.md,
122
+ or (None, []). `writtenBy` is '' unless the entry states one; 'agent' keeps
123
+ the name out of the declared-files fence (see the module docstring).
113
124
  Two frontmatter keys, one list: `account-owned-dirs` yields kind='dir',
114
125
  `account-owned-files` yields kind='file'. A key whose value is not a JSON
115
126
  array of objects is skipped WHOLE: entries are staged and committed only once
@@ -145,7 +156,8 @@ def _parse_plugin_md(path):
145
156
  n = (e.get(field) or "").strip()
146
157
  if n and "/" not in n:
147
158
  staged.append({"name": n, "kind": kind,
148
- "description": (e.get("description") or "").strip()})
159
+ "description": (e.get("description") or "").strip(),
160
+ "writtenBy": (e.get("writtenBy") or "").strip()})
149
161
  except Exception:
150
162
  continue
151
163
  owned.extend(staged)
@@ -190,7 +202,8 @@ def resolve(project_dir):
190
202
  if e["name"] in seen:
191
203
  continue
192
204
  seen[e["name"]] = {"name": e["name"], "kind": e["kind"],
193
- "description": e["description"], "plugin": name or "unknown"}
205
+ "description": e["description"], "plugin": name or "unknown",
206
+ "writtenBy": e["writtenBy"]}
194
207
  order.append(e["name"])
195
208
  return [seen[n] for n in order]
196
209
 
@@ -355,9 +368,10 @@ def merge(project_dir, account_dir):
355
368
  lines = text.split("\n")
356
369
  lines[start + 1:end] = new_entries
357
370
  text = "\n".join(lines)
358
- # Regenerate both descriptive regions wholesale (idempotent).
371
+ # Regenerate every generated region wholesale (idempotent).
359
372
  text = _strip_region(text, MARK_START, MARK_END)
360
373
  text = _strip_region(text, ONT_START, ONT_END)
374
+ text = _strip_region(text, DECL_START, DECL_END)
361
375
  if owned:
362
376
  region = [MARK_START, "## Plugin-owned top-level entries", "",
363
377
  "A plugin declares the account-root entries its feature owns. A",
@@ -393,6 +407,28 @@ def merge(project_dir, account_dir):
393
407
  f"(owned by `{b['owner']}`), not a root bucket.")
394
408
  region.append(ONT_END)
395
409
  text = text.rstrip("\n") + "\n\n" + "\n".join(region) + "\n"
410
+ # The declared-file set, machine-readable and separate from the
411
+ # allowed-top-level fence. That fence is a layout list — it says a name may
412
+ # exist at the account root — and the write guard reads it as its allow-list,
413
+ # so a name unioned into it becomes agent-writable. This fence carries the
414
+ # other half the guard needs: which of those names is written whole by
415
+ # platform code, and which plugin to name when it blocks the write. An
416
+ # agent-written declaration is excluded: its skill authors it with Write, so
417
+ # denying it would block the only path that creates it.
418
+ declared = [e for e in owned if e["kind"] == "file" and e["writtenBy"] != "agent"]
419
+ if declared:
420
+ region = [DECL_START, "## Declared files (written by platform code, owned by a plugin)", "",
421
+ "Each file below is written whole by platform code and owned by the",
422
+ "plugin named beside it. It is control-plane state, not operator data:",
423
+ "never hand-edit one, because the next platform write overwrites the",
424
+ "edit. The write guard reads this block and blocks a write whose first",
425
+ "path segment is one of these names.", "",
426
+ "```declared-files"]
427
+ for e in declared:
428
+ region.append(f"{e['name']}\t{e['plugin']}")
429
+ region.append("```")
430
+ region.append(DECL_END)
431
+ text = text.rstrip("\n") + "\n\n" + "\n".join(region) + "\n"
396
432
  with open(schema_path, "w") as f:
397
433
  f.write(text)
398
434
  root_dirs = [b["dir"] for b in domain if _is_root(b["owner"])]
@@ -26,7 +26,7 @@
26
26
  # For per-session types (agent-stream/system/session/error/public): sessionKey
27
27
  # is required in the first mode. For platform-scoped types (server/vnc/
28
28
  # heartbeat/mem): the id is ignored. chat-attempts is --tail-only:
29
- # cross-grep of edge.log + server.log for the chat-ingress quartet within
29
+ # cross-grep of edge.log + server.log for the chat-ingress checkpoints within
30
30
  # the most recent N minutes.
31
31
  #
32
32
  # Every invocation writes a one-line trailer describing files searched,
@@ -154,14 +154,17 @@ is_per_conversation_type() {
154
154
  SEARCH_TYPES="agent-stream error session public server mcp vnc"
155
155
  VALID_TYPES="agent-stream, system, session, error, heartbeat, public, server, mcp, vnc, mem, chat-attempts"
156
156
 
157
- # chat-attempts cross-grep prefixes. One row per chat-ingress
158
- # checkpoint emitted across edge.log + server.log: edge-side inbound/outcome,
159
- # admin-auth gate verdict, chat-route handler entry. Absence of a row at any
160
- # checkpoint localises the failure to one stage of the ingress pipeline.
157
+ # chat-attempts cross-grep prefixes. Four checkpoint patterns across
158
+ # edge.log + server.log: edge-side inbound/outcome, admin-auth gate verdict,
159
+ # chat-route handler entry. Any one chat emits THREE of the four, and which
160
+ # three depends on the surface: the admin webchat (POST /api/webchat/send)
161
+ # carries an admin-auth row and no chat-route row, public chat
162
+ # (POST /api/chat) carries a chat-route row and no admin-auth row. Absence of
163
+ # a row the surface does emit localises the failure to one ingress stage.
161
164
  #
162
165
  # The `ts=` group is optional and sits between the tag and the keyword because
163
166
  # that is where Task 2024 put it: the shipped line is
164
- # `[edge-admin] ts=<ISO> inbound …` (edge.ts:182, 193; edge-admin.ts:48, 52).
167
+ # `[edge-admin] ts=<ISO> inbound …` (edge.ts:188, 199; edge-admin.ts:48, 52).
165
168
  # Requiring tag and keyword adjacent matched zero lines and reported the
166
169
  # absence as an edge-side silence (Task 2032). Optional rather than required,
167
170
  # so the pattern is not pinned to one format version a second time.
@@ -186,7 +189,7 @@ Usage:
186
189
  is_error result (silent-failure audit)
187
190
  logs-read.sh --tail [type] [lines] Tail most recent log of type
188
191
  logs-read.sh --tail chat-attempts [min] Cross-grep edge.log + server.log
189
- for the chat-ingress quartet
192
+ for the chat-ingress checkpoints
190
193
  logs-read.sh --grep <sessionKey> [type] Legacy grep across log files
191
194
 
192
195
  Types: $VALID_TYPES
@@ -200,9 +203,12 @@ transcript, flagging subagent is_error results inline. An explicit <type>
200
203
  keeps the legacy per-account stream-log read.
201
204
  Per-session stream-log types (agent-stream, system, session, error, public)
202
205
  require sessionKey; platform-scoped types (server, vnc, heartbeat, mem) ignore the id.
203
- chat-attempts is --tail-only — it aggregates the four ingress checkpoints
206
+ chat-attempts is --tail-only — it aggregates four ingress checkpoint patterns
204
207
  ([edge-admin] ts=<ISO> inbound, [admin-auth] outcome, [chat-route] entered,
205
- [edge-admin] ts=<ISO> outcome) into one timeline.
208
+ [edge-admin] ts=<ISO> outcome) into one timeline. Any one chat emits three of
209
+ them: the admin webchat (POST /api/webchat/send) emits the two edge rows plus
210
+ [admin-auth] outcome and no [chat-route] row; public chat (POST /api/chat)
211
+ emits the two edge rows plus [chat-route] entered and no [admin-auth] row.
206
212
  EOF
207
213
  exit 2
208
214
  }
@@ -216,25 +222,42 @@ validate_type() {
216
222
  }
217
223
 
218
224
  # chat-attempts cross-grep mode. Combines edge.log + server.log
219
- # lines matching the chat-ingress quartet, filtered to the last N minutes,
225
+ # lines matching the chat-ingress checkpoints, filtered to the last N minutes,
220
226
  # sorted chronologically. Default window: 5 minutes (matches the typical
221
227
  # "agent doesn't respond" report window — operator sees the silence,
222
228
  # screenshots the chat, then runs this within a few minutes).
223
229
  #
224
230
  # Output preserves source-file prefix so operators can see whether the line
225
231
  # came from the edge process (edge.log) or maxy-ui (server.log). On a
226
- # successful chat POST the timeline reads:
232
+ # successful chat POST the timeline reads THREE rows, and which three depends
233
+ # on the surface.
227
234
  #
228
- # [edge-admin] ts=… inbound method=POST path=/api/admin/chat … (edge.log)
229
- # [admin-auth] outcome=accept (server.log)
230
- # [chat-route] entered route=admin method=POST cacheKey=…(server.log)
231
- # [edge-admin] ts=… outcome method=POST path=… status=200 … (edge.log)
235
+ # admin webchat, POST /api/webchat/send:
236
+ # [edge-admin] ts=… inbound method=POST path=/api/webchat/send (edge.log)
237
+ # [admin-auth] outcome=accept path=/api/webchat/send cacheKey=… (server.log)
238
+ # [edge-admin] ts=… outcome method=POST path=… status=200 … (edge.log)
232
239
  #
233
- # Any missing row in that sequence is itself diagnostic. Three-strike rule:
234
- # zero edge inbound = browser/network problem; inbound present + admin-auth
235
- # missing = handler unreachable; admin-auth=reject = auth failure; entered
236
- # missing after accept = Hono routing regression; outcome missing = upstream
237
- # proxy crash.
240
+ # public chat, POST /api/chat:
241
+ # [edge-admin] ts=… inbound method=POST path=/api/chat … (edge.log)
242
+ # [chat-route] entered route=public method=POST (server.log)
243
+ # [edge-admin] ts=… outcome method=POST path=… status=200 … (edge.log)
244
+ #
245
+ # Nothing emits [chat-route] on the admin surface and nothing emits
246
+ # [admin-auth] on the public one, so the row the OTHER surface would have
247
+ # shown is not evidence of anything. Reading admin against a four-row
248
+ # sequence is what made every healthy admin chat look like a routing
249
+ # regression (Task 2042).
250
+ #
251
+ # A row missing from the surface's OWN sequence is diagnostic.
252
+ # Both surfaces: zero edge inbound = browser/network problem; edge outcome
253
+ # missing = upstream proxy crash.
254
+ # Admin: [admin-auth] outcome is the handler-side checkpoint. Missing against
255
+ # a present inbound = handler unreachable; outcome=reject = auth failure;
256
+ # outcome=accept already proves the route matched, because requireAdminSession
257
+ # is middleware mounted on /send (webchat.ts), so no further handler-side row
258
+ # is expected.
259
+ # Public: [chat-route] entered is the handler-side checkpoint. Missing against
260
+ # a present inbound for /api/chat = public-route routing regression.
238
261
  #
239
262
  # The two log families are stamped differently and neither is negotiable here:
240
263
  # edge.log self-stamps `ts=` inside the line (Task 2024), server.log is stamped
@@ -263,10 +286,10 @@ chat_attempts_mode() {
263
286
  # matched line, which is what the previous form spawned on both dialects.
264
287
  #
265
288
  # Milliseconds are load-bearing, not decoration. Both families emit
266
- # `toISOString()`, and the whole ingress quartet normally completes inside
267
- # one wall-clock second, so a key truncated to seconds collides on all four
268
- # rows and the sort falls back to file-append order — which puts both edge
269
- # rows first and reproduces the clustering this mode was fixed to stop.
289
+ # `toISOString()`, and a chat's three checkpoints normally complete inside
290
+ # one wall-clock second, so a key truncated to seconds collides on every row
291
+ # and the sort falls back to file-append order — which puts the edge rows
292
+ # first and reproduces the clustering this mode was fixed to stop.
270
293
  #
271
294
  # An undated line reaches $tmp with `-` as its key. It cannot be placed in
272
295
  # the window or in the sequence, so it is listed separately below rather
@@ -197,28 +197,31 @@ iso_minutes_ago() {
197
197
  || date -u -d "${m} minutes ago" +%Y-%m-%dT%H:%M:%S 2>/dev/null
198
198
  }
199
199
 
200
- # --- Case 6 (Task 2032): all four checkpoints match, interleaved by time ---
201
- case_chat_attempts_quartet() {
200
+ # --- Case 6 (Task 2032): all four patterns match, interleaved by time ---
201
+ case_chat_attempts_both_surfaces() {
202
202
  local root="/tmp/maxy-logs-read-test-6-$$"
203
203
  local script
204
204
  script=$(setup_install_tree "$root")
205
205
  local logs="$HOME/.$(basename "$root")/logs"
206
- # Four distinct seconds, all inside the 5-minute window, in ingress order.
207
- # Distinct rather than paired so the ordering assertion below is strict and
208
- # does not depend on how the sort breaks a tie.
209
- local t0 t1 t2 t3
210
- t0=$(iso_minutes_ago 4); t1=$(iso_minutes_ago 3)
211
- t2=$(iso_minutes_ago 2); t3=$(iso_minutes_ago 1)
212
-
213
- # Shipped shapes: edge.log self-stamps `ts=` after the tag (edge.ts:182,193);
206
+ # Four checkpoint patterns in one window takes TWO chats, not one: no single
207
+ # surface emits four (Task 2042). A public chat then an admin chat, three
208
+ # rows each. Distinct milliseconds inside two minute-slots, so the ordering
209
+ # assertion below is strict and exercises the millisecond sort key.
210
+ local tp ta
211
+ tp=$(iso_minutes_ago 4); ta=$(iso_minutes_ago 2)
212
+
213
+ # Shipped shapes: edge.log self-stamps `ts=` after the tag (edge.ts:188,199);
214
214
  # server.log is stamped at the line start by server-init-line-stamper.cjs.
215
+ # Both edge rows fire for any /api/ path (lib/proxy-log-path.ts:28-30).
215
216
  {
216
- echo "[edge-admin] ts=${t0}.100Z inbound method=POST path=/api/admin/chat token=present allowlist-branch=passthrough"
217
- echo "[edge-admin] ts=${t3}.900Z outcome method=POST path=/api/admin/chat status=200 duration_ms=812"
217
+ echo "[edge-admin] ts=${tp}.100Z inbound method=POST path=/api/chat token=absent allowlist-branch=passthrough"
218
+ echo "[edge-admin] ts=${tp}.300Z outcome method=POST path=/api/chat status=200 duration_ms=200"
219
+ echo "[edge-admin] ts=${ta}.100Z inbound method=POST path=/api/webchat/send token=present allowlist-branch=passthrough"
220
+ echo "[edge-admin] ts=${ta}.300Z outcome method=POST path=/api/webchat/send status=200 duration_ms=200"
218
221
  } > "$logs/edge.log"
219
222
  {
220
- echo "${t1}.200Z [admin-auth] outcome=accept path=/api/admin/chat cacheKey=deadbeef…"
221
- echo "${t2}.300Z [chat-route] entered route=admin method=POST cacheKey=deadbeef"
223
+ echo "${tp}.200Z [chat-route] entered route=public method=POST"
224
+ echo "${ta}.200Z [admin-auth] outcome=accept path=/api/webchat/send cacheKey=deadbeef"
222
225
  } > "$logs/server.log"
223
226
 
224
227
  local stdout stderr rc=0
@@ -232,14 +235,15 @@ case_chat_attempts_quartet() {
232
235
  if [[ "$stdout" != *"inbound"* ]]; then echo " timeline missing edge inbound"; return 1; fi
233
236
  if [[ "$stdout" != *"[edge-admin]"*"outcome"* ]]; then echo " timeline missing edge outcome"; return 1; fi
234
237
  if [[ "$stdout" != *"[admin-auth] outcome=accept"* ]]; then echo " timeline missing admin-auth"; return 1; fi
235
- if [[ "$stdout" != *"[chat-route] entered"* ]]; then echo " timeline missing chat-route"; return 1; fi
236
- if [[ "$stderr" != *"edge=2"* ]]; then echo " trailer missing edge=2: $stderr"; return 1; fi
238
+ if [[ "$stdout" != *"[chat-route] entered route=public"* ]]; then echo " timeline missing chat-route"; return 1; fi
239
+ if [[ "$stderr" != *"edge=4"* ]]; then echo " trailer missing edge=4: $stderr"; return 1; fi
237
240
  if [[ "$stderr" != *"server=2"* ]]; then echo " trailer missing server=2: $stderr"; return 1; fi
238
241
  if [[ "$stderr" != *"undated=0"* ]]; then echo " trailer missing undated=0: $stderr"; return 1; fi
239
242
 
240
- # Chronological: inbound (t0) precedes the edge outcome (t3), and the
241
- # chat-route row sits between them. A fixed-column sort put the two edge
242
- # rows adjacent instead, which is the ordering defect this pins.
243
+ # Chronological across the two log families: the public chat's edge inbound
244
+ # precedes its chat-route row, which precedes its edge outcome. A fixed-column
245
+ # sort put the two edge rows adjacent instead, which is the ordering defect
246
+ # this pins.
243
247
  local first mid last
244
248
  first=$(echo "$stdout" | grep -n 'inbound' | head -1 | cut -d: -f1)
245
249
  mid=$(echo "$stdout" | grep -n '\[chat-route\] entered' | head -1 | cut -d: -f1)
@@ -293,7 +297,7 @@ case_chat_attempts_window() {
293
297
  recent=$(iso_minutes_ago 1)
294
298
  old=$(iso_minutes_ago 90)
295
299
  {
296
- echo "[edge-admin] ts=${old}.000Z inbound method=POST path=/api/admin/chat token=present allowlist-branch=passthrough"
300
+ echo "[edge-admin] ts=${old}.000Z inbound method=POST path=/api/webchat/send token=present allowlist-branch=passthrough"
297
301
  echo "[edge-admin] ts=${recent}.000Z inbound method=GET path=/api/admin/version token=absent allowlist-branch=passthrough"
298
302
  } > "$logs/edge.log"
299
303
  : > "$logs/server.log"
@@ -312,11 +316,13 @@ case_chat_attempts_window() {
312
316
  return 0
313
317
  }
314
318
 
315
- # --- Case 9 (Task 2032): the whole quartet inside one second still orders ---
316
- # The four checkpoints of one chat POST normally complete in a few hundred
317
- # milliseconds, so they share a wall-clock second. A sort key truncated to
318
- # seconds collides on all four and falls back to file-append order, which puts
319
- # both edge rows first the clustering this mode was fixed to stop.
319
+ # --- Case 9 (Task 2032): all four patterns inside one second still order ---
320
+ # A chat POST completes in a few hundred milliseconds, so its checkpoints share
321
+ # a wall-clock second; an admin chat and a public chat overlapping in that same
322
+ # second put all four patterns there at once. A sort key truncated to seconds
323
+ # collides on every row and falls back to file-append order, which puts the edge
324
+ # rows first — the clustering this mode was fixed to stop. Six rows, because no
325
+ # single surface emits four checkpoints (Task 2042).
320
326
  case_chat_attempts_subsecond_order() {
321
327
  local root="/tmp/maxy-logs-read-test-9-$$"
322
328
  local script
@@ -325,11 +331,13 @@ case_chat_attempts_subsecond_order() {
325
331
  local s
326
332
  s=$(iso_minutes_ago 1)
327
333
  {
328
- echo "[edge-admin] ts=${s}.010Z inbound method=POST path=/api/admin/chat token=present allowlist-branch=passthrough"
329
- echo "[edge-admin] ts=${s}.230Z outcome method=POST path=/api/admin/chat status=200 duration_ms=220"
334
+ echo "[edge-admin] ts=${s}.010Z inbound method=POST path=/api/webchat/send token=present allowlist-branch=passthrough"
335
+ echo "[edge-admin] ts=${s}.060Z inbound method=POST path=/api/chat token=absent allowlist-branch=passthrough"
336
+ echo "[edge-admin] ts=${s}.090Z outcome method=POST path=/api/chat status=200 duration_ms=30"
337
+ echo "[edge-admin] ts=${s}.230Z outcome method=POST path=/api/webchat/send status=200 duration_ms=220"
330
338
  } > "$logs/edge.log"
331
339
  {
332
- echo "${s}.050Z [admin-auth] outcome=accept path=/api/admin/chat cacheKey=deadbeef"
340
+ echo "${s}.050Z [admin-auth] outcome=accept path=/api/webchat/send cacheKey=deadbeef"
333
341
  echo "${s}.070Z [chat-route] entered route=public method=POST"
334
342
  } > "$logs/server.log"
335
343
 
@@ -365,7 +373,7 @@ case_chat_attempts_undated_not_silence() {
365
373
  local script
366
374
  script=$(setup_install_tree "$root")
367
375
  local logs="$HOME/.$(basename "$root")/logs"
368
- echo "[edge-admin] inbound method=POST path=/api/admin/chat token=present allowlist-branch=passthrough" > "$logs/edge.log"
376
+ echo "[edge-admin] inbound method=POST path=/api/webchat/send token=present allowlist-branch=passthrough" > "$logs/edge.log"
369
377
  : > "$logs/server.log"
370
378
 
371
379
  local stdout stderr rc=0
@@ -416,17 +424,51 @@ case_chat_attempts_edge_log_absent() {
416
424
  return 0
417
425
  }
418
426
 
427
+ # --- Case 12 (Task 2042): usage names each surface, not four rows per chat ---
428
+ # The mode greps four checkpoint patterns, but no single chat emits four: the
429
+ # admin webchat has no [chat-route] row and public chat has no [admin-auth]
430
+ # row. Usage that promised four made every healthy admin chat read as a
431
+ # routing regression, so the count and both surface paths are pinned here.
432
+ case_usage_checkpoints_per_surface() {
433
+ local root="/tmp/maxy-logs-read-test-12-$$"
434
+ local script
435
+ script=$(setup_install_tree "$root")
436
+
437
+ local stderr rc=0
438
+ "$script" >/dev/null 2>/tmp/maxy-logs-read-stderr-12-$$ || rc=$?
439
+ stderr=$(cat /tmp/maxy-logs-read-stderr-12-$$)
440
+ rm -f /tmp/maxy-logs-read-stderr-12-$$
441
+
442
+ cleanup_install_tree "$root"
443
+
444
+ if [[ $rc -ne 2 ]]; then echo " expected usage exit 2, got $rc"; return 1; fi
445
+ if [[ "$stderr" == *"four ingress checkpoints"* ]]; then
446
+ echo " usage still claims four checkpoints per chat"; return 1
447
+ fi
448
+ if [[ "$stderr" == *"quartet"* ]]; then
449
+ echo " usage still calls the checkpoints a quartet"; return 1
450
+ fi
451
+ if [[ "$stderr" != *"/api/webchat/send"* ]]; then
452
+ echo " usage does not name the admin surface path"; return 1
453
+ fi
454
+ if [[ "$stderr" != *"/api/chat"* ]]; then
455
+ echo " usage does not name the public surface path"; return 1
456
+ fi
457
+ return 0
458
+ }
459
+
419
460
  run_case "sessionKey file present → exit 0" case_file_present
420
461
  run_case "no file → exit 1 + missing-on-resolve" case_missing
421
462
  run_case "ambiguous prefix → exit 1, refuses to pick" case_ambiguous
422
463
  run_case "--tail mem present → exit 0 + sampler lines" case_mem_present
423
464
  run_case "--tail mem absent → exit 1 + not-found message" case_mem_absent
424
- run_case "chat-attempts quartet matches and interleaves" case_chat_attempts_quartet
465
+ run_case "chat-attempts matches both surfaces and interleaves" case_chat_attempts_both_surfaces
425
466
  run_case "chat-attempts genuine zero states what it means" case_chat_attempts_genuine_zero
426
467
  run_case "chat-attempts window excludes an older line" case_chat_attempts_window
427
- run_case "chat-attempts orders four rows inside one second" case_chat_attempts_subsecond_order
468
+ run_case "chat-attempts orders all four patterns inside one second" case_chat_attempts_subsecond_order
428
469
  run_case "chat-attempts undated match is not edge silence" case_chat_attempts_undated_not_silence
429
470
  run_case "chat-attempts absent edge.log is reader blindness" case_chat_attempts_edge_log_absent
471
+ run_case "usage names each surface's checkpoints" case_usage_checkpoints_per_surface
430
472
 
431
473
  echo ""
432
474
  echo "================================================"
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ # The one documented action that makes a fresh worktree able to run the
3
+ # platform/ui suite. Task 2034.
4
+ #
5
+ # A git worktree carries tracked files only. platform/node_modules,
6
+ # platform/ui/node_modules and platform/lib/*/dist are gitignored build output, so
7
+ # a fresh worktree has none of them, and `npm install` inside platform/ui creates
8
+ # neither the hoisted platform/node_modules nor any lib dist. Without this, the
9
+ # suite fails on absent artifacts in a way that reads as flaky tests.
10
+ #
11
+ # Two things here are not obvious and are the reason this is a script:
12
+ #
13
+ # 1. tsc is called as platform/node_modules/.bin/tsc, never bare. platform's
14
+ # own package.json declares no typescript; it hoists one from 28 workspace
15
+ # members. Inside an npm script npm prepends node_modules/.bin to PATH and a
16
+ # bare `tsc` resolves; from a bare shell it does not, and the tsc on PATH is
17
+ # an unrelated program that refuses to run.
18
+ #
19
+ # 2. The libs are built in a loop, not through `npm run build:lib`. build:lib
20
+ # chains its 27 compiler runs with &&, and one lib emits its output while
21
+ # still reporting type errors, which would abort every run after it. So each
22
+ # lib is built independently and the final gate is artifact presence, not
23
+ # compiler exit status — the only check that separates "emitted with type
24
+ # errors" from "emitted nothing".
25
+
26
+ set -uo pipefail
27
+
28
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
29
+ PLATFORM="$(cd "$SCRIPT_DIR/.." && pwd -P)"
30
+ TSC="$PLATFORM/node_modules/.bin/tsc"
31
+ GATE="$PLATFORM/ui/scripts/check-worktree-provisioned.mjs"
32
+
33
+ echo "[provision] platform: $PLATFORM"
34
+
35
+ echo "[provision] stage 1/3: npm install in platform/"
36
+ if ! (cd "$PLATFORM" && npm install); then
37
+ echo "[provision] FAILED at stage 1: npm install in platform/" >&2
38
+ exit 1
39
+ fi
40
+
41
+ echo "[provision] stage 2/3: npm install in platform/ui"
42
+ if ! (cd "$PLATFORM/ui" && npm install); then
43
+ echo "[provision] FAILED at stage 2: npm install in platform/ui" >&2
44
+ exit 1
45
+ fi
46
+
47
+ if [ ! -x "$TSC" ]; then
48
+ echo "[provision] FAILED: $TSC absent after stage 1." >&2
49
+ echo "[provision] typescript hoists to platform/node_modules from the workspace" >&2
50
+ echo "[provision] members; if it is missing, stage 1 did not complete." >&2
51
+ exit 1
52
+ fi
53
+
54
+ echo "[provision] stage 3/3: building platform/lib/*/dist"
55
+ emitted_with_errors=()
56
+ for tsconfig in "$PLATFORM"/lib/*/tsconfig.json; do
57
+ lib="$(basename "$(dirname "$tsconfig")")"
58
+ if "$TSC" -p "$tsconfig" >/dev/null 2>&1; then
59
+ printf ' %-32s ok\n' "$lib"
60
+ elif [ -f "$PLATFORM/lib/$lib/dist/index.js" ]; then
61
+ printf ' %-32s emitted, with type errors\n' "$lib"
62
+ emitted_with_errors+=("$lib")
63
+ else
64
+ printf ' %-32s FAILED, emitted nothing\n' "$lib"
65
+ fi
66
+ done
67
+
68
+ if [ ${#emitted_with_errors[@]} -gt 0 ]; then
69
+ echo "[provision] ${#emitted_with_errors[@]} lib(s) emitted output while reporting type"
70
+ echo "[provision] errors: ${emitted_with_errors[*]}. Their dist is present, so the suite"
71
+ echo "[provision] can run. The type errors themselves are out of scope for provisioning."
72
+ fi
73
+
74
+ echo "[provision] verifying against the gate"
75
+ if ! node "$GATE"; then
76
+ echo "[provision] FAILED: the tree is still unprovisioned after all three stages." >&2
77
+ exit 1
78
+ fi
79
+
80
+ echo "[provision] done. platform/ui is ready: cd platform/ui && npm test"
@@ -6161,9 +6161,8 @@ async function tryCookieBridgeForConversation(c, cacheKey2, sessionId) {
6161
6161
  console.error(`[session] cookie-bridge-rejected reason=conversation-not-found path=${path} cacheKey=${sk8}\u2026 sessionId=${cid8}\u2026`);
6162
6162
  return { ok: false, reason: "conversation-not-found" };
6163
6163
  }
6164
- const deviceAccount = resolveAccount();
6165
- if (!deviceAccount || owner.accountId !== deviceAccount.accountId) {
6166
- console.error(`[session] cookie-bridge-rejected reason=account-mismatch path=${path} cacheKey=${sk8}\u2026 sessionId=${cid8}\u2026 conversationAccountId=${owner.accountId.slice(0, 8)}\u2026 deviceAccountId=${(deviceAccount?.accountId ?? "none").slice(0, 8)}\u2026`);
6164
+ if (!isValidAccountId(owner.accountId)) {
6165
+ console.error(`[session] cookie-bridge-rejected reason=account-mismatch path=${path} cacheKey=${sk8}\u2026 sessionId=${cid8}\u2026 conversationAccountId=${owner.accountId.slice(0, 8)}\u2026`);
6167
6166
  return { ok: false, reason: "account-mismatch" };
6168
6167
  }
6169
6168
  registerSession(cacheKey2, "admin", owner.accountId, void 0, owner.userId ?? void 0);
@@ -16,7 +16,7 @@ import {
16
16
  sanitizeClientCorrId,
17
17
  vncLog,
18
18
  websockifyLog
19
- } from "./chunk-2WAXM5N2.js";
19
+ } from "./chunk-S6HYTIU3.js";
20
20
  import "./chunk-PFF6I7KP.js";
21
21
 
22
22
  // server/edge.ts