@sellable/install 0.1.314 → 0.1.316
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/HERMES-SLACK-PROFILE-SCOPING.md +154 -0
- package/README.md +39 -0
- package/bin/sellable-install.mjs +175 -10
- package/lib/runtime-verify.mjs +40 -249
- package/package.json +3 -2
- package/skill-templates/create-evergreen-campaigns.md +8 -56
- package/skill-templates/refill-sends-v2.md +0 -5
- package/skill-templates/refill-sends.md +0 -5
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Hermes Slack Profile Scoping
|
|
2
|
+
|
|
3
|
+
Use this when creating or updating customer Hermes profiles that need Slack
|
|
4
|
+
access.
|
|
5
|
+
|
|
6
|
+
## Default Rule
|
|
7
|
+
|
|
8
|
+
For Sellable-managed customer channels, use one shared Sellable Reply Bot
|
|
9
|
+
Socket Mode listener and route inbound events by exact channel id. Do not run
|
|
10
|
+
multiple active Hermes profile gateways against the same `SLACK_APP_TOKEN`.
|
|
11
|
+
|
|
12
|
+
The shared dispatcher maps `slackTeamId + channelId` to `customerSlug`,
|
|
13
|
+
`workspaceId`, `hermesProfile`, and `responsePolicy`. It then invokes
|
|
14
|
+
`hermes -p <profile>` with profile-local `SELLABLE_CONFIG_PATH` and
|
|
15
|
+
`SELLABLE_CONFIGS_DIR`, and posts the response back to the original Slack
|
|
16
|
+
channel/thread.
|
|
17
|
+
|
|
18
|
+
Customer profiles do not store the shared raw Slack token pair; route records
|
|
19
|
+
store metadata and secret references only. Use dedicated per-customer Slack
|
|
20
|
+
app/token pairs only for customer-owned Slack workspaces or strict enterprise
|
|
21
|
+
isolation.
|
|
22
|
+
|
|
23
|
+
The 2026-07-06 Hostinger POC proved the native Hermes listener for the
|
|
24
|
+
`sellable-admin` profile with the existing `Sellable Reply Bot` app in
|
|
25
|
+
`#team-chat`: a human mention produced a new Hermes session and a threaded bot
|
|
26
|
+
reply. Treat that as proof that the gateway/listener path works for one owning
|
|
27
|
+
profile, not as approval for multiple active customer profiles sharing one app
|
|
28
|
+
token.
|
|
29
|
+
|
|
30
|
+
The same day, the `acme` profile reused the same redacted Slack token
|
|
31
|
+
fingerprints for an outbound-only smoke to `#sellable-acme` via:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
hermes --profile acme send --to slack:C0BFERDV3N0 --subject '[Hermes acme smoke]' ...
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
That succeeded and is useful as an internal smoke test. It does not change the
|
|
38
|
+
inbound listener rule: one shared dispatcher should own Socket Mode for
|
|
39
|
+
Sellable-managed channels.
|
|
40
|
+
|
|
41
|
+
## Which Slack Values Are Enough
|
|
42
|
+
|
|
43
|
+
For outbound sends and ordinary Slack Web API calls, the important value is the
|
|
44
|
+
profile-local `SLACK_BOT_TOKEN`.
|
|
45
|
+
|
|
46
|
+
For native Hermes live listening through Slack Socket Mode, the profile also
|
|
47
|
+
needs `SLACK_APP_TOKEN`, an app-level `xapp-...` token with `connections:write`.
|
|
48
|
+
`SLACK_APP_ID`, `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`,
|
|
49
|
+
`SLACK_SIGNING_SECRET`, `SLACK_VERIFICATION_TOKEN`, `SLACK_TEAM_ID`, and
|
|
50
|
+
`SLACK_OPERATOR_EMAIL` do not replace `SLACK_APP_TOKEN`.
|
|
51
|
+
|
|
52
|
+
## Required Slack App Shape
|
|
53
|
+
|
|
54
|
+
Outbound Slack sends can work with fewer permissions. Native Hermes inbound
|
|
55
|
+
listening needs the Slack app itself to be configured for Socket Mode and event
|
|
56
|
+
delivery:
|
|
57
|
+
|
|
58
|
+
- Enable Socket Mode.
|
|
59
|
+
- Create an app-level token with `connections:write`.
|
|
60
|
+
- Install the bot with `app_mentions:read`, `channels:history`,
|
|
61
|
+
`channels:read`, `chat:write`, `groups:history`, `groups:read`,
|
|
62
|
+
`im:history`, `im:read`, `mpim:history`, `mpim:read`, and `users:read`.
|
|
63
|
+
- Subscribe bot events for `app_mention`, `message.channels`,
|
|
64
|
+
`message.groups`, `message.im`, and `message.mpim`.
|
|
65
|
+
- Reinstall the app after changing scopes or event subscriptions.
|
|
66
|
+
|
|
67
|
+
If the Slack app has `incoming-webhook`, Slack will ask for a webhook channel
|
|
68
|
+
during reinstall. Pick the profile home channel. That webhook selection does
|
|
69
|
+
not replace `SLACK_ALLOWED_CHANNELS`; keep the profile-local allowlist explicit.
|
|
70
|
+
|
|
71
|
+
## Profile Bootstrap Command
|
|
72
|
+
|
|
73
|
+
Use the Sellable installer profile bootstrap:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
sellable hermes profile bootstrap \
|
|
77
|
+
--profile acme \
|
|
78
|
+
--profiles-root /srv/hermes/profiles \
|
|
79
|
+
--workspace-id ws_acme \
|
|
80
|
+
--workspace-name Acme \
|
|
81
|
+
--token-file /run/secrets/sellable-acme-token \
|
|
82
|
+
--slack-bot-token "$SLACK_BOT_TOKEN" \
|
|
83
|
+
--slack-app-token "$SLACK_APP_TOKEN" \
|
|
84
|
+
--slack-home-channel C0ACME12345 \
|
|
85
|
+
--slack-home-channel-name sellable-acme \
|
|
86
|
+
--slack-allowed-users U0OPERATOR1 \
|
|
87
|
+
--slack-require-mention true \
|
|
88
|
+
--json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
If `--slack-home-channel` is supplied and `--slack-allowed-channels` is omitted,
|
|
92
|
+
the installer writes `SLACK_ALLOWED_CHANNELS=<home-channel-id>` automatically.
|
|
93
|
+
That makes the profile fail closed to the customer channel by default.
|
|
94
|
+
|
|
95
|
+
Use `--slack-allowed-channels C...,G...` only when a profile is intentionally
|
|
96
|
+
allowed to listen in more than one channel. Pass Slack IDs, not `#channel-name`
|
|
97
|
+
strings or wildcards.
|
|
98
|
+
|
|
99
|
+
## Resulting Profile Env
|
|
100
|
+
|
|
101
|
+
For the dedicated customer Slack app model, the profile-local `.env` should
|
|
102
|
+
contain only scoped Slack keys for that profile:
|
|
103
|
+
|
|
104
|
+
```env
|
|
105
|
+
SLACK_BOT_TOKEN=...
|
|
106
|
+
SLACK_APP_TOKEN=...
|
|
107
|
+
SLACK_HOME_CHANNEL=C0ACME12345
|
|
108
|
+
SLACK_HOME_CHANNEL_NAME=sellable-acme
|
|
109
|
+
SLACK_ALLOWED_CHANNELS=C0ACME12345
|
|
110
|
+
SLACK_ALLOWED_USERS=U0OPERATOR1
|
|
111
|
+
SLACK_REQUIRE_MENTION=true
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Keep the file mode at `0600`. Do not copy raw tokens into planning docs, chat,
|
|
115
|
+
or committed artifacts. Record token fingerprints only.
|
|
116
|
+
|
|
117
|
+
For the shared dispatcher model, do not copy the shared `SLACK_BOT_TOKEN` or
|
|
118
|
+
`SLACK_APP_TOKEN` into customer profile `.env` files. The dispatcher owns those
|
|
119
|
+
tokens and route metadata decides the customer profile.
|
|
120
|
+
|
|
121
|
+
## Shared Dispatcher Ownership
|
|
122
|
+
|
|
123
|
+
The shared Slack dispatcher and its route registry are internal Sellable Admin
|
|
124
|
+
surfaces. This customer-facing installer must not create Slack apps, write
|
|
125
|
+
shared listener tokens, or validate cross-customer route bundles. Sellable Admin
|
|
126
|
+
owns those steps, then calls this installer only for profile-local Sellable
|
|
127
|
+
runtime bootstrap inside the already-provisioned Hermes profile.
|
|
128
|
+
|
|
129
|
+
## Verification
|
|
130
|
+
|
|
131
|
+
Run the installer and dispatcher UAT before publishing installer changes:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
npm run test:unit -- tests/install-package/agent-preferences.test.ts
|
|
135
|
+
scripts/run-phase92-hermes-profile-bootstrap-uat.sh --mode local-temp
|
|
136
|
+
scripts/run-phase92-hermes-profile-bootstrap-uat.sh --mode linux-shaped
|
|
137
|
+
scripts/run-phase031-hermes-slack-dispatcher-uat.sh --mode local-fixture
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Expected proof:
|
|
141
|
+
|
|
142
|
+
- profile-local Sellable config paths remain authoritative
|
|
143
|
+
- generated profile `.env` has `SLACK_HOME_CHANNEL`
|
|
144
|
+
- generated profile `.env` has `SLACK_ALLOWED_CHANNELS`
|
|
145
|
+
- `acme` allowlist points at the `sellable-acme` fixture channel id
|
|
146
|
+
- redaction scan passes
|
|
147
|
+
|
|
148
|
+
## Current Phase 03 Status
|
|
149
|
+
|
|
150
|
+
Phase 03 has proved the profile-scoped installer path, outbound Hermes send
|
|
151
|
+
path, and live Slack listener for the `sellable-admin` profile. Phase 03.1 adds
|
|
152
|
+
the shared dispatcher route registry as a Sellable Admin-owned onboarding
|
|
153
|
+
surface so managed customer channels can be onboarded through the VPS without
|
|
154
|
+
local-only browser state, manual SSH edits, or untracked processes.
|
package/README.md
CHANGED
|
@@ -122,6 +122,15 @@ It creates:
|
|
|
122
122
|
/srv/hermes/profiles/acme/.env # only when Slack token inputs are supplied
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
+
Bootstrap also writes `SELLABLE_LOCK_WORKSPACE_ID=<workspace-id>` into the
|
|
126
|
+
profile's Hermes MCP env. Customer profiles are therefore locked to their own
|
|
127
|
+
Sellable workspace: Sellable MCP requests are forced to that workspace,
|
|
128
|
+
workspace switching/creation is blocked, and workspace listings are filtered.
|
|
129
|
+
Use the `sellable-admin` profile for provisioning or cross-profile inspection.
|
|
130
|
+
|
|
131
|
+
See [`HERMES-SLACK-PROFILE-SCOPING.md`](./HERMES-SLACK-PROFILE-SCOPING.md) for
|
|
132
|
+
the customer Slack isolation contract and Socket Mode token requirements.
|
|
133
|
+
|
|
125
134
|
If no token is supplied, bootstrap still creates a pending
|
|
126
135
|
`sellable/config.json` so the profile path is concrete from the first MCP
|
|
127
136
|
launch. Finish auth with:
|
|
@@ -142,6 +151,36 @@ sellable hermes profile bootstrap \
|
|
|
142
151
|
--slack-app-token "$SLACK_APP_TOKEN"
|
|
143
152
|
```
|
|
144
153
|
|
|
154
|
+
The Slack app must also be configured for inbound Socket Mode delivery. Outbound
|
|
155
|
+
Slack Web API sends can work before this is complete, but Hermes will not hear
|
|
156
|
+
mentions until the app has Socket Mode enabled, an app-level token with
|
|
157
|
+
`connections:write`, bot scopes/events for `app_mention` plus channel/group/IM
|
|
158
|
+
message events, and a fresh workspace reinstall after scope or event changes.
|
|
159
|
+
|
|
160
|
+
Scope the profile to its customer channel at install time:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
sellable hermes profile bootstrap \
|
|
164
|
+
--profile acme \
|
|
165
|
+
--profiles-root /srv/hermes/profiles \
|
|
166
|
+
--workspace-id ws_acme \
|
|
167
|
+
--slack-bot-token "$SLACK_BOT_TOKEN" \
|
|
168
|
+
--slack-app-token "$SLACK_APP_TOKEN" \
|
|
169
|
+
--slack-home-channel C0ACME12345 \
|
|
170
|
+
--slack-home-channel-name sellable-acme \
|
|
171
|
+
--slack-allowed-users U0OPERATOR1 \
|
|
172
|
+
--slack-require-mention true
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
When `--slack-home-channel` is supplied without
|
|
176
|
+
`--slack-allowed-channels`, bootstrap writes
|
|
177
|
+
`SLACK_ALLOWED_CHANNELS=<home-channel-id>` too. That fail-closes a customer
|
|
178
|
+
profile to the intended channel by default. Use
|
|
179
|
+
`--slack-allowed-channels C...,G...` only when a profile is deliberately allowed
|
|
180
|
+
to listen in multiple channels. Pass channel IDs, not `#channel-name`, and use
|
|
181
|
+
separate Slack bot/app tokens per customer profile when you need hard isolation
|
|
182
|
+
between concurrently listening Hermes profiles.
|
|
183
|
+
|
|
145
184
|
Customer profiles should use customer-scoped Sellable credentials. A shared
|
|
146
185
|
Sellable admin token can work for internal admin automation, but it is weaker
|
|
147
186
|
isolation and should not be the default for customer profiles. After changing
|
package/bin/sellable-install.mjs
CHANGED
|
@@ -217,9 +217,18 @@ Hermes profile bootstrap:
|
|
|
217
217
|
|
|
218
218
|
Bootstrap writes <profileRoot>/config.yaml, <profileRoot>/sellable/config.json,
|
|
219
219
|
<profileRoot>/sellable/configs, Sellable Hermes skills, and optional
|
|
220
|
-
<profileRoot>/.env Slack token keys.
|
|
221
|
-
|
|
222
|
-
|
|
220
|
+
<profileRoot>/.env Slack token and channel-scope keys. For customer profiles,
|
|
221
|
+
pass --slack-home-channel; bootstrap defaults SLACK_ALLOWED_CHANNELS to that
|
|
222
|
+
exact channel id unless --slack-allowed-channels is supplied. Use
|
|
223
|
+
customer-scoped Sellable credentials and customer-scoped Slack credentials for
|
|
224
|
+
customer profiles; shared admin tokens are weaker isolation and should remain
|
|
225
|
+
internal-only.
|
|
226
|
+
|
|
227
|
+
Hermes shared Slack dispatcher:
|
|
228
|
+
Shared listener setup and route-registry validation are internal Sellable
|
|
229
|
+
Admin responsibilities. This customer-facing installer only bootstraps a
|
|
230
|
+
profile-local Sellable runtime after Sellable Admin provisions the profile
|
|
231
|
+
and dispatcher route.
|
|
223
232
|
`;
|
|
224
233
|
}
|
|
225
234
|
|
|
@@ -3505,6 +3514,9 @@ function mcpEnvForHost(host, opts) {
|
|
|
3505
3514
|
if (opts.sellableConfigsDir) {
|
|
3506
3515
|
env.SELLABLE_CONFIGS_DIR = opts.sellableConfigsDir;
|
|
3507
3516
|
}
|
|
3517
|
+
if (opts.lockWorkspaceId) {
|
|
3518
|
+
env.SELLABLE_LOCK_WORKSPACE_ID = opts.lockWorkspaceId;
|
|
3519
|
+
}
|
|
3508
3520
|
return env;
|
|
3509
3521
|
}
|
|
3510
3522
|
|
|
@@ -3789,6 +3801,12 @@ function parseHermesProfileBootstrapArgs(argv) {
|
|
|
3789
3801
|
apiUrl: process.env.SELLABLE_API_URL || DEFAULT_API_URL,
|
|
3790
3802
|
slackBotToken: process.env.SLACK_BOT_TOKEN || "",
|
|
3791
3803
|
slackAppToken: process.env.SLACK_APP_TOKEN || "",
|
|
3804
|
+
slackHomeChannel: process.env.SLACK_HOME_CHANNEL || "",
|
|
3805
|
+
slackHomeChannelName: process.env.SLACK_HOME_CHANNEL_NAME || "",
|
|
3806
|
+
slackAllowedChannels: process.env.SLACK_ALLOWED_CHANNELS || "",
|
|
3807
|
+
slackFreeResponseChannels: process.env.SLACK_FREE_RESPONSE_CHANNELS || "",
|
|
3808
|
+
slackAllowedUsers: process.env.SLACK_ALLOWED_USERS || "",
|
|
3809
|
+
slackRequireMention: process.env.SLACK_REQUIRE_MENTION || "",
|
|
3792
3810
|
server: process.env.SELLABLE_INSTALL_SERVER || "package",
|
|
3793
3811
|
mcpPackage: DEFAULT_SERVER_PACKAGE,
|
|
3794
3812
|
localCommand: process.env.SELLABLE_MCP_LOCAL_COMMAND || "",
|
|
@@ -3821,6 +3839,12 @@ function parseHermesProfileBootstrapArgs(argv) {
|
|
|
3821
3839
|
else if (arg === "--api-url") opts.apiUrl = next();
|
|
3822
3840
|
else if (arg === "--slack-bot-token") opts.slackBotToken = next();
|
|
3823
3841
|
else if (arg === "--slack-app-token") opts.slackAppToken = next();
|
|
3842
|
+
else if (arg === "--slack-home-channel") opts.slackHomeChannel = next();
|
|
3843
|
+
else if (arg === "--slack-home-channel-name") opts.slackHomeChannelName = next();
|
|
3844
|
+
else if (arg === "--slack-allowed-channels") opts.slackAllowedChannels = next();
|
|
3845
|
+
else if (arg === "--slack-free-response-channels") opts.slackFreeResponseChannels = next();
|
|
3846
|
+
else if (arg === "--slack-allowed-users") opts.slackAllowedUsers = next();
|
|
3847
|
+
else if (arg === "--slack-require-mention") opts.slackRequireMention = next();
|
|
3824
3848
|
else if (arg === "--server") opts.server = next();
|
|
3825
3849
|
else if (arg === "--mcp-package") {
|
|
3826
3850
|
opts.mcpPackage = normalizeWindowsPackageSpec(next());
|
|
@@ -3922,6 +3946,111 @@ function renderEnvFile({ entries, passthrough }) {
|
|
|
3922
3946
|
return `${lines.join("\n")}${lines.length ? "\n" : ""}`;
|
|
3923
3947
|
}
|
|
3924
3948
|
|
|
3949
|
+
function normalizeSlackChannelIds(value, key) {
|
|
3950
|
+
const raw = String(value || "").trim();
|
|
3951
|
+
if (!raw) return "";
|
|
3952
|
+
const ids = raw
|
|
3953
|
+
.split(",")
|
|
3954
|
+
.map((item) => item.trim())
|
|
3955
|
+
.filter(Boolean);
|
|
3956
|
+
if (ids.length === 0) return "";
|
|
3957
|
+
|
|
3958
|
+
for (const id of ids) {
|
|
3959
|
+
if (
|
|
3960
|
+
id === "*" ||
|
|
3961
|
+
/^all$/i.test(id) ||
|
|
3962
|
+
id.startsWith("#") ||
|
|
3963
|
+
/\s/.test(id) ||
|
|
3964
|
+
!/^[A-Z][A-Z0-9]{8,}$/.test(id)
|
|
3965
|
+
) {
|
|
3966
|
+
throw new Error(
|
|
3967
|
+
`${key} must be comma-separated Slack channel IDs, not names, wildcards, or free text. Bad value: ${id}`
|
|
3968
|
+
);
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
return [...new Set(ids)].join(",");
|
|
3972
|
+
}
|
|
3973
|
+
|
|
3974
|
+
function normalizeSlackSingleChannelId(value, key) {
|
|
3975
|
+
const normalized = normalizeSlackChannelIds(value, key);
|
|
3976
|
+
if (normalized.includes(",")) {
|
|
3977
|
+
throw new Error(`${key} must be a single Slack channel ID`);
|
|
3978
|
+
}
|
|
3979
|
+
return normalized;
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
function normalizeSlackUserIds(value, key) {
|
|
3983
|
+
const raw = String(value || "").trim();
|
|
3984
|
+
if (!raw) return "";
|
|
3985
|
+
const ids = raw
|
|
3986
|
+
.split(",")
|
|
3987
|
+
.map((item) => item.trim())
|
|
3988
|
+
.filter(Boolean);
|
|
3989
|
+
if (ids.length === 0) return "";
|
|
3990
|
+
|
|
3991
|
+
for (const id of ids) {
|
|
3992
|
+
if (!/^[UW][A-Z0-9]{8,}$/.test(id)) {
|
|
3993
|
+
throw new Error(`${key} must be comma-separated Slack user IDs. Bad value: ${id}`);
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
return [...new Set(ids)].join(",");
|
|
3997
|
+
}
|
|
3998
|
+
|
|
3999
|
+
function normalizeSlackChannelName(value, key) {
|
|
4000
|
+
const raw = String(value || "").trim();
|
|
4001
|
+
if (!raw) return "";
|
|
4002
|
+
if (raw.startsWith("#") || /[\s,]/.test(raw)) {
|
|
4003
|
+
throw new Error(`${key} must be a Slack channel name without #, spaces, or commas`);
|
|
4004
|
+
}
|
|
4005
|
+
return raw;
|
|
4006
|
+
}
|
|
4007
|
+
|
|
4008
|
+
function normalizeSlackBoolean(value, key) {
|
|
4009
|
+
const raw = String(value || "").trim().toLowerCase();
|
|
4010
|
+
if (!raw) return "";
|
|
4011
|
+
if (raw !== "true" && raw !== "false") {
|
|
4012
|
+
throw new Error(`${key} must be true or false`);
|
|
4013
|
+
}
|
|
4014
|
+
return raw;
|
|
4015
|
+
}
|
|
4016
|
+
|
|
4017
|
+
function buildSlackProfileEnv(parsed) {
|
|
4018
|
+
const homeChannel = normalizeSlackSingleChannelId(
|
|
4019
|
+
parsed.slackHomeChannel,
|
|
4020
|
+
"SLACK_HOME_CHANNEL"
|
|
4021
|
+
);
|
|
4022
|
+
const explicitAllowedChannels = normalizeSlackChannelIds(
|
|
4023
|
+
parsed.slackAllowedChannels,
|
|
4024
|
+
"SLACK_ALLOWED_CHANNELS"
|
|
4025
|
+
);
|
|
4026
|
+
const allowedChannels = explicitAllowedChannels || homeChannel;
|
|
4027
|
+
|
|
4028
|
+
return {
|
|
4029
|
+
SLACK_BOT_TOKEN: parsed.slackBotToken.trim(),
|
|
4030
|
+
SLACK_APP_TOKEN: parsed.slackAppToken.trim(),
|
|
4031
|
+
SLACK_HOME_CHANNEL: homeChannel,
|
|
4032
|
+
SLACK_HOME_CHANNEL_NAME: normalizeSlackChannelName(
|
|
4033
|
+
parsed.slackHomeChannelName,
|
|
4034
|
+
"SLACK_HOME_CHANNEL_NAME"
|
|
4035
|
+
),
|
|
4036
|
+
// Customer Hermes profiles must fail closed to their channel. If the caller
|
|
4037
|
+
// supplies only a home channel, we treat that as the allowlist too.
|
|
4038
|
+
SLACK_ALLOWED_CHANNELS: allowedChannels,
|
|
4039
|
+
SLACK_FREE_RESPONSE_CHANNELS: normalizeSlackChannelIds(
|
|
4040
|
+
parsed.slackFreeResponseChannels,
|
|
4041
|
+
"SLACK_FREE_RESPONSE_CHANNELS"
|
|
4042
|
+
),
|
|
4043
|
+
SLACK_ALLOWED_USERS: normalizeSlackUserIds(
|
|
4044
|
+
parsed.slackAllowedUsers,
|
|
4045
|
+
"SLACK_ALLOWED_USERS"
|
|
4046
|
+
),
|
|
4047
|
+
SLACK_REQUIRE_MENTION: normalizeSlackBoolean(
|
|
4048
|
+
parsed.slackRequireMention,
|
|
4049
|
+
"SLACK_REQUIRE_MENTION"
|
|
4050
|
+
),
|
|
4051
|
+
};
|
|
4052
|
+
}
|
|
4053
|
+
|
|
3925
4054
|
function findDuplicateSlackTokenProfiles(profileRoot, slackTokens) {
|
|
3926
4055
|
const duplicates = [];
|
|
3927
4056
|
const profilesRoot = dirname(profileRoot);
|
|
@@ -3974,10 +4103,14 @@ function runHermesProfileBootstrap(argv) {
|
|
|
3974
4103
|
const sellableConfigsDir = join(profileRoot, "sellable", "configs");
|
|
3975
4104
|
const envPath = join(profileRoot, ".env");
|
|
3976
4105
|
const { token, source: tokenSource } = readBootstrapToken(parsed);
|
|
4106
|
+
const slackEnv = buildSlackProfileEnv(parsed);
|
|
3977
4107
|
const slackTokens = {
|
|
3978
|
-
SLACK_BOT_TOKEN:
|
|
3979
|
-
SLACK_APP_TOKEN:
|
|
4108
|
+
SLACK_BOT_TOKEN: slackEnv.SLACK_BOT_TOKEN,
|
|
4109
|
+
SLACK_APP_TOKEN: slackEnv.SLACK_APP_TOKEN,
|
|
3980
4110
|
};
|
|
4111
|
+
const slackEnvKeys = Object.entries(slackEnv)
|
|
4112
|
+
.filter(([, value]) => value)
|
|
4113
|
+
.map(([key]) => key);
|
|
3981
4114
|
const opts = {
|
|
3982
4115
|
...parsed,
|
|
3983
4116
|
host: "hermes",
|
|
@@ -3987,6 +4120,7 @@ function runHermesProfileBootstrap(argv) {
|
|
|
3987
4120
|
token,
|
|
3988
4121
|
tokenSource,
|
|
3989
4122
|
workspaceId: parsed.workspaceId,
|
|
4123
|
+
lockWorkspaceId: parsed.workspaceId,
|
|
3990
4124
|
apiUrl: parsed.apiUrl,
|
|
3991
4125
|
};
|
|
3992
4126
|
|
|
@@ -4011,6 +4145,9 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4011
4145
|
apiUrl: opts.apiUrl,
|
|
4012
4146
|
activeWorkspaceId: parsed.workspaceId || null,
|
|
4013
4147
|
activeWorkspaceName: parsed.workspaceName || null,
|
|
4148
|
+
workspaceLock: parsed.workspaceId
|
|
4149
|
+
? { enabled: true, workspaceId: parsed.workspaceId }
|
|
4150
|
+
: { enabled: false, workspaceId: null },
|
|
4014
4151
|
tokenPresent: Boolean(token),
|
|
4015
4152
|
tokenSource,
|
|
4016
4153
|
tokenFingerprint: tokenFingerprint(token),
|
|
@@ -4019,11 +4156,29 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4019
4156
|
keys: Object.entries(slackTokens)
|
|
4020
4157
|
.filter(([, value]) => value)
|
|
4021
4158
|
.map(([key]) => key),
|
|
4159
|
+
envKeys: slackEnvKeys,
|
|
4022
4160
|
fingerprints: Object.fromEntries(
|
|
4023
4161
|
Object.entries(slackTokens)
|
|
4024
4162
|
.filter(([, value]) => value)
|
|
4025
4163
|
.map(([key, value]) => [key, tokenFingerprint(value)])
|
|
4026
4164
|
),
|
|
4165
|
+
scope: {
|
|
4166
|
+
homeChannel: slackEnv.SLACK_HOME_CHANNEL || null,
|
|
4167
|
+
homeChannelName: slackEnv.SLACK_HOME_CHANNEL_NAME || null,
|
|
4168
|
+
allowedChannels: slackEnv.SLACK_ALLOWED_CHANNELS
|
|
4169
|
+
? slackEnv.SLACK_ALLOWED_CHANNELS.split(",")
|
|
4170
|
+
: [],
|
|
4171
|
+
freeResponseChannels: slackEnv.SLACK_FREE_RESPONSE_CHANNELS
|
|
4172
|
+
? slackEnv.SLACK_FREE_RESPONSE_CHANNELS.split(",")
|
|
4173
|
+
: [],
|
|
4174
|
+
allowedUsersCount: slackEnv.SLACK_ALLOWED_USERS
|
|
4175
|
+
? slackEnv.SLACK_ALLOWED_USERS.split(",").length
|
|
4176
|
+
: 0,
|
|
4177
|
+
requireMention:
|
|
4178
|
+
slackEnv.SLACK_REQUIRE_MENTION === ""
|
|
4179
|
+
? null
|
|
4180
|
+
: slackEnv.SLACK_REQUIRE_MENTION === "true",
|
|
4181
|
+
},
|
|
4027
4182
|
},
|
|
4028
4183
|
created: [],
|
|
4029
4184
|
updated: [],
|
|
@@ -4068,7 +4223,7 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4068
4223
|
if (existsSync(envPath)) {
|
|
4069
4224
|
envState = parseEnvLines(readFileSync(envPath, "utf8"));
|
|
4070
4225
|
}
|
|
4071
|
-
for (const [key, value] of Object.entries(
|
|
4226
|
+
for (const [key, value] of Object.entries(slackEnv)) {
|
|
4072
4227
|
if (!value) continue;
|
|
4073
4228
|
const existing = envState.entries.get(key);
|
|
4074
4229
|
if (existing && existing !== value && !opts.overwriteEnv) {
|
|
@@ -4128,8 +4283,8 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4128
4283
|
mkdirSync(sellableConfigsDir, { recursive: true, mode: 0o700 });
|
|
4129
4284
|
writeHermesProfileSellableConfig(sellableConfigPath, nextAuth, opts);
|
|
4130
4285
|
installHermes(opts);
|
|
4131
|
-
if (Object.values(
|
|
4132
|
-
for (const [key, value] of Object.entries(
|
|
4286
|
+
if (Object.values(slackEnv).some(Boolean)) {
|
|
4287
|
+
for (const [key, value] of Object.entries(slackEnv)) {
|
|
4133
4288
|
if (value) envState.entries.set(key, value);
|
|
4134
4289
|
}
|
|
4135
4290
|
writeFile(envPath, renderEnvFile(envState), opts, 0o600);
|
|
@@ -4144,10 +4299,20 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4144
4299
|
hermesConfigPath(opts)
|
|
4145
4300
|
);
|
|
4146
4301
|
(skillsExisted ? summary.updated : summary.created).push(hermesSkillsRoot(opts));
|
|
4147
|
-
if (Object.values(
|
|
4302
|
+
if (Object.values(slackEnv).some(Boolean)) {
|
|
4148
4303
|
(envExisted ? summary.updated : summary.created).push(envPath);
|
|
4149
4304
|
} else {
|
|
4150
|
-
summary.skipped.push("Slack env: no Slack tokens supplied");
|
|
4305
|
+
summary.skipped.push("Slack env: no Slack tokens or channel scope supplied");
|
|
4306
|
+
}
|
|
4307
|
+
if (slackEnv.SLACK_HOME_CHANNEL && !parsed.slackAllowedChannels.trim()) {
|
|
4308
|
+
summary.warnings.push(
|
|
4309
|
+
"Slack scope defaulted SLACK_ALLOWED_CHANNELS to SLACK_HOME_CHANNEL for fail-closed profile listening."
|
|
4310
|
+
);
|
|
4311
|
+
}
|
|
4312
|
+
if (slackEnv.SLACK_BOT_TOKEN && !slackEnv.SLACK_APP_TOKEN) {
|
|
4313
|
+
summary.warnings.push(
|
|
4314
|
+
"Slack bot token is present without SLACK_APP_TOKEN; outbound Slack sends may work, but native Hermes Socket Mode listening still requires an xapp app-level token with connections:write."
|
|
4315
|
+
);
|
|
4151
4316
|
}
|
|
4152
4317
|
if (!token) {
|
|
4153
4318
|
summary.warnings.push(
|
package/lib/runtime-verify.mjs
CHANGED
|
@@ -45,14 +45,6 @@ export const REQUIRED_SELLABLE_MCP_TOOLS = [
|
|
|
45
45
|
|
|
46
46
|
export const FORBIDDEN_SELLABLE_MCP_TOOLS = ["refill_campaign_sends"];
|
|
47
47
|
|
|
48
|
-
const CONNECTION_ONLY_EVERGREEN_PROMPT_CHUNK_LIMIT = 48_000;
|
|
49
|
-
const CONNECTION_ONLY_EVERGREEN_MAX_PROMPT_CHUNKS = 8;
|
|
50
|
-
const CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES = {
|
|
51
|
-
campaignSequenceOptions: 'campaignSequenceOptions:{ mode:"connection_only" }',
|
|
52
|
-
templateRef: 'templateRef:"connection_only"',
|
|
53
|
-
sendInviteReceipt: 'sequenceReceipt.actionTypes:["send_invite"]',
|
|
54
|
-
};
|
|
55
|
-
|
|
56
48
|
export const CREATE_CAMPAIGN_SMOKE_CALLS = [
|
|
57
49
|
{
|
|
58
50
|
name: "get_auth_status",
|
|
@@ -92,14 +84,6 @@ export const CREATE_CAMPAIGN_SMOKE_CALLS = [
|
|
|
92
84
|
limit: 1200,
|
|
93
85
|
},
|
|
94
86
|
},
|
|
95
|
-
{
|
|
96
|
-
name: "get_subskill_prompt",
|
|
97
|
-
arguments: {
|
|
98
|
-
subskillName: "create-evergreen-campaigns",
|
|
99
|
-
offset: 0,
|
|
100
|
-
limit: CONNECTION_ONLY_EVERGREEN_PROMPT_CHUNK_LIMIT,
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
87
|
{
|
|
104
88
|
name: "refill_sends",
|
|
105
89
|
arguments: {
|
|
@@ -203,43 +187,6 @@ function parseJsonText(text) {
|
|
|
203
187
|
}
|
|
204
188
|
}
|
|
205
189
|
|
|
206
|
-
function emptyConnectionOnlyEvergreenProofs() {
|
|
207
|
-
return Object.fromEntries(
|
|
208
|
-
Object.keys(CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES).map((key) => [
|
|
209
|
-
key,
|
|
210
|
-
false,
|
|
211
|
-
])
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function getConnectionOnlyEvergreenProofs(prompt) {
|
|
216
|
-
return Object.fromEntries(
|
|
217
|
-
Object.entries(CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES).map(
|
|
218
|
-
([key, needle]) => [key, prompt.includes(needle)]
|
|
219
|
-
)
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function mergeConnectionOnlyEvergreenProofs(target, source) {
|
|
224
|
-
if (!source) return target;
|
|
225
|
-
for (const key of Object.keys(CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES)) {
|
|
226
|
-
target[key] = target[key] === true || source[key] === true;
|
|
227
|
-
}
|
|
228
|
-
return target;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function summarizeConnectionOnlyEvergreenProofs(proofs) {
|
|
232
|
-
const merged = { ...emptyConnectionOnlyEvergreenProofs(), ...(proofs || {}) };
|
|
233
|
-
const missing = Object.keys(CONNECTION_ONLY_EVERGREEN_PROOF_NEEDLES).filter(
|
|
234
|
-
(key) => merged[key] !== true
|
|
235
|
-
);
|
|
236
|
-
return {
|
|
237
|
-
ok: missing.length === 0,
|
|
238
|
-
proofs: merged,
|
|
239
|
-
missing,
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
|
|
243
190
|
function summarizeToolResult(name, result) {
|
|
244
191
|
const text = redact(textFromToolResult(result));
|
|
245
192
|
const parsed = parseJsonText(text);
|
|
@@ -283,26 +230,10 @@ function summarizeToolResult(name, result) {
|
|
|
283
230
|
}
|
|
284
231
|
|
|
285
232
|
if (name === "get_subskill_prompt" && parsed) {
|
|
286
|
-
const prompt = typeof parsed.prompt === "string" ? parsed.prompt : "";
|
|
287
|
-
const connectionOnlyEvergreenProofs =
|
|
288
|
-
parsed.name === "create-evergreen-campaigns"
|
|
289
|
-
? getConnectionOnlyEvergreenProofs(prompt)
|
|
290
|
-
: null;
|
|
291
233
|
return {
|
|
292
234
|
...summary,
|
|
293
235
|
subskillName: parsed.name || null,
|
|
294
|
-
promptChars: prompt.length,
|
|
295
|
-
promptLength:
|
|
296
|
-
typeof parsed.promptLength === "number" ? parsed.promptLength : null,
|
|
297
|
-
offset: typeof parsed.offset === "number" ? parsed.offset : null,
|
|
298
|
-
limit: typeof parsed.limit === "number" ? parsed.limit : null,
|
|
299
|
-
connectionOnlyEvergreenProofs,
|
|
300
|
-
connectionOnlyEvergreenGuidance:
|
|
301
|
-
parsed.name === "create-evergreen-campaigns"
|
|
302
|
-
? summarizeConnectionOnlyEvergreenProofs(
|
|
303
|
-
connectionOnlyEvergreenProofs
|
|
304
|
-
).ok
|
|
305
|
-
: null,
|
|
236
|
+
promptChars: typeof parsed.prompt === "string" ? parsed.prompt.length : 0,
|
|
306
237
|
hasMore: parsed.hasMore === true,
|
|
307
238
|
nextOffset:
|
|
308
239
|
typeof parsed.nextOffset === "number" ? parsed.nextOffset : null,
|
|
@@ -336,7 +267,6 @@ async function verifyCreateCampaignSmoke({
|
|
|
336
267
|
.map((call) => call.name)
|
|
337
268
|
.filter((name) => !available.has(name));
|
|
338
269
|
const calls = [];
|
|
339
|
-
const connectionOnlyEvergreenProofs = emptyConnectionOnlyEvergreenProofs();
|
|
340
270
|
|
|
341
271
|
if (missingTools.length > 0) {
|
|
342
272
|
return {
|
|
@@ -350,195 +280,67 @@ async function verifyCreateCampaignSmoke({
|
|
|
350
280
|
}
|
|
351
281
|
|
|
352
282
|
for (const call of smokeCalls) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
{ name: nextCall.name, arguments: nextCall.arguments },
|
|
362
|
-
undefined,
|
|
363
|
-
{
|
|
364
|
-
timeout: timeoutMs,
|
|
365
|
-
maxTotalTimeout: timeoutMs,
|
|
366
|
-
}
|
|
367
|
-
);
|
|
368
|
-
const summary = summarizeToolResult(nextCall.name, result);
|
|
369
|
-
calls.push({
|
|
370
|
-
name: nextCall.name,
|
|
371
|
-
arguments: nextCall.arguments,
|
|
372
|
-
ok: summary.isError !== true,
|
|
373
|
-
summary,
|
|
374
|
-
startedAt: callStartedAt,
|
|
375
|
-
completedAt: new Date().toISOString(),
|
|
376
|
-
});
|
|
377
|
-
if (summary.isError === true) {
|
|
378
|
-
return {
|
|
379
|
-
ok: false,
|
|
380
|
-
missingTools: [],
|
|
381
|
-
calls,
|
|
382
|
-
error: `${nextCall.name} returned an MCP tool error`,
|
|
383
|
-
startedAt,
|
|
384
|
-
completedAt: new Date().toISOString(),
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
const isConnectionOnlyEvergreenPrompt =
|
|
389
|
-
nextCall.name === "get_subskill_prompt" &&
|
|
390
|
-
nextCall.arguments?.subskillName === "create-evergreen-campaigns";
|
|
391
|
-
if (!isConnectionOnlyEvergreenPrompt) {
|
|
392
|
-
break;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
evergreenPromptChunks += 1;
|
|
396
|
-
evergreenPromptOffsets.add(nextCall.arguments?.offset ?? 0);
|
|
397
|
-
mergeConnectionOnlyEvergreenProofs(
|
|
398
|
-
connectionOnlyEvergreenProofs,
|
|
399
|
-
summary.connectionOnlyEvergreenProofs
|
|
400
|
-
);
|
|
401
|
-
|
|
402
|
-
if (summary.hasMore !== true) {
|
|
403
|
-
break;
|
|
404
|
-
}
|
|
405
|
-
if (typeof summary.nextOffset !== "number") {
|
|
406
|
-
return {
|
|
407
|
-
ok: false,
|
|
408
|
-
missingTools: [],
|
|
409
|
-
calls,
|
|
410
|
-
connectionOnlyEvergreenGuidance:
|
|
411
|
-
summarizeConnectionOnlyEvergreenProofs(
|
|
412
|
-
connectionOnlyEvergreenProofs
|
|
413
|
-
),
|
|
414
|
-
error:
|
|
415
|
-
"create-evergreen-campaigns prompt chunk is missing nextOffset",
|
|
416
|
-
startedAt,
|
|
417
|
-
completedAt: new Date().toISOString(),
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
if (evergreenPromptOffsets.has(summary.nextOffset)) {
|
|
421
|
-
return {
|
|
422
|
-
ok: false,
|
|
423
|
-
missingTools: [],
|
|
424
|
-
calls,
|
|
425
|
-
connectionOnlyEvergreenGuidance:
|
|
426
|
-
summarizeConnectionOnlyEvergreenProofs(
|
|
427
|
-
connectionOnlyEvergreenProofs
|
|
428
|
-
),
|
|
429
|
-
error:
|
|
430
|
-
"create-evergreen-campaigns prompt chunk repeated nextOffset",
|
|
431
|
-
startedAt,
|
|
432
|
-
completedAt: new Date().toISOString(),
|
|
433
|
-
};
|
|
434
|
-
}
|
|
435
|
-
if (
|
|
436
|
-
evergreenPromptChunks >= CONNECTION_ONLY_EVERGREEN_MAX_PROMPT_CHUNKS
|
|
437
|
-
) {
|
|
438
|
-
return {
|
|
439
|
-
ok: false,
|
|
440
|
-
missingTools: [],
|
|
441
|
-
calls,
|
|
442
|
-
connectionOnlyEvergreenGuidance:
|
|
443
|
-
summarizeConnectionOnlyEvergreenProofs(
|
|
444
|
-
connectionOnlyEvergreenProofs
|
|
445
|
-
),
|
|
446
|
-
error:
|
|
447
|
-
"create-evergreen-campaigns prompt exceeded smoke chunk limit",
|
|
448
|
-
startedAt,
|
|
449
|
-
completedAt: new Date().toISOString(),
|
|
450
|
-
};
|
|
283
|
+
const callStartedAt = new Date().toISOString();
|
|
284
|
+
try {
|
|
285
|
+
const result = await client.callTool(
|
|
286
|
+
{ name: call.name, arguments: call.arguments },
|
|
287
|
+
undefined,
|
|
288
|
+
{
|
|
289
|
+
timeout: timeoutMs,
|
|
290
|
+
maxTotalTimeout: timeoutMs,
|
|
451
291
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
} catch (err) {
|
|
464
|
-
const error = err instanceof Error ? err.message : String(err);
|
|
465
|
-
calls.push({
|
|
466
|
-
name: nextCall.name,
|
|
467
|
-
arguments: nextCall.arguments,
|
|
468
|
-
ok: false,
|
|
469
|
-
summary: null,
|
|
470
|
-
error: redact(error),
|
|
471
|
-
startedAt: callStartedAt,
|
|
472
|
-
completedAt: new Date().toISOString(),
|
|
473
|
-
});
|
|
292
|
+
);
|
|
293
|
+
const summary = summarizeToolResult(call.name, result);
|
|
294
|
+
calls.push({
|
|
295
|
+
name: call.name,
|
|
296
|
+
arguments: call.arguments,
|
|
297
|
+
ok: summary.isError !== true,
|
|
298
|
+
summary,
|
|
299
|
+
startedAt: callStartedAt,
|
|
300
|
+
completedAt: new Date().toISOString(),
|
|
301
|
+
});
|
|
302
|
+
if (summary.isError === true) {
|
|
474
303
|
return {
|
|
475
304
|
ok: false,
|
|
476
305
|
missingTools: [],
|
|
477
306
|
calls,
|
|
478
|
-
error:
|
|
307
|
+
error: `${call.name} returned an MCP tool error`,
|
|
479
308
|
startedAt,
|
|
480
309
|
completedAt: new Date().toISOString(),
|
|
481
310
|
};
|
|
482
311
|
}
|
|
312
|
+
} catch (err) {
|
|
313
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
314
|
+
calls.push({
|
|
315
|
+
name: call.name,
|
|
316
|
+
arguments: call.arguments,
|
|
317
|
+
ok: false,
|
|
318
|
+
summary: null,
|
|
319
|
+
error: redact(error),
|
|
320
|
+
startedAt: callStartedAt,
|
|
321
|
+
completedAt: new Date().toISOString(),
|
|
322
|
+
});
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
missingTools: [],
|
|
326
|
+
calls,
|
|
327
|
+
error: redact(error),
|
|
328
|
+
startedAt,
|
|
329
|
+
completedAt: new Date().toISOString(),
|
|
330
|
+
};
|
|
483
331
|
}
|
|
484
332
|
}
|
|
485
333
|
|
|
486
|
-
const connectionOnlyEvergreenGuidance =
|
|
487
|
-
summarizeConnectionOnlyEvergreenProofs(connectionOnlyEvergreenProofs);
|
|
488
|
-
if (connectionOnlyEvergreenGuidance.ok !== true) {
|
|
489
|
-
return {
|
|
490
|
-
ok: false,
|
|
491
|
-
missingTools: [],
|
|
492
|
-
calls,
|
|
493
|
-
connectionOnlyEvergreenGuidance,
|
|
494
|
-
error: `create-evergreen-campaigns prompt is missing connection-only guidance: ${connectionOnlyEvergreenGuidance.missing.join(", ")}`,
|
|
495
|
-
startedAt,
|
|
496
|
-
completedAt: new Date().toISOString(),
|
|
497
|
-
};
|
|
498
|
-
}
|
|
499
|
-
|
|
500
334
|
return {
|
|
501
335
|
ok: true,
|
|
502
336
|
missingTools: [],
|
|
503
337
|
calls,
|
|
504
|
-
connectionOnlyEvergreenGuidance,
|
|
505
338
|
error: null,
|
|
506
339
|
startedAt,
|
|
507
340
|
completedAt: new Date().toISOString(),
|
|
508
341
|
};
|
|
509
342
|
}
|
|
510
343
|
|
|
511
|
-
function verifyConnectionOnlyEvergreenContract(tools) {
|
|
512
|
-
const setupTool = (tools || []).find(
|
|
513
|
-
(tool) => tool?.name === "setup_evergreen_campaigns"
|
|
514
|
-
);
|
|
515
|
-
const schema = setupTool?.inputSchema || {};
|
|
516
|
-
const properties = schema.properties || {};
|
|
517
|
-
const sequenceOptions = properties.campaignSequenceOptions || {};
|
|
518
|
-
const mode = sequenceOptions.properties?.mode || {};
|
|
519
|
-
const issues = [];
|
|
520
|
-
|
|
521
|
-
if (!properties.workspaceId) {
|
|
522
|
-
issues.push("setup_evergreen_campaigns.workspaceId");
|
|
523
|
-
}
|
|
524
|
-
if (!properties.campaignSequenceOptions) {
|
|
525
|
-
issues.push("setup_evergreen_campaigns.campaignSequenceOptions");
|
|
526
|
-
}
|
|
527
|
-
if (!Array.isArray(mode.enum) || !mode.enum.includes("connection_only")) {
|
|
528
|
-
issues.push("setup_evergreen_campaigns.campaignSequenceOptions.mode");
|
|
529
|
-
}
|
|
530
|
-
if (sequenceOptions.additionalProperties !== false) {
|
|
531
|
-
issues.push(
|
|
532
|
-
"setup_evergreen_campaigns.campaignSequenceOptions.additionalProperties"
|
|
533
|
-
);
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
return {
|
|
537
|
-
ok: issues.length === 0,
|
|
538
|
-
issues,
|
|
539
|
-
};
|
|
540
|
-
}
|
|
541
|
-
|
|
542
344
|
function summarizeAttempt(result, attempt) {
|
|
543
345
|
return {
|
|
544
346
|
attempt,
|
|
@@ -546,12 +348,6 @@ function summarizeAttempt(result, attempt) {
|
|
|
546
348
|
availableToolCount: result.availableTools?.length || 0,
|
|
547
349
|
missingToolCount: result.missingTools?.length || 0,
|
|
548
350
|
forbiddenToolCount: result.forbiddenTools?.length || 0,
|
|
549
|
-
connectionOnlyEvergreenContract: result.connectionOnlyEvergreenContract
|
|
550
|
-
? {
|
|
551
|
-
ok: result.connectionOnlyEvergreenContract.ok === true,
|
|
552
|
-
issues: result.connectionOnlyEvergreenContract.issues || [],
|
|
553
|
-
}
|
|
554
|
-
: null,
|
|
555
351
|
error: result.error || null,
|
|
556
352
|
createCampaignSmoke: result.createCampaignSmoke
|
|
557
353
|
? {
|
|
@@ -623,7 +419,6 @@ async function verifySellableMcpRuntimeOnce({
|
|
|
623
419
|
let missingTools = [...requiredTools];
|
|
624
420
|
let forbiddenTools = [];
|
|
625
421
|
let createCampaignSmoke = null;
|
|
626
|
-
let connectionOnlyEvergreenContract = { ok: false, issues: [] };
|
|
627
422
|
let error = null;
|
|
628
423
|
|
|
629
424
|
try {
|
|
@@ -637,8 +432,6 @@ async function verifySellableMcpRuntimeOnce({
|
|
|
637
432
|
forbiddenTools = availableTools.filter((tool) =>
|
|
638
433
|
FORBIDDEN_SELLABLE_MCP_TOOLS.includes(tool)
|
|
639
434
|
);
|
|
640
|
-
connectionOnlyEvergreenContract =
|
|
641
|
-
verifyConnectionOnlyEvergreenContract(toolList.tools);
|
|
642
435
|
if (forbiddenTools.length > 0) {
|
|
643
436
|
createCampaignSmoke = {
|
|
644
437
|
ok: false,
|
|
@@ -682,7 +475,6 @@ async function verifySellableMcpRuntimeOnce({
|
|
|
682
475
|
!error &&
|
|
683
476
|
missingTools.length === 0 &&
|
|
684
477
|
forbiddenTools.length === 0 &&
|
|
685
|
-
connectionOnlyEvergreenContract.ok === true &&
|
|
686
478
|
createCampaignSmoke?.ok === true,
|
|
687
479
|
skipped: false,
|
|
688
480
|
command,
|
|
@@ -691,7 +483,6 @@ async function verifySellableMcpRuntimeOnce({
|
|
|
691
483
|
availableTools,
|
|
692
484
|
missingTools,
|
|
693
485
|
forbiddenTools,
|
|
694
|
-
connectionOnlyEvergreenContract,
|
|
695
486
|
createCampaignSmoke,
|
|
696
487
|
stderrTail: stderrLines,
|
|
697
488
|
error: error ? redact(error) : null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sellable/install",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.316",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "One-command installer for Sellable MCP in Claude Code, Codex, and Hermes",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"lib",
|
|
16
16
|
"agents",
|
|
17
17
|
"skill-templates",
|
|
18
|
-
"README.md"
|
|
18
|
+
"README.md",
|
|
19
|
+
"HERMES-SLACK-PROFILE-SCOPING.md"
|
|
19
20
|
],
|
|
20
21
|
"dependencies": {
|
|
21
22
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
@@ -134,18 +134,6 @@ run without user input, or similar, run in **automation mode**:
|
|
|
134
134
|
exactly one quality-valid route-proof row when needed, and attach the
|
|
135
135
|
recommended sequence, but it still must not launch campaigns, schedule
|
|
136
136
|
sends, send messages, or spend paid InMail.
|
|
137
|
-
- **Connection-only evergreen completion is optional and non-default.** Use it
|
|
138
|
-
only when the prompt explicitly says connection-only, invite-only,
|
|
139
|
-
connection request only, no DMs, or no follow-ups. Pass the typed option
|
|
140
|
-
`campaignSequenceOptions:{ mode:"connection_only" }` plus an explicit
|
|
141
|
-
`workspaceId` to `setup_evergreen_campaigns`. Omit
|
|
142
|
-
`campaignSequenceOptions` for the standard tier-recommended Premium,
|
|
143
|
-
Sales Nav, Recruiter, and paid InMail behavior.
|
|
144
|
-
Connection-only completion still creates/reuses the same evergreen lanes,
|
|
145
|
-
source rows, and paused/unlaunched review state, but it attaches only the
|
|
146
|
-
canonical invite template. It does not require Message Drafting, generated
|
|
147
|
-
messages, route-proof approval, first DM, InMail, View Profile fallback,
|
|
148
|
-
follow-up branch, `currentStep:"send"`, launch, scheduling, or sends.
|
|
149
137
|
|
|
150
138
|
If the invoking prompt explicitly asks for interactive message polish or sample
|
|
151
139
|
proof, run in **interactive polish mode** and use the confirmation/sample steps
|
|
@@ -732,9 +720,6 @@ not a parent-thread summary. The receipt must include:
|
|
|
732
720
|
creation, preserve that original packet only in a separate `parentLanePacket`
|
|
733
721
|
object; do not leave `setupPlanCall.campaignId:null` or
|
|
734
722
|
`setupPlanCall.tableId:null`.
|
|
735
|
-
If the parent plan used connection-only, the receipt must also include
|
|
736
|
-
`setupPlanCall.campaignSequenceOptions:{ "mode":"connection_only" }` or
|
|
737
|
-
`setupPlanCall.sequencePolicy.mode:"connection_only"`.
|
|
738
723
|
Short form: For create lanes, `setupPlanCall.campaignId` and `setupPlanCall.tableId` must be the actual created campaign/table ids.
|
|
739
724
|
Short form: Do not leave `setupPlanCall.campaignId:null` or `setupPlanCall.tableId:null`.
|
|
740
725
|
- `createCampaignWorkflowReceipt`: proof the worker loaded the actual installed
|
|
@@ -825,40 +810,22 @@ not a parent-thread summary. The receipt must include:
|
|
|
825
810
|
them into canonical `promptLoadedToHasMoreFalse`, `requiredAssetsLoaded`,
|
|
826
811
|
`validationLoaded`, `reviewBatchRowHash`, and
|
|
827
812
|
`messageDraftRecommendation`.
|
|
828
|
-
Connection-only lanes are the exception: do not include
|
|
829
|
-
`messageDraftingReceipt` or run generated-message prep unless the operator
|
|
830
|
-
separately asked for message copy. The parent verifier expects no message
|
|
831
|
-
proof for connection-only lanes.
|
|
832
813
|
- `reviewBatchReceipt`: review-batch row ids/hash, generated row count,
|
|
833
814
|
quality-valid route-proof row id when approved, and proof that no broad
|
|
834
815
|
approve-all occurred.
|
|
835
|
-
Connection-only lanes are the exception: do not approve a route-proof row and
|
|
836
|
-
do not include a required review-batch receipt.
|
|
837
816
|
- `sequenceReceipt`: exact current workflowTableId, recommended non-paid
|
|
838
817
|
sequence attach/precheck result, and readback showing `hasSequence:true` when
|
|
839
818
|
completion is claimed. If the tool output uses
|
|
840
819
|
`attachRecommendedSequenceResult.actionTypes` or
|
|
841
820
|
`nonPaidRecommendedSequence`, copy them to canonical `actionTypes` and
|
|
842
821
|
`nonPaid`.
|
|
843
|
-
For connection-only lanes, do not call `attach_recommended_sequence`. Attach
|
|
844
|
-
with `attach_sequence({ tableId, templateRef:"connection_only" })`, then
|
|
845
|
-
include exact current-template proof:
|
|
846
|
-
`sequenceReceipt.hasSequence:true`,
|
|
847
|
-
`sequenceReceipt.actionTypes:["send_invite"]`, and
|
|
848
|
-
`sequenceReceipt.nonPaid:true`. Receipts that only say
|
|
849
|
-
`tool:"attach_recommended_sequence"` or include `send_dm`,
|
|
850
|
-
`send_inmail_open`, `send_inmail_closed`, or `view_profile` are failing
|
|
851
|
-
connection-only receipts.
|
|
852
822
|
- final paused-send proof: if the current campaign table is `DRAFT` after the
|
|
853
823
|
sequence is attached, call the product `pause_campaign({ campaignId })`
|
|
854
824
|
endpoint/tool to put the unlaunched campaign into `PAUSED` review state, then
|
|
855
825
|
reread the campaign/table. Do not raw-write `campaignStatus`, do not start or
|
|
856
826
|
launch, and do not schedule/send. Completion requires reread proof of
|
|
857
|
-
`currentStep:"send"` and `campaignStatus:"PAUSED"
|
|
858
|
-
|
|
859
|
-
review step; do not use unsupported aliases such as `review` as final proof.
|
|
860
|
-
If the raw output nests this in `finalCampaignRead` or `finalTableRead`, copy
|
|
861
|
-
it to canonical
|
|
827
|
+
`currentStep:"send"` and `campaignStatus:"PAUSED"`. If the raw output nests
|
|
828
|
+
this in `finalCampaignRead` or `finalTableRead`, copy it to canonical
|
|
862
829
|
`finalPausedSendProof.currentStep` and
|
|
863
830
|
`finalPausedSendProof.campaignStatus`.
|
|
864
831
|
- `verifyCall`: the exact `setup_evergreen_campaigns({ mode:"verify",
|
|
@@ -1121,14 +1088,9 @@ plain row generation. The lane worker must inline the same
|
|
|
1121
1088
|
approvedGeneratedMessageCount exactly 1. Do not report completion with 2+
|
|
1122
1089
|
approved rows.
|
|
1123
1090
|
Approval shorthand: Do not report completion with 2+ approved rows.
|
|
1124
|
-
8.
|
|
1125
|
-
`
|
|
1126
|
-
the campaign
|
|
1127
|
-
campaign/table before claiming completion.
|
|
1128
|
-
Connection-only mode: skip Message Drafting and route-proof approval, call
|
|
1129
|
-
`attach_sequence({ tableId, templateRef:"connection_only" })`, prove
|
|
1130
|
-
`sequenceReceipt.actionTypes:["send_invite"]`, and pause/reread without
|
|
1131
|
-
launching or sending.
|
|
1091
|
+
8. Continue to `attach_recommended_sequence({ campaignId, currentStep:"send" })`
|
|
1092
|
+
and, if the campaign is still `DRAFT`, `pause_campaign({ campaignId })`.
|
|
1093
|
+
Reread the campaign/table before claiming completion.
|
|
1132
1094
|
|
|
1133
1095
|
That packaged worker path must return `messageDraftingReceipt.statusSource:
|
|
1134
1096
|
"packaged-generate-messages-worker"`. It is accepted only when the receipt
|
|
@@ -1553,14 +1515,11 @@ Message, and verify current-revision sample messages before final completion.
|
|
|
1553
1515
|
- DM lanes: add a `Delivery format:` line — either `multiline (each paragraph sends as its own DM message)` or `single message`. When multiline, the template's blank-line paragraphs ARE the message boundaries — write each one as a standalone typed message.
|
|
1554
1516
|
- **InMail lanes can never be multiline**: an InMail is one message and the recipient must reply before anything else can be sent. InMail-bound templates must read as one cohesive message — declare `Delivery format: single message (InMail — no follow-up until reply)` and never structure the copy to depend on multi-message pacing.
|
|
1555
1517
|
|
|
1556
|
-
-
|
|
1518
|
+
- The sequence is auto-selected by sender tier; do not hand-author sequence
|
|
1557
1519
|
templates here. Sales Nav/Recruiter senders may receive the unified Sales
|
|
1558
1520
|
Nav cascade through `attach_recommended_sequence`; attaching it does not
|
|
1559
1521
|
spend paid InMail credits by itself. Do not substitute the manual Paid
|
|
1560
1522
|
InMail Campaign template.
|
|
1561
|
-
For explicit connection-only evergreen lanes only, use the backend-owned
|
|
1562
|
-
`attach_sequence({ tableId, templateRef:"connection_only" })` path and do
|
|
1563
|
-
not call `attach_recommended_sequence`.
|
|
1564
1523
|
3. **Customer-Visible Completion Contract**: a named evergreen lane that appears
|
|
1565
1524
|
as a campaign card or campaign-backed table is not done when the shell exists.
|
|
1566
1525
|
It is done only when the customer can open the campaign and land on final
|
|
@@ -1584,7 +1543,7 @@ Message, and verify current-revision sample messages before final completion.
|
|
|
1584
1543
|
evergreen setup. Do not leave customer-visible campaigns at
|
|
1585
1544
|
`filter-choice`, `filter-rules`, or `apply-icp-rubric` and report success.
|
|
1586
1545
|
Do not proceed to Message Drafting until saved filters are applied.
|
|
1587
|
-
-
|
|
1546
|
+
- Message Drafting has run from the current campaign/table basis, using the
|
|
1588
1547
|
create-campaign message prompt/assets and validation gate. Updating
|
|
1589
1548
|
`currentStep:"messages"` is not proof. Parent-thread handwritten copy is
|
|
1590
1549
|
not a substitute. The proof receipt must show
|
|
@@ -1625,9 +1584,6 @@ Message, and verify current-revision sample messages before final completion.
|
|
|
1625
1584
|
Fallback samples must still reject source/conversation hedges such as
|
|
1626
1585
|
`"hope this is relevant"`, `"might be interested"`, or `"saw you in a few
|
|
1627
1586
|
conversations"`.
|
|
1628
|
-
For explicit connection-only lanes, this whole Message Drafting and sample
|
|
1629
|
-
proof block is not required and must not be faked with parent-written
|
|
1630
|
-
messages.
|
|
1631
1587
|
- The first review batch exists and at least 3 review rows have generated
|
|
1632
1588
|
messages from the approved brief. If fewer than 3 usable rows exist, report
|
|
1633
1589
|
the actual count and why.
|
|
@@ -1640,7 +1596,7 @@ Message, and verify current-revision sample messages before final completion.
|
|
|
1640
1596
|
completion, approve exactly one quality-valid generated row. If one or more
|
|
1641
1597
|
rows are already approved, do not add more approvals during evergreen
|
|
1642
1598
|
completion. Never broad approve all rows.
|
|
1643
|
-
-
|
|
1599
|
+
- The recommended tier-aware sequence is attached to the current campaign
|
|
1644
1600
|
table, and the watched campaign is on Send. Use
|
|
1645
1601
|
`attach_recommended_sequence({ campaignId, currentStep:"send" })` when a
|
|
1646
1602
|
safe attach is needed. After `confirm_lead_list` or any source-list copy,
|
|
@@ -1655,10 +1611,6 @@ Message, and verify current-revision sample messages before final completion.
|
|
|
1655
1611
|
manual Paid InMail Campaign, and never attach/replace sequence outside the
|
|
1656
1612
|
current table unless the current lane packet explicitly allowed sequence
|
|
1657
1613
|
repair.
|
|
1658
|
-
For explicit connection-only lanes, the current campaign table must have
|
|
1659
|
-
exactly the canonical invite-only sequence:
|
|
1660
|
-
`sequenceReceipt.actionTypes:["send_invite"]`. Any DM, InMail, or View
|
|
1661
|
-
Profile action means the lane is not complete.
|
|
1662
1614
|
- If the current campaign table is still `DRAFT` after sequence/readiness
|
|
1663
1615
|
proof, call `pause_campaign({ campaignId })` and reread. `pause_campaign`
|
|
1664
1616
|
is the product-native review-state transition; it is not a launch and does
|
|
@@ -62,11 +62,6 @@ for lease expiry; never guess a fence.
|
|
|
62
62
|
The default `intent:"auto"` inspects `managed_waterfall`,
|
|
63
63
|
`dashboard_evergreen`, and `active_campaign` lane sources. Report the lane
|
|
64
64
|
source and lane chain as proof for every selected sender.
|
|
65
|
-
Connection-only evergreen lanes are invite-only refill targets. When the packet
|
|
66
|
-
or campaign proof shows exactly `["send_invite"]`, report and execute only
|
|
67
|
-
`selectedLane:"send_invite"` work; do not infer DM, InMail, Sales Nav cascade,
|
|
68
|
-
paid-credit refresh, message generation, follow-up, sequence mutation,
|
|
69
|
-
launch/start beyond packet authority, scheduler writes, or direct sends.
|
|
70
65
|
|
|
71
66
|
If membership blocks a workspace read, report the structured
|
|
72
67
|
`workspace_access` blocker instead of retrying auth or switching workspaces.
|
|
@@ -196,11 +196,6 @@ rather than asking which campaign class to fill. If a stale target plan selects
|
|
|
196
196
|
planner before mutation.
|
|
197
197
|
Short form: trust the target plan's inferred lane when it is a connection invite,
|
|
198
198
|
paid-InMail refill lane, or unified Sales Nav cascade.
|
|
199
|
-
Connection-only evergreen campaigns with exact `["send_invite"]` sequence proof
|
|
200
|
-
are plain invite capacity: treat them as `selectedLane:"send_invite"` only. Do
|
|
201
|
-
not infer DM, InMail, Sales Nav cascade, paid-credit refresh, message
|
|
202
|
-
generation, follow-up, sequence mutation, launch/start, scheduling override, or
|
|
203
|
-
direct-send work from a connection-only lane.
|
|
204
199
|
|
|
205
200
|
Structured planner packet:
|
|
206
201
|
|