@rubytech/create-maxy-code 0.1.286 → 0.1.288

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/__tests__/cloudflared-slice.test.js +3 -2
  2. package/dist/__tests__/cloudflared-version-pin.test.js +24 -0
  3. package/dist/__tests__/samba-provision.test.js +84 -16
  4. package/dist/index.js +29 -12
  5. package/dist/port-resolution.js +4 -3
  6. package/dist/samba-provision.js +81 -24
  7. package/dist/uninstall.js +16 -11
  8. package/package.json +1 -1
  9. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  10. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +52 -3
  11. package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-directive.test.sh +55 -0
  12. package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +29 -13
  13. package/payload/platform/plugins/admin/hooks/prompt-optimiser-directive.sh +60 -11
  14. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +30 -17
  15. package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +8 -4
  16. package/payload/platform/plugins/cloudflare/references/manual-setup.md +18 -2
  17. package/payload/platform/plugins/docs/references/admin-ui.md +2 -2
  18. package/payload/platform/plugins/docs/references/deployment.md +1 -1
  19. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  20. package/payload/platform/plugins/docs/references/samba.md +25 -12
  21. package/payload/platform/plugins/whatsapp/PLUGIN.md +4 -0
  22. package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +1 -1
  23. package/payload/platform/scripts/__tests__/prompt-optimiser-audit.test.sh +59 -0
  24. package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +47 -10
  25. package/payload/platform/scripts/installer-device-verify.sh +74 -3
  26. package/payload/platform/scripts/prompt-optimiser-audit.sh +82 -0
  27. package/payload/platform/scripts/resume-tunnel.sh +45 -28
  28. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +46 -7
  29. package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
  30. package/payload/platform/services/whatsapp-channel/dist/notification.js +49 -9
  31. package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
  32. package/payload/platform/services/whatsapp-channel/dist/server.js +70 -17
  33. package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
  34. package/payload/server/{chunk-QHD5TKLQ.js → chunk-EAJMRICW.js} +21 -0
  35. package/payload/server/maxy-edge.js +1 -1
  36. package/payload/server/public/assets/AdminShell-DkP2rJdF.js +1 -0
  37. package/payload/server/public/assets/{Checkbox-DlwyBGbH.js → Checkbox-CsQq7YPC.js} +1 -1
  38. package/payload/server/public/assets/{admin-sjHlmwFk.js → admin-C_bkVvXh.js} +1 -1
  39. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BheZXEzK.js → architectureDiagram-Q4EWVU46-PGePlVpb.js} +1 -1
  40. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-dYNElb4l.js → blockDiagram-DXYQGD6D-BJCbJRRs.js} +1 -1
  41. package/payload/server/public/assets/{browser-Dx1D0tI_.js → browser-DynPFG_A.js} +1 -1
  42. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Q6g8vHmX.js → c4Diagram-AHTNJAMY-DcMhi4gF.js} +1 -1
  43. package/payload/server/public/assets/channel-idaGQqlP.js +1 -0
  44. package/payload/server/public/assets/{chunk-336JU56O-D-Cn77jA.js → chunk-336JU56O-0vhS0MI-.js} +2 -2
  45. package/payload/server/public/assets/{chunk-426QAEUC-BAE0JfL6.js → chunk-426QAEUC-DbVU-p-q.js} +1 -1
  46. package/payload/server/public/assets/{chunk-4TB4RGXK-BI2HTCey.js → chunk-4TB4RGXK-dgx3qIz_.js} +1 -1
  47. package/payload/server/public/assets/{chunk-5FUZZQ4R-BsWXvze8.js → chunk-5FUZZQ4R-BglTosdT.js} +1 -1
  48. package/payload/server/public/assets/{chunk-5PVQY5BW-DWO5wTr8.js → chunk-5PVQY5BW-hZB3AxUF.js} +1 -1
  49. package/payload/server/public/assets/{chunk-EDXVE4YY-BM4DLBML.js → chunk-EDXVE4YY-DXqLJyYa.js} +1 -1
  50. package/payload/server/public/assets/{chunk-ENJZ2VHE-aOt2Re1y.js → chunk-ENJZ2VHE-BD4I_a54.js} +1 -1
  51. package/payload/server/public/assets/{chunk-ICPOFSXX-_ioQjgQ1.js → chunk-ICPOFSXX-BdQx0Ahc.js} +1 -1
  52. package/payload/server/public/assets/{chunk-OYMX7WX6-DkuLduF1.js → chunk-OYMX7WX6-BDjtbZvS.js} +1 -1
  53. package/payload/server/public/assets/{chunk-U2HBQHQK-1ESGrUQ6.js → chunk-U2HBQHQK-hYN7A3g_.js} +1 -1
  54. package/payload/server/public/assets/{chunk-X2U36JSP-C0MUqJKJ.js → chunk-X2U36JSP-D3njJ7WG.js} +1 -1
  55. package/payload/server/public/assets/{chunk-YZCP3GAM-BjIcv7r1.js → chunk-YZCP3GAM-BfZKEcvU.js} +1 -1
  56. package/payload/server/public/assets/{chunk-ZZ45TVLE-LW116-Vw.js → chunk-ZZ45TVLE-DLJMNqLn.js} +1 -1
  57. package/payload/server/public/assets/classDiagram-6PBFFD2Q-CmRFpirU.js +1 -0
  58. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DFy8xZjP.js +1 -0
  59. package/payload/server/public/assets/clone-kFPktUPQ.js +1 -0
  60. package/payload/server/public/assets/{dagre-CxO2ywW2.js → dagre-DVLE4-m6.js} +1 -1
  61. package/payload/server/public/assets/{dagre-KV5264BT-DcPqQxUG.js → dagre-KV5264BT-DGjcX5uz.js} +1 -1
  62. package/payload/server/public/assets/{data-Ca6hPEtp.js → data-D1FFhz2k.js} +1 -1
  63. package/payload/server/public/assets/{diagram-5BDNPKRD-B25vZ2cO.js → diagram-5BDNPKRD-CQI2o8Cv.js} +1 -1
  64. package/payload/server/public/assets/{diagram-G4DWMVQ6-p7maXzYr.js → diagram-G4DWMVQ6-C8kXsEdQ.js} +1 -1
  65. package/payload/server/public/assets/{diagram-MMDJMWI5-Deu8Io1I.js → diagram-MMDJMWI5-DaWrNg_c.js} +1 -1
  66. package/payload/server/public/assets/{diagram-TYMM5635-Cp3l9iiP.js → diagram-TYMM5635-Ql9QPUxX.js} +1 -1
  67. package/payload/server/public/assets/{erDiagram-SMLLAGMA-D8_SMZL-.js → erDiagram-SMLLAGMA-B2fBZhPo.js} +1 -1
  68. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-pECrNCIH.js → flowDiagram-DWJPFMVM-BA0LiOm5.js} +1 -1
  69. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-DAbNRh0p.js → ganttDiagram-T4ZO3ILL-CBKdEgUk.js} +1 -1
  70. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-CHVyAvfu.js → gitGraphDiagram-UUTBAWPF-BIyEfuwB.js} +1 -1
  71. package/payload/server/public/assets/{graph-ChJs-qY8.js → graph-BxWBNsFs.js} +1 -1
  72. package/payload/server/public/assets/{graph-labels-B-BTHvfh.js → graph-labels-DSLdDXoF.js} +1 -1
  73. package/payload/server/public/assets/{graphlib-DS0srZLX.js → graphlib-Cv4SvsQN.js} +1 -1
  74. package/payload/server/public/assets/{infoDiagram-42DDH7IO-lioeuA4m.js → infoDiagram-42DDH7IO-DovHLDHz.js} +1 -1
  75. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-Dq2MKHxM.js → ishikawaDiagram-UXIWVN3A-haFza9s4.js} +1 -1
  76. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-BTC5zuDZ.js → journeyDiagram-VCZTEJTY-l-8tJ21z.js} +1 -1
  77. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DQDOg3tk.js → kanban-definition-6JOO6SKY-DSZUvFLs.js} +1 -1
  78. package/payload/server/public/assets/{line---rBtN__.js → line-mfTElknw.js} +1 -1
  79. package/payload/server/public/assets/{mermaid-parser.core-BivSpldT.js → mermaid-parser.core-4temHHPS.js} +1 -1
  80. package/payload/server/public/assets/{mermaid.core-2mrA7rmo.js → mermaid.core-DO2iS9my.js} +3 -3
  81. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-CduFYM-3.js → mindmap-definition-QFDTVHPH-Cymf2IGG.js} +1 -1
  82. package/payload/server/public/assets/{pieDiagram-DEJITSTG-s68UJf6f.js → pieDiagram-DEJITSTG-_2tmuvMb.js} +1 -1
  83. package/payload/server/public/assets/{public-B2WWbnkK.js → public-Rz_GzOrN.js} +3 -3
  84. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-ykHWuSCg.js → quadrantDiagram-34T5L4WZ-CVCrGnBi.js} +1 -1
  85. package/payload/server/public/assets/{requirementDiagram-MS252O5E-DaRlmBk1.js → requirementDiagram-MS252O5E-Bc-o8i8Z.js} +1 -1
  86. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-CjSaDmJD.js → sankeyDiagram-XADWPNL6-DrTBxqE6.js} +1 -1
  87. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-DJcun441.js → sequenceDiagram-FGHM5R23-BjVjSsUJ.js} +1 -1
  88. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DMLxc8L0.js → stateDiagram-FHFEXIEX-DWAJP6ly.js} +1 -1
  89. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BHDyrxpj.js +1 -0
  90. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-Ls4Sv2EN.js → timeline-definition-GMOUNBTQ-Br_nbteH.js} +1 -1
  91. package/payload/server/public/assets/{useSelectionMode-BiULiLka.css → useSelectionMode-BLb-o9RX.css} +1 -1
  92. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-NpboV334.js → vennDiagram-DHZGUBPP-D_ozeKV6.js} +1 -1
  93. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-s71AWI0_.js → wardleyDiagram-NUSXRM2D-B87ZKC2B.js} +1 -1
  94. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-DYjbwXgQ.js → xychartDiagram-5P7HB3ND-DosRlk2T.js} +1 -1
  95. package/payload/server/public/browser.html +4 -4
  96. package/payload/server/public/data.html +5 -5
  97. package/payload/server/public/graph.html +6 -6
  98. package/payload/server/public/index.html +5 -5
  99. package/payload/server/public/public.html +4 -4
  100. package/payload/server/server.js +269 -81
  101. package/payload/server/public/assets/AdminShell-D06jh8Lz.js +0 -1
  102. package/payload/server/public/assets/channel-yIp47StE.js +0 -1
  103. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DGPa3QR8.js +0 -1
  104. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-De6lAWAi.js +0 -1
  105. package/payload/server/public/assets/clone-C29IA5qA.js +0 -1
  106. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-a-wkzuCq.js +0 -1
  107. /package/payload/server/public/assets/{useSelectionMode-DMNfbxHW.js → useSelectionMode-hZ5bdL8b.js} +0 -0
