@rubytech/create-sitedesk-code 0.1.518 → 0.1.519
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__/edge-bind-template-wiring.test.js +37 -0
- package/dist/__tests__/samba-provision.test.js +103 -1
- package/dist/index.js +19 -4
- package/dist/samba-provision.js +72 -0
- package/package.json +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +69 -19
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +6 -0
- package/payload/platform/plugins/docs/references/admin-ui.md +43 -5
- package/payload/platform/plugins/docs/references/samba.md +2 -0
- package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +7 -3
- package/payload/platform/scripts/check-no-esm-require.mjs +5 -3
- package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.js +5 -3
- package/payload/platform/services/claude-session-manager/dist/webchat-channel-mcp.js.map +1 -1
- package/payload/platform/services/telegram-channel/dist/instructions.d.ts.map +1 -1
- package/payload/platform/services/telegram-channel/dist/instructions.js +13 -10
- package/payload/platform/services/telegram-channel/dist/instructions.js.map +1 -1
- package/payload/platform/services/webchat-channel/dist/instructions.d.ts +6 -2
- package/payload/platform/services/webchat-channel/dist/instructions.d.ts.map +1 -1
- package/payload/platform/services/webchat-channel/dist/instructions.js +66 -32
- package/payload/platform/services/webchat-channel/dist/instructions.js.map +1 -1
- package/payload/platform/services/webchat-channel/dist/notification.d.ts +2 -1
- package/payload/platform/services/webchat-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/webchat-channel/dist/notification.js +11 -4
- package/payload/platform/services/webchat-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/webchat-channel/dist/server.d.ts.map +1 -1
- package/payload/platform/services/webchat-channel/dist/server.js +30 -14
- package/payload/platform/services/webchat-channel/dist/server.js.map +1 -1
- package/payload/platform/services/webchat-channel/dist/turn-follow.d.ts.map +1 -1
- package/payload/platform/services/webchat-channel/dist/turn-follow.js +11 -5
- package/payload/platform/services/webchat-channel/dist/turn-follow.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +6 -3
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.js +9 -5
- package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/server.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/server.js +6 -1
- package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/targets.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/targets.js +8 -2
- package/payload/platform/services/whatsapp-channel/dist/targets.js.map +1 -1
- package/payload/platform/templates/systemd/edge.service.template +1 -1
- package/payload/server/chunk-PXNY2TAP.js +1980 -0
- package/payload/server/chunk-TIIWMF5G.js +4837 -0
- package/payload/server/{chunk-S6HYTIU3.js → chunk-UX2FRNO7.js} +72 -2031
- package/payload/server/manager-B6OFOXSW.js +61 -0
- package/payload/server/maxy-edge.js +5 -3
- package/payload/server/package.json +2 -1
- package/payload/server/public/activity.html +5 -5
- package/payload/server/public/agents.html +4 -4
- package/payload/server/public/assets/{AdminLoginScreens-CDpNkwkf.js → AdminLoginScreens-BZPbCk8g.js} +1 -1
- package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-BZPbCk8g.js.gz +0 -0
- package/payload/server/public/assets/AdminShell-Bpo_wgxm.js +2 -0
- package/payload/server/public/assets/AdminShell-Bpo_wgxm.js.br +0 -0
- package/payload/server/public/assets/AdminShell-Bpo_wgxm.js.gz +0 -0
- package/payload/server/public/assets/{activity-BE9gv5xL.js → activity-DOsfKzT3.js} +1 -1
- package/payload/server/public/assets/activity-DOsfKzT3.js.br +0 -0
- package/payload/server/public/assets/activity-DOsfKzT3.js.gz +0 -0
- package/payload/server/public/assets/admin-COgD1z5a.js +1 -0
- package/payload/server/public/assets/admin-COgD1z5a.js.br +0 -0
- package/payload/server/public/assets/admin-COgD1z5a.js.gz +0 -0
- package/payload/server/public/assets/{agents-B-x0Ai2v.js → agents-DqDnZTH_.js} +1 -1
- package/payload/server/public/assets/agents-DqDnZTH_.js.br +0 -0
- package/payload/server/public/assets/agents-DqDnZTH_.js.gz +0 -0
- package/payload/server/public/assets/{browser-ZEQUkPfs.js → browser-FL17Dfp0.js} +1 -1
- package/payload/server/public/assets/browser-FL17Dfp0.js.br +0 -0
- package/payload/server/public/assets/browser-FL17Dfp0.js.gz +0 -0
- package/payload/server/public/assets/calendar-CLfLvsnV.js +1 -0
- package/payload/server/public/assets/calendar-CLfLvsnV.js.br +0 -0
- package/payload/server/public/assets/calendar-CLfLvsnV.js.gz +0 -0
- package/payload/server/public/assets/chat-DYq4FxMG.js +1 -0
- package/payload/server/public/assets/chat-DYq4FxMG.js.br +0 -0
- package/payload/server/public/assets/chat-DYq4FxMG.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-DWt5cdUo.js +1 -0
- package/payload/server/public/assets/chevron-right-B9suVrYy.js +1 -0
- package/payload/server/public/assets/chevron-right-B9suVrYy.js.br +0 -0
- package/payload/server/public/assets/clock-DW6qVXtv.js +1 -0
- package/payload/server/public/assets/clock-DW6qVXtv.js.br +0 -0
- package/payload/server/public/assets/clock-DW6qVXtv.js.gz +0 -0
- package/payload/server/public/assets/data-C8rbOgds.js +1 -0
- package/payload/server/public/assets/data-C8rbOgds.js.br +0 -0
- package/payload/server/public/assets/data-C8rbOgds.js.gz +0 -0
- package/payload/server/public/assets/{file-text-BTV6EdBv.js → file-text-DNm0AzUz.js} +1 -1
- package/payload/server/public/assets/file-text-DNm0AzUz.js.br +0 -0
- package/payload/server/public/assets/file-text-DNm0AzUz.js.gz +0 -0
- package/payload/server/public/assets/{graph-hD_gPbbL.js → graph-fAvmlVzh.js} +3 -3
- package/payload/server/public/assets/graph-fAvmlVzh.js.br +0 -0
- package/payload/server/public/assets/graph-fAvmlVzh.js.gz +0 -0
- package/payload/server/public/assets/{graph-labels-CY1T19PY.js → graph-labels-CwdkCxfs.js} +1 -1
- package/payload/server/public/assets/graph-labels-CwdkCxfs.js.br +0 -0
- package/payload/server/public/assets/graph-labels-CwdkCxfs.js.gz +0 -0
- package/payload/server/public/assets/{maximize-2-BFV9VKHO.js → maximize-2-B6tAPhC2.js} +1 -1
- package/payload/server/public/assets/maximize-2-B6tAPhC2.js.br +0 -0
- package/payload/server/public/assets/maximize-2-B6tAPhC2.js.gz +0 -0
- package/payload/server/public/assets/operator-CTKvBVjk.js +1 -0
- package/payload/server/public/assets/operator-CTKvBVjk.js.br +0 -0
- package/payload/server/public/assets/operator-CTKvBVjk.js.gz +0 -0
- package/payload/server/public/assets/page-BWoNW3sa.js +32 -0
- package/payload/server/public/assets/page-BWoNW3sa.js.br +0 -0
- package/payload/server/public/assets/page-BWoNW3sa.js.gz +0 -0
- package/payload/server/public/assets/page-O5HkHQw4.js +1 -0
- package/payload/server/public/assets/page-O5HkHQw4.js.br +0 -0
- package/payload/server/public/assets/page-O5HkHQw4.js.gz +0 -0
- package/payload/server/public/assets/{public-D6Tnj5LU.js → public-BF1rnVPa.js} +1 -1
- package/payload/server/public/assets/public-BF1rnVPa.js.br +0 -0
- package/payload/server/public/assets/public-BF1rnVPa.js.gz +0 -0
- package/payload/server/public/assets/{rotate-ccw-C-RfQUza.js → rotate-ccw-B8TqfCy4.js} +1 -1
- package/payload/server/public/assets/rotate-ccw-B8TqfCy4.js.br +0 -0
- package/payload/server/public/assets/rotate-ccw-B8TqfCy4.js.gz +0 -0
- package/payload/server/public/assets/{routines-NIMElUzV.js → routines-4R7BLZyH.js} +1 -1
- package/payload/server/public/assets/routines-4R7BLZyH.js.br +0 -0
- package/payload/server/public/assets/routines-4R7BLZyH.js.gz +0 -0
- package/payload/server/public/assets/{skills-N0ZlHosx.js → skills-CQ9Qs2tg.js} +1 -1
- package/payload/server/public/assets/skills-CQ9Qs2tg.js.br +0 -0
- package/payload/server/public/assets/skills-CQ9Qs2tg.js.gz +0 -0
- package/payload/server/public/assets/tasks-DePyOrRk.js +1 -0
- package/payload/server/public/assets/tasks-DePyOrRk.js.br +0 -0
- package/payload/server/public/assets/tasks-DePyOrRk.js.gz +0 -0
- package/payload/server/public/assets/{time-entry-format-jQxlRPoI.js → time-entry-format-D9gtnG01.js} +1 -1
- package/payload/server/public/assets/time-entry-format-D9gtnG01.js.br +0 -0
- package/payload/server/public/assets/time-entry-format-D9gtnG01.js.gz +0 -0
- package/payload/server/public/assets/{triangle-alert-wUS4rpzv.js → triangle-alert-DAtm2mLL.js} +1 -1
- package/payload/server/public/assets/triangle-alert-DAtm2mLL.js.br +0 -0
- package/payload/server/public/assets/triangle-alert-DAtm2mLL.js.gz +0 -0
- package/payload/server/public/assets/{useCopyFeedback-BRix_wIl.js → useCopyFeedback-uMJT-p6i.js} +1 -1
- package/payload/server/public/assets/useCopyFeedback-uMJT-p6i.js.br +0 -0
- package/payload/server/public/assets/useCopyFeedback-uMJT-p6i.js.gz +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js +14 -0
- package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js.br +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-BufCPDDH.js.gz +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css +1 -0
- package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css.br +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-iCzsV96x.css.gz +0 -0
- package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js +2 -0
- package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-BFrPC-s7.js.gz +0 -0
- package/payload/server/public/assets/{wrench-sDivAjhf.js → wrench-CYgaN7K9.js} +1 -1
- package/payload/server/public/assets/wrench-CYgaN7K9.js.br +1 -0
- package/payload/server/public/assets/wrench-CYgaN7K9.js.gz +0 -0
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/calendar.html +7 -7
- package/payload/server/public/chat.html +13 -13
- package/payload/server/public/data.html +11 -11
- package/payload/server/public/graph.html +9 -9
- package/payload/server/public/index.html +14 -14
- package/payload/server/public/operator.html +14 -14
- package/payload/server/public/public.html +13 -13
- package/payload/server/public/routines.html +6 -6
- package/payload/server/public/skills.html +5 -5
- package/payload/server/public/tasks.html +6 -6
- package/payload/server/server.js +6100 -9940
- package/payload/server/public/assets/AdminLoginScreens-CDpNkwkf.js.br +0 -0
- package/payload/server/public/assets/AdminLoginScreens-CDpNkwkf.js.gz +0 -0
- package/payload/server/public/assets/AdminShell-TSa8KgID.js +0 -2
- package/payload/server/public/assets/AdminShell-TSa8KgID.js.br +0 -0
- package/payload/server/public/assets/AdminShell-TSa8KgID.js.gz +0 -0
- package/payload/server/public/assets/activity-BE9gv5xL.js.br +0 -0
- package/payload/server/public/assets/activity-BE9gv5xL.js.gz +0 -0
- package/payload/server/public/assets/admin-Cb0BZriv.js +0 -1
- package/payload/server/public/assets/admin-Cb0BZriv.js.br +0 -0
- package/payload/server/public/assets/admin-Cb0BZriv.js.gz +0 -0
- package/payload/server/public/assets/agents-B-x0Ai2v.js.br +0 -0
- package/payload/server/public/assets/agents-B-x0Ai2v.js.gz +0 -0
- package/payload/server/public/assets/browser-ZEQUkPfs.js.br +0 -0
- package/payload/server/public/assets/browser-ZEQUkPfs.js.gz +0 -0
- package/payload/server/public/assets/calendar-DsRxrW0G.js +0 -1
- package/payload/server/public/assets/calendar-DsRxrW0G.js.br +0 -0
- package/payload/server/public/assets/calendar-DsRxrW0G.js.gz +0 -0
- package/payload/server/public/assets/chat-BS9B9lDo.js +0 -1
- package/payload/server/public/assets/chat-BS9B9lDo.js.br +0 -1
- package/payload/server/public/assets/chat-BS9B9lDo.js.gz +0 -0
- package/payload/server/public/assets/chevron-left-DLtGuH6K.js +0 -1
- package/payload/server/public/assets/chevron-right-CY_trg5L.js +0 -1
- package/payload/server/public/assets/chevron-right-CY_trg5L.js.br +0 -0
- package/payload/server/public/assets/clock-oM5driJm.js +0 -1
- package/payload/server/public/assets/clock-oM5driJm.js.br +0 -0
- package/payload/server/public/assets/clock-oM5driJm.js.gz +0 -0
- package/payload/server/public/assets/data-Cio6PO0F.js +0 -1
- package/payload/server/public/assets/data-Cio6PO0F.js.br +0 -2
- package/payload/server/public/assets/data-Cio6PO0F.js.gz +0 -0
- package/payload/server/public/assets/file-text-BTV6EdBv.js.br +0 -0
- package/payload/server/public/assets/file-text-BTV6EdBv.js.gz +0 -0
- package/payload/server/public/assets/graph-hD_gPbbL.js.br +0 -0
- package/payload/server/public/assets/graph-hD_gPbbL.js.gz +0 -0
- package/payload/server/public/assets/graph-labels-CY1T19PY.js.br +0 -0
- package/payload/server/public/assets/graph-labels-CY1T19PY.js.gz +0 -0
- package/payload/server/public/assets/maximize-2-BFV9VKHO.js.br +0 -0
- package/payload/server/public/assets/maximize-2-BFV9VKHO.js.gz +0 -0
- package/payload/server/public/assets/operator-Dxze8CwV.js +0 -1
- package/payload/server/public/assets/operator-Dxze8CwV.js.br +0 -0
- package/payload/server/public/assets/operator-Dxze8CwV.js.gz +0 -0
- package/payload/server/public/assets/page-Bi86QF6_.js +0 -32
- package/payload/server/public/assets/page-Bi86QF6_.js.br +0 -0
- package/payload/server/public/assets/page-Bi86QF6_.js.gz +0 -0
- package/payload/server/public/assets/page-BnXly9aN.js +0 -1
- package/payload/server/public/assets/page-BnXly9aN.js.br +0 -0
- package/payload/server/public/assets/page-BnXly9aN.js.gz +0 -0
- package/payload/server/public/assets/public-D6Tnj5LU.js.br +0 -0
- package/payload/server/public/assets/public-D6Tnj5LU.js.gz +0 -0
- package/payload/server/public/assets/rotate-ccw-C-RfQUza.js.br +0 -0
- package/payload/server/public/assets/rotate-ccw-C-RfQUza.js.gz +0 -0
- package/payload/server/public/assets/routines-NIMElUzV.js.br +0 -0
- package/payload/server/public/assets/routines-NIMElUzV.js.gz +0 -0
- package/payload/server/public/assets/skills-N0ZlHosx.js.br +0 -0
- package/payload/server/public/assets/skills-N0ZlHosx.js.gz +0 -0
- package/payload/server/public/assets/tasks-BsB_0duv.js +0 -1
- package/payload/server/public/assets/tasks-BsB_0duv.js.br +0 -0
- package/payload/server/public/assets/tasks-BsB_0duv.js.gz +0 -0
- package/payload/server/public/assets/time-entry-format-jQxlRPoI.js.br +0 -0
- package/payload/server/public/assets/time-entry-format-jQxlRPoI.js.gz +0 -0
- package/payload/server/public/assets/triangle-alert-wUS4rpzv.js.br +0 -0
- package/payload/server/public/assets/triangle-alert-wUS4rpzv.js.gz +0 -0
- package/payload/server/public/assets/useCopyFeedback-BRix_wIl.js.br +0 -0
- package/payload/server/public/assets/useCopyFeedback-BRix_wIl.js.gz +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-DWMWxvYo.js +0 -14
- package/payload/server/public/assets/useSubAccountSwitcher-DWMWxvYo.js.br +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-DWMWxvYo.js.gz +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-UQQDcj0B.css +0 -1
- package/payload/server/public/assets/useSubAccountSwitcher-UQQDcj0B.css.br +0 -0
- package/payload/server/public/assets/useSubAccountSwitcher-UQQDcj0B.css.gz +0 -0
- package/payload/server/public/assets/useVoiceRecorder-1TAfCP2k.js +0 -2
- package/payload/server/public/assets/useVoiceRecorder-1TAfCP2k.js.br +0 -0
- package/payload/server/public/assets/useVoiceRecorder-1TAfCP2k.js.gz +0 -0
- package/payload/server/public/assets/wrench-sDivAjhf.js.br +0 -0
- package/payload/server/public/assets/wrench-sDivAjhf.js.gz +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Nothing else in the repo ties the edge unit template's placeholders to the
|
|
2
|
+
// substitutions in index.ts, so either half can be edited alone and the whole
|
|
3
|
+
// suite stays green. Two silent failures this closes:
|
|
4
|
+
//
|
|
5
|
+
// - substitution added, template left at a literal value → the replace is a
|
|
6
|
+
// no-op, the unit still binds 0.0.0.0, and the Task 2082 security fix ships
|
|
7
|
+
// inert with every pickEdgeBindDecision test passing;
|
|
8
|
+
// - template placeholder added, substitution forgotten → the unit ships a
|
|
9
|
+
// literal `Environment=EDGE_HOSTNAME=__EDGE_HOSTNAME__`, which edge.ts's
|
|
10
|
+
// `process.env.EDGE_HOSTNAME ?? '0.0.0.0'` does NOT catch, because the
|
|
11
|
+
// variable is present, just not an address, so server.listen() gets a
|
|
12
|
+
// non-address and the edge restart-loops.
|
|
13
|
+
//
|
|
14
|
+
// Both files are read from disk at runtime, the same pattern as
|
|
15
|
+
// macos-darwin-branch.test.ts and base-toolchain-deps.test.ts.
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { dirname, resolve } from "node:path";
|
|
21
|
+
// dist/__tests__/edge-bind-template-wiring.test.js → ../../src/index.ts
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const INDEX_TS = resolve(here, "../../src/index.ts");
|
|
24
|
+
// dist/__tests__ → up four is the maxy-code root that holds platform/.
|
|
25
|
+
const EDGE_TEMPLATE = resolve(here, "../../../../platform/templates/systemd/edge.service.template");
|
|
26
|
+
const SRC = readFileSync(INDEX_TS, "utf-8");
|
|
27
|
+
const TEMPLATE = readFileSync(EDGE_TEMPLATE, "utf-8");
|
|
28
|
+
test("edge template: EDGE_HOSTNAME is a placeholder, not a hardcoded address", () => {
|
|
29
|
+
assert.match(TEMPLATE, /^Environment=EDGE_HOSTNAME=__EDGE_HOSTNAME__$/m, "edge.service.template must take EDGE_HOSTNAME from the installer's bind decision (Task 2082), not hardcode it");
|
|
30
|
+
});
|
|
31
|
+
test("edge template: every placeholder has a matching substitution in index.ts", () => {
|
|
32
|
+
const placeholders = [...new Set(TEMPLATE.match(/__[A-Z0-9_]+__/g) ?? [])].sort();
|
|
33
|
+
assert.ok(placeholders.includes("__EDGE_HOSTNAME__"), "template must carry __EDGE_HOSTNAME__");
|
|
34
|
+
for (const p of placeholders) {
|
|
35
|
+
assert.ok(SRC.includes(`.replace(/${p}/g,`), `${p} appears in edge.service.template but index.ts has no .replace(/${p}/g, ...) — the rendered unit would ship the literal placeholder`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// style and runs under `node --test dist/__tests__/*.test.js` after build.
|
|
7
7
|
import test from "node:test";
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
|
-
import { renderBrandStanza, renderGlobalSection, renderFullSmbConf, isPrivateIPv4, pickBindDecision, mergeSmbConf, removeBrandStanza, hasAnyBrandStanza, formatSambaMarker, SAMBA_STEPS, SAMBA_ENABLE_UNITS, } from "../samba-provision.js";
|
|
9
|
+
import { renderBrandStanza, renderGlobalSection, renderFullSmbConf, isPrivateIPv4, pickBindDecision, pickEdgeBindDecision, formatEdgeBindDecision, mergeSmbConf, removeBrandStanza, hasAnyBrandStanza, formatSambaMarker, SAMBA_STEPS, SAMBA_ENABLE_UNITS, } from "../samba-provision.js";
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
// Fixtures
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
@@ -49,6 +49,42 @@ const MIXED_PUBLIC_AND_PRIVATE = {
|
|
|
49
49
|
eth0: [{ address: "178.105.251.82", netmask: "255.255.255.255", family: "IPv4", mac: "96:00:00:00:00:02", internal: false, cidr: "178.105.251.82/32" }],
|
|
50
50
|
wg0: [{ address: "10.8.0.3", netmask: "255.255.255.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: false, cidr: "10.8.0.3/24" }],
|
|
51
51
|
};
|
|
52
|
+
// Hetzner Cloud shape: public eth0 plus a provider private network on ens10.
|
|
53
|
+
// pickBindDecision calls this `lan`; the edge must still bind loopback.
|
|
54
|
+
const HETZNER_CLOUD_PUBLIC_PLUS_PRIVNET = {
|
|
55
|
+
lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
|
|
56
|
+
eth0: [{ address: "167.233.21.246", netmask: "255.255.255.255", family: "IPv4", mac: "96:00:00:00:00:03", internal: false, cidr: "167.233.21.246/32" }],
|
|
57
|
+
ens10: [{ address: "10.0.0.2", netmask: "255.255.0.0", family: "IPv4", mac: "96:00:00:00:00:04", internal: false, cidr: "10.0.0.2/16" }],
|
|
58
|
+
};
|
|
59
|
+
// Private wlan0 plus a public address elsewhere. Reaches pickBindDecision's
|
|
60
|
+
// wlan0 early return, which MIXED_PUBLIC_AND_PRIVATE never does because its
|
|
61
|
+
// private address is on wg0.
|
|
62
|
+
const PRIVATE_WLAN0_PLUS_PUBLIC = {
|
|
63
|
+
lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
|
|
64
|
+
wlan0: [{ address: "192.168.1.50", netmask: "255.255.255.0", family: "IPv4", mac: "aa:bb:cc:dd:ee:ff", internal: false, cidr: "192.168.1.50/24" }],
|
|
65
|
+
eth0: [{ address: "203.0.113.7", netmask: "255.255.255.255", family: "IPv4", mac: "96:00:00:00:00:05", internal: false, cidr: "203.0.113.7/32" }],
|
|
66
|
+
};
|
|
67
|
+
// Private eth0 plus a public address elsewhere. Reaches the eth0 early return.
|
|
68
|
+
const PRIVATE_ETH0_PLUS_PUBLIC = {
|
|
69
|
+
lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
|
|
70
|
+
eth0: [{ address: "10.0.0.5", netmask: "255.255.255.0", family: "IPv4", mac: "11:11:11:11:11:11", internal: false, cidr: "10.0.0.5/24" }],
|
|
71
|
+
ens5: [{ address: "198.51.100.9", netmask: "255.255.255.255", family: "IPv4", mac: "96:00:00:00:00:06", internal: false, cidr: "198.51.100.9/32" }],
|
|
72
|
+
};
|
|
73
|
+
// One interface carrying both a public and a private IPv4. No other fixture in
|
|
74
|
+
// this file has a multi-address interface.
|
|
75
|
+
const ONE_IFACE_PUBLIC_AND_PRIVATE = {
|
|
76
|
+
lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
|
|
77
|
+
eth0: [
|
|
78
|
+
{ address: "192.0.2.44", netmask: "255.255.255.255", family: "IPv4", mac: "96:00:00:00:00:07", internal: false, cidr: "192.0.2.44/32" },
|
|
79
|
+
{ address: "172.16.4.9", netmask: "255.255.0.0", family: "IPv4", mac: "96:00:00:00:00:07", internal: false, cidr: "172.16.4.9/16" },
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
// CGNAT-only host. isPrivateIPv4 counts 100.64/10 as private, so this binds
|
|
83
|
+
// 0.0.0.0. Pinned so the outcome is declared rather than incidental.
|
|
84
|
+
const CGNAT_ONLY = {
|
|
85
|
+
lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
|
|
86
|
+
eth0: [{ address: "100.100.3.14", netmask: "255.192.0.0", family: "IPv4", mac: "96:00:00:00:00:08", internal: false, cidr: "100.100.3.14/10" }],
|
|
87
|
+
};
|
|
52
88
|
// ---------------------------------------------------------------------------
|
|
53
89
|
// isPrivateIPv4 — RFC1918 / CGNAT / link-local classification
|
|
54
90
|
// ---------------------------------------------------------------------------
|
|
@@ -107,6 +143,72 @@ test("pickBindDecision: none when the only non-loopback iface has no IPv4", () =
|
|
|
107
143
|
assert.deepEqual(pickBindDecision(PI_IPV6_ONLY_LAN), { kind: "none" });
|
|
108
144
|
});
|
|
109
145
|
// ---------------------------------------------------------------------------
|
|
146
|
+
// pickEdgeBindDecision — 0.0.0.0 only when private exists and public does not
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
test("pickEdgeBindDecision: private-only hosts keep the public bind", () => {
|
|
149
|
+
for (const [name, ifaces] of [
|
|
150
|
+
["PI_WLAN0_ONLY", PI_WLAN0_ONLY],
|
|
151
|
+
["PI_BOTH_IFACES", PI_BOTH_IFACES],
|
|
152
|
+
["PI_ETH0_ONLY", PI_ETH0_ONLY],
|
|
153
|
+
["PI_USB0_FALLBACK", PI_USB0_FALLBACK],
|
|
154
|
+
["CGNAT_ONLY", CGNAT_ONLY],
|
|
155
|
+
]) {
|
|
156
|
+
assert.equal(pickEdgeBindDecision(ifaces).hostname, "0.0.0.0", name);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
// The fail-open this task exists to close: pickBindDecision calls every one of
|
|
160
|
+
// these `lan`, so keeping its verdict verbatim would leave the edge public on a
|
|
161
|
+
// cloud box that happens to carry a provider private network.
|
|
162
|
+
test("pickEdgeBindDecision: a public IPv4 forces loopback even when a private one exists", () => {
|
|
163
|
+
for (const [name, ifaces] of [
|
|
164
|
+
["MIXED_PUBLIC_AND_PRIVATE", MIXED_PUBLIC_AND_PRIVATE],
|
|
165
|
+
["HETZNER_CLOUD_PUBLIC_PLUS_PRIVNET", HETZNER_CLOUD_PUBLIC_PLUS_PRIVNET],
|
|
166
|
+
["PRIVATE_WLAN0_PLUS_PUBLIC", PRIVATE_WLAN0_PLUS_PUBLIC],
|
|
167
|
+
["PRIVATE_ETH0_PLUS_PUBLIC", PRIVATE_ETH0_PLUS_PUBLIC],
|
|
168
|
+
["ONE_IFACE_PUBLIC_AND_PRIVATE", ONE_IFACE_PUBLIC_AND_PRIVATE],
|
|
169
|
+
]) {
|
|
170
|
+
const d = pickEdgeBindDecision(ifaces);
|
|
171
|
+
assert.equal(d.hostname, "127.0.0.1", name);
|
|
172
|
+
assert.equal(d.kind, "lan", `${name} still classifies as lan for smbd`);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
test("pickEdgeBindDecision: public-only host binds loopback", () => {
|
|
176
|
+
const d = pickEdgeBindDecision(HETZNER_PUBLIC_ETH0_ONLY);
|
|
177
|
+
assert.equal(d.hostname, "127.0.0.1");
|
|
178
|
+
assert.equal(d.kind, "loopback-only");
|
|
179
|
+
});
|
|
180
|
+
// Unlike the Samba call site, which throws on `none`, the edge binds loopback
|
|
181
|
+
// and lets the install finish: a loopback edge still serves the tunnel.
|
|
182
|
+
test("pickEdgeBindDecision: no non-loopback IPv4 binds loopback rather than throwing", () => {
|
|
183
|
+
for (const ifaces of [PI_LO_ONLY, PI_IPV6_ONLY_LAN]) {
|
|
184
|
+
const d = pickEdgeBindDecision(ifaces);
|
|
185
|
+
assert.equal(d.hostname, "127.0.0.1");
|
|
186
|
+
assert.equal(d.kind, "none");
|
|
187
|
+
assert.deepEqual(d.privateAddresses, []);
|
|
188
|
+
assert.deepEqual(d.publicAddresses, []);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
test("pickEdgeBindDecision: reports the interface and addresses it decided from", () => {
|
|
192
|
+
assert.deepEqual(pickEdgeBindDecision(MIXED_PUBLIC_AND_PRIVATE), {
|
|
193
|
+
hostname: "127.0.0.1",
|
|
194
|
+
kind: "lan",
|
|
195
|
+
iface: "wg0",
|
|
196
|
+
publicAddresses: ["178.105.251.82"],
|
|
197
|
+
privateAddresses: ["10.8.0.3"],
|
|
198
|
+
});
|
|
199
|
+
assert.deepEqual(pickEdgeBindDecision(PI_WLAN0_ONLY), {
|
|
200
|
+
hostname: "0.0.0.0",
|
|
201
|
+
kind: "lan",
|
|
202
|
+
iface: "wlan0",
|
|
203
|
+
publicAddresses: [],
|
|
204
|
+
privateAddresses: ["192.168.1.50"],
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
test("formatEdgeBindDecision: carries both grep tokens and never leaves an empty field", () => {
|
|
208
|
+
assert.equal(formatEdgeBindDecision(pickEdgeBindDecision(PI_WLAN0_ONLY)), "[edge-bind-decision] kind=lan iface=wlan0 private=192.168.1.50 public=none EDGE_HOSTNAME=0.0.0.0");
|
|
209
|
+
assert.equal(formatEdgeBindDecision(pickEdgeBindDecision(HETZNER_PUBLIC_ETH0_ONLY)), "[edge-bind-decision] kind=loopback-only iface=none private=none public=167.233.21.246 EDGE_HOSTNAME=127.0.0.1");
|
|
210
|
+
});
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
110
212
|
// renderBrandStanza — exact spec match
|
|
111
213
|
// ---------------------------------------------------------------------------
|
|
112
214
|
test("renderBrandStanza emits the spec directives in order", () => {
|
package/dist/index.js
CHANGED
|
@@ -29,7 +29,7 @@ import { classifyPortHolder } from "./preflight-port-classifier.js";
|
|
|
29
29
|
import { memoryControllerMissing, cmdlineAlreadyPatched, patchCmdline } from "./cgroup-memory-controller.js";
|
|
30
30
|
import { parsePluginList, computeInstallActions, parseExternalPlugins, findUnregisteredResyncs, discoverMarketplaceCandidateDirs, } from "./lib/plugin-install.js";
|
|
31
31
|
import { findPremiumMcpDirs } from "./lib/premium-mcp-discover.js";
|
|
32
|
-
import { pickBindDecision, mergeSmbConf, formatSambaMarker, SAMBA_ENABLE_UNITS, } from "./samba-provision.js";
|
|
32
|
+
import { pickBindDecision, pickEdgeBindDecision, formatEdgeBindDecision, mergeSmbConf, formatSambaMarker, SAMBA_ENABLE_UNITS, } from "./samba-provision.js";
|
|
33
33
|
import { networkInterfaces, userInfo, cpus } from "node:os";
|
|
34
34
|
const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
|
|
35
35
|
// Brand manifest — read from payload to derive all brand-specific installation values.
|
|
@@ -4493,11 +4493,20 @@ WantedBy=default.target
|
|
|
4493
4493
|
// VNC_DISPLAY (defined above for the main brand unit) is also stamped into
|
|
4494
4494
|
// the edge unit so both services agree on the X display. Per-brand display
|
|
4495
4495
|
// closes the cross-brand cookie leak documented.
|
|
4496
|
+
// Task 2082 — the edge binds 0.0.0.0 only on a host with a private IPv4 and
|
|
4497
|
+
// no public one; anything else binds loopback so a rented server never puts
|
|
4498
|
+
// the admin edge on the internet. Logged with its inputs because a wrong
|
|
4499
|
+
// classification is otherwise invisible until an external probe finds it.
|
|
4500
|
+
// Linux-only by construction: this function returns at the darwin branch
|
|
4501
|
+
// above, and darwin runs no edge process at all.
|
|
4502
|
+
const edgeBind = pickEdgeBindDecision(networkInterfaces());
|
|
4503
|
+
logFile(` ${formatEdgeBindDecision(edgeBind)}`);
|
|
4496
4504
|
const edgeTemplatePath = resolve(INSTALL_DIR, "platform/templates/systemd/edge.service.template");
|
|
4497
4505
|
if (existsSync(edgeTemplatePath)) {
|
|
4498
4506
|
const edgeServiceContent = readFileSync(edgeTemplatePath, "utf-8")
|
|
4499
4507
|
.replace(/__INSTALL_DIR__/g, INSTALL_DIR)
|
|
4500
4508
|
.replace(/__EDGE_PORT__/g, String(PORT))
|
|
4509
|
+
.replace(/__EDGE_HOSTNAME__/g, edgeBind.hostname)
|
|
4501
4510
|
.replace(/__MAXY_UI_PORT__/g, String(MAXY_UI_INTERNAL_PORT))
|
|
4502
4511
|
.replace(/__PERSIST_DIR__/g, persistDir)
|
|
4503
4512
|
.replace(/__VNC_DISPLAY__/g, String(VNC_DISPLAY))
|
|
@@ -4505,7 +4514,7 @@ WantedBy=default.target
|
|
|
4505
4514
|
.replace(/__CDP_PORT__/g, String(CDP_PORT_BRAND))
|
|
4506
4515
|
.replace(/__RFB_PORT__/g, String(RFB_PORT));
|
|
4507
4516
|
writeFileSync(join(serviceDir, edgeUnitName), edgeServiceContent);
|
|
4508
|
-
logFile(` ${edgeUnitName}: EDGE_PORT=${PORT} MAXY_UI_PORT=${MAXY_UI_INTERNAL_PORT} VNC_DISPLAY=:${VNC_DISPLAY} RFB_PORT=${RFB_PORT} WEBSOCKIFY_PORT=${WEBSOCKIFY_PORT_BRAND} CDP_PORT=${CDP_PORT_BRAND}`);
|
|
4517
|
+
logFile(` ${edgeUnitName}: EDGE_PORT=${PORT} EDGE_HOSTNAME=${edgeBind.hostname} MAXY_UI_PORT=${MAXY_UI_INTERNAL_PORT} VNC_DISPLAY=:${VNC_DISPLAY} RFB_PORT=${RFB_PORT} WEBSOCKIFY_PORT=${WEBSOCKIFY_PORT_BRAND} CDP_PORT=${CDP_PORT_BRAND}`);
|
|
4509
4518
|
}
|
|
4510
4519
|
else {
|
|
4511
4520
|
console.error(` WARNING: edge.service.template missing at ${edgeTemplatePath} — VNC transport unavailable`);
|
|
@@ -5389,8 +5398,14 @@ try {
|
|
|
5389
5398
|
console.log("================================================================");
|
|
5390
5399
|
console.log("");
|
|
5391
5400
|
if (isLinux()) {
|
|
5392
|
-
|
|
5393
|
-
|
|
5401
|
+
// Only true when the edge binds 0.0.0.0. On a host classified to loopback
|
|
5402
|
+
// (Task 2082) nothing answers on the LAN address, so printing this line
|
|
5403
|
+
// there would be a false instruction; the SSH-forward recipe below is the
|
|
5404
|
+
// whole story for that host class.
|
|
5405
|
+
if (pickEdgeBindDecision(networkInterfaces()).hostname === "0.0.0.0") {
|
|
5406
|
+
console.log(` Same network (Pi / LAN): http://${DEVICE_HOSTNAME}.local:${PORT}`);
|
|
5407
|
+
console.log("");
|
|
5408
|
+
}
|
|
5394
5409
|
console.log(` Remote access (Hetzner / cloud) — on your local machine run:`);
|
|
5395
5410
|
console.log(` ssh -L ${PORT}:localhost:${PORT} -L ${BRAND.websockifyPort}:localhost:${BRAND.websockifyPort} admin@<server-ipv4>`);
|
|
5396
5411
|
console.log(` Then open:`);
|
package/dist/samba-provision.js
CHANGED
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
// stanza intact (peer-brand isolation is the structural invariant).
|
|
15
15
|
// 5. Report whether any brand stanza remains so the uninstall step can
|
|
16
16
|
// decide whether to apt-purge samba.
|
|
17
|
+
//
|
|
18
|
+
// Despite the file name, the interface classification here serves two
|
|
19
|
+
// consumers, not one: `isPrivateIPv4` and `pickBindDecision` answer where smbd
|
|
20
|
+
// binds, and `pickEdgeBindDecision` (Task 2082) answers where the edge binds.
|
|
21
|
+
// The two deliberately disagree on a dual-homed host; see that function.
|
|
17
22
|
// ---------------------------------------------------------------------------
|
|
18
23
|
// Brand-scoped Samba stanza
|
|
19
24
|
// ---------------------------------------------------------------------------
|
|
@@ -152,6 +157,73 @@ export function pickBindDecision(ifaces) {
|
|
|
152
157
|
const hasNonLoopbackIPv4 = Object.keys(ifaces).some((name) => name !== "lo" && ipv4Addrs(name).length > 0);
|
|
153
158
|
return hasNonLoopbackIPv4 ? { kind: "loopback-only" } : { kind: "none" };
|
|
154
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Decide how the edge should bind, from the same interface table
|
|
162
|
+
* `pickBindDecision` reads. The edge binds `0.0.0.0` only when the host has at
|
|
163
|
+
* least one private IPv4 AND zero public IPv4; every other shape binds
|
|
164
|
+
* `127.0.0.1`. Task 2082.
|
|
165
|
+
*
|
|
166
|
+
* Three properties are deliberate, not oversights:
|
|
167
|
+
*
|
|
168
|
+
* 1. This DISAGREES with `pickBindDecision` on a dual-homed host. For SMB, a
|
|
169
|
+
* private interface alongside a public one keeps the LAN bind (operator
|
|
170
|
+
* confirmation, Task 755 — see the MIXED_PUBLIC_AND_PRIVATE fixture). For
|
|
171
|
+
* the edge the operator decided the opposite: a cloud server carrying a
|
|
172
|
+
* provider private network (Hetzner Cloud's public `eth0` plus private
|
|
173
|
+
* `ens10`) must not silently keep a public bind. Both functions are right
|
|
174
|
+
* for their own consumer; this is not a rename of the other.
|
|
175
|
+
* 2. IPv6 is ignored. `pickBindDecision` already filters to IPv4, and
|
|
176
|
+
* `0.0.0.0` binds IPv4 only, so a public IPv6 address cannot be exposed by
|
|
177
|
+
* this bind under either outcome.
|
|
178
|
+
* 3. A cloud host presenting its public address by 1:1 NAT is NOT covered.
|
|
179
|
+
* Its guest NIC carries only a private address, so this reads a
|
|
180
|
+
* private-only host and binds `0.0.0.0` while the box is publicly
|
|
181
|
+
* reachable. The scoped signal is a public IPv4 alongside the private one;
|
|
182
|
+
* no host in `.docs/install/` behaves that way, and catching it would need
|
|
183
|
+
* an outbound probe rather than the interface table.
|
|
184
|
+
*
|
|
185
|
+
* `kind: "none"` binds `127.0.0.1` rather than throwing, unlike the Samba call
|
|
186
|
+
* site, which hard-fails: a share bound to nothing is useless, whereas a
|
|
187
|
+
* loopback edge still serves the Cloudflare tunnel, which dials
|
|
188
|
+
* `http://localhost:<port>`. Cost: a host that gains an interface later stays
|
|
189
|
+
* loopback-only until the installer is re-run, which the decision line makes
|
|
190
|
+
* visible in the install log.
|
|
191
|
+
*/
|
|
192
|
+
export function pickEdgeBindDecision(ifaces) {
|
|
193
|
+
const bind = pickBindDecision(ifaces);
|
|
194
|
+
const publicAddresses = [];
|
|
195
|
+
const privateAddresses = [];
|
|
196
|
+
for (const [name, addrs] of Object.entries(ifaces)) {
|
|
197
|
+
if (name === "lo" || !addrs)
|
|
198
|
+
continue;
|
|
199
|
+
for (const a of addrs) {
|
|
200
|
+
if (a.family !== "IPv4" || a.internal)
|
|
201
|
+
continue;
|
|
202
|
+
if (isPrivateIPv4(a.address))
|
|
203
|
+
privateAddresses.push(a.address);
|
|
204
|
+
else
|
|
205
|
+
publicAddresses.push(a.address);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
hostname: privateAddresses.length > 0 && publicAddresses.length === 0 ? "0.0.0.0" : "127.0.0.1",
|
|
210
|
+
kind: bind.kind,
|
|
211
|
+
iface: bind.kind === "lan" ? bind.lanInterface : null,
|
|
212
|
+
publicAddresses,
|
|
213
|
+
privateAddresses,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The install-log line for an edge bind decision. The shape is load-bearing:
|
|
218
|
+
* the task's diagnostic path greps `bind-decision` and `EDGE_HOSTNAME`, so both
|
|
219
|
+
* tokens must survive any edit. Empty lists render as the literal `none` so a
|
|
220
|
+
* host with no addresses is distinguishable from a truncated line.
|
|
221
|
+
*/
|
|
222
|
+
export function formatEdgeBindDecision(d) {
|
|
223
|
+
const list = (xs) => (xs.length === 0 ? "none" : xs.join(","));
|
|
224
|
+
return `[edge-bind-decision] kind=${d.kind} iface=${d.iface ?? "none"} ` +
|
|
225
|
+
`private=${list(d.privateAddresses)} public=${list(d.publicAddresses)} EDGE_HOSTNAME=${d.hostname}`;
|
|
226
|
+
}
|
|
155
227
|
// ---------------------------------------------------------------------------
|
|
156
228
|
// smb.conf merge / remove
|
|
157
229
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: platform-architecture
|
|
3
3
|
description: Use when grounding any documented-surface claim about what SiteDesk 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:69ee9681cbe3bd78fa8d38f916aa5979d4814681b59f3c1bd3570c613315ca7c
|
|
5
5
|
brand: sitedesk-code
|
|
6
6
|
product-name: SiteDesk
|
|
7
7
|
---
|
|
@@ -1213,7 +1213,7 @@ npx -y @rubytech/create-realagent-code@latest --uninstall
|
|
|
1213
1213
|
- **No SCP / rsync.** Updates are `npx -y @rubytech/create-maxy-code@latest …` again, never a file push from the operator's laptop.
|
|
1214
1214
|
- **Tunnel auth is OAuth; the API is permitted for the rest.** The tunnel's only auth path is `cloudflared tunnel login` in the noVNC browser over SSH forward. DNS, Pages, and D1 use the Cloudflare API with a short-lived narrow token the agent mints from an operator-provisioned master token (see the `cloudflare` plugin).
|
|
1215
1215
|
- **No shared state across brands.** Two brands on one server each have their own Neo4j port, systemd unit, VNC display, websockify port, tunnel, and persist directory.
|
|
1216
|
-
- **No public IPv4 exposure.** The Hetzner firewall opens port 22 only; every operator-facing surface is fronted by the Cloudflare tunnel.
|
|
1216
|
+
- **No public IPv4 exposure.** The Hetzner firewall opens port 22 only; every operator-facing surface is fronted by the Cloudflare tunnel. The provider firewall is no longer the only thing making that true for the admin edge: this host carries a public IPv4, so the installer writes `EDGE_HOSTNAME=127.0.0.1` into the edge unit and the edge port is loopback-only by construction. That holds whether or not the box also has a Cloud Network attached. It covers the edge port only; see [netcup.md](netcup.md) for what a host without a provider firewall still depends on `ufw` for.
|
|
1217
1217
|
|
|
1218
1218
|
## Smoke checklist
|
|
1219
1219
|
|
|
@@ -1294,7 +1294,7 @@ In netcup's Customer Control Panel:
|
|
|
1294
1294
|
2. When the server is provisioned, install **Ubuntu 24.04 LTS** from the CCP's image list.
|
|
1295
1295
|
3. Add your SSH public key during image setup, or paste it into `/root/.ssh/authorized_keys` from the remote console afterwards.
|
|
1296
1296
|
|
|
1297
|
-
netcup does not provide a network firewall product in front of the server, so the host firewall is the only inbound control. Close everything except SSH before going further:
|
|
1297
|
+
netcup does not provide a network firewall product in front of the server, so at this point the host firewall is the only inbound control. Close everything except SSH before going further:
|
|
1298
1298
|
|
|
1299
1299
|
```bash
|
|
1300
1300
|
ufw default deny incoming
|
|
@@ -1369,7 +1369,7 @@ npx -y @rubytech/create-realagent-code@latest --hostname <realagent-hostname>
|
|
|
1369
1369
|
|
|
1370
1370
|
The persist directory becomes `$HOME/.realagent-code`, the systemd user-service becomes `<realagent-hostname>.service`, Neo4j is provisioned on its own port, and the VNC display + websockify + ttyd ports shift to the brand's reserved range.
|
|
1371
1371
|
|
|
1372
|
-
Co-residency has an aggregate cost the per-brand capacity figure does not express
|
|
1372
|
+
Co-residency has an aggregate cost the per-brand capacity figure does not express, and clearing the 56 GiB step makes it sharper, not safer. Each brand's daemon reads whole-box RAM independently and reaches its own conclusion, so on a 64 GB host **four co-resident brands each resolve 40**, an aggregate of 160 concurrent sessions at roughly 1.2 GB each against a box that holds 64 GB. No brand reaches its own cap and is wrong; the box runs out first. A count-based per-daemon cap derived from box-wide RAM cannot express a constraint shared by co-resident daemons — the box-wide `MemAvailable` reclaim in `slice-memory-policy.ts` is what actually bounds this. Treat a multi-brand 64 GB install as needing that reclaim path verified under load, not assumed.
|
|
1373
1373
|
|
|
1374
1374
|
## 5. Reach the dashboard and VNC browser over SSH port-forwarding
|
|
1375
1375
|
|
|
@@ -1411,21 +1411,31 @@ This stops and disables the systemd user-service, removes the unit file, removes
|
|
|
1411
1411
|
- **No SCP / rsync.** Updates are `npx -y @rubytech/create-maxy-code@latest …` again, never a file push from the operator's laptop.
|
|
1412
1412
|
- **Tunnel auth is OAuth; the API is permitted for the rest.** As on Hetzner.
|
|
1413
1413
|
- **No shared state across brands.** Two brands on one server each have their own Neo4j port, systemd unit, VNC display, websockify port, tunnel, and persist directory.
|
|
1414
|
-
- **No provider firewall.**
|
|
1414
|
+
- **No provider firewall, so `ufw` is the only network-level layer.** On Hetzner Cloud the provider firewall opens 22 only; netcup has nothing in front of the server. What keeps the admin edge private here is the bind itself: the installer classifies the host from its interface table and, because this box carries a public IPv4, writes `EDGE_HOSTNAME=127.0.0.1` into the edge unit, so the edge port is loopback-only by construction and `ufw` is the backstop rather than the single control. Confirm which was written with `grep -E 'bind-decision|EDGE_HOSTNAME' $HOME/.<brand>/logs/install-*.log`. This covers the **edge port only**, not the whole box: the noVNC websockify transport still binds all interfaces, so `ufw` remains load-bearing for that port, and anything that clears the ruleset — a flushed nftables set after a kernel change, `ufw disable` during debugging, a CCP image reinstall — exposes it. Verify from **off** the box, not with `ufw status` on it.
|
|
1415
1415
|
|
|
1416
1416
|
## Smoke checklist
|
|
1417
1417
|
|
|
1418
1418
|
Fresh-netcup smoke pass criteria:
|
|
1419
1419
|
|
|
1420
|
-
1. Provision an RS 8000 G12 with Ubuntu 24.04 x86_64 and an SSH key;
|
|
1421
|
-
2.
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1420
|
+
1. Provision an RS 8000 G12 with Ubuntu 24.04 x86_64 and an SSH key; SSH in as `root`, create `admin`, switch.
|
|
1421
|
+
2. **From a machine off the server**, port 22 answers and the edge port does not. Two independent things make that true here: the edge binds loopback on this host class, and `ufw` denies inbound. `ufw status` on the box states the intention; only an external probe confirms the outcome:
|
|
1422
|
+
|
|
1423
|
+
```bash
|
|
1424
|
+
nc -z -w 5 <ipv4> 22 && echo "22 open (expected)"
|
|
1425
|
+
nc -z -w 5 <ipv4> 19200 && echo "EDGE EXPOSED — check the bind, then ufw"
|
|
1426
|
+
```
|
|
1427
|
+
|
|
1428
|
+
If the edge port answers, read the bind first: `grep -E 'bind-decision|EDGE_HOSTNAME' $HOME/.<brand>/logs/install-*.log` should show `EDGE_HOSTNAME=127.0.0.1` on this host, and `ss -lntp` should show the edge on `127.0.0.1:19200`. A public bind there is a classification fault, not a firewall fault.
|
|
1429
|
+
|
|
1430
|
+
Re-run this after every reboot, after any brand install, and after any CCP image reinstall.
|
|
1431
|
+
3. Snapshot taken with the OS prepared and nothing installed.
|
|
1432
|
+
4. Install completes on the clean image, prints a loopback URL, and the systemd user-service is `active (running)`.
|
|
1433
|
+
5. `logs-read.sh --tail server 200 | grep 'op=capacity-resolved'` returns one line reporting `capacity=40`, with `totalMemGiB` matching the provisioned RAM. A `capacity=10` on a 64 GB box means the host resolved below the 56 GiB step and needs investigating before load.
|
|
1434
|
+
6. The noVNC page reached over `ssh -L` displays the admin UI.
|
|
1435
|
+
7. Cloudflare setup ends with `curl -I https://<hostname>.<your-zone>` returning `HTTP/2 200` from the operator's laptop.
|
|
1436
|
+
8. Reboot; both units come back up; the public URL is reachable again without any manual action; the step-2 external probe still shows the edge port closed.
|
|
1437
|
+
9. Install a second brand with a different `--hostname`; both admin UIs are reachable on their own public hostnames and neither has touched the other's state.
|
|
1438
|
+
10. Uninstall removes the systemd unit and the persist directory.
|
|
1429
1439
|
|
|
1430
1440
|
If any step fails, attach `$HOME/.<brand>/logs/install-<timestamp>.log` to the report.
|
|
1431
1441
|
|
|
@@ -3257,7 +3267,7 @@ either is a regression.
|
|
|
3257
3267
|
|
|
3258
3268
|
**`/chat` Claude-desktop transcript presentation.** The admin webchat keeps the shared `Transcript` shell (stream, follow-tail) but injects a /chat-only item renderer via the component's optional `renderItems` prop: `renderChatTimeline` in `app/chat/transcript-render.tsx`. Presentation: operator turns render as a right-aligned grey bubble showing the message text and, beneath it, a subtle always-visible time-of-day stamp (no label); delivered agent replies render as plain prose with the same time-of-day stamp beneath (the reply-document filename line stays, as prose); the stamp is HH:MM from the turn's `ts` (locale-formatted, right-aligned inside the operator bubble, left-aligned under agent prose), and a turn whose `ts` is null/unparseable shows no stamp, never an empty line — so only delivered operator and agent-reply turns are stamped, while tool runs, the collapsed "Thinking" block, and the agent-error banner stay time-free; a maximal consecutive run of `tool-call`/`tool-result` turns renders as one collapsed grey one-liner ("Used N tools ›") whose expansion shows every call and result payload — a lone call still gets the one-liner, and prose or a directive row ends a run. The WhatsApp/operator reader (`/whatsapp`, `OperatorConversations`) omits the prop and keeps the default `renderTimeline`, so its bubble/DM chrome is unchanged — except that since the attachment-render extraction the default renderer draws inbound `operator-inbound` uploads and `agent-file` deliveries inline (image thumbnail / pdf.js preview / voice-note player / download chip) via the attachment renderer shared with `/chat`, extracted to `app/chat/attachment-render.tsx` to break the cycle with `transcript-render.tsx`; `renderChatTimeline` is untouched. Both presentations are test-pinned (`app/whatsapp/__tests__/Transcript-*.test.tsx`, `app/chat/__tests__/transcript-render.test.tsx`, `app/chat/__tests__/pdf-preview.test.tsx`). **Day-divider:** both renderers insert a centered `.day-divider` row between two consecutive timeline items on different local calendar days (label `Today`/`Yesterday`, else `Sat 14 Jun 2026`), so a thread spanning midnight is never an ambiguous run of HH:MM; the first dated item gets a leading divider, a null/unparseable `ts` marks no boundary, dividers are computed over the filtered `visibleItems`, and in `/chat` a day crossover flushes any open tool/think run first so a collapsed run never spans it. Per-bubble HH:MM stamps are unchanged. Shared helpers `dayKey`/`dayLabel`/`itemTs`/`DayDivider` live in `app/whatsapp/Transcript.tsx`.
|
|
3259
3269
|
|
|
3260
|
-
**One delivered bubble per turn
|
|
3270
|
+
**One delivered bubble per turn on the admin `/chat`.** The admin webchat has no outbound transport, so the agent has no reply tool there: the text its turn ends with **is** the delivered answer, and it renders as the one prominent prose bubble. Mid-turn prose collapses into the folded run as before, so a turn shows exactly one delivered bubble. A turn that reaches its end with no trailing text delivers nothing and is logged as `op=turn-answer-absent`. Public webchat, WhatsApp and Telegram keep their reply tool, and every historical admin transcript still renders its recorded reply-tool bubbles unchanged.
|
|
3261
3271
|
|
|
3262
3272
|
**WhatsApp reader is read/write (WhatsApp-Web surface).** A WhatsApp store conversation in the operator Conversations reader (`OperatorConversations`) and the admin root channel viewer (`app/page.tsx`) no longer opens the plain read-only DM chrome — it opens `app/whatsapp/WhatsappWebConversation.tsx`, a WhatsApp-Web-styled surface: a conversation header, the delivered-only store transcript reskinned green/white under a `.wa-web` root class, and a reply composer (`app/whatsapp/ReplyComposer.tsx`). The operator can type text, insert emoji (a codepoint-data picker — no OS input, so the `check-no-emoji-in-jsx` gate stays green), record a voice note (MediaRecorder → the existing audio send path), attach a file **from device storage** (a modal browsing the DATA_ROOT tree via `GET /api/admin/files`, sent by reference — the OS file dialog is no longer the reader's picker), reply to a specific message (a per-bubble affordance that quotes the original in the composer and threads natively on WhatsApp), and search the loaded transcript (a header control that filters to matching turns and highlights the match). The header carries only that Search control — the former Video / Phone / menu icons are gone, because no call or presence data exists behind them. Sent files honour the webchat allowlist (`SUPPORTED_MIME_SET` / 50 MB / 5 files); a device-file reference is authorised server-side (`authorizeReplyFileRef`) against the send's own account (the conversation owner) **or** the session's pinned account (`getAccountIdForSession` — the exact scope the DeviceFilePicker used to offer it), so a house/managing-admin session can attach a managed-client file the picker listed. Because a reply sends bytes OUT to an external WhatsApp recipient, any other account, a `public` partition, and any install-wide / non-account path are refused fail-closed; the cross-account admission's authority is the session's pinned account, never the caller-supplied body `accountId` (the own-account admission matches the body `accountId`, which the route already trusts via `isValidAccountId` and the existing-conversation guard). A scope refusal returns a distinct HTTP 403 reason (`file-ref-cross-account` / `file-ref-public` / `file-ref-foreign`) and copy, never "device file not found" (reserved for a genuine resolve/stat miss). Per-ref `[wa-manual-send] op=reply-file-ref … via=own|session|deny reason=<…> ok=<bool>` logs the decision before any send; `via=deny ok=true` is the exfiltration signature that must never appear. **Send is reply-only and DM-only:** `POST /api/whatsapp-reader/reply` (`requireAdminSession`) refuses any target with no existing stored conversation (`reason=no-existing-conversation`, HTTP 403) — a cold first-contact send bans the account, so the composer is never a cold-contact path, enforced server-side — and refuses `@g.us` groups (`reason=group-send-blocked`), matching the agent's observe-only group invariant; a group thread shows a read-only note in place of the composer. The endpoint is the sole store writer for a manual send: right after each send resolves it appends the record itself, stamped `origin:'operator-manual'` (attributed "You", distinct from an assistant reply), because the Baileys echo that normally persists the store is gated on message text (`manager.ts:906`) and would leave an uncaptioned media send delivered-but-invisible. The record's `messageId` matches the echo's derivation so the async text echo's own persist dedups to a no-op; the send is confirmed by re-read (`op=recorded present=<bool>`). A sent send does not spawn an assistant turn (echo dispatch stays suppressed). Attachments deliver as WhatsApp media/documents and render **inline** in the store reader (image thumbnail / pdf preview / audio player / download chip), because the manual-send path records the message with the servable `attachmentId`. The `.wa-web` skin is scoped to WhatsApp store threads only, so `/chat`, the admin agent session view, and the public visitor webchat are unchanged; the visitor webchat gains no send capability. The in-bubble delivered-tick is cosmetic (no read-receipt data is stored); inbound bubbles keep their sender name so group threads stay attributable, and both directions carry a WhatsApp callout tail (`.wa-web`-scoped only). Non-WhatsApp reader rows (Telegram/webchat session JSONLs) keep the plain read-only `Transcript`. Observability rides `[wa-manual-send]` lines correlated by a per-request `sendId`. **Composer sizing matches the webchat composer:** the reply textarea auto-grows to fit multi-line content (measured from `scrollHeight`, clamped to a one-row minimum and a `viewport/3` maximum, scrollable past the cap with the scrollbar hidden), and a top-border grab handle drags (pointer) or nudges (ArrowUp/ArrowDown, keyboard-focusable) to an explicit height in the same clamp; a manual drag pins the height and overrides auto-grow until the field is cleared, which releases it back to auto-grow. A completed manual resize logs `[operator-ui] op=wa-composer-resize mode=drag|key h=<px>`; a bare click on the handle (no drag) logs nothing.
|
|
3263
3273
|
|
|
@@ -3935,7 +3945,7 @@ authoritative. This section names the surfaces and what backs each.
|
|
|
3935
3945
|
> a read-only reader. The item gates on the flyout handler being wired, exactly
|
|
3936
3946
|
> like the admin and lite items. The read-only inbound-channel **Conversations**
|
|
3937
3947
|
> reader (WhatsApp, Telegram, public webchat, via the admin-gated
|
|
3938
|
-
> `/api/
|
|
3948
|
+
> `/api/operator-conversations/conversations` route) remains on `/data`'s Conversations
|
|
3939
3949
|
> item and in the admin Sidebar; it is no longer reachable from the operator chat
|
|
3940
3950
|
> surface. The operator surface is documented in full in the internal doc
|
|
3941
3951
|
> `maxy-code/.docs/operator-dashboard.md`.
|
|
@@ -3977,7 +3987,7 @@ authoritative. This section names the surfaces and what backs each.
|
|
|
3977
3987
|
> count. On the operator **chat** surface the handler is always wired, so the
|
|
3978
3988
|
> item is always present and opens the live session switcher (above). On `/data`
|
|
3979
3989
|
> the handler is wired only when ≥1 reviewable inbound conversation exists
|
|
3980
|
-
> (fetched from `/
|
|
3990
|
+
> (fetched from `/operator-conversations/conversations` with the operator's session
|
|
3981
3991
|
> key), and selecting it renders the read-only reader in place (the file browser
|
|
3982
3992
|
> is swapped for it; Back returns) — so `/data` keeps its hide-when-empty
|
|
3983
3993
|
> behaviour. The flyout-open signal `[operator-ui] op=conversations-menu
|
|
@@ -3991,8 +4001,8 @@ authoritative. This section names the surfaces and what backs each.
|
|
|
3991
4001
|
| 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. **The "Projects" row is brand-aware:** its visible term and the graph label it filters to both come from the injected `window.__BRAND__.primaryContainer` (`brand.json` → `server/index.ts` inject → `brand.ts` `brandPrimaryContainer()`), so SiteDesk reads "Projects" → `:Project`, SiteDesk "Jobs" → `:Job`, Real Agent "Properties" → `:Property`. A brand JSON missing `primaryContainer` falls back to Project/Projects (server logs `[brand] op=primaryContainer-default reason=absent brand=<hostname>`; the populated case logs `[brand] op=inject primaryContainer=<label>/<term>`). | `graph-search.ts`, `graph-subgraph.ts`, `sidebar-artefacts.ts` |
|
|
3992
4002
|
| Sessions list (Active / Archived / All) | Live row store driven by SSE; manual reconcile button on the segmented control re-fetches the full id set. Every listed row carries two launch actions: the claude.ai/code resume (`ExternalLink`) and a chat-launch (`MessageSquare`) that opens `/chat?session=<full sessionId>` — the admin webchat pointed at that exact session; sending there resumes a stopped session with the webchat channel bound to that id (see `admin-webchat-native-channel.md`, "Session-targeted mode"). **Clicking the row's body** (live-dot, title, id, or timestamp) opens that same `/chat?session=<id>` as the chat-launch action. The main area stays a `<div>` (it wraps the inline rename input, which a `<button>` may not contain) carrying `role="button"`, `tabIndex`, an "Open <title> in webchat" aria-label, and Enter/Space activation, styled with `conv-main-clickable` for pointer + hover parity with the channel-row button. The click and keyboard handlers no-op while the row is being renamed, so an in-progress edit is preserved; the action icons and the `⋯` overflow menu are a sibling cluster running their own handlers, so activating them never triggers the row-body open. At the ≤720 px drawer breakpoint each row stacks into two lines — dot + title + id/stamp, then the action icons — with a divider between rows and actions at full opacity (the desktop hover-gated 0.5 opacity never resolves on touch). Below a 400 px list width each row's action icons collapse to a single `MoreHorizontal` ellipsis trigger; its popover menu lists the same actions as the inline icons but labels them with concise verbs (`Resume in claude.ai/code`, `Open in webchat`, `Stop`, `Archive` / `Unarchive`, `Rename`, `Delete`, `Re-seat`) rather than the inline icon buttons' verbose per-session aria-labels — the inline buttons keep those verbose labels because an icon-only control needs the full descriptive accessible name. **Re-seat:** Re-seat is a member action of the one `SessionRowActions` cluster, not a separate sibling control — wide it is the sliders icon inline with the other icons (sharing the cluster geometry and alignment); collapsed it is the `Re-seat` menu item, which expands the model/mode/effort form inline inside the overflow menu. The form (`SessionReseatControl`) forks the session via `/api/admin/session-reseat` and navigates to the fork (the same fork mechanism `/chat`'s pickers drive); its model picker defaults to the row's current model (a `model` field on each row, read from the JSONL tail), and mode and effort each carry a "Keep" option that omits the field. The overflow menu and the Re-seat form render through a `document.body` portal, fixed-positioned from the trigger's rect (right-aligned, flipped above the trigger when there is no room below), so neither is clipped by `.side-list`'s `overflow-y:auto`; Escape, a pointerdown outside the trigger/popover, and any scroll or resize dismiss them. | `/claude-sessions/events`, `/claude-sessions`, `/session-reseat` |
|
|
3993
4003
|
| Data pane | A "Data" nav row beside Artefacts/Sessions. Uses the same `activeNav` toggle, but its content — the headless `DataFileBrowser` (graph search + `{installDir}/data/` file browser) — renders in the shell's **main column** via `DataPaneSurface`, like the WhatsApp Transcript, not in the side-list; there is no side-list block under `activeNav==='data'`. `AdminShell` owns a `dataOpen` main-column state (mutually exclusive with the WhatsApp selection); the row lifts the choice through `onSelectData`. On `/` it opens in place; from any other route (`/graph`, `/chat`, `/browser`) it navigates to `/?data=1`, which the root shell hydrates on mount (`hydrateDataFromUrl`, then strips the query). The admin burger no longer carries a Data item (the operator surface, which has no Sidebar, keeps the burger → `/data` route). Client breadcrumbs: `[admin-ui] sidebar-nav surface=data …`, `[admin-ui] data-open route=… via=<in-place\|navigate>`, `[admin-ui] data-hydrate route=/`. | `sidebar-artefacts.ts`, `files.ts`, `graph-search.ts` |
|
|
3994
|
-
| Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row per channel that has ≥1 conversation. Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no conversations shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount**, so a failed load shows one muted, non-clickable line instead of channel rows. **WhatsApp rows are sourced from the on-disk message store** (`readAllConversations`), one row per stored conversation keyed by `remoteJid`, independent of whether any session JSONL exists — a blocked non-admin DM or a group-observed thread that never produced a session is listed here. **Telegram rows are session-sourced** (one per admin Telegram session), unchanged. Each conversation row shows a **display name**: for WhatsApp, the store record's `senderName` (else the `senderTelephone`, else the `remoteJid`); for Telegram, the precedence `operatorName ?? senderId ?? title` where `operatorName` is the bound `Person givenName familyName`. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`) from `lastMessageAt`, never "Invalid Date". **Scope colour (WhatsApp):** each WhatsApp row is coloured by its conversation's scope — a thin left accent bar, green for `admin`, orange for `public`, taken from the conversation's most-recent stored record (this replaces the former "Public" text pill). **Re-seat:** only session-sourced rows (Telegram, and webchat in the Sessions panel) carry a `SessionRowActions` Re-seat cluster; a WhatsApp store row has no session, so it carries no Re-seat. A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route it navigates to `/?wa=<remoteJid>&acct=<accountId>`, which the root shell hydrates on mount (then strips the query). **Account-scoped** exactly as the sidebar Sessions list: both row sources (WhatsApp store rows and Telegram/public-webchat session rows) are scoped to the caller's pinned account (`getAccountIdForSession(cacheKey)`), multi-account-gated (`listValidAccounts().length > 1`). On a multi-account install a subaccount view shows only its own conversations; the house account's WhatsApp appears only under the house view; an untagged session sidecar is excluded and counted; an unresolvable scope fails closed to an empty list (logs `op=list-scope-unresolved`). A single-account install filters nothing, byte-identical to before. Server logs `[wa-reader] op=list pinnedAccount=<id8> resolvedAccount=<id8> multiAccount=<bool> conversations=<n> sessionRows=<m> storeRows=<k> adminScope=<n> publicScope=<n> sessionRowsExcludedUntagged=<n>` per list build (`pinnedAccount === resolvedAccount` is the post-fix invariant when `multiAccount=true`; `conversations=0` on an account with a non-empty store dir is the over-filtering drift signal); the client logs `[admin-ui] wa-open route=… via=<in-place\|navigate> …` and `[admin-ui] wa-hydrate route=/ remoteJid=…`. | `/
|
|
3995
|
-
| Channel transcript (`<Transcript>`) | **WhatsApp** reads its conversation verbatim from the message store file (`<remoteJid>.jsonl`) over SSE (`/whatsapp-reader/store-stream`): both directions, `dateSent`-ordered, backlog on connect then a live tail so a newly stored message appears without reload — no session JSONL is read. **Telegram** still reads one session's JSONL over SSE (`/
|
|
4004
|
+
| Channel reader nav rows (WhatsApp / Telegram) | One conditional nav row per channel that has ≥1 conversation. Each row carries its brand glyph (WhatsApp `#25D366`, Telegram `#229ED9`); a channel with no conversations shows no row, and no install ever shows an always-empty "No conversations" row. The channel list loads **once on sidebar mount**, so a failed load shows one muted, non-clickable line instead of channel rows. **WhatsApp rows are sourced from the on-disk message store** (`readAllConversations`), one row per stored conversation keyed by `remoteJid`, independent of whether any session JSONL exists — a blocked non-admin DM or a group-observed thread that never produced a session is listed here. **Telegram rows are session-sourced** (one per admin Telegram session), unchanged. Each conversation row shows a **display name**: for WhatsApp, the store record's `senderName` (else the `senderTelephone`, else the `remoteJid`); for Telegram, the precedence `operatorName ?? senderId ?? title` where `operatorName` is the bound `Person givenName familyName`. Under the name each row shows a **last-message-time breadcrumb** (`conv-timestamp`) from `lastMessageAt`, never "Invalid Date". **Scope colour (WhatsApp):** each WhatsApp row is coloured by its conversation's scope — a thin left accent bar, green for `admin`, orange for `public`, taken from the conversation's most-recent stored record (this replaces the former "Public" text pill). **Re-seat:** only session-sourced rows (Telegram, and webchat in the Sessions panel) carry a `SessionRowActions` Re-seat cluster; a WhatsApp store row has no session, so it carries no Re-seat. A click on a conversation **opens its transcript on the home surface**: on `/` it selects in place, and from any other route it navigates to `/?wa=<remoteJid>&acct=<accountId>`, which the root shell hydrates on mount (then strips the query). **Account-scoped** exactly as the sidebar Sessions list: both row sources (WhatsApp store rows and Telegram/public-webchat session rows) are scoped to the caller's pinned account (`getAccountIdForSession(cacheKey)`), multi-account-gated (`listValidAccounts().length > 1`). On a multi-account install a subaccount view shows only its own conversations; the house account's WhatsApp appears only under the house view; an untagged session sidecar is excluded and counted; an unresolvable scope fails closed to an empty list (logs `op=list-scope-unresolved`). A single-account install filters nothing, byte-identical to before. Server logs `[wa-reader] op=list pinnedAccount=<id8> resolvedAccount=<id8> multiAccount=<bool> conversations=<n> sessionRows=<m> storeRows=<k> adminScope=<n> publicScope=<n> sessionRowsExcludedUntagged=<n>` per list build (`pinnedAccount === resolvedAccount` is the post-fix invariant when `multiAccount=true`; `conversations=0` on an account with a non-empty store dir is the over-filtering drift signal); the client logs `[admin-ui] wa-open route=… via=<in-place\|navigate> …` and `[admin-ui] wa-hydrate route=/ remoteJid=…`. | `/operator-conversations/conversations` |
|
|
4005
|
+
| Channel transcript (`<Transcript>`) | **WhatsApp** reads its conversation verbatim from the message store file (`<remoteJid>.jsonl`) over SSE (`/whatsapp-reader/store-stream`): both directions, `dateSent`-ordered, backlog on connect then a live tail so a newly stored message appears without reload — no session JSONL is read. **Telegram** still reads one session's JSONL over SSE (`/session-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"). On the Telegram session stream, `tool-call`/`tool-result` turns render as **collapsed cards** so the human↔agent text is not buried in JSON. 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**; while scrolled up a **jump-to-bottom control** (`.wa-jump`) appears. Both streams send a 20 s heartbeat so an idle Cloudflare tunnel does not idle-drop, tag each frame with a byte-offset `id:`, and resume 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 Telegram session stream also interleaves a collapsed `⚙ directive injected (N B)` row per turn (listed by `GET /session-reader/directives?sessionId=`, bytes served by `GET /session-reader/directive?sessionId=&name=`); a WhatsApp store conversation has no session, so it has no directives. A centered day-divider row (`.day-divider`) marks each local calendar-day crossover. This viewer renders **delivered messages only** (both WhatsApp store rows and Telegram session rows, via `forceDeliveredOnly`) with **no per-viewer toggle** — the former "Messages only" pill is retired; the admin `/chat` composer's own messages-only control is unaffected. The panel — wallpaper included — is **bounded to `--chat-measure` and centred** (`.platform > .wa-reader`), so it reads as a column the same width as `/data`, not an edge-to-edge beige surface. The store stream open logs `[wa-reader] op=open remoteJid=<jid> scope=<admin\|public> messages=<n> source=store`. | `/whatsapp-reader/store-stream`, `/session-reader/stream`, `/session-reader/directives`, `/session-reader/directive` |
|
|
3996
4006
|
| 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` |
|
|
3997
4007
|
| Artefacts list | Lists every `:FileArtifact` under this account's tree (`relativePath STARTS WITH 'accounts/'`, all file types, excluding the `uploads/<id>/` subtree) 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. | `sidebar-artefacts.ts`, `files.ts` |
|
|
3998
4008
|
| System-stats widget | CPU / RAM widget at the foot of the sidebar. | `system-stats.ts` (see above) |
|
|
@@ -4094,6 +4104,44 @@ equivalent wording: the reader reply path and the `/send-admin` route.
|
|
|
4094
4104
|
Consolidating them is tracked separately; do not copy their strings into
|
|
4095
4105
|
new callers.
|
|
4096
4106
|
|
|
4107
|
+
## WhatsApp inbound notifications
|
|
4108
|
+
|
|
4109
|
+
An inbound WhatsApp message raises a WhatsApp-green card at the **top right** of
|
|
4110
|
+
every admin surface within about a second of arrival. The card shows the
|
|
4111
|
+
sub-account it arrived on, the sender (their WhatsApp profile name, or their
|
|
4112
|
+
number when they have none), and the first two lines of the message, with an
|
|
4113
|
+
ellipsis when there was more. A media message with no caption shows its media
|
|
4114
|
+
type instead.
|
|
4115
|
+
|
|
4116
|
+
The card disappears within five seconds and is **stored nowhere** — there is no
|
|
4117
|
+
history, no unread count, and nothing to click. It exists to tell the operator a
|
|
4118
|
+
message landed; the reader is where the message itself is read.
|
|
4119
|
+
|
|
4120
|
+
**Bursts drain rather than pile up.** One card alone lasts five seconds. As more
|
|
4121
|
+
arrive, every card on screen speeds up: two share 2.5 seconds each, three 1.7,
|
|
4122
|
+
four 1.25, and five or more sit on a 1.2 second floor. A card's dwell only ever
|
|
4123
|
+
shortens, so it does not linger once the stack empties. There is no cap on how
|
|
4124
|
+
many cards can appear at once.
|
|
4125
|
+
|
|
4126
|
+
**Four sender types, one icon each:**
|
|
4127
|
+
|
|
4128
|
+
| Icon | Sender |
|
|
4129
|
+
|---|---|
|
|
4130
|
+
| Shield | **Admin** — the operator's own number, or a bound admin |
|
|
4131
|
+
| Two-way arrows | **Account manager (active)** — a manager whose messages drive an agent turn |
|
|
4132
|
+
| Eye | **Account manager (passive)** — a passive bind, which files work but never replies |
|
|
4133
|
+
| Person | **Public** — a member of the public |
|
|
4134
|
+
|
|
4135
|
+
Cards from **every** sub-account appear regardless of which account the tab is
|
|
4136
|
+
currently pinned to, and each is labelled with that sub-account's name. That is
|
|
4137
|
+
deliberate: seeing another account's message is what tells the operator to
|
|
4138
|
+
switch to it.
|
|
4139
|
+
|
|
4140
|
+
Messages that raise no card, by design: anything the operator sent, group
|
|
4141
|
+
messages (groups are observation-only), history catch-up during a re-sync, and
|
|
4142
|
+
any sender the access rules decline. A blocked or unrecognised contact is
|
|
4143
|
+
silent.
|
|
4144
|
+
|
|
4097
4145
|
## Related references
|
|
4098
4146
|
|
|
4099
4147
|
- [`platform.md`](platform.md) — UI layout, session reconcile model,
|
|
@@ -5402,6 +5450,8 @@ So:
|
|
|
5402
5450
|
|
|
5403
5451
|
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.
|
|
5404
5452
|
|
|
5453
|
+
That second remedy applies to SMB only. The admin edge uses a different rule: it binds loopback whenever the host carries a public IPv4, whether or not a private interface exists alongside it, so joining a private network does not make the edge LAN-reachable on a cloud box.
|
|
5454
|
+
|
|
5405
5455
|
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.
|
|
5406
5456
|
|
|
5407
5457
|
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.
|
|
@@ -9,6 +9,12 @@ Invoked by the admin agent directly.
|
|
|
9
9
|
|
|
10
10
|
This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
|
|
11
11
|
|
|
12
|
+
## 2026-07-28 (0.1.519)
|
|
13
|
+
|
|
14
|
+
- The admin webchat now delivers the assistant's answer as the turn's final text, and the separate reply tool has been retired for admin sessions (public visitors still get it).
|
|
15
|
+
- The Edge tunnel now binds to the correct interface on hosts without a LAN, and the port is documented as private by construction with ufw as the backstop.
|
|
16
|
+
- WhatsApp group messages in the reader show sender colours and avatars.
|
|
17
|
+
|
|
12
18
|
## 2026-07-28 (0.1.518)
|
|
13
19
|
|
|
14
20
|
- WhatsApp sender numbers are now shown in full international form instead of a bare string of digits.
|