@rubytech/create-maxy-code 0.1.573 → 0.1.574

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 (127) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/admins-write/__tests__/index.test.ts +97 -0
  3. package/payload/platform/lib/admins-write/dist/index.d.ts +12 -0
  4. package/payload/platform/lib/admins-write/dist/index.d.ts.map +1 -1
  5. package/payload/platform/lib/admins-write/dist/index.js +27 -5
  6. package/payload/platform/lib/admins-write/dist/index.js.map +1 -1
  7. package/payload/platform/lib/admins-write/src/index.ts +35 -5
  8. package/payload/platform/lib/admins-write/vitest.config.ts +14 -0
  9. package/payload/platform/lib/dispatch-read/dist/session.d.ts.map +1 -1
  10. package/payload/platform/lib/dispatch-read/dist/session.js +19 -4
  11. package/payload/platform/lib/dispatch-read/dist/session.js.map +1 -1
  12. package/payload/platform/lib/dispatch-read/src/__tests__/session.test.ts +91 -0
  13. package/payload/platform/lib/dispatch-read/src/session.ts +25 -6
  14. package/payload/platform/plugins/admin/.claude-plugin/plugin.json +1 -1
  15. package/payload/platform/plugins/admin/PLUGIN.md +8 -3
  16. package/payload/platform/plugins/admin/hooks/__tests__/html-structure-gate.test.sh +264 -0
  17. package/payload/platform/plugins/admin/hooks/__tests__/quote-render-pdf-conformance.test.sh +122 -24
  18. package/payload/platform/plugins/admin/hooks/__tests__/specialist-dispatch-gate.test.sh +158 -0
  19. package/payload/platform/plugins/admin/hooks/html-structure-gate.sh +165 -0
  20. package/payload/platform/plugins/admin/hooks/quote-render-pdf-conformance.sh +111 -19
  21. package/payload/platform/plugins/admin/hooks/specialist-dispatch-gate.sh +159 -0
  22. package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.d.ts +27 -0
  23. package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.d.ts.map +1 -0
  24. package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.js +29 -0
  25. package/payload/platform/plugins/admin/mcp/dist/admin-bind-classify.js.map +1 -0
  26. package/payload/platform/plugins/admin/mcp/dist/binding-validate.d.ts +11 -0
  27. package/payload/platform/plugins/admin/mcp/dist/binding-validate.d.ts.map +1 -0
  28. package/payload/platform/plugins/admin/mcp/dist/binding-validate.js +20 -0
  29. package/payload/platform/plugins/admin/mcp/dist/binding-validate.js.map +1 -0
  30. package/payload/platform/plugins/admin/mcp/dist/index.js +133 -1
  31. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  32. package/payload/platform/plugins/admin/skills/admin-user-management/SKILL.md +32 -2
  33. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +157 -46
  34. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +8 -0
  35. package/payload/platform/plugins/docs/references/admin-ui.md +71 -31
  36. package/payload/platform/plugins/docs/references/channel-wake-and-prompt.md +38 -13
  37. package/payload/platform/plugins/docs/references/internals.md +6 -0
  38. package/payload/platform/plugins/docs/references/outlook-guide.md +31 -1
  39. package/payload/platform/plugins/docs/references/troubleshooting.md +10 -0
  40. package/payload/platform/plugins/outlook/PLUGIN.md +3 -1
  41. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.d.ts +2 -0
  42. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.d.ts.map +1 -0
  43. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.js +177 -0
  44. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger-write-sites.test.js.map +1 -0
  45. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.d.ts +2 -0
  46. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.d.ts.map +1 -0
  47. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.js +130 -0
  48. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-ledger.test.js.map +1 -0
  49. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.d.ts +2 -0
  50. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.d.ts.map +1 -0
  51. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.js +162 -0
  52. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-reconcile.test.js.map +1 -0
  53. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.d.ts +2 -0
  54. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.d.ts.map +1 -0
  55. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.js +168 -0
  56. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-state-fields.test.js.map +1 -0
  57. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.d.ts +64 -0
  58. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.d.ts.map +1 -0
  59. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.js +110 -0
  60. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-ledger.js.map +1 -0
  61. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.d.ts +119 -0
  62. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.d.ts.map +1 -0
  63. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.js +0 -0
  64. package/payload/platform/plugins/outlook/mcp/dist/lib/draft-reconcile.js.map +1 -0
  65. package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.d.ts +8 -0
  66. package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.d.ts.map +1 -0
  67. package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.js +247 -0
  68. package/payload/platform/plugins/outlook/mcp/dist/scripts/draft-reconcile.js.map +1 -0
  69. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-send.d.ts.map +1 -1
  70. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-send.js +10 -0
  71. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-send.js.map +1 -1
  72. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.d.ts.map +1 -1
  73. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js +21 -0
  74. package/payload/platform/plugins/outlook/mcp/dist/tools/draft.js.map +1 -1
  75. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.d.ts +16 -0
  76. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.d.ts.map +1 -1
  77. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js +5 -1
  78. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-fetch-body.js.map +1 -1
  79. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.d.ts +4 -0
  80. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.d.ts.map +1 -1
  81. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.js +2 -1
  82. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-list.js.map +1 -1
  83. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.d.ts.map +1 -1
  84. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js +24 -0
  85. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-reply.js.map +1 -1
  86. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.d.ts.map +1 -1
  87. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.js +2 -1
  88. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-search.js.map +1 -1
  89. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.d.ts.map +1 -1
  90. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js +29 -0
  91. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-send.js.map +1 -1
  92. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.d.ts +2 -0
  93. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.d.ts.map +1 -0
  94. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.js +21 -0
  95. package/payload/platform/plugins/scheduling/mcp/dist/__tests__/draft-reconcile-throttle.test.js.map +1 -0
  96. package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.d.ts +13 -0
  97. package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.d.ts.map +1 -0
  98. package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.js +10 -0
  99. package/payload/platform/plugins/scheduling/mcp/dist/lib/draft-reconcile-throttle.js.map +1 -0
  100. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +54 -0
  101. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  102. package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.d.ts +32 -0
  103. package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.d.ts.map +1 -0
  104. package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.js +144 -0
  105. package/payload/platform/services/claude-session-manager/dist/authored-specialist-tools.js.map +1 -0
  106. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
  107. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
  108. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  109. package/payload/platform/services/claude-session-manager/dist/html-structure-census.d.ts +44 -0
  110. package/payload/platform/services/claude-session-manager/dist/html-structure-census.d.ts.map +1 -0
  111. package/payload/platform/services/claude-session-manager/dist/html-structure-census.js +185 -0
  112. package/payload/platform/services/claude-session-manager/dist/html-structure-census.js.map +1 -0
  113. package/payload/platform/services/claude-session-manager/dist/index.js +125 -0
  114. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  115. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.d.ts.map +1 -1
  116. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js +7 -0
  117. package/payload/platform/services/claude-session-manager/dist/quote-delivery-reconcile.js.map +1 -1
  118. package/payload/platform/services/claude-session-manager/dist/quote-print-census.d.ts +42 -0
  119. package/payload/platform/services/claude-session-manager/dist/quote-print-census.d.ts.map +1 -0
  120. package/payload/platform/services/claude-session-manager/dist/quote-print-census.js +254 -0
  121. package/payload/platform/services/claude-session-manager/dist/quote-print-census.js.map +1 -0
  122. package/payload/platform/services/claude-session-manager/dist/specialist-census.d.ts +92 -0
  123. package/payload/platform/services/claude-session-manager/dist/specialist-census.d.ts.map +1 -0
  124. package/payload/platform/services/claude-session-manager/dist/specialist-census.js +227 -0
  125. package/payload/platform/services/claude-session-manager/dist/specialist-census.js.map +1 -0
  126. package/payload/platform/templates/account-settings.json +8 -4
  127. package/payload/server/server.js +465 -361
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env bash
2
+ # specialist-dispatch-gate — PreToolUse gate on every tool.
3
+ #
4
+ # An account's own specialists own their tools. This gate refuses the main
5
+ # conversation a tool one of the account's authored cards declares, and names the
6
+ # specialist to dispatch instead. It exists because skipped delegation writes no
7
+ # record: an inline draft logs an ordinary success, so nothing in any log
8
+ # separates "the clerk did it" from "the admin did it by hand". On one account
9
+ # four authored clerks owned email, works orders, invoices and quotes while the
10
+ # admin produced all of them inline for three days.
11
+ #
12
+ # Two narrowings, both load-bearing.
13
+ #
14
+ # Card axis — the account's OWN cards only. Provisioning copies every shipped
15
+ # template into each account, and one of them declares roughly 130 tools
16
+ # including the whole email, WhatsApp, calendar and browser surfaces, so reading
17
+ # every card would refuse the admin nearly its entire seat. A card is the
18
+ # account's own when its filename appears in none of the shipped roots. The
19
+ # account's own plugin root is deliberately NOT one of those: a card filed there
20
+ # is that account's durable copy, not something we ship.
21
+ #
22
+ # Tool axis — native tools are never gated. Every card declares some of Bash,
23
+ # Read, Write and Edit, and refusing those would brick the seat. The NATIVE set
24
+ # below mirrors CC_NATIVE_TOOLS in the session manager, which a bash hook cannot
25
+ # import; the gate's own test compares the two sets rather than trusting them to
26
+ # stay equal.
27
+ #
28
+ # Caller identity is `agent_type` from the hook envelope, which Claude Code sets
29
+ # when a hook fires inside a subagent. A non-empty value exits 0 unconditionally:
30
+ # this gate must never filter a subagent's tool surface, which is the failure an
31
+ # inherited CLI deny produced when it stripped the writers from a specialist's
32
+ # own frontmatter. The MAXY_SPECIALIST env is not usable — it is stamped only for
33
+ # a specialist spawned as its own PTY process, and native Agent-tool subagents
34
+ # inherit the admin process env.
35
+ #
36
+ # Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail OPEN on an
37
+ # uninspectable envelope, and say so with op=degraded when the card set cannot be
38
+ # read: failing open is fine, failing open silently is the fault. No task numbers
39
+ # or internal refs in any operator-visible string.
40
+
41
+ set -uo pipefail
42
+
43
+ # No envelope (tty or empty stdin) → allow: cannot inspect, must not brick tools.
44
+ if [ -t 0 ]; then exit 0; fi
45
+ INPUT=$(cat)
46
+ [ -z "$INPUT" ] && exit 0
47
+
48
+ # Prints nothing for an out-of-scope call, "block <specialist>" for a refusal, or
49
+ # "degraded <reason>" when the card set could not be read.
50
+ DECISION=$(INPUT="$INPUT" python3 - <<'PY' 2>/dev/null || true
51
+ import os, json, re, sys, glob
52
+
53
+ # Mirrors CC_NATIVE_TOOLS (claude-session-manager/src/specialist-drift.ts). A
54
+ # card that declares these declares them for its own use; they are the admin's
55
+ # too, and gating them would refuse the seat its own disk and shell.
56
+ NATIVE = {
57
+ "Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "LS", "Bash",
58
+ "BashOutput", "KillShell", "Skill", "WebSearch", "WebFetch", "Task",
59
+ "TodoWrite", "NotebookEdit", "NotebookRead", "AskUserQuestion",
60
+ }
61
+
62
+ try:
63
+ d = json.loads(os.environ["INPUT"])
64
+ except Exception:
65
+ sys.exit(0)
66
+
67
+ tool = (d.get("tool_name", "") or "").strip()
68
+ if not tool or tool in NATIVE:
69
+ sys.exit(0)
70
+
71
+ # A subagent is never filtered. This is the whole reason the gate can be broad:
72
+ # it constrains one seat, not a tool surface.
73
+ if (d.get("agent_type") or "").strip():
74
+ sys.exit(0)
75
+
76
+ agents_dir = os.path.join(os.getcwd(), "specialists", "agents")
77
+ platform_root = os.environ.get("PLATFORM_ROOT", "")
78
+
79
+ shipped = set()
80
+ try:
81
+ if platform_root:
82
+ for f in glob.glob(os.path.join(platform_root, "templates", "specialists", "agents", "*.md")):
83
+ shipped.add(os.path.basename(f))
84
+ for f in glob.glob(os.path.join(platform_root, "plugins", "*", "agents", "*.md")):
85
+ shipped.add(os.path.basename(f))
86
+ premium = os.path.join(os.path.dirname(platform_root), "premium-plugins")
87
+ for f in glob.glob(os.path.join(premium, "*", "agents", "*.md")):
88
+ shipped.add(os.path.basename(f))
89
+ if not os.path.isdir(agents_dir):
90
+ # An account with no specialists directory is normal, not degraded.
91
+ sys.exit(0)
92
+ cards = sorted(os.listdir(agents_dir))
93
+ except Exception as err:
94
+ print("degraded %s" % type(err).__name__)
95
+ sys.exit(0)
96
+
97
+
98
+ def fields(path):
99
+ """(name, tools) from a card's frontmatter, or None when there is none."""
100
+ try:
101
+ with open(path, encoding="utf-8") as fh:
102
+ lines = fh.read().split("\n")
103
+ except Exception:
104
+ return None
105
+ if not lines or lines[0].strip() != "---":
106
+ return None
107
+ name = tools = None
108
+ for line in lines[1:]:
109
+ if line.strip() == "---":
110
+ break
111
+ m = re.match(r'^name:\s*(.+?)\s*$', line)
112
+ if m:
113
+ name = m.group(1).strip().strip('"').strip("'")
114
+ m = re.match(r'^tools:\s*(.+?)\s*$', line)
115
+ if m:
116
+ tools = m.group(1)
117
+ return (name, tools)
118
+
119
+
120
+ for filename in cards:
121
+ if not filename.endswith(".md"):
122
+ continue
123
+ if filename in shipped:
124
+ continue
125
+ got = fields(os.path.join(agents_dir, filename))
126
+ if not got:
127
+ continue
128
+ name, tools = got
129
+ if not name or not tools:
130
+ continue
131
+ # Match the tool itself, never a substring of a longer name.
132
+ for declared in tools.split(","):
133
+ declared = declared.strip()
134
+ if not declared or declared in NATIVE:
135
+ continue
136
+ if declared == tool:
137
+ print("block %s" % name)
138
+ sys.exit(0)
139
+ PY
140
+ )
141
+
142
+ [ -z "$DECISION" ] && exit 0
143
+
144
+ VERB=${DECISION%% *}
145
+ ARG=${DECISION#* }
146
+
147
+ if [ "$VERB" = "degraded" ]; then
148
+ echo "[specialist-gate] op=degraded reason=$ARG" >&2
149
+ exit 0
150
+ fi
151
+
152
+ ACCOUNT_ID8=$(basename "$PWD" | cut -c1-8)
153
+ TOOL=$(printf '%s' "$INPUT" \
154
+ | python3 -c 'import sys,json;print(json.load(sys.stdin).get("tool_name",""))' 2>/dev/null \
155
+ || echo "")
156
+
157
+ echo "[specialist-gate] op=refuse account=$ACCOUNT_ID8 tool=$TOOL specialist=$ARG" >&2
158
+ echo "Blocked: this tool belongs to your $ARG. Dispatch that specialist with the Agent tool and let it do the work — it holds the tool and the standards that go with it." >&2
159
+ exit 2
@@ -0,0 +1,27 @@
1
+ export type AdminBindVerdict = {
2
+ kind: "ok";
3
+ pin: string;
4
+ } | {
5
+ kind: "nothing-to-set";
6
+ } | {
7
+ kind: "bad-phone";
8
+ } | {
9
+ kind: "bad-telegram";
10
+ } | {
11
+ kind: "no-such-user";
12
+ } | {
13
+ kind: "not-an-admin";
14
+ };
15
+ export declare function classifyAdminBind(input: {
16
+ userId: string;
17
+ phone?: string;
18
+ telegram?: string;
19
+ users: ReadonlyArray<{
20
+ userId: string;
21
+ pin: string;
22
+ }>;
23
+ admins: ReadonlyArray<{
24
+ userId: string;
25
+ }>;
26
+ }): AdminBindVerdict;
27
+ //# sourceMappingURL=admin-bind-classify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-bind-classify.d.ts","sourceRoot":"","sources":["../src/admin-bind-classify.ts"],"names":[],"mappings":"AAaA,MAAM,MAAM,gBAAgB,GACxB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC3B;IAAE,IAAI,EAAE,gBAAgB,CAAA;CAAE,GAC1B;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,GACrB;IAAE,IAAI,EAAE,cAAc,CAAA;CAAE,GACxB;IAAE,IAAI,EAAE,cAAc,CAAA;CAAE,GACxB;IAAE,IAAI,EAAE,cAAc,CAAA;CAAE,CAAC;AAE7B,wBAAgB,iBAAiB,CAAC,KAAK,EAAE;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,aAAa,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtD,MAAM,EAAE,aAAa,CAAC;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC3C,GAAG,gBAAgB,CAUnB"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Task 2531 — the pure refusal policy for `admin-bind`, split from the IO
3
+ * wrapper for the same reason `resolve-admin-userid.ts` splits its picker: every
4
+ * refusal is then unit-testable without a filesystem, and the policy cannot
5
+ * drift from the tool that enforces it.
6
+ *
7
+ * Never infers a userId. `addAdminPhone` takes an account directory and a phone
8
+ * with no userId, which is exactly why it cannot write a binding: the allowlist
9
+ * knows a number, not a person. Binding runs the other way, so the caller names
10
+ * whom and an unknown userId is refused rather than guessed at.
11
+ */
12
+ import { isE164, isTelegramChatId } from "./binding-validate.js";
13
+ export function classifyAdminBind(input) {
14
+ if (input.phone === undefined && input.telegram === undefined)
15
+ return { kind: "nothing-to-set" };
16
+ if (input.phone !== undefined && !isE164(input.phone))
17
+ return { kind: "bad-phone" };
18
+ if (input.telegram !== undefined && !isTelegramChatId(input.telegram))
19
+ return { kind: "bad-telegram" };
20
+ const row = input.users.find(u => u.userId === input.userId);
21
+ if (!row)
22
+ return { kind: "no-such-user" };
23
+ if (!input.admins.some(a => a.userId === input.userId))
24
+ return { kind: "not-an-admin" };
25
+ // The existing hash, returned so the caller can route through writeAdminEntry
26
+ // (which requires a pin) without rotating the credential it is not touching.
27
+ return { kind: "ok", pin: row.pin };
28
+ }
29
+ //# sourceMappingURL=admin-bind-classify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-bind-classify.js","sourceRoot":"","sources":["../src/admin-bind-classify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAUjE,MAAM,UAAU,iBAAiB,CAAC,KAMjC;IACC,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS,IAAI,KAAK,CAAC,QAAQ,KAAK,SAAS;QAAE,OAAO,EAAE,IAAI,EAAE,gBAAgB,EAAE,CAAC;IACjG,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC;IACpF,IAAI,KAAK,CAAC,QAAQ,KAAK,SAAS,IAAI,CAAC,gBAAgB,CAAC,KAAK,CAAC,QAAQ,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC;IACvG,MAAM,GAAG,GAAG,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,KAAK,CAAC,MAAM,CAAC,CAAC;IAC7D,IAAI,CAAC,GAAG;QAAE,OAAO,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC;IAC1C,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,KAAK,CAAC,MAAM,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC;IACxF,8EAA8E;IAC9E,6EAA6E;IAC7E,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,CAAC,GAAG,EAAE,CAAC;AACtC,CAAC"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Task 2531 — the two channel-binding shapes, in one place.
3
+ *
4
+ * Imported by `index.ts` (admin-add's own validation) and by
5
+ * `admin-bind-classify.ts` (admin-bind's refusal policy), so the tool and the
6
+ * policy cannot disagree about what a valid binding is. Two copies of a
7
+ * validation rule drift the same way two copies of the record type did.
8
+ */
9
+ export declare function isE164(v: string): boolean;
10
+ export declare function isTelegramChatId(v: string): boolean;
11
+ //# sourceMappingURL=binding-validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"binding-validate.d.ts","sourceRoot":"","sources":["../src/binding-validate.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,wBAAgB,MAAM,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAEzC;AAMD,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAEnD"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Task 2531 — the two channel-binding shapes, in one place.
3
+ *
4
+ * Imported by `index.ts` (admin-add's own validation) and by
5
+ * `admin-bind-classify.ts` (admin-bind's refusal policy), so the tool and the
6
+ * policy cannot disagree about what a valid binding is. Two copies of a
7
+ * validation rule drift the same way two copies of the record type did.
8
+ */
9
+ /** E.164, the same shape `config-persist.ts` enforces for `adminPhones`. */
10
+ const E164 = /^\+[1-9]\d{1,14}$/;
11
+ export function isE164(v) {
12
+ return E164.test(v);
13
+ }
14
+ /** A Telegram chat id is an integer. A group chat's is negative, so the leading
15
+ * minus is legal and refusing it would reject a real binding. */
16
+ const TG_CHAT_ID = /^-?\d+$/;
17
+ export function isTelegramChatId(v) {
18
+ return TG_CHAT_ID.test(v);
19
+ }
20
+ //# sourceMappingURL=binding-validate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"binding-validate.js","sourceRoot":"","sources":["../src/binding-validate.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,4EAA4E;AAC5E,MAAM,IAAI,GAAG,mBAAmB,CAAC;AAEjC,MAAM,UAAU,MAAM,CAAC,CAAS;IAC9B,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AACtB,CAAC;AAED;kEACkE;AAClE,MAAM,UAAU,GAAG,SAAS,CAAC;AAE7B,MAAM,UAAU,gBAAgB,CAAC,CAAS;IACxC,OAAO,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAC5B,CAAC"}
@@ -9,6 +9,8 @@ import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSyn
9
9
  import { writeAdminEntry, removeAdminFromAccount, logUsersAudit, formatAuditRowIds } from "../../../../lib/admins-write/dist/index.js";
10
10
  import { setAccessPassword as setAccessPasswordHash, clearAccessPassword as clearAccessPasswordHash, accessPasswordCollides } from "../../../../lib/admin-access-password/dist/index.js";
11
11
  import { resolveCredentialTarget } from "./credential-identity.js";
12
+ import { isE164, isTelegramChatId } from "./binding-validate.js";
13
+ import { classifyAdminBind } from "./admin-bind-classify.js";
12
14
  import { embed } from "../../../../lib/embed-client/dist/index.js";
13
15
  import { requirePortEnv } from "../../../../lib/require-port-env/dist/index.js";
14
16
  import { deviceUrlBlock } from "../../../../lib/device-url/dist/index.js";
@@ -286,6 +288,32 @@ function readUsersJson() {
286
288
  function writeUsersJson(users) {
287
289
  writeFileSync(USERS_FILE, JSON.stringify(users, null, 2) + "\n", "utf-8");
288
290
  }
291
+ /**
292
+ * Task 2531 — re-read users.json after a binding write and report what is
293
+ * actually on disk.
294
+ *
295
+ * "Called the writer" is not evidence the field landed. The whole reason a
296
+ * missing binding went unnoticed for the life of this install is that nothing
297
+ * ever checked the post-condition; the boot-time drift check reports it, but
298
+ * only on the NEXT boot. This closes that gap at the moment of the write.
299
+ * Returns the observed verdict and logs it either way.
300
+ */
301
+ function verifyBindingWrite(tag, userId, want) {
302
+ let ok = false;
303
+ try {
304
+ const row = readUsersJson().find(u => u.userId === userId);
305
+ ok =
306
+ !!row &&
307
+ (want.phone === undefined || row.phone === want.phone) &&
308
+ (want.telegram === undefined || row.telegram === want.telegram);
309
+ }
310
+ catch {
311
+ ok = false;
312
+ }
313
+ const state = (v) => (v === undefined ? "absent" : "set");
314
+ console.error(`${tag} op=binding-write userId=${userId.slice(0, 8)} phone=${state(want.phone)} telegram=${state(want.telegram)} verified=${ok}`);
315
+ return ok;
316
+ }
289
317
  function generateUniquePin(users, maxRetries = 10) {
290
318
  const existingPins = new Set(users.map(u => u.pin));
291
319
  for (let i = 0; i < maxRetries; i++) {
@@ -965,7 +993,9 @@ lifelineTool(server, "plugin-toggle-enabled", "Enable or disable a plugin in thi
965
993
  eagerTool(server, "admin-add", "Add a new admin user to this account. Creates a device-level user entry (users.json) and adds them to this account's admins list (account.json). PIN must be at least 4 digits. If no PIN is provided, a unique 4-digit PIN is generated. Returns the userId and PIN to share with the new admin.\n\nIMPORTANT — retry behaviour: if the user already stated a specific PIN earlier in the conversation and a first admin-add call failed (e.g. tier cap reached, PIN collision), every retry MUST re-pass that PIN as the `pin` parameter. Omitting `pin` on the retry auto-generates a different 4-digit PIN, silently substituting what the user asked for.", {
966
994
  name: z.string().describe("Display name for the new admin (stored on the AdminUser node in Neo4j)."),
967
995
  pin: z.string().optional().describe("Optional PIN (minimum 4 digits). If omitted, a unique 4-digit PIN is generated."),
968
- }, async ({ name, pin: rawPin }) => {
996
+ phone: z.string().optional().describe("E.164 phone (e.g. +441234567890) binding this admin to their WhatsApp sender. Without it a WhatsApp turn from this person resolves to no admin and their answer reaches only the door it arrived on. Settable later with admin-bind."),
997
+ telegram: z.string().optional().describe("Telegram chat id binding this admin to their Telegram chat (negative for a group). Without it a Telegram turn from this person resolves to no admin. Settable later with admin-bind."),
998
+ }, async ({ name, pin: rawPin, phone, telegram }) => {
969
999
  if (!ACCOUNT_ID)
970
1000
  return refuseNoAccount("admin-add");
971
1001
  const TAG = "[admin]";
@@ -984,6 +1014,16 @@ eagerTool(server, "admin-add", "Add a new admin user to this account. Creates a
984
1014
  process.stderr.write(`[admin-add] op=refused reason=no-name\n`);
985
1015
  return { content: [{ type: "text", text: `${TAG} A real name is required (it cannot be blank or the placeholder "Owner").` }], isError: true };
986
1016
  }
1017
+ // Task 2531 — validate the bindings before any store write, so a malformed
1018
+ // one cannot land a half-created admin.
1019
+ if (phone !== undefined && !isE164(phone)) {
1020
+ process.stderr.write(`[admin-add] op=refused reason=bad-phone\n`);
1021
+ return { content: [{ type: "text", text: `${TAG} phone must be E.164, e.g. +441234567890.` }], isError: true };
1022
+ }
1023
+ if (telegram !== undefined && !isTelegramChatId(telegram)) {
1024
+ process.stderr.write(`[admin-add] op=refused reason=bad-telegram\n`);
1025
+ return { content: [{ type: "text", text: `${TAG} telegram must be a chat id (digits, optionally negative for a group).` }], isError: true };
1026
+ }
987
1027
  let users;
988
1028
  try {
989
1029
  users = readUsersJson();
@@ -1053,11 +1093,20 @@ eagerTool(server, "admin-add", "Add a new admin user to this account. Creates a
1053
1093
  accountDir: getAccountDir(),
1054
1094
  caller: "admin-add",
1055
1095
  audit: { actor: callerUserId ?? "unknown", session: process.env.SESSION_NODE_ID, logFile: USERS_AUDIT_LOG },
1096
+ // Task 2531 — the channel bindings, written by the same chokepoint as the
1097
+ // PIN so a new admin can be born reachable on their own channels.
1098
+ phone,
1099
+ telegram,
1056
1100
  });
1057
1101
  console.error(`[admin-auth-store] action=add userId=${userIdShort} result=${authResult.usersJsonResult} store=users${authResult.usersError ? ` error=${authResult.usersError}` : ""}`);
1058
1102
  if (authResult.usersJsonResult !== "ok") {
1059
1103
  return { content: [{ type: "text", text: `${TAG} Failed to write users.json: ${authResult.usersError ?? "unknown error"}` }], isError: true };
1060
1104
  }
1105
+ // Task 2531 — the observed post-condition, re-read from disk. A binding that
1106
+ // silently failed to land looks identical to one that was never asked for.
1107
+ if (phone !== undefined || telegram !== undefined) {
1108
+ verifyBindingWrite("[admin-add]", userId, { phone, telegram });
1109
+ }
1061
1110
  console.error(`[admin-auth-store] action=add userId=${userIdShort} result=${authResult.accountJsonResult} store=account${authResult.accountError ? ` error=${authResult.accountError}` : ""}`);
1062
1111
  if (authResult.accountJsonResult === "fail") {
1063
1112
  return { content: [{ type: "text", text: `${TAG} users.json updated; account.json write FAILED — manual reconciliation needed: ${authResult.accountError ?? "unknown error"}` }], isError: true };
@@ -1388,6 +1437,89 @@ eagerTool(server, "admin-update-pin", "Update an existing admin user's PIN. Defa
1388
1437
  content: [{ type: "text", text: `PIN updated${self ? "" : ` for userId ${userId}`}. The new PIN takes effect on the next login.` }],
1389
1438
  };
1390
1439
  });
1440
+ lifelineTool(server, "admin-bind", "Bind an admin to their channels by setting the phone and/or Telegram chat id on their users.json entry. This is what lets one answer reach every channel that person holds: without it a WhatsApp or Telegram turn resolves to no admin and the answer reaches only the door it arrived on. Defaults to the calling admin; pass userId (from admin-list) to bind someone else, which additionally requires confirm=true because a binding decides whose identity an inbound resolves to. Sets only the fields supplied; an omitted field is left untouched. A new admin can be born bound by passing the same fields to admin-add.", {
1441
+ userId: z.string().optional().describe("The userId of the admin to bind. Defaults to the caller (the admin invoking this tool)."),
1442
+ phone: z.string().optional().describe("E.164 phone (e.g. +441234567890) binding this admin to their WhatsApp sender."),
1443
+ telegram: z.string().optional().describe("Telegram chat id binding this admin to their Telegram chat (negative for a group)."),
1444
+ confirm: z.boolean().optional().describe("Required (true) only when userId names a DIFFERENT admin than the caller — confirms a deliberate cross-identity binding."),
1445
+ }, async ({ userId: targetUserId, phone, telegram, confirm }) => {
1446
+ if (!ACCOUNT_ID)
1447
+ return refuseNoAccount("admin-bind");
1448
+ const TAG = "[admin-bind]";
1449
+ // A binding decides which sender resolves AS this person, so it is a
1450
+ // credential-class write: the same Task 1573 gate the PIN tools use. Without
1451
+ // it any admin could point their own number at another admin's userId and
1452
+ // have their inbound turns attributed to that person.
1453
+ const callerUserId = verifiedCaller();
1454
+ const resolved = resolveCredentialTarget({ explicitUserId: targetUserId, callerUserId, confirm });
1455
+ if (!resolved.ok) {
1456
+ console.error(`${TAG} result=refused reason=${resolved.error.split(":")[0]}`);
1457
+ return { content: [{ type: "text", text: `${TAG} ${resolved.error}` }], isError: true };
1458
+ }
1459
+ const userId = resolved.target;
1460
+ const label = userId.slice(0, 8);
1461
+ const refuse = (reason, message) => {
1462
+ console.error(`${TAG} userId=${label} result=refused reason=${reason}`);
1463
+ return { content: [{ type: "text", text: `${TAG} ${message}` }], isError: true };
1464
+ };
1465
+ let users;
1466
+ try {
1467
+ users = readUsersJson();
1468
+ }
1469
+ catch (err) {
1470
+ return refuse("users-json-read-failed", err instanceof Error ? err.message : String(err));
1471
+ }
1472
+ let admins;
1473
+ try {
1474
+ const config = await readAccountConfig();
1475
+ admins = (config.admins ?? []);
1476
+ }
1477
+ catch (err) {
1478
+ return refuse("account-config-read-failed", err instanceof Error ? err.message : String(err));
1479
+ }
1480
+ const verdict = classifyAdminBind({ userId, phone, telegram, users, admins });
1481
+ if (verdict.kind !== "ok") {
1482
+ const message = {
1483
+ "nothing-to-set": "Supply phone, telegram, or both.",
1484
+ "bad-phone": "phone must be E.164, e.g. +441234567890.",
1485
+ "bad-telegram": "telegram must be a chat id (digits, optionally negative for a group).",
1486
+ "no-such-user": `User ${userId} not found in users.json.`,
1487
+ "not-an-admin": `User ${userId} is not an admin of this account.`,
1488
+ };
1489
+ return refuse(verdict.kind, message[verdict.kind]);
1490
+ }
1491
+ // The one chokepoint, same as admin-add. `verdict.pin` is the existing hash,
1492
+ // so the credential is carried through untouched rather than rotated.
1493
+ const result = writeAdminEntry({
1494
+ userId,
1495
+ pin: verdict.pin,
1496
+ role: "admin",
1497
+ usersFile: USERS_FILE,
1498
+ accountDir: getAccountDir(),
1499
+ caller: "admin-bind",
1500
+ audit: {
1501
+ actor: callerUserId ?? "unknown",
1502
+ session: process.env.SESSION_NODE_ID,
1503
+ logFile: USERS_AUDIT_LOG,
1504
+ target: userId,
1505
+ explicitUserId: resolved.isCrossIdentity,
1506
+ },
1507
+ phone,
1508
+ telegram,
1509
+ });
1510
+ if (result.usersJsonResult !== "ok") {
1511
+ return refuse("write-failed", `Failed to write users.json: ${result.usersError ?? "unknown error"}`);
1512
+ }
1513
+ if (!verifyBindingWrite(TAG, userId, { phone, telegram })) {
1514
+ return refuse("not-verified", "The write reported success but the value is not on disk.");
1515
+ }
1516
+ const set = [phone !== undefined ? "phone" : null, telegram !== undefined ? "telegram" : null]
1517
+ .filter(Boolean)
1518
+ .join(" and ");
1519
+ return {
1520
+ content: [{ type: "text", text: `${TAG} Bound ${set} for ${label}. A turn from this person now reaches every channel they hold, not only the one it arrived on.` }],
1521
+ };
1522
+ });
1391
1523
  lifelineTool(server, "agent-image", "Upload, update, or remove an agent's image. action=set: copy the file at filePath to the agent's assets directory and update config.json with image URL and shape. action=remove: delete the image file and clear the image fields from config.json. imageShape: 'circle' for avatars/icons, 'rounded' for logos.", {
1392
1524
  agentSlug: z.string().describe("Agent slug (e.g. 'coaching', 'sales')"),
1393
1525
  action: z.enum(["set", "remove"]),