@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.
- package/dist/__tests__/cloudflared-slice.test.js +3 -2
- package/dist/__tests__/cloudflared-version-pin.test.js +24 -0
- package/dist/__tests__/samba-provision.test.js +84 -16
- package/dist/index.js +29 -12
- package/dist/port-resolution.js +4 -3
- package/dist/samba-provision.js +81 -24
- package/dist/uninstall.js +16 -11
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-compliance.test.sh +52 -3
- package/payload/platform/plugins/admin/hooks/__tests__/prompt-optimiser-directive.test.sh +55 -0
- package/payload/platform/plugins/admin/hooks/prompt-optimiser-compliance.sh +29 -13
- package/payload/platform/plugins/admin/hooks/prompt-optimiser-directive.sh +60 -11
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +30 -17
- package/payload/platform/plugins/business-assistant/skills/e-sign/SKILL.md +8 -4
- package/payload/platform/plugins/cloudflare/references/manual-setup.md +18 -2
- package/payload/platform/plugins/docs/references/admin-ui.md +2 -2
- package/payload/platform/plugins/docs/references/deployment.md +1 -1
- package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
- package/payload/platform/plugins/docs/references/samba.md +25 -12
- package/payload/platform/plugins/whatsapp/PLUGIN.md +4 -0
- package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +1 -1
- package/payload/platform/scripts/__tests__/prompt-optimiser-audit.test.sh +59 -0
- package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +47 -10
- package/payload/platform/scripts/installer-device-verify.sh +74 -3
- package/payload/platform/scripts/prompt-optimiser-audit.sh +82 -0
- package/payload/platform/scripts/resume-tunnel.sh +45 -28
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts +46 -7
- package/payload/platform/services/whatsapp-channel/dist/notification.d.ts.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/notification.js +49 -9
- package/payload/platform/services/whatsapp-channel/dist/notification.js.map +1 -1
- package/payload/platform/services/whatsapp-channel/dist/server.js +70 -17
- package/payload/platform/services/whatsapp-channel/dist/server.js.map +1 -1
- package/payload/server/{chunk-QHD5TKLQ.js → chunk-EAJMRICW.js} +21 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/assets/AdminShell-DkP2rJdF.js +1 -0
- package/payload/server/public/assets/{Checkbox-DlwyBGbH.js → Checkbox-CsQq7YPC.js} +1 -1
- package/payload/server/public/assets/{admin-sjHlmwFk.js → admin-C_bkVvXh.js} +1 -1
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-BheZXEzK.js → architectureDiagram-Q4EWVU46-PGePlVpb.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-dYNElb4l.js → blockDiagram-DXYQGD6D-BJCbJRRs.js} +1 -1
- package/payload/server/public/assets/{browser-Dx1D0tI_.js → browser-DynPFG_A.js} +1 -1
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-Q6g8vHmX.js → c4Diagram-AHTNJAMY-DcMhi4gF.js} +1 -1
- package/payload/server/public/assets/channel-idaGQqlP.js +1 -0
- package/payload/server/public/assets/{chunk-336JU56O-D-Cn77jA.js → chunk-336JU56O-0vhS0MI-.js} +2 -2
- package/payload/server/public/assets/{chunk-426QAEUC-BAE0JfL6.js → chunk-426QAEUC-DbVU-p-q.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-BI2HTCey.js → chunk-4TB4RGXK-dgx3qIz_.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-BsWXvze8.js → chunk-5FUZZQ4R-BglTosdT.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-DWO5wTr8.js → chunk-5PVQY5BW-hZB3AxUF.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-BM4DLBML.js → chunk-EDXVE4YY-DXqLJyYa.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-aOt2Re1y.js → chunk-ENJZ2VHE-BD4I_a54.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-_ioQjgQ1.js → chunk-ICPOFSXX-BdQx0Ahc.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-DkuLduF1.js → chunk-OYMX7WX6-BDjtbZvS.js} +1 -1
- package/payload/server/public/assets/{chunk-U2HBQHQK-1ESGrUQ6.js → chunk-U2HBQHQK-hYN7A3g_.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP-C0MUqJKJ.js → chunk-X2U36JSP-D3njJ7WG.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-BjIcv7r1.js → chunk-YZCP3GAM-BfZKEcvU.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-LW116-Vw.js → chunk-ZZ45TVLE-DLJMNqLn.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-CmRFpirU.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-DFy8xZjP.js +1 -0
- package/payload/server/public/assets/clone-kFPktUPQ.js +1 -0
- package/payload/server/public/assets/{dagre-CxO2ywW2.js → dagre-DVLE4-m6.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-DcPqQxUG.js → dagre-KV5264BT-DGjcX5uz.js} +1 -1
- package/payload/server/public/assets/{data-Ca6hPEtp.js → data-D1FFhz2k.js} +1 -1
- package/payload/server/public/assets/{diagram-5BDNPKRD-B25vZ2cO.js → diagram-5BDNPKRD-CQI2o8Cv.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-p7maXzYr.js → diagram-G4DWMVQ6-C8kXsEdQ.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-Deu8Io1I.js → diagram-MMDJMWI5-DaWrNg_c.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-Cp3l9iiP.js → diagram-TYMM5635-Ql9QPUxX.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-D8_SMZL-.js → erDiagram-SMLLAGMA-B2fBZhPo.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-pECrNCIH.js → flowDiagram-DWJPFMVM-BA0LiOm5.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-DAbNRh0p.js → ganttDiagram-T4ZO3ILL-CBKdEgUk.js} +1 -1
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-CHVyAvfu.js → gitGraphDiagram-UUTBAWPF-BIyEfuwB.js} +1 -1
- package/payload/server/public/assets/{graph-ChJs-qY8.js → graph-BxWBNsFs.js} +1 -1
- package/payload/server/public/assets/{graph-labels-B-BTHvfh.js → graph-labels-DSLdDXoF.js} +1 -1
- package/payload/server/public/assets/{graphlib-DS0srZLX.js → graphlib-Cv4SvsQN.js} +1 -1
- package/payload/server/public/assets/{infoDiagram-42DDH7IO-lioeuA4m.js → infoDiagram-42DDH7IO-DovHLDHz.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-Dq2MKHxM.js → ishikawaDiagram-UXIWVN3A-haFza9s4.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-BTC5zuDZ.js → journeyDiagram-VCZTEJTY-l-8tJ21z.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-DQDOg3tk.js → kanban-definition-6JOO6SKY-DSZUvFLs.js} +1 -1
- package/payload/server/public/assets/{line---rBtN__.js → line-mfTElknw.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-BivSpldT.js → mermaid-parser.core-4temHHPS.js} +1 -1
- package/payload/server/public/assets/{mermaid.core-2mrA7rmo.js → mermaid.core-DO2iS9my.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-CduFYM-3.js → mindmap-definition-QFDTVHPH-Cymf2IGG.js} +1 -1
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-s68UJf6f.js → pieDiagram-DEJITSTG-_2tmuvMb.js} +1 -1
- package/payload/server/public/assets/{public-B2WWbnkK.js → public-Rz_GzOrN.js} +3 -3
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-ykHWuSCg.js → quadrantDiagram-34T5L4WZ-CVCrGnBi.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-DaRlmBk1.js → requirementDiagram-MS252O5E-Bc-o8i8Z.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-CjSaDmJD.js → sankeyDiagram-XADWPNL6-DrTBxqE6.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-DJcun441.js → sequenceDiagram-FGHM5R23-BjVjSsUJ.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-DMLxc8L0.js → stateDiagram-FHFEXIEX-DWAJP6ly.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BHDyrxpj.js +1 -0
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-Ls4Sv2EN.js → timeline-definition-GMOUNBTQ-Br_nbteH.js} +1 -1
- package/payload/server/public/assets/{useSelectionMode-BiULiLka.css → useSelectionMode-BLb-o9RX.css} +1 -1
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-NpboV334.js → vennDiagram-DHZGUBPP-D_ozeKV6.js} +1 -1
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-s71AWI0_.js → wardleyDiagram-NUSXRM2D-B87ZKC2B.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-DYjbwXgQ.js → xychartDiagram-5P7HB3ND-DosRlk2T.js} +1 -1
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +5 -5
- package/payload/server/public/public.html +4 -4
- package/payload/server/server.js +269 -81
- package/payload/server/public/assets/AdminShell-D06jh8Lz.js +0 -1
- package/payload/server/public/assets/channel-yIp47StE.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DGPa3QR8.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-De6lAWAi.js +0 -1
- package/payload/server/public/assets/clone-C29IA5qA.js +0 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-a-wkzuCq.js +0 -1
- /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>.
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
79
|
+
// pickBindDecision — private→LAN, public-only→loopback-only, none→none
|
|
39
80
|
// ---------------------------------------------------------------------------
|
|
40
|
-
test("
|
|
41
|
-
assert.
|
|
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("
|
|
44
|
-
assert.
|
|
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("
|
|
47
|
-
assert.
|
|
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("
|
|
50
|
-
assert.
|
|
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("
|
|
53
|
-
assert.
|
|
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("
|
|
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
|
|
58
|
-
// than writing a broken smb.conf.
|
|
59
|
-
|
|
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 {
|
|
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/
|
|
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>.
|
|
3184
|
-
// slice
|
|
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-*.
|
|
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
|
-
|
|
3568
|
-
|
|
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
|
|
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
|
|
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",
|
|
3646
|
+
shell("systemctl", ["enable", "--now", ...SAMBA_ENABLE_UNITS], { sudo: true, timeout: 30_000 });
|
|
3630
3647
|
emitSambaMarker("units", "ok");
|
|
3631
3648
|
}
|
|
3632
3649
|
catch (err) {
|
package/dist/port-resolution.js
CHANGED
|
@@ -187,16 +187,17 @@ WantedBy=default.target
|
|
|
187
187
|
`;
|
|
188
188
|
}
|
|
189
189
|
// ---------------------------------------------------------------------------
|
|
190
|
-
// Task 602 — `cloudflared.slice` unit. Every cloudflared-<brand>.
|
|
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
|
|
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
|
|
package/dist/samba-provision.js
CHANGED
|
@@ -40,19 +40,31 @@ export function renderBrandStanza(input) {
|
|
|
40
40
|
].join("\n");
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
|
-
* Render the `[global]` section. `interfaces =
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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 =
|
|
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
|
-
//
|
|
90
|
+
// Interface classification and bind decision
|
|
79
91
|
// ---------------------------------------------------------------------------
|
|
80
92
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
-
*
|
|
87
|
-
*
|
|
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
|
|
90
|
-
const
|
|
131
|
+
export function pickBindDecision(ifaces) {
|
|
132
|
+
const ipv4Addrs = (name) => {
|
|
91
133
|
const addrs = ifaces[name];
|
|
92
134
|
if (!addrs)
|
|
93
|
-
return
|
|
94
|
-
return addrs.
|
|
135
|
+
return [];
|
|
136
|
+
return addrs.filter((a) => a.family === "IPv4" && !a.internal).map((a) => a.address);
|
|
95
137
|
};
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (
|
|
99
|
-
return "
|
|
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 (
|
|
104
|
-
return name;
|
|
147
|
+
if (isPrivateIface(name))
|
|
148
|
+
return { kind: "lan", lanInterface: name };
|
|
105
149
|
}
|
|
106
|
-
|
|
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
|
|
185
|
-
//
|
|
186
|
-
//
|
|
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) =
|
|
189
|
+
// Exit 5 (no such unit) = service absent = success.
|
|
189
190
|
const brand = BRAND.configDir.replace(/^\./, "");
|
|
190
|
-
const
|
|
191
|
-
const stopResult = spawnSync("systemctl", ["--user", "stop", "--no-block",
|
|
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 ${
|
|
194
|
+
console.log(` Stopped ${serviceUnit}`);
|
|
194
195
|
}
|
|
195
196
|
else if (stopResult.status === 5) {
|
|
196
|
-
// exit 5 = unit not found —
|
|
197
|
-
console.log(` ${
|
|
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 ${
|
|
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
|
@@ -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–
|
|
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
|
-
|
|
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
|
-
|
|
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 ]]
|