@@ -1,13 +1,14 @@
1
1
  // Task 602 — cloudflared.slice unit emitted at install time.
2
- // Every cloudflared-<brand>.scope spawned by resume-tunnel.sh drops under
2
+ // Every cloudflared-<brand>.service spawned by resume-tunnel.sh drops under
3
3
  // this slice for cohort observability via `systemctl --user status cloudflared.slice`.
4
+ // (Task 757 changed the per-brand unit from a scope to a supervised service.)
4
5
  import test from "node:test";
5
6
  import assert from "node:assert/strict";
6
7
  import { buildCloudflaredSliceUnitFile } from "../port-resolution.js";
7
8
  test("buildCloudflaredSliceUnitFile emits a valid systemd slice unit", () => {
8
9
  const unit = buildCloudflaredSliceUnitFile();
9
10
  assert.match(unit, /^\[Unit\]$/m);
10
- assert.match(unit, /^Description=Cloudflare tunnel scopes \(Task 602\)$/m);
11
+ assert.match(unit, /^Description=Cloudflare tunnel connectors \(Task 602\)$/m);
11
12
  assert.match(unit, /^\[Slice\]$/m);
12
13
  assert.match(unit, /^\[Install\]$/m);
13
14
  assert.match(unit, /^WantedBy=default\.target$/m);
@@ -0,0 +1,24 @@
1
+ // Task 757 — the cloudflared binary version is installer-owned and pinned.
2
+ // The connector runs with --no-autoupdate (resume-tunnel.sh), so it never
3
+ // self-replaces; the only way the binary version moves is bumping the pin in
4
+ // installCloudflared and republishing the installer. This guards against a
5
+ // regression back to the `releases/latest/download/` URL that let the binary
6
+ // drift (the 2026-06-10 realagent-code outage root cause).
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { readFileSync } from "node:fs";
10
+ import { fileURLToPath } from "node:url";
11
+ import { dirname, join } from "node:path";
12
+ // The test runner compiles src -> dist and runs dist/__tests__/*.test.js, so
13
+ // read the compiled sibling index.js; the URL string literal survives tsc.
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ const compiled = readFileSync(join(here, "../index.js"), "utf8");
16
+ test("cloudflared version is pinned to 2026.6.0", () => {
17
+ assert.match(compiled, /CLOUDFLARED_VERSION\s*=\s*"2026\.6\.0"/);
18
+ });
19
+ test("the .deb fetch URL is wired to the pinned version constant", () => {
20
+ assert.match(compiled, /releases\/download\/\$\{CLOUDFLARED_VERSION\}\/cloudflared-linux-/);
21
+ });
22
+ test("cloudflared .deb fetch no longer uses the drifting latest URL", () => {
23
+ assert.doesNotMatch(compiled, /releases\/latest\/download\/cloudflared-linux-/);
24
+ });
@@ -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, pickLanInterface, mergeSmbConf, removeBrandStanza, hasAnyBrandStanza, formatSambaMarker, SAMBA_STEPS, } from "../samba-provision.js";
9
+ import { renderBrandStanza, renderGlobalSection, renderFullSmbConf, isPrivateIPv4, pickBindDecision, mergeSmbConf, removeBrandStanza, hasAnyBrandStanza, formatSambaMarker, SAMBA_STEPS, SAMBA_ENABLE_UNITS, } from "../samba-provision.js";
10
10
  // ---------------------------------------------------------------------------
11
11
  // Fixtures
12
12
  // ---------------------------------------------------------------------------
@@ -34,29 +34,77 @@ const PI_USB0_FALLBACK = {
34
34
  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" }],
35
35
  usb0: [{ address: "172.17.0.2", netmask: "255.255.255.0", family: "IPv4", mac: "22:22:22:22:22:22", internal: false, cidr: "172.17.0.2/24" }],
36
36
  };
37
+ // Hetzner shape: the only non-loopback interface is eth0 carrying the public
38
+ // IP directly (/32, no private LAN). Binding `lo eth0` here would expose smbd
39
+ // to the public internet — the defect Task 755 fixes by binding loopback-only.
40
+ const HETZNER_PUBLIC_ETH0_ONLY = {
41
+ 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" }],
42
+ eth0: [{ address: "167.233.21.246", netmask: "255.255.255.255", family: "IPv4", mac: "96:00:00:00:00:01", internal: false, cidr: "167.233.21.246/32" }],
43
+ };
44
+ // Dual-homed box: a public eth0 plus a private wireguard/LAN interface. The
45
+ // private interface exists, so the bind stays LAN (operator confirmation,
46
+ // Task 755): loopback-only fires only when NO private interface exists.
47
+ const MIXED_PUBLIC_AND_PRIVATE = {
48
+ 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" }],
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
+ 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
+ };
52
+ // ---------------------------------------------------------------------------
53
+ // isPrivateIPv4 — RFC1918 / CGNAT / link-local classification
54
+ // ---------------------------------------------------------------------------
55
+ test("isPrivateIPv4: RFC1918 ranges are private", () => {
56
+ assert.equal(isPrivateIPv4("10.0.0.5"), true);
57
+ assert.equal(isPrivateIPv4("172.16.0.1"), true);
58
+ assert.equal(isPrivateIPv4("172.31.255.254"), true);
59
+ assert.equal(isPrivateIPv4("192.168.1.50"), true);
60
+ });
61
+ test("isPrivateIPv4: CGNAT (100.64/10) and link-local (169.254/16) are private", () => {
62
+ assert.equal(isPrivateIPv4("100.64.0.1"), true);
63
+ assert.equal(isPrivateIPv4("100.127.255.254"), true);
64
+ assert.equal(isPrivateIPv4("169.254.1.1"), true);
65
+ });
66
+ test("isPrivateIPv4: routable public addresses are not private", () => {
67
+ assert.equal(isPrivateIPv4("167.233.21.246"), false);
68
+ assert.equal(isPrivateIPv4("178.105.251.82"), false);
69
+ assert.equal(isPrivateIPv4("8.8.8.8"), false);
70
+ // Boundary misses: 172.15/172.32 are outside the /12; 100.63/100.128 outside
71
+ // the CGNAT /10; 169.253/169.255 outside link-local.
72
+ assert.equal(isPrivateIPv4("172.15.0.1"), false);
73
+ assert.equal(isPrivateIPv4("172.32.0.1"), false);
74
+ assert.equal(isPrivateIPv4("100.63.255.255"), false);
75
+ assert.equal(isPrivateIPv4("100.128.0.0"), false);
76
+ assert.equal(isPrivateIPv4("169.253.0.1"), false);
77
+ });
37
78
  // ---------------------------------------------------------------------------
38
- // pickLanInterface
79
+ // pickBindDecision — private→LAN, public-only→loopback-only, none→none
39
80
  // ---------------------------------------------------------------------------
40
- test("pickLanInterface: wlan0 wins over eth0 when both present", () => {
41
- assert.equal(pickLanInterface(PI_BOTH_IFACES), "wlan0");
81
+ test("pickBindDecision: wlan0 wins over eth0 when both private", () => {
82
+ assert.deepEqual(pickBindDecision(PI_BOTH_IFACES), { kind: "lan", lanInterface: "wlan0" });
83
+ });
84
+ test("pickBindDecision: private wlan0 alone is a LAN bind", () => {
85
+ assert.deepEqual(pickBindDecision(PI_WLAN0_ONLY), { kind: "lan", lanInterface: "wlan0" });
42
86
  });
43
- test("pickLanInterface: wlan0 alone is picked", () => {
44
- assert.equal(pickLanInterface(PI_WLAN0_ONLY), "wlan0");
87
+ test("pickBindDecision: private eth0 alone is a LAN bind", () => {
88
+ assert.deepEqual(pickBindDecision(PI_ETH0_ONLY), { kind: "lan", lanInterface: "eth0" });
45
89
  });
46
- test("pickLanInterface: eth0 alone is picked", () => {
47
- assert.equal(pickLanInterface(PI_ETH0_ONLY), "eth0");
90
+ test("pickBindDecision: fallback to any non-loopback private IPv4 (usb0)", () => {
91
+ assert.deepEqual(pickBindDecision(PI_USB0_FALLBACK), { kind: "lan", lanInterface: "usb0" });
48
92
  });
49
- test("pickLanInterface: fallback to any non-loopback IPv4 (usb0)", () => {
50
- assert.equal(pickLanInterface(PI_USB0_FALLBACK), "usb0");
93
+ test("pickBindDecision: public-only host (Hetzner eth0 /32) binds loopback-only", () => {
94
+ assert.deepEqual(pickBindDecision(HETZNER_PUBLIC_ETH0_ONLY), { kind: "loopback-only" });
51
95
  });
52
- test("pickLanInterface: returns null when only loopback is present", () => {
53
- assert.equal(pickLanInterface(PI_LO_ONLY), null);
96
+ test("pickBindDecision: dual-homed (public eth0 + private wg0) binds to the private interface", () => {
97
+ assert.deepEqual(pickBindDecision(MIXED_PUBLIC_AND_PRIVATE), { kind: "lan", lanInterface: "wg0" });
54
98
  });
55
- test("pickLanInterface: returns null when the only non-loopback iface has no IPv4", () => {
99
+ test("pickBindDecision: none when only loopback is present", () => {
100
+ assert.deepEqual(pickBindDecision(PI_LO_ONLY), { kind: "none" });
101
+ });
102
+ test("pickBindDecision: none when the only non-loopback iface has no IPv4", () => {
56
103
  // smbd cannot bind to an IPv6-only address with `interfaces = lo eth0` syntax
57
- // unmodified; treat as no usable LAN interface so caller loud-fails rather
58
- // than writing a broken smb.conf.
59
- assert.equal(pickLanInterface(PI_IPV6_ONLY_LAN), null);
104
+ // unmodified; treat as no usable interface so the caller loud-fails rather
105
+ // than writing a broken smb.conf. Distinct from loopback-only, which still
106
+ // provisions a working (host-local) share.
107
+ assert.deepEqual(pickBindDecision(PI_IPV6_ONLY_LAN), { kind: "none" });
60
108
  });
61
109
  // ---------------------------------------------------------------------------
62
110
  // renderBrandStanza — exact spec match
@@ -115,6 +163,15 @@ test("renderGlobalSection binds to lo + LAN interface only", () => {
115
163
  // No `map to guest = ok` — guests are explicitly rejected as bad-user.
116
164
  assert.match(globals, /\n map to guest = bad user\n/);
117
165
  });
166
+ test("renderGlobalSection: loopback-only bind emits `interfaces = lo` with no second token", () => {
167
+ // Public-only host (Hetzner): lanInterface=null means bind smbd to loopback
168
+ // only, so the share is reachable solely via the box itself (SSH tunnel).
169
+ // `bind interfaces only = yes` still holds, so nothing else can connect.
170
+ const globals = renderGlobalSection({ lanInterface: null });
171
+ assert.match(globals, /\n interfaces = lo\n/);
172
+ assert.doesNotMatch(globals, /\n interfaces = lo \S/, "loopback-only must not name a second interface");
173
+ assert.match(globals, /\n bind interfaces only = yes\n/);
174
+ });
118
175
  // ---------------------------------------------------------------------------
119
176
  // renderFullSmbConf — composition order
120
177
  // ---------------------------------------------------------------------------
@@ -224,3 +281,14 @@ test("formatSambaMarker emits the `[install-invariant] samba-provision-<step> <s
224
281
  assert.equal(formatSambaMarker("user", "deferred reason=no-plaintext-pin"), "[install-invariant] samba-provision-user deferred reason=no-plaintext-pin");
225
282
  assert.equal(formatSambaMarker("units", "fail: Job for smbd.service failed"), "[install-invariant] samba-provision-units fail: Job for smbd.service failed");
226
283
  });
284
+ // ---------------------------------------------------------------------------
285
+ // SAMBA_ENABLE_UNITS — the units step must never enable nmbd (Task 755)
286
+ // ---------------------------------------------------------------------------
287
+ test("SAMBA_ENABLE_UNITS enables smbd only — nmbd is never started", () => {
288
+ // nmbd is the NetBIOS name service BSI flagged as a DDoS-reflection vector;
289
+ // nothing mounts the share by NetBIOS name (mDNS/LAN-IP only), so it is dead
290
+ // weight. The units step enables smbd alone. Uninstall still *disables* nmbd
291
+ // defensively to clean pre-fix boxes — that is a separate command vector.
292
+ assert.deepEqual([...SAMBA_ENABLE_UNITS], ["smbd"]);
293
+ assert.ok(!SAMBA_ENABLE_UNITS.includes("nmbd"), "nmbd must not be in the enable --now unit list");
294
+ });
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import { decideChromiumAction, isSnapConfinedPath } from "./snap-chromium.js";
18
18
  import { classifyPortHolder } from "./preflight-port-classifier.js";
19
19
  import { parsePluginList, computeInstallActions, parseExternalPlugins, } from "./lib/plugin-install.js";
20
20
  import { findPremiumMcpDirs } from "./lib/premium-mcp-discover.js";
21
- import { pickLanInterface, mergeSmbConf, formatSambaMarker, } from "./samba-provision.js";
21
+ import { pickBindDecision, mergeSmbConf, formatSambaMarker, SAMBA_ENABLE_UNITS, } from "./samba-provision.js";
22
22
  import { networkInterfaces, userInfo } from "node:os";
23
23
  const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
24
24
  // Brand manifest — read from payload to derive all brand-specific installation values.
@@ -1599,6 +1599,13 @@ function installUv() {
1599
1599
  console.error(" WARNING: uv installed but uvx not on PATH — check $HOME/.local/bin");
1600
1600
  }
1601
1601
  }
1602
+ // Task 757 — cloudflared version is installer-owned and pinned. The connector
1603
+ // runs with --no-autoupdate (resume-tunnel.sh), so it never self-replaces; the
1604
+ // only way the binary version moves is bumping this pin and republishing the
1605
+ // installer. Fetching `latest` (the prior behaviour) let the binary drift at
1606
+ // install time and, combined with the connector's own auto-update, caused the
1607
+ // 2026-06-10 realagent-code tunnel outage.
1608
+ const CLOUDFLARED_VERSION = "2026.6.0";
1602
1609
  function installCloudflared() {
1603
1610
  if (commandExists("cloudflared")) {
1604
1611
  log("6", TOTAL, "Cloudflared already installed.");
@@ -1619,7 +1626,7 @@ function installCloudflared() {
1619
1626
  }
1620
1627
  const arch = isArm64() ? "arm64" : "amd64";
1621
1628
  const debPath = "/tmp/cloudflared.deb";
1622
- shellRetry("curl", ["-fSL", "--progress-bar", `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}.deb`, "-o", debPath], { timeout: 120_000 }, 3, 10);
1629
+ shellRetry("curl", ["-fSL", "--progress-bar", `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-${arch}.deb`, "-o", debPath], { timeout: 120_000 }, 3, 10);
1623
1630
  console.log(" [privileged] dpkg -i");
1624
1631
  shell("dpkg", ["-i", debPath], { sudo: true });
1625
1632
  spawnSync("rm", ["-f", debPath]);
@@ -3180,11 +3187,12 @@ function installService() {
3180
3187
  writeFileSync(join(serviceDir, claudePtysSliceName), buildClaudePtysSliceUnitFile());
3181
3188
  logFile(` ${claudePtysSliceName}: Task 250 slice for claude-session-*.scope cohort`);
3182
3189
  // Task 602 — write the brand-agnostic `cloudflared.slice` unit. Every
3183
- // cloudflared-<brand>.scope spawned by resume-tunnel.sh drops under this
3184
- // slice. Writing once per install is idempotent; daemon-reload below picks it up.
3190
+ // cloudflared-<brand>.service spawned by resume-tunnel.sh drops under this
3191
+ // slice (Task 757 changed the unit from a scope to a supervised service).
3192
+ // Writing once per install is idempotent; daemon-reload below picks it up.
3185
3193
  const cloudflaredSliceName = "cloudflared.slice";
3186
3194
  writeFileSync(join(serviceDir, cloudflaredSliceName), buildCloudflaredSliceUnitFile());
3187
- logFile(` ${cloudflaredSliceName}: Task 602 slice for cloudflared-*.scope cohort`);
3195
+ logFile(` ${cloudflaredSliceName}: Task 602 slice for cloudflared-*.service cohort`);
3188
3196
  // Task 600 — per-brand RSS sampler: a long-running user service that
3189
3197
  // periodically writes per-claude.exe RSS + aggregate RSS to disk.
3190
3198
  // Named per-brand (BRAND.hostname prefix) so two brands on the same
@@ -3512,7 +3520,7 @@ WantedBy=multi-user.target
3512
3520
  // conf — write the brand stanza + LAN-only globals to /etc/samba/smb.conf
3513
3521
  // user — smbpasswd the install-owner Linux user; deferred at install time
3514
3522
  // on fresh Pi installs (no plaintext PIN until the operator runs set-pin)
3515
- // units — systemctl enable --now smbd nmbd
3523
+ // units — systemctl enable --now smbd (nmbd is never enabled; Task 755)
3516
3524
  //
3517
3525
  // The platform's set-pin route handler closes the deferral loop by running
3518
3526
  // `smbpasswd -a -s <install-owner>` inline whenever the PIN is set or
@@ -3563,12 +3571,19 @@ function provisionSamba() {
3563
3571
  emitSambaMarker("apt", `fail: ${stderr}`);
3564
3572
  throw err;
3565
3573
  }
3566
- // Step 2 — write the brand stanza into /etc/samba/smb.conf.
3567
- const lanInterface = pickLanInterface(networkInterfaces());
3568
- if (!lanInterface) {
3574
+ // Step 2 — write the brand stanza into /etc/samba/smb.conf. Classify the
3575
+ // host's interfaces (Task 755): a private LAN interface → bind `lo <iface>`;
3576
+ // a public-only host (Hetzner) → bind loopback-only (`lanInterface = null`)
3577
+ // so the share is reachable only from the box itself; no non-loopback IPv4
3578
+ // interface at all → hard-fail, as before. Loopback-only is a distinct third
3579
+ // outcome, NOT the null/throw case.
3580
+ const bind = pickBindDecision(networkInterfaces());
3581
+ if (bind.kind === "none") {
3569
3582
  emitSambaMarker("conf", "fail: no non-loopback IPv4 interface");
3570
3583
  throw new Error("samba-provision: no LAN interface with IPv4 — cannot bind smbd safely");
3571
3584
  }
3585
+ const lanInterface = bind.kind === "lan" ? bind.lanInterface : null;
3586
+ const bindPosture = bind.kind === "lan" ? `bind=lan iface=${bind.lanInterface}` : "bind=loopback-only";
3572
3587
  const SMB_CONF = "/etc/samba/smb.conf";
3573
3588
  let existing = "";
3574
3589
  if (existsSync(SMB_CONF)) {
@@ -3610,7 +3625,7 @@ function provisionSamba() {
3610
3625
  if (visudoCheck.status !== 0) {
3611
3626
  throw new Error(`visudo rejected ${SUDOERS}: ${(visudoCheck.stderr ?? "").trim()}`);
3612
3627
  }
3613
- emitSambaMarker("conf", `ok lan=${lanInterface} owner=${installOwner}`);
3628
+ emitSambaMarker("conf", `ok ${bindPosture} owner=${installOwner}`);
3614
3629
  }
3615
3630
  catch (err) {
3616
3631
  const stderr = err instanceof Error ? err.message : String(err);
@@ -3624,9 +3639,11 @@ function provisionSamba() {
3624
3639
  // unbroken. Owner is recorded in the marker state so the install log shows
3625
3640
  // which Unix user the smbpasswd entry will target. Task 534.
3626
3641
  emitSambaMarker("user", `deferred reason=no-plaintext-pin-at-install owner=${installOwner} (set-pin route syncs)`);
3627
- // Step 4 — enable + start smbd and nmbd.
3642
+ // Step 4 — enable + start smbd. nmbd (NetBIOS) is deliberately excluded —
3643
+ // nothing mounts the share by NetBIOS name and it is a public DDoS-reflection
3644
+ // vector (Task 755). The unit list is locked in samba-provision.ts.
3628
3645
  try {
3629
- shell("systemctl", ["enable", "--now", "smbd", "nmbd"], { sudo: true, timeout: 30_000 });
3646
+ shell("systemctl", ["enable", "--now", ...SAMBA_ENABLE_UNITS], { sudo: true, timeout: 30_000 });
3630
3647
  emitSambaMarker("units", "ok");
3631
3648
  }
3632
3649
  catch (err) {
@@ -187,16 +187,17 @@ WantedBy=default.target
187
187
  `;
188
188
  }
189
189
  // ---------------------------------------------------------------------------
190
- // Task 602 — `cloudflared.slice` unit. Every cloudflared-<brand>.scope
190
+ // Task 602 — `cloudflared.slice` unit. Every cloudflared-<brand>.service
191
191
  // spawned by resume-tunnel.sh drops under this slice for one-glance cohort
192
- // observability (`systemctl --user status cloudflared.slice`).
192
+ // observability (`systemctl --user status cloudflared.slice`). Task 757
193
+ // changed the per-brand unit from a transient scope to a supervised service.
193
194
  //
194
195
  // Brand-agnostic: one slice across all co-resident brands, mirroring the
195
196
  // Task-250 claude-ptys.slice precedent.
196
197
  // ---------------------------------------------------------------------------
197
198
  export function buildCloudflaredSliceUnitFile() {
198
199
  return `[Unit]
199
- Description=Cloudflare tunnel scopes (Task 602)
200
+ Description=Cloudflare tunnel connectors (Task 602)
200
201
  Documentation=https://github.com/rubytech/maxy-code/blob/main/.tasks/archive/602-cloudflared-tunnel-must-survive-brand-service-restart-via-systemd-scope.md
201
202
  Before=shutdown.target
202
203
 
@@ -40,19 +40,31 @@ export function renderBrandStanza(input) {
40
40
  ].join("\n");
41
41
  }
42
42
  /**
43
- * Render the `[global]` section. `interfaces = lo <lan>` plus `bind interfaces
44
- * only = yes` is the LAN-only posture: smbd accepts connections on the LAN
45
- * interface and loopback, nothing else. Cloudflare tunnels carry HTTPS only,
46
- * so this is the structural guarantee that SMB never leaves the LAN even if
47
- * the firewall is misconfigured upstream.
43
+ * Render the `[global]` section. `bind interfaces only = yes` is always set; it
44
+ * is what makes `interfaces` an allow-list rather than a hint. The `interfaces`
45
+ * line has two shapes, keyed by `lanInterface`:
46
+ *
47
+ * - `lanInterface = "<iface>"` `interfaces = lo <iface>`. LAN-only posture:
48
+ * smbd accepts connections on that interface and loopback, nothing else.
49
+ * Used when the host has a private LAN interface (Pi).
50
+ * - `lanInterface = null` → `interfaces = lo`. Loopback-only posture: smbd is
51
+ * reachable solely from the box itself (SSH tunnel to `localhost:445`).
52
+ * Used when the only non-loopback interface carries a public address
53
+ * (Hetzner), so `bind interfaces only` would otherwise expose the share to
54
+ * the public internet (Task 755).
55
+ *
56
+ * Cloudflare tunnels carry HTTPS only, so this is the structural guarantee that
57
+ * SMB never leaves the box (loopback-only) or the LAN (LAN-only) even if the
58
+ * firewall is misconfigured upstream.
48
59
  */
49
60
  export function renderGlobalSection(input) {
61
+ const interfaces = input.lanInterface === null ? `lo` : `lo ${input.lanInterface}`;
50
62
  return [
51
63
  `[global]`,
52
64
  ` workgroup = WORKGROUP`,
53
65
  ` server string = %h Samba`,
54
66
  ` server role = standalone server`,
55
- ` interfaces = lo ${input.lanInterface}`,
67
+ ` interfaces = ${interfaces}`,
56
68
  ` bind interfaces only = yes`,
57
69
  ` log file = /var/log/samba/log.%m`,
58
70
  ` max log size = 1000`,
@@ -75,35 +87,70 @@ export function renderFullSmbConf(input) {
75
87
  renderBrandStanza({ brand: input.brand, sharePath: input.sharePath, installOwner: input.installOwner });
76
88
  }
77
89
  // ---------------------------------------------------------------------------
78
- // LAN interface detection
90
+ // Interface classification and bind decision
79
91
  // ---------------------------------------------------------------------------
80
92
  /**
81
- * Pick the LAN interface to bind smbd to. Preference order: wlan0, eth0, then
82
- * the first non-loopback interface with a non-internal IPv4 address. Returns
83
- * null when no such interface exists the caller treats that as a hard
84
- * failure (the Pi has no LAN connectivity; SMB has nothing to bind to).
93
+ * True when `address` is a private IPv4 address: RFC1918 (`10/8`, `172.16/12`,
94
+ * `192.168/16`), CGNAT (`100.64/10`), or link-local (`169.254/16`). These are
95
+ * the address spaces a Pi's LAN interface lives in. A routable public address
96
+ * (a Hetzner box's `eth0`) is not private binding smbd to it would expose the
97
+ * share to the internet, which is the defect Task 755 fixes.
85
98
  *
86
- * Input is the shape returned by `os.networkInterfaces()` so the test can pass
87
- * realistic fixtures without spinning up real interfaces.
99
+ * Loopback (`127/8`) is not classified here because `os.networkInterfaces()`
100
+ * marks it `internal: true` and the caller excludes it before ever asking.
101
+ */
102
+ export function isPrivateIPv4(address) {
103
+ const parts = address.split(".");
104
+ if (parts.length !== 4)
105
+ return false;
106
+ const octets = parts.map((p) => Number(p));
107
+ if (octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
108
+ return false;
109
+ const [a, b] = octets;
110
+ if (a === 10)
111
+ return true; // 10.0.0.0/8
112
+ if (a === 172 && b >= 16 && b <= 31)
113
+ return true; // 172.16.0.0/12
114
+ if (a === 192 && b === 168)
115
+ return true; // 192.168.0.0/16
116
+ if (a === 100 && b >= 64 && b <= 127)
117
+ return true; // 100.64.0.0/10 (CGNAT)
118
+ if (a === 169 && b === 254)
119
+ return true; // 169.254.0.0/16 (link-local)
120
+ return false;
121
+ }
122
+ /**
123
+ * Decide how smbd should bind, given the shape returned by
124
+ * `os.networkInterfaces()` (so tests pass realistic fixtures without real
125
+ * interfaces). A private-addressed interface yields a LAN bind, in preference
126
+ * order wlan0, eth0, then the first other; this keeps the Pi path byte-
127
+ * identical to before. If non-loopback IPv4 interfaces exist but none is
128
+ * private, the host is public-only and the decision is loopback-only. If no
129
+ * non-loopback IPv4 interface exists, the decision is none.
88
130
  */
89
- export function pickLanInterface(ifaces) {
90
- const hasIPv4 = (name) => {
131
+ export function pickBindDecision(ifaces) {
132
+ const ipv4Addrs = (name) => {
91
133
  const addrs = ifaces[name];
92
134
  if (!addrs)
93
- return false;
94
- return addrs.some((a) => a.family === "IPv4" && !a.internal);
135
+ return [];
136
+ return addrs.filter((a) => a.family === "IPv4" && !a.internal).map((a) => a.address);
95
137
  };
96
- if (hasIPv4("wlan0"))
97
- return "wlan0";
98
- if (hasIPv4("eth0"))
99
- return "eth0";
138
+ const isPrivateIface = (name) => ipv4Addrs(name).some(isPrivateIPv4);
139
+ // LAN bind: a private-addressed interface, in the established preference order.
140
+ if (isPrivateIface("wlan0"))
141
+ return { kind: "lan", lanInterface: "wlan0" };
142
+ if (isPrivateIface("eth0"))
143
+ return { kind: "lan", lanInterface: "eth0" };
100
144
  for (const name of Object.keys(ifaces)) {
101
145
  if (name === "lo")
102
146
  continue;
103
- if (hasIPv4(name))
104
- return name;
147
+ if (isPrivateIface(name))
148
+ return { kind: "lan", lanInterface: name };
105
149
  }
106
- return null;
150
+ // No private interface. If any non-loopback IPv4 interface exists at all, the
151
+ // host is public-only → loopback-only. Otherwise there is nothing to bind to.
152
+ const hasNonLoopbackIPv4 = Object.keys(ifaces).some((name) => name !== "lo" && ipv4Addrs(name).length > 0);
153
+ return hasNonLoopbackIPv4 ? { kind: "loopback-only" } : { kind: "none" };
107
154
  }
108
155
  // ---------------------------------------------------------------------------
109
156
  // smb.conf merge / remove
@@ -213,6 +260,16 @@ export function hasAnyBrandStanza(conf) {
213
260
  * intentionally skipped because a precondition wasn't met).
214
261
  */
215
262
  export const SAMBA_STEPS = ["apt", "conf", "user", "units"];
263
+ /**
264
+ * The systemd units the install's `units` step runs `enable --now` against.
265
+ * `smbd` only — `nmbd` (NetBIOS name service) is never enabled: nothing mounts
266
+ * the share by NetBIOS name (mDNS / LAN-IP only), and on a public-facing host
267
+ * nmbd answers on `:137`/`:138` as a DDoS-reflection vector (BSI/CERT-Bund,
268
+ * Task 755). Locked here, in the tested pure layer, so the install wrapper
269
+ * cannot silently reintroduce nmbd. Uninstall still *disables* nmbd defensively
270
+ * to converge pre-fix boxes — a separate command, see uninstall.ts.
271
+ */
272
+ export const SAMBA_ENABLE_UNITS = ["smbd"];
216
273
  export function formatSambaMarker(step, state) {
217
274
  return `[install-invariant] samba-provision-${step} ${state}`;
218
275
  }
package/dist/uninstall.js CHANGED
@@ -181,24 +181,25 @@ function stopServices() {
181
181
  catch {
182
182
  console.log(` ${neo4jService} not running`);
183
183
  }
184
- // Stop the cloudflared scope for this brand. The scope unit name mirrors
185
- // what resume-tunnel.sh derives: strip leading dot from configDir.
186
- // BRAND.configDir is ".maxy-code"; strip the dot → "maxy-code".
184
+ // Stop the cloudflared service for this brand (Task 757 the connector now
185
+ // runs as a supervised cloudflared-<brand>.service, not a transient scope).
186
+ // The unit name mirrors what resume-tunnel.sh derives: strip leading dot from
187
+ // configDir. BRAND.configDir is ".maxy-code"; strip the dot → "maxy-code".
187
188
  // --no-block: uninstall does not wait for graceful shutdown of the tunnel.
188
- // Exit 5 (no such unit) = scope absent = success.
189
+ // Exit 5 (no such unit) = service absent = success.
189
190
  const brand = BRAND.configDir.replace(/^\./, "");
190
- const scopeUnit = `cloudflared-${brand}.scope`;
191
- const stopResult = spawnSync("systemctl", ["--user", "stop", "--no-block", scopeUnit], { stdio: "pipe" });
191
+ const serviceUnit = `cloudflared-${brand}.service`;
192
+ const stopResult = spawnSync("systemctl", ["--user", "stop", "--no-block", serviceUnit], { stdio: "pipe" });
192
193
  if (stopResult.status === 0) {
193
- console.log(` Stopped ${scopeUnit}`);
194
+ console.log(` Stopped ${serviceUnit}`);
194
195
  }
195
196
  else if (stopResult.status === 5) {
196
- // exit 5 = unit not found — scope already absent, which is expected
197
- console.log(` ${scopeUnit} not running (scope absent)`);
197
+ // exit 5 = unit not found — service already absent, which is expected
198
+ console.log(` ${serviceUnit} not running (service absent)`);
198
199
  }
199
200
  else {
200
201
  // Any other non-zero exit means systemctl itself failed (e.g. DBus unavailable)
201
- console.log(` systemctl stop ${scopeUnit} exited ${stopResult.status} — scope may still be running`);
202
+ console.log(` systemctl stop ${serviceUnit} exited ${stopResult.status} — service may still be running`);
202
203
  }
203
204
  // Brand isolation: the VNC stack and Ollama daemon are
204
205
  // device-wide singletons. Killing them during uninstall would interrupt
@@ -754,7 +755,11 @@ function removeSamba() {
754
755
  console.log(` Leaving smbd/nmbd + samba package in place — ${reason}`);
755
756
  return;
756
757
  }
757
- // No brand stanza, no peer brand — full device-wide teardown.
758
+ // No brand stanza, no peer brand — full device-wide teardown. nmbd is kept
759
+ // in the disable list even though install (Task 755) no longer enables it:
760
+ // disabling an absent unit is a no-op, and this defensively converges a box
761
+ // that was provisioned by a pre-fix installer (where nmbd was enabled) to the
762
+ // intended nmbd-off end state. Do not "simplify" nmbd out of this list.
758
763
  try {
759
764
  spawnSync("sudo", ["systemctl", "disable", "--now", "smbd", "nmbd"], { stdio: "pipe", timeout: 30_000 });
760
765
  console.log(" Stopped + disabled smbd, nmbd");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.286",
3
+ "version": "0.1.288",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -147,7 +147,7 @@ Tools are available via the `admin` MCP server.
147
147
  - `hooks/mcp-tool-missing.sh` — **PostToolUse hook on `mcp__.*` (Task 502, directive 3).** Defence-in-depth for the `No such tool available: mcp__…` failure class that the Task 502 name-binding is built to eliminate. Fires on any MCP tool call; no-op unless the `tool_response` carries `No such tool available` AND the qualified name resolves to a maxy plugin (read from the generated `hooks/lib/maxy-mcp-plugins.txt`). On a maxy match it logs one deterministic `[mcp-tool-missing] server=<server> tool=<tool>` line and exits 2 with a fixed envelope on stderr, so the agent relays a named server-unavailable failure instead of narrating "warming up" or blind-retrying. A missing non-maxy bridge tool (Playwright etc., upstream-owned) passes through (exit 0). The maxy-plugin list is regenerated and gate-diffed by `platform/scripts/check-canonical-tool-names.mjs`.
148
148
  - `hooks/post-tool-use-agent.sh` — **PostToolUse hook on `Agent` (Task 560).** Drains any subagent hook-decision buffers under `~/.maxy-code/logs/hook-decisions/` modified since this parent's previous PostToolUse-Agent fire (cursor file keyed by parent session id), prints one `[hook-propagate]` line per record to stdout — Claude Code attaches the stdout as a `hook_success` attachment on the parent JSONL, making the records grep-queryable from the parent session alone (Task 559 motivating case). Rotates consumed buffers to `consumed/`. Emits one `[hook-propagate-census] parentSession=<…> subagentHooksObserved=<N> attachmentsEmitted=<M>` line per fire to stdout and server.log; `N != M` is the propagation regression signal. The companion emitter library `hooks/lib/hook-emit.sh` is sourced by `post-tool-use-agent.sh` and any other hook that records a block decision (4 KB stderr truncation, `truncated=true` set on the record).
149
149
  - `hooks/admin-authoring-observer.sh` — **PostToolUse hook on Write and Edit (Task 486).** Observation only — never blocks; exits 0 on every path. Fires when the admin agent (not a specialist subagent — gated by `MAXY_SPECIALIST` env) writes or edits a file under `<accountDir>/output/`. Walks the session transcript from the latest real-user turn forward to detect any prior `Task` `tool_use` whose `subagent_type` starts with `specialists:`. Emits one stderr line `[admin-authoring] inline-write path=<rel> priorSpecialistSpawnInTurn=<true|false|unknown>`. A `false` value on a long-form prose file is the regression signal Task 486 was designed to make visible — the BioSymm proposal session (admin authored a customer-facing proposal inline despite content-producer being installed) is the failure mode this surfaces mechanically. Mechanical enforcement (refuse the write, force a re-spawn) is deferred per the task spec.
150
- - `hooks/prompt-optimiser-directive.sh` — **UserPromptSubmit hook (Task 698).** Injects the standing prompt-optimiser restatement directive plus the per-turn three-tier routing ladder as `additionalContext`: **(1)** delegate to the specialist that owns the deliverable via the Agent tool — the brief states the outcome plus binding constraints, never lines/anchors/literal text; **(2)** only if none fits, load an admin-usable skill with `skill-load`; **(3)** only if neither fits, author inline — freestyle is the named last resort. Re-emits the full agent roster (`agents/admin/AGENTS.md`) and the full admin-usable skills list (`agents/admin/ADMIN-SKILLS.md`) every turn by reading the two generated files from the account dir (the hook fires with the account dir as cwd); it never walks the plugins tree per turn. Fail-open is **visible**: a missing list logs `[prompt-optimiser] missing=<AGENTS.md\|ADMIN-SKILLS.md> emitting-partial` to stderr and the ladder still injects. The trivial-turn skip (one-word confirmation, slash-command, direct continuation) is unchanged. **Staleness:** `ADMIN-SKILLS.md` is regenerated only by `setup-account.sh`; a plugin add/remove since the last setup leaves the list stale — compare `ADMIN-SKILLS.md` mtime against the newest `SKILL.md` mtime and re-run setup to refresh. The list generator is `platform/scripts/lib/admin-skills-bootstrap.sh`; it logs `[admin-skills] scanned=<N> admin-usable=<M> no-declaration=<K>` (failure signature: `admin-usable=0` while `scanned>0`, or any `missing-declaration` line). **Task 719:** the directive now carries a standing CAPABILITY-QUESTIONS-ARE-OWNED-WORK clause (how-to / "do you have instructions for X" / config questions about platform features are answered from the owning specialist or plugin tool/reference, never from training memory), and the hook appends a durable `<ts> [prompt-optimiser-directive] injected len=<n> session=<id>` breadcrumb to `$LOG_DIR/prompt-optimiser-directive.log` so per-turn injection is greppable, not stderr-only.
150
+ - `hooks/prompt-optimiser-directive.sh` — **UserPromptSubmit hook (Task 698).** Injects the standing prompt-optimiser restatement directive plus the per-turn three-tier routing ladder as `additionalContext`: **(1)** delegate to the specialist that owns the deliverable via the Agent tool — the brief states the outcome plus binding constraints, never lines/anchors/literal text; **(2)** only if none fits, load an admin-usable skill with `skill-load`; **(3)** only if neither fits, author inline — freestyle is the named last resort. Re-emits the full agent roster (`agents/admin/AGENTS.md`) and the full admin-usable skills list (`agents/admin/ADMIN-SKILLS.md`) every turn by reading the two generated files from the account dir (the hook fires with the account dir as cwd); it never walks the plugins tree per turn. Fail-open is **visible**: a missing list logs `[prompt-optimiser] missing=<AGENTS.md\|ADMIN-SKILLS.md> emitting-partial` to stderr and the ladder still injects. The trivial-turn skip (one-word confirmation, slash-command, direct continuation) is unchanged. **Staleness:** `ADMIN-SKILLS.md` is regenerated only by `setup-account.sh`; a plugin add/remove since the last setup leaves the list stale — compare `ADMIN-SKILLS.md` mtime against the newest `SKILL.md` mtime and re-run setup to refresh. The list generator is `platform/scripts/lib/admin-skills-bootstrap.sh`; it logs `[admin-skills] scanned=<N> admin-usable=<M> no-declaration=<K>` (failure signature: `admin-usable=0` while `scanned>0`, or any `missing-declaration` line). **Task 719:** the directive now carries a standing CAPABILITY-QUESTIONS-ARE-OWNED-WORK clause (how-to / "do you have instructions for X" / config questions about platform features are answered from the owning specialist or plugin tool/reference, never from training memory), and the hook appends a durable `<ts> [prompt-optimiser-directive] injected len=<n> session=<id>` breadcrumb to `$LOG_DIR/prompt-optimiser-directive.log` so per-turn injection is greppable, not stderr-only. **Task 753:** the directive is **suppressed on native channel turns** — when the parsed `.prompt` starts with the `<channel source=` event marker, the hook logs `[prompt-optimiser-directive] skipped reason=channel-turn session=<id>` to stderr and exits without injecting, because the channel service already reframes the inbound into a select-and-dispatch turn (`composeAdminContent`, see `.docs/whatsapp-inbound-lifeline.md`). Marker-matched at start-of-prompt only, so an admin/Terminal prompt that merely mentions "channel" still gets the directive; fail-open injects if the prompt cannot be parsed.
151
151
  - `hooks/prompt-optimiser-compliance.sh` — **Stop hook (Task 719).** After each admin turn, reads the just-finished turn from `transcript_path` and appends `<ts> [prompt-optimiser-compliance] directive-fired no-route-taken session=<id8> prompt="<clip>"` to `$LOG_DIR/prompt-optimiser-directive.log` (and stderr) when the routing directive fired, the prompt was non-trivial (not a slash-command, not a one-word confirmation), and the turn took **no route** — no `Agent` dispatch, no `Skill` load, no `ToolSearch`, no `mcp__*` tool call. This is the standing compliance signal that surfaces the session-`da0b12d4` failure class (agent answers a capability question from memory) as a visible event instead of a silent stale answer. Directive-fired is detected by the marker `PROMPT-OPTIMISER DIRECTIVE` in the turn slice, so it is robust to the CC-version difference in how `UserPromptSubmit` `additionalContext` is recorded (`attachment`/`hook_success` vs `hook_additional_context`). **Known limitation:** "direct continuation of the prior turn" is not detectable from the transcript, so a continuation turn that legitimately needs no route can be flagged; treat the log as a review signal, not a gate. **Fail-open:** no python3, no `transcript_path`, or an unreadable transcript → exit 0, no output. Lives in the same log as the directive breadcrumb, so a single `grep` interleaves "fired" and "no-route" into one per-session timeline; cross-check via `platform/scripts/logs-read.sh <sessionKey>`. This is a lightweight transcript read, not a per-turn spawn (contrast the Task-626 turn recorder below).
152
152
  - **Turn recorder — removed entirely (Task 626).** The `turn-completed-graph-write.sh` Stop hook, the `/api/admin/claude-sessions` loopback bypass it relied on, the `[turn-recorder]` emitters, the envelope walker, and the recorder-auto-archive subscriber are deleted. It had been dormant since Task 214 (never re-registered in settings.json); the admin now writes to the graph by delegating to `database-operator` via the Task tool inside the live session, and the on-demand `/insight` pass (`skills/insight/SKILL.md`, a registered admin skill — Task 641) is the per-session review. There is no per-turn spawn.
153
153
 
@@ -41,6 +41,10 @@ ASSISTANT_AGENT='{"type":"assistant","message":{"role":"assistant","content":[{"
41
41
  ASSISTANT_SKILL='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Skill","input":{}}]}}'
42
42
  ASSISTANT_MCP='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__plugin_email__email-provider-info","input":{}}]}}'
43
43
  ASSISTANT_TOOLSEARCH='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"ToolSearch","input":{}}]}}'
44
+ ASSISTANT_REPLY='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__whatsapp-channel__reply","input":{}}]}}'
45
+ ASSISTANT_REPLYDOC='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__whatsapp-channel__reply-document","input":{}}]}}'
46
+ ASSISTANT_SKILLLOAD='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"mcp__admin__skill-load","input":{}}]}}'
47
+ ASSISTANT_TASK='{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Task","input":{}}]}}'
44
48
 
45
49
  assert_flagged() {
46
50
  local name="$1" t="$2" out
@@ -72,15 +76,37 @@ assert_flagged "attachment-shape directive + no route -> flagged" \
72
76
  assert_flagged "pi-shape directive + no route -> flagged" \
73
77
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_PI" "$ASSISTANT_TEXT")"
74
78
 
75
- # 3–6. Each route surface suppresses the flag.
79
+ # 3–4. Genuine routes suppress the flag.
76
80
  assert_not_flagged "Agent dispatch -> not flagged" \
77
81
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_AGENT")"
78
82
  assert_not_flagged "Skill load -> not flagged" \
79
83
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_SKILL")"
80
- assert_not_flagged "mcp__ tool -> not flagged" \
84
+
85
+ # 5. A non-owning direct mcp__ call is NOT a route -> flagged (kills the
86
+ # "any direct admin MCP call satisfies it" defect; Task 747).
87
+ assert_flagged "non-route mcp__ tool only -> flagged" \
81
88
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_MCP")"
82
- assert_not_flagged "ToolSearch -> not flagged" \
89
+ # 6. ToolSearch is a schema load, not a route -> flagged (Task 747).
90
+ assert_flagged "ToolSearch only -> flagged" \
83
91
  "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_TOOLSEARCH")"
92
+ # 6a. Channel transport reply alone is mandatory transport, not a route -> flagged.
93
+ assert_flagged "channel reply transport only -> flagged" \
94
+ "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLY")"
95
+ # 6b. reply-document transport alone -> flagged.
96
+ assert_flagged "channel reply-document transport only -> flagged" \
97
+ "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLYDOC")"
98
+ # 6c. The e48c2a5e shape: reply + ToolSearch only -> flagged.
99
+ assert_flagged "reply + ToolSearch only (e48c2a5e shape) -> flagged" \
100
+ "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLY" "$ASSISTANT_TOOLSEARCH")"
101
+ # 6d. Task dispatch is a route -> not flagged.
102
+ assert_not_flagged "Task dispatch -> not flagged" \
103
+ "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_TASK")"
104
+ # 6e. The mcp__admin__skill-load tool is a route -> not flagged.
105
+ assert_not_flagged "skill-load MCP tool -> not flagged" \
106
+ "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_SKILLLOAD")"
107
+ # 6f. reply transport then a genuine Agent dispatch -> not flagged (route present).
108
+ assert_not_flagged "reply then Agent dispatch -> not flagged" \
109
+ "$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLY" "$ASSISTANT_AGENT")"
84
110
 
85
111
  # 7. No directive in the slice -> not flagged.
86
112
  assert_not_flagged "no directive -> not flagged" \
@@ -149,6 +175,29 @@ else
149
175
  fi
150
176
  rm -rf "$LD" "$t13"
151
177
 
178
+ # 14. The emitted flag carries the tools= CSV of tool names seen in the slice,
179
+ # in order (Task 747 observability). A future false-negative shows which tool was
180
+ # (mis)counted.
181
+ t14=$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_REPLY" "$ASSISTANT_TOOLSEARCH")
182
+ out14=$(run_hook "$t14")
183
+ if printf '%s' "$out14" | grep -q 'tools=mcp__whatsapp-channel__reply,ToolSearch'; then
184
+ echo "PASS: tools= CSV lists the slice's tool names in order"; PASS=$((PASS+1))
185
+ else
186
+ echo "FAIL: tools= CSV wrong (got: $out14)" >&2; FAIL=$((FAIL+1))
187
+ fi
188
+ rm -f "$t14"
189
+
190
+ # 15. A no-mcp freestyle turn (the 45617005 shape) still flags, with an empty
191
+ # tools= field (no tool calls in the slice).
192
+ t15=$(mk_transcript "$USER_PROMPT" "$DIRECTIVE_ATTACH" "$ASSISTANT_TEXT")
193
+ out15=$(run_hook "$t15")
194
+ if printf '%s' "$out15" | grep -q "no-route-taken" && printf '%s' "$out15" | grep -q 'tools= prompt='; then
195
+ echo "PASS: no-tool freestyle turn flags with empty tools="; PASS=$((PASS+1))
196
+ else
197
+ echo "FAIL: no-tool freestyle tools= wrong (got: $out15)" >&2; FAIL=$((FAIL+1))
198
+ fi
199
+ rm -f "$t15"
200
+
152
201
  echo "----"
153
202
  echo "PASS=$PASS FAIL=$FAIL"
154
203
  [[ "$FAIL" -eq 0 ]]