@rubytech/create-maxy-code 0.1.285 → 0.1.287
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/samba-provision.test.js +84 -16
- package/dist/index.js +17 -8
- package/dist/samba-provision.js +81 -24
- package/dist/uninstall.js +5 -1
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +52 -3
- package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-directive.test.sh +55 -0
- package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +29 -13
- package/payload/platform/plugins/admin/hooks/prompt-optimiser-directive.sh +60 -11
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +30 -17
- package/payload/platform/plugins/docs/references/admin-ui.md +2 -2
- package/payload/platform/plugins/docs/references/deployment.md +1 -1
- package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
- package/payload/platform/plugins/docs/references/samba.md +25 -12
- package/payload/platform/plugins/whatsapp/PLUGIN.md +4 -0
- package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +1 -1
- package/payload/platform/scripts/__tests__/prompt-optimiser-audit.test.sh +59 -0
- package/payload/platform/scripts/installer-device-verify.sh +74 -3
- package/payload/platform/scripts/prompt-optimiser-audit.sh +82 -0
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +46 -7
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.js +49 -9
- package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/server.js +70 -17
- package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
- package/payload/server/{chunk-QHD5TKLQ.js → chunk-EAJMRICW.js} +21 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/assets/AdminShell-DkP2rJdF.js +1 -0
- package/payload/server/public/assets/{Checkbox-BdDmGlvK.js → Checkbox-CsQq7YPC.js} +1 -1
- package/payload/server/public/assets/{admin-BYtGQJ0d.js → admin-C_bkVvXh.js} +1 -1
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-CBx_GoUY.js → architectureDiagram-Q4EWVU46-PGePlVpb.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-DvWR4fpv.js → blockDiagram-DXYQGD6D-BJCbJRRs.js} +1 -1
- package/payload/server/public/assets/{browser-2PXsTraZ.js → browser-DynPFG_A.js} +1 -1
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-BbSOxeeU.js → c4Diagram-AHTNJAMY-DcMhi4gF.js} +1 -1
- package/payload/server/public/assets/channel-idaGQqlP.js +1 -0
- package/payload/server/public/assets/{chunk-336JU56O-DJzmXTk5.js → chunk-336JU56O-0vhS0MI-.js} +2 -2
- package/payload/server/public/assets/{chunk-426QAEUC-BSwzskM5.js → chunk-426QAEUC-DbVU-p-q.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-C-fH8BTZ.js → chunk-4TB4RGXK-dgx3qIz_.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-DW-MaiGX.js → chunk-5FUZZQ4R-BglTosdT.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-DlKbwC1X.js → chunk-5PVQY5BW-hZB3AxUF.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-DnkdxwAt.js → chunk-EDXVE4YY-DXqLJyYa.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-BTfezWsj.js → chunk-ENJZ2VHE-BD4I_a54.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-BGg8uTtu.js → chunk-ICPOFSXX-BdQx0Ahc.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-CYJInav8.js → chunk-OYMX7WX6-BDjtbZvS.js} +1 -1
- package/payload/server/public/assets/{chunk-U2HBQHQK-B6kD6ZHU.js → chunk-U2HBQHQK-hYN7A3g_.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-7tvWkjym.js → chunk-X2U36JSP-D3njJ7WG.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-D6NjOb5l.js → chunk-YZCP3GAM-BfZKEcvU.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-Dqz69eJy.js → chunk-ZZ45TVLE-DLJMNqLn.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-CmRFpirU.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DFy8xZjP.js +1 -0
- package/payload/server/public/assets/clone-kFPktUPQ.js +1 -0
- package/payload/server/public/assets/{dagre-di-KHpsq.js → dagre-DVLE4-m6.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-DswETLwm.js → dagre-KV5264BT-DGjcX5uz.js} +1 -1
- package/payload/server/public/assets/{data-B6vpe0Zj.js → data-D1FFhz2k.js} +1 -1
- package/payload/server/public/assets/{diagram-5BDNPKRD-DWmq_Zkl.js → diagram-5BDNPKRD-CQI2o8Cv.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-BVi6yjLL.js → diagram-G4DWMVQ6-C8kXsEdQ.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-D6XKyUAT.js → diagram-MMDJMWI5-DaWrNg_c.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-sU1d7P6W.js → diagram-TYMM5635-Ql9QPUxX.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-CY6qPZvu.js → erDiagram-SMLLAGMA-B2fBZhPo.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-CE9hd869.js → flowDiagram-DWJPFMVM-BA0LiOm5.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-Cbb885gt.js → ganttDiagram-T4ZO3ILL-CBKdEgUk.js} +1 -1
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-BNzO3jBu.js → gitGraphDiagram-UUTBAWPF-BIyEfuwB.js} +1 -1
- package/payload/server/public/assets/{graph-DfegJdnT.js → graph-BxWBNsFs.js} +1 -1
- package/payload/server/public/assets/{graph-labels-BG4aqzs6.js → graph-labels-DSLdDXoF.js} +1 -1
- package/payload/server/public/assets/{graphlib-CIzKPoW_.js → graphlib-Cv4SvsQN.js} +1 -1
- package/payload/server/public/assets/{infoDiagram-42DDH7IO-DWGtofAT.js → infoDiagram-42DDH7IO-DovHLDHz.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-UG1rus6G.js → ishikawaDiagram-UXIWVN3A-haFza9s4.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-CYhHYXmj.js → journeyDiagram-VCZTEJTY-l-8tJ21z.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-CWHj9DHX.js → kanban-definition-6JOO6SKY-DSZUvFLs.js} +1 -1
- package/payload/server/public/assets/{line-vMN9CmXx.js → line-mfTElknw.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-5X7WWCZe.js → mermaid-parser.core-4temHHPS.js} +1 -1
- package/payload/server/public/assets/{mermaid.core-B0zUZsT6.js → mermaid.core-DO2iS9my.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-BGr68ynR.js → mindmap-definition-QFDTVHPH-Cymf2IGG.js} +1 -1
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-Bo3zU_w2.js → pieDiagram-DEJITSTG-_2tmuvMb.js} +1 -1
- package/payload/server/public/assets/{public-DCZ92LR9.js → public-Rz_GzOrN.js} +3 -3
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-o-dLMRj-.js → quadrantDiagram-34T5L4WZ-CVCrGnBi.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-D_O_ZclX.js → requirementDiagram-MS252O5E-Bc-o8i8Z.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-Doz7uWIn.js → sankeyDiagram-XADWPNL6-DrTBxqE6.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-r1VW_jgc.js → sequenceDiagram-FGHM5R23-BjVjSsUJ.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-B7O06aua.js → stateDiagram-FHFEXIEX-DWAJP6ly.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BHDyrxpj.js +1 -0
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-WS2AL0yL.js → timeline-definition-GMOUNBTQ-Br_nbteH.js} +1 -1
- package/payload/server/public/assets/{useSelectionMode-lFraIQzT.css → useSelectionMode-BLb-o9RX.css} +1 -1
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-_-FLcVAh.js → vennDiagram-DHZGUBPP-D_ozeKV6.js} +1 -1
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-nZ3oO0ih.js → wardleyDiagram-NUSXRM2D-B87ZKC2B.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-CqTdRM37.js → xychartDiagram-5P7HB3ND-DosRlk2T.js} +1 -1
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +5 -5
- package/payload/server/public/public.html +4 -4
- package/payload/server/server.js +269 -81
- package/payload/server/public/assets/AdminShell-DwPVr9K1.js +0 -1
- package/payload/server/public/assets/channel-C4NqYS8u.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-CI3_kz04.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-UP3RcKH9.js +0 -1
- package/payload/server/public/assets/clone-CvAPweMB.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-Bo1g66pA.js +0 -1
- /package/payload/server/public/assets/{useSelectionMode-mzxTOyBv.js → useSelectionMode-hZ5bdL8b.js} +0 -0
|
@@ -39,6 +39,31 @@ HOOK_INPUT=$(cat 2>/dev/null || true)
|
|
|
39
39
|
# Fail-open if the JSON encoder is unavailable.
|
|
40
40
|
command -v python3 >/dev/null 2>&1 || exit 0
|
|
41
41
|
|
|
42
|
+
# Task 753 — suppress the directive on a native channel turn. A channel inbound
|
|
43
|
+
# arrives as a `<channel source="..." ...>` event; the channel service already
|
|
44
|
+
# reframes that event text into a select-and-dispatch instruction, so injecting
|
|
45
|
+
# the standing routing ladder on top is redundant. The marker is matched on the
|
|
46
|
+
# parsed `.prompt` field (not raw stdin) and only at the start of the prompt, so
|
|
47
|
+
# a normal admin prompt that merely mentions "channel" is never suppressed.
|
|
48
|
+
# Fail-open: if the prompt cannot be parsed, do NOT suppress (inject as usual).
|
|
49
|
+
IS_CHANNEL_TURN=$(printf '%s' "$HOOK_INPUT" | python3 -c '
|
|
50
|
+
import json, sys
|
|
51
|
+
try:
|
|
52
|
+
p = json.load(sys.stdin).get("prompt", "")
|
|
53
|
+
except Exception:
|
|
54
|
+
print("no"); sys.exit(0)
|
|
55
|
+
print("yes" if isinstance(p, str) and p.lstrip().startswith("<channel source=") else "no")
|
|
56
|
+
' 2>/dev/null || echo "no")
|
|
57
|
+
if [ "$IS_CHANNEL_TURN" = "yes" ]; then
|
|
58
|
+
SID_C=$(printf '%s' "$HOOK_INPUT" | python3 -c 'import json,sys
|
|
59
|
+
try:
|
|
60
|
+
print(json.load(sys.stdin).get("session_id","") or "?")
|
|
61
|
+
except Exception:
|
|
62
|
+
print("?")' 2>/dev/null)
|
|
63
|
+
echo "[prompt-optimiser-directive] skipped reason=channel-turn session=${SID_C:-?}" >&2
|
|
64
|
+
exit 0
|
|
65
|
+
fi
|
|
66
|
+
|
|
42
67
|
# Read a generated list from the account dir (cwd). On a missing file, emit a
|
|
43
68
|
# visible breadcrumb to stderr and echo a one-line placeholder so the ladder
|
|
44
69
|
# still names the tier.
|
|
@@ -97,21 +122,45 @@ print(json.dumps({
|
|
|
97
122
|
[ -n "$OUT" ] || exit 0
|
|
98
123
|
printf '%s\n' "$OUT"
|
|
99
124
|
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
# (
|
|
103
|
-
|
|
104
|
-
# stdin; absent -> "?".
|
|
105
|
-
if [ -n "${LOG_DIR:-}" ] && [ -d "$LOG_DIR" ]; then
|
|
106
|
-
SID=$(printf '%s' "$HOOK_INPUT" | python3 -c 'import json,sys
|
|
125
|
+
# Session id, content hash, timestamp — computed unconditionally (cheap) so the
|
|
126
|
+
# stored file path and the breadcrumb can reference them even when LOG_DIR is
|
|
127
|
+
# absent (file=-). session_id is parsed from the captured stdin; absent -> "?".
|
|
128
|
+
SID=$(printf '%s' "$HOOK_INPUT" | python3 -c 'import json,sys
|
|
107
129
|
try:
|
|
108
130
|
print(json.load(sys.stdin).get("session_id","") or "")
|
|
109
131
|
except Exception:
|
|
110
132
|
print("")' 2>/dev/null)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
133
|
+
SID=${SID:-?}
|
|
134
|
+
SHA=$(printf '%s' "$DIRECTIVE" | python3 -c 'import hashlib,sys
|
|
135
|
+
sys.stdout.write(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' 2>/dev/null || echo "?")
|
|
136
|
+
TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
137
|
+
|
|
138
|
+
# Task 746: persist the EXACT emitted additionalContext bytes per turn, so the
|
|
139
|
+
# full directive is recoverable from an operator surface (the JSONL attachment
|
|
140
|
+
# row Claude Code keeps is truncated to <=2.6 KB; this file is not). Keyed by
|
|
141
|
+
# session + epoch seconds + pid -> one file per injection. Fail-open: a missing
|
|
142
|
+
# LOG_DIR, an unknown session, an un-creatable dir, or an unwritable file all
|
|
143
|
+
# skip the write and leave DIR_FILE=- ; the turn is never blocked.
|
|
144
|
+
DIR_FILE="-"
|
|
145
|
+
if [ -n "${LOG_DIR:-}" ] && [ -d "$LOG_DIR" ] && [ "$SID" != "?" ]; then
|
|
146
|
+
STORE="$LOG_DIR/prompt-optimiser-directives/$SID"
|
|
147
|
+
if mkdir -p "$STORE" 2>/dev/null; then
|
|
148
|
+
CAND="$STORE/$(date +%s)-$$.txt"
|
|
149
|
+
if printf '%s' "$DIRECTIVE" > "$CAND" 2>/dev/null; then
|
|
150
|
+
DIR_FILE="$CAND"
|
|
151
|
+
fi
|
|
152
|
+
fi
|
|
153
|
+
fi
|
|
154
|
+
|
|
155
|
+
# Durable breadcrumb (Task 719 + 746): length + content hash + stored path +
|
|
156
|
+
# session, so "directive fired on this turn" carries enough to recover and
|
|
157
|
+
# verify the exact bytes. The `injected len=` and `session=` substrings are
|
|
158
|
+
# preserved for the existing greps/tests.
|
|
159
|
+
if [ -n "${LOG_DIR:-}" ] && [ -d "$LOG_DIR" ]; then
|
|
160
|
+
printf '%s [prompt-optimiser-directive] injected len=%s sha256=%s file=%s session=%s\n' \
|
|
161
|
+
"$TS" "${#DIRECTIVE}" "$SHA" "$DIR_FILE" "$SID" \
|
|
162
|
+
>> "$LOG_DIR/prompt-optimiser-directive.log" 2>/dev/null || true
|
|
114
163
|
fi
|
|
115
164
|
|
|
116
|
-
echo "[prompt-optimiser-directive] injected len=${#DIRECTIVE}" >&2
|
|
165
|
+
echo "[prompt-optimiser-directive] injected len=${#DIRECTIVE} sha256=$SHA file=$DIR_FILE session=$SID" >&2
|
|
117
166
|
exit 0
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: platform-architecture
|
|
3
3
|
description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
|
|
4
|
-
content-hash: sha256:
|
|
4
|
+
content-hash: sha256:a058a1f676b20300c5b0cbba3a8eb4f1ccb4d3e127a19865286b1500a23b9cd0
|
|
5
5
|
brand: maxy-code
|
|
6
6
|
product-name: Maxy
|
|
7
7
|
---
|
|
@@ -344,7 +344,7 @@ These are part of Maxy's foundation and cannot be disabled:
|
|
|
344
344
|
| `tasks` | Task lifecycle — create, update, list, relate, complete |
|
|
345
345
|
| `workflows` | Persistent named workflows — reusable instruction sets |
|
|
346
346
|
| `contacts` | CRM contact management — create, lookup, update, list |
|
|
347
|
-
| `prompt-optimiser` | Prompt optimiser — two modes. Chat-app mode turns a rough draft or task description into a single finished, copy-pasteable prompt tuned for Opus 4.7 adaptive thinking (claude.ai, Mac, iOS). In-session mode is applied automatically: a standing `UserPromptSubmit` directive hook (`admin/hooks/prompt-optimiser-directive.sh`) injects context every turn telling the admin agent to restate each non-trivial prompt through this skill and act on the restatement, skipped for one-word confirmations, slash-commands, and direct continuations. Compliance is behavioural — the hook steers the agent, it cannot force the skill call. |
|
|
347
|
+
| `prompt-optimiser` | Prompt optimiser — two modes. Chat-app mode turns a rough draft or task description into a single finished, copy-pasteable prompt tuned for Opus 4.7 adaptive thinking (claude.ai, Mac, iOS). In-session mode is applied automatically: a standing `UserPromptSubmit` directive hook (`admin/hooks/prompt-optimiser-directive.sh`) injects context every turn telling the admin agent to restate each non-trivial prompt through this skill and act on the restatement, skipped for one-word confirmations, slash-commands, and direct continuations. Compliance is behavioural — the hook steers the agent, it cannot force the skill call. The hook also persists the exact injected `additionalContext` per turn to `$LOG_DIR/prompt-optimiser-directives/<session>/<epoch>-<pid>.txt` and records `len`, `sha256`, and the stored `file=` path in `$LOG_DIR/prompt-optimiser-directive.log`, so any past turn's full ~17 KB directive is recoverable and verifiable (the JSONL attachment row Claude Code keeps is truncated to ≤2.6 KB). `platform/scripts/prompt-optimiser-audit.sh` reconciles breadcrumbs against stored files per session (`injected=N stored=M`; `N!=M` is the leak signature, and any breadcrumb whose `file=` is missing is named). |
|
|
348
348
|
| `url-get` | Faithful page retrieval — fetches a server-rendered page, writes a verbatim markdown copy to an account-scoped reference file (no model in the path, so no copyright refusal), and returns the cleaned page text (capped) plus the file path. No summary and no subprocess: a caller that wants a summary invokes url-get from a delegated subagent. Use instead of WebFetch when a faithful copy is needed (e.g. ingesting your own published writing). |
|
|
349
349
|
|
|
350
350
|
### Maxy Plugins (user-selectable)
|
|
@@ -2443,8 +2443,8 @@ authoritative. This section names the surfaces and what backs each.
|
|
|
2443
2443
|
| Mode trigger | Per-`(accountId, userId)` `SpawnPreference` for `permissionMode` and `model`; persists across reload, tab, device. | `session-defaults.ts` |
|
|
2444
2444
|
| Nav rows (Chat / People / Agents / Projects / Tasks / Artefacts) | People, Agents, Tasks open the artefact-pane Graph filtered to the matching label. Chat selects the active conversation. Artefacts swaps the list to editable documents. | `graph-search.ts`, `graph-subgraph.ts`, `sidebar-artefacts.ts` |
|
|
2445
2445
|
| Sessions list (Active / Archived / All) | Live row store driven by SSE; manual reconcile button on the segmented control re-fetches the full id set. | `/claude-sessions/events`, `/claude-sessions` |
|
|
2446
|
-
| Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row **per interactive channel that has ≥1 admin session** (Task 738). Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no admin session shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount** (the per-channel counts must be known before the nav renders), so a failed load shows one muted, non-clickable line instead of channel rows. Clicking a channel row lists that channel's admin conversations; each conversation row shows
|
|
2447
|
-
| Channel transcript (`<Transcript>`) | Reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. The thread scrolls inside its grid cell (header/sidebar/footer fixed). The stream sends a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tags each frame with a byte-offset `id:`, and resumes from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. | `/whatsapp-reader/stream` |
|
|
2446
|
+
| Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row **per interactive channel that has ≥1 admin session** (Task 738). Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no admin session shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount** (the per-channel counts must be known before the nav renders), so a failed load shows one muted, non-clickable line instead of channel rows. Clicking a channel row lists that channel's admin conversations; each conversation row shows a **display name** resolved by precedence `operatorName ?? whatsappName ?? senderId ?? title` (Task 747): the bound `Person givenName familyName` when a `senderId`→`userId` binding exists, else the persisted WhatsApp display name (`pushName`, read from the latest `:Message:WhatsAppMessage.senderName`), else the raw `senderId`, else the agent title; the agent session title is the hover tooltip only. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`, same markup as the Sessions rows) formatted from the row's `lastMessageAt` (the last parseable turn's `ts`); a session with no parseable turn shows no time, never "Invalid Date". A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route (`/graph`, `/data`, `/browser`) it navigates to `/?wa=<sessionId>&projectDir=<dir>`, which the root shell hydrates on mount (then strips the query so a later refresh does not re-pin a closed conversation). Server logs `[wa-reader] op=operator-name-fallback senderId=… source=whatsapp-pushname` when the pushName fallback fires and `[wa-reader] op=conversations count=<n> withTimestamp=<m>` per list build; the client logs `[admin-ui] wa-open route=… via=<in-place\|navigate> …` and `[admin-ui] wa-hydrate route=/ …`. | `/whatsapp-reader/conversations` |
|
|
2447
|
+
| Channel transcript (`<Transcript>`) | Reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). `tool-call`/`tool-result` turns render as **collapsed cards** (chevron + label + time, default collapsed, expandable per card; one card's state never affects another) so the human↔agent text is not buried in JSON; the three conversation kinds always render in full. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom** — a scroll up suppresses the jump so reading history is never yanked. While the operator is scrolled up a **jump-to-bottom control** (`.wa-jump`, bottom-right of the viewport) appears; clicking it scrolls to the newest turn and re-arms follow-tail, and it hides again at the bottom (Task 747). The thread scrolls inside its grid cell (header/sidebar/footer fixed). The stream sends a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tags each frame with a byte-offset `id:`, and resumes from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. The thread also interleaves a collapsed `⚙ directive injected (N B)` row per turn (Task 746), sorted by timestamp just before the turn it informed; expanding one fetches the exact stored `prompt-optimiser-directive` bytes for that turn. The entries are listed by `GET /whatsapp-reader/directives?sessionId=` and the bytes served by `GET /whatsapp-reader/directive?sessionId=&name=`, both admin-gated and account-scoped; a missing store degrades to no rows. | `/whatsapp-reader/stream`, `/whatsapp-reader/directives`, `/whatsapp-reader/directive` |
|
|
2448
2448
|
| Conversations row hover actions | Inline rename, archive, delete, JSONL view / download per row. The historical `.conversations-modal` CSS block exists in `globals.css` but is no longer mounted from any TSX — Sidebar.tsx now owns every per-row affordance directly. | `claude-sessions.ts` |
|
|
2449
2449
|
| Artefacts list | Lists every `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'`, all file types, excluding the `uploads/<id>/` subtree — Task 684) plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates. Click downloads the row's backing file (`downloadPath` → `GET /api/admin/files/download`) so the operator opens it in their local app; rows whose file is outside `DATA_ROOT` (bundled-fallback templates) show a "can't be downloaded" pill. The in-app artefact pane is dead pending removal (Task 518). | `sidebar-artefacts.ts`, `files.ts` |
|
|
2450
2450
|
| System-stats widget | CPU / RAM widget at the foot of the sidebar. | `system-stats.ts` (see above) |
|
|
@@ -3421,7 +3421,7 @@ If you need to restart the service manually (rare), ask Maxy to do it for you.
|
|
|
3421
3421
|
|
|
3422
3422
|
## Browsing the brand filesystem on your LAN (SMB)
|
|
3423
3423
|
|
|
3424
|
-
Every install provisions a per-brand SMB share against the brand's install folder. See [Samba Share](./samba.md) for the share path, credentials, per-OS mount instructions, peer-brand lifecycle, and the LAN-only
|
|
3424
|
+
Every install provisions a per-brand SMB share against the brand's install folder. See [Samba Share](./samba.md) for the share path, credentials, per-OS mount instructions, peer-brand lifecycle, and the interface-binding posture (LAN-only on a private-LAN host, loopback-only on a public-only cloud host).
|
|
3425
3425
|
|
|
3426
3426
|
## Remote Access via Cloudflare
|
|
3427
3427
|
|
|
@@ -3604,9 +3604,9 @@ The share lives next to the rest of the brand. On a device that runs more than o
|
|
|
3604
3604
|
The installer runs the same Samba step on every supported footprint — Raspberry Pi, Hetzner Cloud server, and self-hosted Linux laptop. Four sub-steps emit `[install-invariant] samba-provision-<step>` markers in order:
|
|
3605
3605
|
|
|
3606
3606
|
1. **apt** — installs the `samba` package (skipped if `dpkg -s samba` already reports installed, so re-runs don't fight `unattended-upgrades` for the dpkg lock).
|
|
3607
|
-
2. **conf** — writes `/etc/samba/smb.conf` with a
|
|
3607
|
+
2. **conf** — writes `/etc/samba/smb.conf` with a `[global]` section plus a `[<brand>]` stanza pointing at the brand's install directory. The stanza is owned by the install owner (see below) and is marked `read only = no`, `browseable = yes`. The `[global]` bind posture depends on the host's interfaces (see "Interface binding" below); the marker records which posture fired — `bind=lan iface=<name>` or `bind=loopback-only`.
|
|
3608
3608
|
3. **user** — deferred at install time on a fresh Pi or Hetzner box because there is no PIN to hand to `smbpasswd` yet. The user is created the moment the operator sets a PIN in the admin UI (see "PIN rotation" below).
|
|
3609
|
-
4. **units** — `systemctl enable --now smbd
|
|
3609
|
+
4. **units** — `systemctl enable --now smbd` so the share is reachable as soon as the install finishes. `nmbd` (NetBIOS name service) is never enabled: nothing mounts the share by NetBIOS name, and on a public-facing host it answers on `:137`/`:138` as a DDoS-reflection vector.
|
|
3610
3610
|
|
|
3611
3611
|
macOS install is a no-op for this step — the installer logs `samba-provision skipped: platform=darwin` and returns. Mac operators do not get an SMB share against their laptop.
|
|
3612
3612
|
|
|
@@ -3641,18 +3641,31 @@ So:
|
|
|
3641
3641
|
- Rotating the PIN re-syncs the SMB password to the new value on the next set-pin request. Mounts using the old PIN start failing immediately; remount with the new PIN.
|
|
3642
3642
|
- If `set-pin` cannot read `~/.<brand>/.install-owner` (file missing or empty), it logs `[set-pin] smbpasswd sync failed owner=<unknown> rc=-1 reason=install-owner-file-missing` and skips the sync. The PIN still writes to the admin UI, but the SMB mount keeps refusing the new password until the install-owner file is restored.
|
|
3643
3643
|
|
|
3644
|
-
##
|
|
3644
|
+
## Interface binding
|
|
3645
3645
|
|
|
3646
|
-
|
|
3646
|
+
`bind interfaces only = yes` is always set, which makes the `interfaces` line an allow-list rather than a hint. Smbd binds only to the interfaces named there. The installer classifies the host's interfaces and writes one of two postures:
|
|
3647
3647
|
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3648
|
+
- **Private LAN host (Raspberry Pi).** At least one non-loopback interface carries a private address — RFC1918 (`10/8`, `172.16/12`, `192.168/16`), CGNAT (`100.64/10`), or link-local (`169.254/16`). The installer binds loopback plus that interface (`wlan0` preferred, then `eth0`, then the first other private interface):
|
|
3649
|
+
|
|
3650
|
+
```
|
|
3651
|
+
interfaces = lo <lan>
|
|
3652
|
+
bind interfaces only = yes
|
|
3653
|
+
```
|
|
3654
|
+
|
|
3655
|
+
The share is reachable on the LAN and nowhere else.
|
|
3656
|
+
|
|
3657
|
+
- **Public-only host (Hetzner Cloud).** The only non-loopback interface carries a routable public address (typically `eth0` with a `/32`, no private LAN). Binding `lo eth0` here would put smbd directly on the public internet, so the installer binds **loopback only**:
|
|
3658
|
+
|
|
3659
|
+
```
|
|
3660
|
+
interfaces = lo
|
|
3661
|
+
bind interfaces only = yes
|
|
3662
|
+
```
|
|
3663
|
+
|
|
3664
|
+
The share is reachable solely from the box itself. Operators reach it by forwarding loopback over SSH — `ssh -L 4445:localhost:445 admin@<tunnel-host>` and then mounting `smb://localhost:4445` — or by joining the box to a private network and re-installing, which makes the private interface the chosen LAN bind.
|
|
3652
3665
|
|
|
3653
|
-
|
|
3666
|
+
If the device has no non-loopback IPv4 interface at all, the installer refuses to provision and exits — there is nothing safe to bind to. This is distinct from the public-only case, which still provisions a working (host-local) share.
|
|
3654
3667
|
|
|
3655
|
-
This is the structural guarantee that SMB never leaves the LAN, even if upstream firewall rules are misconfigured. The Cloudflare tunnel that fronts the admin UI carries HTTPS only; it does not route SMB.
|
|
3668
|
+
This classification is the structural guarantee that SMB never leaves the box (loopback-only) or the LAN (LAN-only), even if upstream firewall rules are misconfigured. The Cloudflare tunnel that fronts the admin UI carries HTTPS only; it does not route SMB.
|
|
3656
3669
|
|
|
3657
3670
|
## Peer-brand lifecycle
|
|
3658
3671
|
|
|
@@ -3669,8 +3682,8 @@ The brand-stanza name is the only identifier the uninstaller matches on, so two
|
|
|
3669
3682
|
|
|
3670
3683
|
- **"Logon failure" on mount.** The PIN you typed does not match the current `smbpasswd` entry. Set a new PIN in the admin UI and remount. If the PIN was just rotated and the mount still fails, check `~/.<brand>/.install-owner` exists and is non-empty.
|
|
3671
3684
|
- **Share does not show up in Finder / network browser.** mDNS may not be routed on your network. Mount by LAN IP instead of `<hostname>.local`.
|
|
3672
|
-
- **`smbd` not running after install.** Check the install log for the four `[install-invariant] samba-provision-<step>` markers. The `units` step running `systemctl enable --now smbd
|
|
3673
|
-
- **Hetzner share not reachable from outside the box.** This is by design — see "
|
|
3685
|
+
- **`smbd` not running after install.** Check the install log for the four `[install-invariant] samba-provision-<step>` markers. The `units` step running `systemctl enable --now smbd` is the last to fire; if it failed the marker prints `fail: <reason>`.
|
|
3686
|
+
- **Hetzner share not reachable from outside the box.** This is by design — see "Interface binding" above. A public-only host binds loopback only; use SSH port forwarding (`ssh -L 4445:localhost:445 …`).
|
|
3674
3687
|
|
|
3675
3688
|
Also see [Deployment Guide](./deployment.md) for the surrounding install flow, and [Access Control](./access-control.md) for how the brand isolation extends from the admin UI to the SMB share.
|
|
3676
3689
|
|
|
@@ -216,8 +216,8 @@ authoritative. This section names the surfaces and what backs each.
|
|
|
216
216
|
| Mode trigger | Per-`(accountId, userId)` `SpawnPreference` for `permissionMode` and `model`; persists across reload, tab, device. | `session-defaults.ts` |
|
|
217
217
|
| Nav rows (Chat / People / Agents / Projects / Tasks / Artefacts) | People, Agents, Tasks open the artefact-pane Graph filtered to the matching label. Chat selects the active conversation. Artefacts swaps the list to editable documents. | `graph-search.ts`, `graph-subgraph.ts`, `sidebar-artefacts.ts` |
|
|
218
218
|
| Sessions list (Active / Archived / All) | Live row store driven by SSE; manual reconcile button on the segmented control re-fetches the full id set. | `/claude-sessions/events`, `/claude-sessions` |
|
|
219
|
-
| Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row **per interactive channel that has ≥1 admin session** (Task 738). Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no admin session shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount** (the per-channel counts must be known before the nav renders), so a failed load shows one muted, non-clickable line instead of channel rows. Clicking a channel row lists that channel's admin conversations; each conversation row shows
|
|
220
|
-
| Channel transcript (`<Transcript>`) | Reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. The thread scrolls inside its grid cell (header/sidebar/footer fixed). The stream sends a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tags each frame with a byte-offset `id:`, and resumes from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. | `/whatsapp-reader/stream` |
|
|
219
|
+
| Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row **per interactive channel that has ≥1 admin session** (Task 738). Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no admin session shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount** (the per-channel counts must be known before the nav renders), so a failed load shows one muted, non-clickable line instead of channel rows. Clicking a channel row lists that channel's admin conversations; each conversation row shows a **display name** resolved by precedence `operatorName ?? whatsappName ?? senderId ?? title` (Task 747): the bound `Person givenName familyName` when a `senderId`→`userId` binding exists, else the persisted WhatsApp display name (`pushName`, read from the latest `:Message:WhatsAppMessage.senderName`), else the raw `senderId`, else the agent title; the agent session title is the hover tooltip only. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`, same markup as the Sessions rows) formatted from the row's `lastMessageAt` (the last parseable turn's `ts`); a session with no parseable turn shows no time, never "Invalid Date". A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route (`/graph`, `/data`, `/browser`) it navigates to `/?wa=<sessionId>&projectDir=<dir>`, which the root shell hydrates on mount (then strips the query so a later refresh does not re-pin a closed conversation). Server logs `[wa-reader] op=operator-name-fallback senderId=… source=whatsapp-pushname` when the pushName fallback fires and `[wa-reader] op=conversations count=<n> withTimestamp=<m>` per list build; the client logs `[admin-ui] wa-open route=… via=<in-place\|navigate> …` and `[admin-ui] wa-hydrate route=/ …`. | `/whatsapp-reader/conversations` |
|
|
220
|
+
| Channel transcript (`<Transcript>`) | Reads one session's JSONL over SSE (`/whatsapp-reader/stream`) and renders both sides plus tool calls — the turns claude.ai/code hides because channel inbound is stamped `isMeta`. Every turn shows its time-of-day (HH:MM from the turn's `ts`; a null/unparseable `ts` shows no time, never "Invalid Date"). `tool-call`/`tool-result` turns render as **collapsed cards** (chevron + label + time, default collapsed, expandable per card; one card's state never affects another) so the human↔agent text is not buried in JSON; the three conversation kinds always render in full. The thread **follows the tail**: it opens at the newest turn and pins to the bottom on each live append **only while the operator is already within 32 px of the bottom** — a scroll up suppresses the jump so reading history is never yanked. While the operator is scrolled up a **jump-to-bottom control** (`.wa-jump`, bottom-right of the viewport) appears; clicking it scrolls to the newest turn and re-arms follow-tail, and it hides again at the bottom (Task 747). The thread scrolls inside its grid cell (header/sidebar/footer fixed). The stream sends a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tags each frame with a byte-offset `id:`, and resumes from the client's `Last-Event-ID` on reconnect (no duplicate backlog); the client clears the "Stream disconnected" banner on reopen and latches it only on a terminal CLOSED state. The thread also interleaves a collapsed `⚙ directive injected (N B)` row per turn (Task 746), sorted by timestamp just before the turn it informed; expanding one fetches the exact stored `prompt-optimiser-directive` bytes for that turn. The entries are listed by `GET /whatsapp-reader/directives?sessionId=` and the bytes served by `GET /whatsapp-reader/directive?sessionId=&name=`, both admin-gated and account-scoped; a missing store degrades to no rows. | `/whatsapp-reader/stream`, `/whatsapp-reader/directives`, `/whatsapp-reader/directive` |
|
|
221
221
|
| Conversations row hover actions | Inline rename, archive, delete, JSONL view / download per row. The historical `.conversations-modal` CSS block exists in `globals.css` but is no longer mounted from any TSX — Sidebar.tsx now owns every per-row affordance directly. | `claude-sessions.ts` |
|
|
222
222
|
| Artefacts list | Lists every `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'`, all file types, excluding the `uploads/<id>/` subtree — Task 684) plus this account's IDENTITY / SOUL / KNOWLEDGE / specialist templates. Click downloads the row's backing file (`downloadPath` → `GET /api/admin/files/download`) so the operator opens it in their local app; rows whose file is outside `DATA_ROOT` (bundled-fallback templates) show a "can't be downloaded" pill. The in-app artefact pane is dead pending removal (Task 518). | `sidebar-artefacts.ts`, `files.ts` |
|
|
223
223
|
| System-stats widget | CPU / RAM widget at the foot of the sidebar. | `system-stats.ts` (see above) |
|
|
@@ -134,7 +134,7 @@ If you need to restart the service manually (rare), ask {{productName}} to do it
|
|
|
134
134
|
|
|
135
135
|
## Browsing the brand filesystem on your LAN (SMB)
|
|
136
136
|
|
|
137
|
-
Every install provisions a per-brand SMB share against the brand's install folder. See [Samba Share](./samba.md) for the share path, credentials, per-OS mount instructions, peer-brand lifecycle, and the LAN-only
|
|
137
|
+
Every install provisions a per-brand SMB share against the brand's install folder. See [Samba Share](./samba.md) for the share path, credentials, per-OS mount instructions, peer-brand lifecycle, and the interface-binding posture (LAN-only on a private-LAN host, loopback-only on a public-only cloud host).
|
|
138
138
|
|
|
139
139
|
## Remote Access via Cloudflare
|
|
140
140
|
|
|
@@ -26,7 +26,7 @@ These are part of {{productName}}'s foundation and cannot be disabled:
|
|
|
26
26
|
| `tasks` | Task lifecycle — create, update, list, relate, complete |
|
|
27
27
|
| `workflows` | Persistent named workflows — reusable instruction sets |
|
|
28
28
|
| `contacts` | CRM contact management — create, lookup, update, list |
|
|
29
|
-
| `prompt-optimiser` | Prompt optimiser — two modes. Chat-app mode turns a rough draft or task description into a single finished, copy-pasteable prompt tuned for Opus 4.7 adaptive thinking (claude.ai, Mac, iOS). In-session mode is applied automatically: a standing `UserPromptSubmit` directive hook (`admin/hooks/prompt-optimiser-directive.sh`) injects context every turn telling the admin agent to restate each non-trivial prompt through this skill and act on the restatement, skipped for one-word confirmations, slash-commands, and direct continuations. Compliance is behavioural — the hook steers the agent, it cannot force the skill call. |
|
|
29
|
+
| `prompt-optimiser` | Prompt optimiser — two modes. Chat-app mode turns a rough draft or task description into a single finished, copy-pasteable prompt tuned for Opus 4.7 adaptive thinking (claude.ai, Mac, iOS). In-session mode is applied automatically: a standing `UserPromptSubmit` directive hook (`admin/hooks/prompt-optimiser-directive.sh`) injects context every turn telling the admin agent to restate each non-trivial prompt through this skill and act on the restatement, skipped for one-word confirmations, slash-commands, and direct continuations. Compliance is behavioural — the hook steers the agent, it cannot force the skill call. The hook also persists the exact injected `additionalContext` per turn to `$LOG_DIR/prompt-optimiser-directives/<session>/<epoch>-<pid>.txt` and records `len`, `sha256`, and the stored `file=` path in `$LOG_DIR/prompt-optimiser-directive.log`, so any past turn's full ~17 KB directive is recoverable and verifiable (the JSONL attachment row Claude Code keeps is truncated to ≤2.6 KB). `platform/scripts/prompt-optimiser-audit.sh` reconciles breadcrumbs against stored files per session (`injected=N stored=M`; `N!=M` is the leak signature, and any breadcrumb whose `file=` is missing is named). |
|
|
30
30
|
| `url-get` | Faithful page retrieval — fetches a server-rendered page, writes a verbatim markdown copy to an account-scoped reference file (no model in the path, so no copyright refusal), and returns the cleaned page text (capped) plus the file path. No summary and no subprocess: a caller that wants a summary invokes url-get from a delegated subagent. Use instead of WebFetch when a faithful copy is needed (e.g. ingesting your own published writing). |
|
|
31
31
|
|
|
32
32
|
### {{productName}} Plugins (user-selectable)
|
|
@@ -9,9 +9,9 @@ The share lives next to the rest of the brand. On a device that runs more than o
|
|
|
9
9
|
The installer runs the same Samba step on every supported footprint — Raspberry Pi, Hetzner Cloud server, and self-hosted Linux laptop. Four sub-steps emit `[install-invariant] samba-provision-<step>` markers in order:
|
|
10
10
|
|
|
11
11
|
1. **apt** — installs the `samba` package (skipped if `dpkg -s samba` already reports installed, so re-runs don't fight `unattended-upgrades` for the dpkg lock).
|
|
12
|
-
2. **conf** — writes `/etc/samba/smb.conf` with a
|
|
12
|
+
2. **conf** — writes `/etc/samba/smb.conf` with a `[global]` section plus a `[<brand>]` stanza pointing at the brand's install directory. The stanza is owned by the install owner (see below) and is marked `read only = no`, `browseable = yes`. The `[global]` bind posture depends on the host's interfaces (see "Interface binding" below); the marker records which posture fired — `bind=lan iface=<name>` or `bind=loopback-only`.
|
|
13
13
|
3. **user** — deferred at install time on a fresh Pi or Hetzner box because there is no PIN to hand to `smbpasswd` yet. The user is created the moment the operator sets a PIN in the admin UI (see "PIN rotation" below).
|
|
14
|
-
4. **units** — `systemctl enable --now smbd
|
|
14
|
+
4. **units** — `systemctl enable --now smbd` so the share is reachable as soon as the install finishes. `nmbd` (NetBIOS name service) is never enabled: nothing mounts the share by NetBIOS name, and on a public-facing host it answers on `:137`/`:138` as a DDoS-reflection vector.
|
|
15
15
|
|
|
16
16
|
macOS install is a no-op for this step — the installer logs `samba-provision skipped: platform=darwin` and returns. Mac operators do not get an SMB share against their laptop.
|
|
17
17
|
|
|
@@ -46,18 +46,31 @@ So:
|
|
|
46
46
|
- Rotating the PIN re-syncs the SMB password to the new value on the next set-pin request. Mounts using the old PIN start failing immediately; remount with the new PIN.
|
|
47
47
|
- If `set-pin` cannot read `~/.<brand>/.install-owner` (file missing or empty), it logs `[set-pin] smbpasswd sync failed owner=<unknown> rc=-1 reason=install-owner-file-missing` and skips the sync. The PIN still writes to the admin UI, but the SMB mount keeps refusing the new password until the install-owner file is restored.
|
|
48
48
|
|
|
49
|
-
##
|
|
49
|
+
## Interface binding
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
`bind interfaces only = yes` is always set, which makes the `interfaces` line an allow-list rather than a hint. Smbd binds only to the interfaces named there. The installer classifies the host's interfaces and writes one of two postures:
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
interfaces = lo <lan>
|
|
55
|
-
bind interfaces only = yes
|
|
56
|
-
```
|
|
53
|
+
- **Private LAN host (Raspberry Pi).** At least one non-loopback interface carries a private address — RFC1918 (`10/8`, `172.16/12`, `192.168/16`), CGNAT (`100.64/10`), or link-local (`169.254/16`). The installer binds loopback plus that interface (`wlan0` preferred, then `eth0`, then the first other private interface):
|
|
57
54
|
|
|
58
|
-
|
|
55
|
+
```
|
|
56
|
+
interfaces = lo <lan>
|
|
57
|
+
bind interfaces only = yes
|
|
58
|
+
```
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
The share is reachable on the LAN and nowhere else.
|
|
61
|
+
|
|
62
|
+
- **Public-only host (Hetzner Cloud).** The only non-loopback interface carries a routable public address (typically `eth0` with a `/32`, no private LAN). Binding `lo eth0` here would put smbd directly on the public internet, so the installer binds **loopback only**:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
interfaces = lo
|
|
66
|
+
bind interfaces only = yes
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The share is reachable solely from the box itself. Operators reach it by forwarding loopback over SSH — `ssh -L 4445:localhost:445 admin@<tunnel-host>` and then mounting `smb://localhost:4445` — or by joining the box to a private network and re-installing, which makes the private interface the chosen LAN bind.
|
|
70
|
+
|
|
71
|
+
If the device has no non-loopback IPv4 interface at all, the installer refuses to provision and exits — there is nothing safe to bind to. This is distinct from the public-only case, which still provisions a working (host-local) share.
|
|
72
|
+
|
|
73
|
+
This classification is the structural guarantee that SMB never leaves the box (loopback-only) or the LAN (LAN-only), even if upstream firewall rules are misconfigured. The Cloudflare tunnel that fronts the admin UI carries HTTPS only; it does not route SMB.
|
|
61
74
|
|
|
62
75
|
## Peer-brand lifecycle
|
|
63
76
|
|
|
@@ -74,7 +87,7 @@ The brand-stanza name is the only identifier the uninstaller matches on, so two
|
|
|
74
87
|
|
|
75
88
|
- **"Logon failure" on mount.** The PIN you typed does not match the current `smbpasswd` entry. Set a new PIN in the admin UI and remount. If the PIN was just rotated and the mount still fails, check `~/.<brand>/.install-owner` exists and is non-empty.
|
|
76
89
|
- **Share does not show up in Finder / network browser.** mDNS may not be routed on your network. Mount by LAN IP instead of `<hostname>.local`.
|
|
77
|
-
- **`smbd` not running after install.** Check the install log for the four `[install-invariant] samba-provision-<step>` markers. The `units` step running `systemctl enable --now smbd
|
|
78
|
-
- **Hetzner share not reachable from outside the box.** This is by design — see "
|
|
90
|
+
- **`smbd` not running after install.** Check the install log for the four `[install-invariant] samba-provision-<step>` markers. The `units` step running `systemctl enable --now smbd` is the last to fire; if it failed the marker prints `fail: <reason>`.
|
|
91
|
+
- **Hetzner share not reachable from outside the box.** This is by design — see "Interface binding" above. A public-only host binds loopback only; use SSH port forwarding (`ssh -L 4445:localhost:445 …`).
|
|
79
92
|
|
|
80
93
|
Also see [Deployment Guide](./deployment.md) for the surrounding install flow, and [Access Control](./access-control.md) for how the brand isolation extends from the admin UI to the SMB share.
|
|
@@ -82,6 +82,10 @@ The agent never responds in a group. `checkGroupAccess` ([`inbound/access-contro
|
|
|
82
82
|
|
|
83
83
|
Observation is fully preserved: group messages are stored in the ring buffer, recorded as activity, and persisted to the graph at the access-independent capture site, and remain visible via `whatsapp-conversations`, `whatsapp-group-info`, `list-groups`, and the `/whatsapp` reader. This is a Meta-policy constraint (automated messaging from an unofficial linked device is penalised, the same class as the reply-only DM posture); for agent-driven group messaging, Telegram is the recommended channel.
|
|
84
84
|
|
|
85
|
+
## Native channel reply surface (Task 751)
|
|
86
|
+
|
|
87
|
+
A native admin channel session runs a per-sender `whatsapp-channel` MCP server (`platform/services/whatsapp-channel/`) exposing two reply-only tools: `reply` (text) and `reply-document` (one or more files delivered as WhatsApp documents). `reply-document` POSTs to the gateway's `POST /wa-channel/reply-document`, which funnels through `sendWhatsAppDocument` — the single send core that owns account-dir path validation, the 100 MB ceiling, the canonical `[whatsapp:outbound] sent document …` log, and per-file reconciliation (`op=reply-document-reconciled` on delivery, `file-delivery-unreconciled` on a miss — no silent failure). A bare `SendUserFile` is also caught and delivered through the same core by a read-only JSONL follower on the session, so an attachment reaches the sender even when the agent skips the explicit tool. Both surfaces are reply-only: a file reply to a sender with no live conversation is refused. See [channels-whatsapp.md](references/channels-whatsapp.md) and `.docs/whatsapp-inbound-lifeline.md`.
|
|
88
|
+
|
|
85
89
|
## Live persistence
|
|
86
90
|
|
|
87
91
|
Every `messages.upsert` event (both `notify` and `append`, both `fromMe` directions) writes a `:Message:WhatsAppMessage` row to Neo4j attached to the sessionKey-keyed `:Conversation`. A single capture site at `platform/ui/app/lib/whatsapp/manager.ts` covers inbound, outbound (Baileys echoes agent-sent messages back through `messages.upsert` with `fromMe=true`), and owner-mirror — without touching `outbound/send.ts`. `messageId` namespace is `whatsapp-live:<waName>:<remoteJid>:<msg.key.id>` where `<waName>` is the Baileys credential dirname (e.g. `default`); distinct from the `:ConversationArchive` `:Section` chunks written by the source-agnostic `conversation-archive` skill (parent `:ConversationArchive` keyed on `conversationIdentity`, plus `:Section` children) — live and archive live in disjoint shapes. Persist failures are loud (`[whatsapp-persist] FAIL …`) and never block dispatch — silent loss is the worse failure mode.
|
|
@@ -10,7 +10,7 @@ WhatsApp connects via Baileys multi-account:
|
|
|
10
10
|
|
|
11
11
|
The agent has **no tool to initiate a WhatsApp message to an arbitrary number.** The `whatsapp-send` and `whatsapp-send-document` MCP tools were removed after an agent-initiated cold first-contact send (to a number with no prior conversation) tripped WhatsApp's anti-automation guard and restricted the operator's account. Initiating is intentionally unavailable, not merely gated — a soft approval gate already existed and did not prevent the incident.
|
|
12
12
|
|
|
13
|
-
What the agent **can** still do: reply within an existing chat.
|
|
13
|
+
What the agent **can** still do: reply within an existing chat. On a native admin channel session the whatsapp-channel server (`platform/services/whatsapp-channel/`) exposes two reply tools, both reply-only: `reply` (text) and `reply-document` (one or more files delivered as WhatsApp documents). `reply-document` POSTs to `POST /wa-channel/reply-document` → `sendWhatsAppDocument`, the one send core that owns path validation, the 100 MB ceiling, the canonical log, and per-file reconciliation (Task 751). A bare `SendUserFile` is also caught and delivered through the same core by a read-only JSONL follower on the session, so an attachment reaches the sender even if the agent skips the explicit tool. File delivery is refused for a sender with no live conversation — same reply-only posture as text. (The removed `whatsapp-send-document` route tool and the bridge path `SendUserFile` → `file-delivery-bridge.ts` → `POST /api/whatsapp/send-document` are the legacy webchat/email bridge surface, not the native WhatsApp channel.) Replying within your chats is what WhatsApp permits; starting new chats from a linked device is what it penalises.
|
|
14
14
|
|
|
15
15
|
## Observation-only in groups (Task 726)
|
|
16
16
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Reconciliation audit for the per-turn directive store (Task 746). For each
|
|
3
|
+
# session it emits `[prompt-optimiser-audit] session=<id> injected=<N> stored=<M>`
|
|
4
|
+
# (N = breadcrumb lines, M = content files) and names any breadcrumb whose
|
|
5
|
+
# file= path is missing on disk (the precise silent loss).
|
|
6
|
+
set -u
|
|
7
|
+
SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/prompt-optimiser-audit.sh"
|
|
8
|
+
[[ -x "$SCRIPT" ]] || { echo "FAIL: $SCRIPT not executable" >&2; exit 1; }
|
|
9
|
+
PASS=0; FAIL=0
|
|
10
|
+
|
|
11
|
+
LD=$(mktemp -d)
|
|
12
|
+
mkdir -p "$LD/prompt-optimiser-directives/sessA"
|
|
13
|
+
f1="$LD/prompt-optimiser-directives/sessA/100-11.txt"; printf 'one' > "$f1"
|
|
14
|
+
f2="$LD/prompt-optimiser-directives/sessA/101-12.txt"; printf 'two' > "$f2"
|
|
15
|
+
crumb="$LD/prompt-optimiser-directive.log"
|
|
16
|
+
printf 'T [prompt-optimiser-directive] injected len=3 sha256=x file=%s session=sessA\n' "$f1" >> "$crumb"
|
|
17
|
+
printf 'T [prompt-optimiser-directive] injected len=3 sha256=y file=%s session=sessA\n' "$f2" >> "$crumb"
|
|
18
|
+
|
|
19
|
+
# Healthy: 2 breadcrumbs, 2 files.
|
|
20
|
+
out=$(bash "$SCRIPT" --log-dir "$LD" sessA 2>/dev/null)
|
|
21
|
+
if printf '%s' "$out" | grep -q "\[prompt-optimiser-audit\] session=sessA injected=2 stored=2"; then
|
|
22
|
+
echo "PASS: healthy session reconciles injected==stored"; PASS=$((PASS+1))
|
|
23
|
+
else
|
|
24
|
+
echo "FAIL: healthy reconcile wrong: $out" >&2; FAIL=$((FAIL+1))
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
# Leak: delete one content file -> injected=2 stored=1 + a missing-file line.
|
|
28
|
+
rm -f "$f2"
|
|
29
|
+
out=$(bash "$SCRIPT" --log-dir "$LD" sessA 2>/dev/null)
|
|
30
|
+
if printf '%s' "$out" | grep -q "session=sessA injected=2 stored=1" \
|
|
31
|
+
&& printf '%s' "$out" | grep -q "missing-file=$f2"; then
|
|
32
|
+
echo "PASS: deleted file flagged as leak (injected>stored + missing-file)"; PASS=$((PASS+1))
|
|
33
|
+
else
|
|
34
|
+
echo "FAIL: leak not flagged: $out" >&2; FAIL=$((FAIL+1))
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
# No argument -> walks every session in the breadcrumb log.
|
|
38
|
+
printf 'T [prompt-optimiser-directive] injected len=1 sha256=z file=- session=sessB\n' >> "$crumb"
|
|
39
|
+
out=$(bash "$SCRIPT" --log-dir "$LD" 2>/dev/null)
|
|
40
|
+
if printf '%s' "$out" | grep -q "session=sessA " && printf '%s' "$out" | grep -q "session=sessB "; then
|
|
41
|
+
echo "PASS: no-arg mode walks all sessions"; PASS=$((PASS+1))
|
|
42
|
+
else
|
|
43
|
+
echo "FAIL: no-arg mode missed a session: $out" >&2; FAIL=$((FAIL+1))
|
|
44
|
+
fi
|
|
45
|
+
# An explicitly-named session with no breadcrumbs reconciles cleanly to 0==0 on
|
|
46
|
+
# a single line (regression: grep -c's exit-1 must not double-emit "0\n0").
|
|
47
|
+
out=$(bash "$SCRIPT" --log-dir "$LD" sessZ 2>/dev/null); rc=$?
|
|
48
|
+
nlines=$(printf '%s\n' "$out" | grep -c 'session=sessZ injected=')
|
|
49
|
+
if [[ "$rc" -eq 0 ]] \
|
|
50
|
+
&& printf '%s' "$out" | grep -Fqx "[prompt-optimiser-audit] session=sessZ injected=0 stored=0" \
|
|
51
|
+
&& [[ "$nlines" -eq 1 ]]; then
|
|
52
|
+
echo "PASS: empty named session -> single clean injected=0 stored=0, rc=0"; PASS=$((PASS+1))
|
|
53
|
+
else
|
|
54
|
+
echo "FAIL: empty named session mis-reported (rc=$rc lines=$nlines out=$out)" >&2; FAIL=$((FAIL+1))
|
|
55
|
+
fi
|
|
56
|
+
rm -rf "$LD"
|
|
57
|
+
|
|
58
|
+
echo "----"; echo "PASS=$PASS FAIL=$FAIL"
|
|
59
|
+
[[ "$FAIL" -eq 0 ]]
|
|
@@ -229,11 +229,82 @@ REMOTE
|
|
|
229
229
|
CHECK_RC=$?
|
|
230
230
|
set -e
|
|
231
231
|
|
|
232
|
-
if [[ $CHECK_RC -
|
|
233
|
-
echo "OK $NAME — CDP banner present" | tee -a "$SUMMARY_LOG"
|
|
234
|
-
else
|
|
232
|
+
if [[ $CHECK_RC -ne 0 ]]; then
|
|
235
233
|
echo "FAIL $NAME — CDP banner missing (rc=$CHECK_RC, see $SUMMARY_LOG)" | tee -a "$SUMMARY_LOG"
|
|
236
234
|
FAILED=1
|
|
235
|
+
continue
|
|
236
|
+
fi
|
|
237
|
+
|
|
238
|
+
# Step 3 — standing Samba socket audit (Task 755). Reconcile the *actual*
|
|
239
|
+
# listening sockets against the bind invariant, independent of any install
|
|
240
|
+
# log, so this catches a drifted or re-opened box as well as a fresh
|
|
241
|
+
# mis-provision. Two assertions:
|
|
242
|
+
# • smbd (TCP :445/:139) listens only on loopback or a private LAN address.
|
|
243
|
+
# A public or wildcard (0.0.0.0 / [::] / public IP) listener is internet
|
|
244
|
+
# exposure — the BSI-reported defect. Private LAN listeners are expected
|
|
245
|
+
# on a Pi (LAN+loopback); loopback-only is expected on a cloud box. The
|
|
246
|
+
# private-vs-public test mirrors isPrivateIPv4() in samba-provision.ts.
|
|
247
|
+
# • nmbd is not active (the NetBIOS DDoS-reflection service is never run).
|
|
248
|
+
# Matched by port, so no root is needed to read process names.
|
|
249
|
+
#
|
|
250
|
+
# Loaded via `read -d ''` rather than `$(cat <<…)`: the audit body contains a
|
|
251
|
+
# `case` and process substitution, whose parens break bash 3.2's command-
|
|
252
|
+
# substitution scanner (operators may run this script from a macOS bash 3.2).
|
|
253
|
+
# The body itself runs on the device's modern bash over ssh.
|
|
254
|
+
IFS= read -r -d '' SOCKET_AUDIT <<'REMOTE' || true
|
|
255
|
+
set -uo pipefail
|
|
256
|
+
|
|
257
|
+
is_private_v4() {
|
|
258
|
+
local ip="$1" a b _rest
|
|
259
|
+
IFS=. read -r a b _rest <<<"$ip"
|
|
260
|
+
[[ "$a" =~ ^[0-9]+$ && "$b" =~ ^[0-9]+$ ]] || return 1
|
|
261
|
+
(( a == 10 )) && return 0
|
|
262
|
+
(( a == 172 && b >= 16 && b <= 31 )) && return 0
|
|
263
|
+
(( a == 192 && b == 168 )) && return 0
|
|
264
|
+
(( a == 100 && b >= 64 && b <= 127 )) && return 0
|
|
265
|
+
(( a == 169 && b == 254 )) && return 0
|
|
266
|
+
return 1
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
BAD=""
|
|
270
|
+
while read -r localaddr; do
|
|
271
|
+
[[ -z "$localaddr" ]] && continue
|
|
272
|
+
host="${localaddr%:*}" # strip :port
|
|
273
|
+
host="${host#'['}"; host="${host%']'}" # strip IPv6 brackets (quoted: '[' is a glob class)
|
|
274
|
+
case "$host" in
|
|
275
|
+
127.*|::1) continue ;; # loopback — allowed
|
|
276
|
+
esac
|
|
277
|
+
if [[ "$host" == *.*.*.* ]] && is_private_v4 "$host"; then
|
|
278
|
+
continue # private IPv4 LAN (Pi) — allowed
|
|
279
|
+
fi
|
|
280
|
+
BAD+="$localaddr"$'\n'
|
|
281
|
+
done < <(ss -tuln 2>/dev/null | awk '{print $5}' | grep -E ':(445|139)$' || true)
|
|
282
|
+
|
|
283
|
+
NMBD=$(systemctl is-active nmbd 2>/dev/null || true)
|
|
284
|
+
FAIL=0
|
|
285
|
+
if [[ -n "${BAD//$'\n'/}" ]]; then
|
|
286
|
+
echo "smbd-exposed-listener:"
|
|
287
|
+
printf '%s' "$BAD"
|
|
288
|
+
FAIL=1
|
|
289
|
+
fi
|
|
290
|
+
if [[ "$NMBD" == "active" ]]; then
|
|
291
|
+
echo "nmbd-active"
|
|
292
|
+
FAIL=1
|
|
293
|
+
fi
|
|
294
|
+
[[ $FAIL -eq 0 ]] && echo "socket-audit-ok smbd=loopback-or-private nmbd=$NMBD"
|
|
295
|
+
exit $FAIL
|
|
296
|
+
REMOTE
|
|
297
|
+
|
|
298
|
+
set +e
|
|
299
|
+
run_ssh "$TARGET" "$PASS" "$SOCKET_AUDIT" >>"$SUMMARY_LOG" 2>&1
|
|
300
|
+
AUDIT_RC=$?
|
|
301
|
+
set -e
|
|
302
|
+
|
|
303
|
+
if [[ $AUDIT_RC -eq 0 ]]; then
|
|
304
|
+
echo "OK $NAME — CDP banner present; smbd loopback/LAN-only, nmbd inactive" | tee -a "$SUMMARY_LOG"
|
|
305
|
+
else
|
|
306
|
+
echo "FAIL $NAME — Samba socket audit failed (rc=$AUDIT_RC, see $SUMMARY_LOG)" | tee -a "$SUMMARY_LOG"
|
|
307
|
+
FAILED=1
|
|
237
308
|
fi
|
|
238
309
|
done
|
|
239
310
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# prompt-optimiser-audit.sh — reconcile per-turn directive-injection breadcrumbs
|
|
3
|
+
# against the stored content files (Task 746). A missing content write emits no
|
|
4
|
+
# error on its own, so this standing check is the leak detector: for each
|
|
5
|
+
# session it prints
|
|
6
|
+
# [prompt-optimiser-audit] session=<id> injected=<N> stored=<M>
|
|
7
|
+
# where N is the count of breadcrumb injection lines and M is the count of
|
|
8
|
+
# content files. N != M is the leak signature. Every breadcrumb whose file=
|
|
9
|
+
# path is absent on disk is named on its own `missing-file=` line.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# prompt-optimiser-audit.sh [--log-dir <dir>] [<session_id>]
|
|
13
|
+
# With no <session_id> it walks every session that appears in the breadcrumb
|
|
14
|
+
# log. --log-dir overrides the resolved account logs dir (used by tests); the
|
|
15
|
+
# default resolves the single account's logs dir, the same dir the hook writes.
|
|
16
|
+
#
|
|
17
|
+
# Exit codes:
|
|
18
|
+
# 0 every reported session reconciles (injected == stored)
|
|
19
|
+
# 1 at least one session leaks (injected != stored)
|
|
20
|
+
# 2 no log dir could be resolved
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
LOG_DIR=""
|
|
24
|
+
SESSION=""
|
|
25
|
+
while [ $# -gt 0 ]; do
|
|
26
|
+
case "$1" in
|
|
27
|
+
--log-dir) LOG_DIR="$2"; shift 2 ;;
|
|
28
|
+
*) SESSION="$1"; shift ;;
|
|
29
|
+
esac
|
|
30
|
+
done
|
|
31
|
+
|
|
32
|
+
# Resolve the account logs dir when not overridden: {installDir}/data/accounts/*/logs.
|
|
33
|
+
if [ -z "$LOG_DIR" ]; then
|
|
34
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
35
|
+
PLATFORM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
36
|
+
INSTALL_DIR="$(dirname "$PLATFORM_ROOT")"
|
|
37
|
+
for d in "$INSTALL_DIR"/data/accounts/*/logs; do
|
|
38
|
+
[ -d "$d" ] && LOG_DIR="$d" && break
|
|
39
|
+
done
|
|
40
|
+
fi
|
|
41
|
+
if [ -z "$LOG_DIR" ] || [ ! -d "$LOG_DIR" ]; then
|
|
42
|
+
echo "[prompt-optimiser-audit] no-log-dir resolved=${LOG_DIR:-<none>}" >&2
|
|
43
|
+
exit 2
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
CRUMB="$LOG_DIR/prompt-optimiser-directive.log"
|
|
47
|
+
STORE_ROOT="$LOG_DIR/prompt-optimiser-directives"
|
|
48
|
+
|
|
49
|
+
# Sessions to report: the explicit arg, else every distinct non-"?" session in
|
|
50
|
+
# the breadcrumb log (the breadcrumb's session= is always the last field).
|
|
51
|
+
sessions() {
|
|
52
|
+
if [ -n "$SESSION" ]; then
|
|
53
|
+
echo "$SESSION"; return
|
|
54
|
+
fi
|
|
55
|
+
[ -f "$CRUMB" ] || return
|
|
56
|
+
grep -o 'session=[^ ]*$' "$CRUMB" 2>/dev/null | cut -d= -f2 | grep -v '^?$' | sort -u
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
rc=0
|
|
60
|
+
while IFS= read -r sid; do
|
|
61
|
+
[ -n "$sid" ] || continue
|
|
62
|
+
injected=0
|
|
63
|
+
# grep -c prints a count AND exits 1 on zero matches, so a `|| echo 0` would
|
|
64
|
+
# double-emit "0\n0"; capture the count and default an empty/unreadable file.
|
|
65
|
+
if [ -f "$CRUMB" ]; then
|
|
66
|
+
injected=$(grep -c "session=$sid\$" "$CRUMB" 2>/dev/null)
|
|
67
|
+
injected=${injected:-0}
|
|
68
|
+
fi
|
|
69
|
+
stored=0
|
|
70
|
+
[ -d "$STORE_ROOT/$sid" ] && stored=$(find "$STORE_ROOT/$sid" -maxdepth 1 -name '*.txt' 2>/dev/null | wc -l | tr -d ' ')
|
|
71
|
+
echo "[prompt-optimiser-audit] session=$sid injected=$injected stored=$stored"
|
|
72
|
+
[ "$injected" != "$stored" ] && rc=1
|
|
73
|
+
# Name every breadcrumb whose stored file is absent.
|
|
74
|
+
if [ -f "$CRUMB" ]; then
|
|
75
|
+
grep "session=$sid\$" "$CRUMB" 2>/dev/null | grep -o 'file=[^ ]*' | cut -d= -f2- | while IFS= read -r f; do
|
|
76
|
+
[ "$f" = "-" ] && continue
|
|
77
|
+
[ -e "$f" ] || echo "[prompt-optimiser-audit] session=$sid missing-file=$f"
|
|
78
|
+
done
|
|
79
|
+
fi
|
|
80
|
+
done < <(sessions)
|
|
81
|
+
|
|
82
|
+
exit "$rc"
|