@sellable/install 0.1.597 → 0.1.598

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.
@@ -3,12 +3,12 @@ FROM ${HERMES_IMAGE}
3
3
 
4
4
  USER root
5
5
 
6
- ARG INSTALLER_PACKAGE=@sellable/install@0.1.597
6
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.598
7
7
  ARG MCP_PACKAGE=@sellable/mcp@0.1.860
8
8
  ARG INSTALLER_INTEGRITY
9
9
  ARG MCP_INTEGRITY=sha512-jRA1KsCV2d4Zcuo5/UmJiP8P0NaoC3ZDvUFRQOc2AMSNDVmUR6gWvtkyAiqb1lukbFWKzfVlz2/4EWzxvb7hkQ==
10
10
 
11
- RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.597" \
11
+ RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.598" \
12
12
  && test "${MCP_PACKAGE}" = "@sellable/mcp@0.1.860" \
13
13
  && test -n "${INSTALLER_INTEGRITY}" \
14
14
  && test "$(npm view "${INSTALLER_PACKAGE}" dist.integrity)" = "${INSTALLER_INTEGRITY}" \
@@ -23,9 +23,9 @@ RUN test "${INSTALLER_PACKAGE}" = "@sellable/install@0.1.597" \
23
23
  && mkdir -p /opt/sellable-agent/install-root /opt/sellable-agent/mcp-root \
24
24
  && npm install --prefix /opt/sellable-agent/install-root --omit=dev --ignore-scripts --no-audit --no-fund "${INSTALLER_PACKAGE}" \
25
25
  && npm install --prefix /opt/sellable-agent/mcp-root --omit=dev --ignore-scripts --no-audit --no-fund "${MCP_PACKAGE}" \
26
- && node --input-type=module -e "import { readFileSync } from 'node:fs'; const install = JSON.parse(readFileSync('/opt/sellable-agent/install-root/node_modules/@sellable/install/package.json')); const mcp = JSON.parse(readFileSync('/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp/package.json')); if (install.name !== '@sellable/install' || install.version !== '0.1.597' || mcp.name !== '@sellable/mcp' || mcp.version !== '0.1.860') throw new Error('package identity rejected');" \
26
+ && node --input-type=module -e "import { readFileSync } from 'node:fs'; const install = JSON.parse(readFileSync('/opt/sellable-agent/install-root/node_modules/@sellable/install/package.json')); const mcp = JSON.parse(readFileSync('/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp/package.json')); if (install.name !== '@sellable/install' || install.version !== '0.1.598' || mcp.name !== '@sellable/mcp' || mcp.version !== '0.1.860') throw new Error('package identity rejected');" \
27
27
  && node --input-type=module -e "import { buildExternalRuntimeClosure } from '/opt/sellable-agent/install-root/node_modules/@sellable/install/lib/sellable-agent/external-runtime-builder.mjs'; const built = buildExternalRuntimeClosure({ mcpPackageRoot: '/opt/sellable-agent/mcp-root/node_modules/@sellable/mcp', mcpNodeModulesRoot: '/opt/sellable-agent/mcp-root/node_modules', hermesSourceRoot: '/opt/hermes', ownerUid: 0, ownerGid: 0 }); if (!built.closureDigest) throw new Error('runtime closure rejected');" \
28
- && node --input-type=module -e "import { chmodSync, writeFileSync } from 'node:fs'; const release = { installerPackage: '@sellable/install@0.1.597', installerIntegrity: process.argv[1], mcpPackage: '@sellable/mcp@0.1.860', mcpIntegrity: process.argv[2] }; writeFileSync('/opt/sellable-agent/release.json', JSON.stringify(release) + '\\n', { mode: 0o444 }); chmodSync('/opt/sellable-agent/release.json', 0o444);" "${INSTALLER_INTEGRITY}" "${MCP_INTEGRITY}" \
28
+ && node --input-type=module -e "import { chmodSync, writeFileSync } from 'node:fs'; const release = { installerPackage: '@sellable/install@0.1.598', installerIntegrity: process.argv[1], mcpPackage: '@sellable/mcp@0.1.860', mcpIntegrity: process.argv[2] }; writeFileSync('/opt/sellable-agent/release.json', JSON.stringify(release) + '\\n', { mode: 0o444 }); chmodSync('/opt/sellable-agent/release.json', 0o444);" "${INSTALLER_INTEGRITY}" "${MCP_INTEGRITY}" \
29
29
  && npm cache clean --force
30
30
 
31
31
  COPY entrypoint.sh /usr/local/bin/sellable-agent-container-entrypoint
@@ -3,13 +3,13 @@
3
3
  This image is a dedicated Agent worker/runtime boundary beside an existing Hermes dashboard. It never replaces or reconfigures the dashboard container.
4
4
 
5
5
  Build from this directory after
6
- `@sellable/install@0.1.597` is
6
+ `@sellable/install@0.1.598` is
7
7
  published:
8
8
 
9
9
  ```sh
10
10
  docker build --platform linux/amd64 \
11
11
  --build-arg INSTALLER_INTEGRITY='sha512-…' \
12
- --tag sellable-agent-host:0.1.597 .
12
+ --tag sellable-agent-host:0.1.598 .
13
13
  ```
14
14
 
15
15
  Create a private `bootstrap/` directory owned by root with mode `0700`. It must contain `bootstrap.json`, `provider-auth`, and `slack-app`, each owned by root with mode `0600`. `bootstrap.json` contains only non-secret identities, hashes, package integrities, and the control-plane URL. The other two files contain the provider JSON and global Slack app token whose hashes are pinned by `bootstrap.json`.
@@ -103,6 +103,8 @@ COPY fly-soul-bridge.mjs /usr/local/lib/sellable-agent/fly-soul-bridge.mjs
103
103
  COPY fly-soul-bridge-exec.mjs /usr/local/lib/sellable-agent/fly-soul-bridge-exec.mjs
104
104
  COPY fly-skills-bridge.mjs /usr/local/lib/sellable-agent/fly-skills-bridge.mjs
105
105
  COPY fly-skills-bridge-exec.mjs /usr/local/lib/sellable-agent/fly-skills-bridge-exec.mjs
106
+ COPY fly-capabilities-bridge.mjs /usr/local/lib/sellable-agent/fly-capabilities-bridge.mjs
107
+ COPY fly-capabilities-bridge-exec.mjs /usr/local/lib/sellable-agent/fly-capabilities-bridge-exec.mjs
106
108
  COPY soul-artifact-validator.mjs /usr/local/lib/sellable-agent/soul-artifact-validator.mjs
107
109
  COPY hermes-memory-dirty.mjs /opt/sellable/bin/hermes-memory-dirty
108
110
  COPY hermes-memory-reconcile.sh /opt/sellable/share/hermes-memory-reconcile.sh
@@ -131,13 +133,22 @@ RUN node --input-type=module -e \
131
133
  'import("file:///usr/local/lib/sellable-agent/fly-skills-bridge-exec.mjs").then(({ runFlySkillsBridgeExecCli }) => runFlySkillsBridgeExecCli());' \
132
134
  > /usr/local/bin/sellable-skills-bridge-exec \
133
135
  && chmod 0555 /usr/local/bin/sellable-skills-bridge-exec \
136
+ && printf '%s\n' \
137
+ '#!/usr/bin/env node' \
138
+ 'import("file:///usr/local/lib/sellable-agent/fly-capabilities-bridge-exec.mjs").then(({ runFlyCapabilitiesBridgeExecCli }) => runFlyCapabilitiesBridgeExecCli());' \
139
+ > /usr/local/bin/sellable-capabilities-bridge-exec \
140
+ && chmod 0555 /usr/local/bin/sellable-capabilities-bridge-exec \
134
141
  && sha256sum /usr/local/lib/sellable-agent/fly-soul-bridge.mjs \
135
142
  > /usr/local/share/sellable-agent/soul-bridge-source.sha256 \
136
143
  && sha256sum /usr/local/lib/sellable-agent/fly-skills-bridge.mjs \
137
144
  /usr/local/lib/sellable-agent/fly-skills-bridge-exec.mjs \
138
145
  > /usr/local/share/sellable-agent/skills-bridge-source.sha256 \
146
+ && sha256sum /usr/local/lib/sellable-agent/fly-capabilities-bridge.mjs \
147
+ /usr/local/lib/sellable-agent/fly-capabilities-bridge-exec.mjs \
148
+ > /usr/local/share/sellable-agent/capabilities-bridge-source.sha256 \
139
149
  && chmod 0444 /usr/local/share/sellable-agent/soul-bridge-source.sha256 \
140
150
  /usr/local/share/sellable-agent/skills-bridge-source.sha256 \
151
+ /usr/local/share/sellable-agent/capabilities-bridge-source.sha256 \
141
152
  && chmod 0755 /etc \
142
153
  && chmod 0555 \
143
154
  /usr/local/lib/sellable-agent/admin-runtime/admin-runtime.mjs \
@@ -42,6 +42,7 @@ import {
42
42
  buildNativeHermesLaunch,
43
43
  } from "../admin-artifact/node_modules/@sellable/install/lib/sellable-agent/fly-customer-worker.mjs";
44
44
  import sodium from "../admin-artifact/node_modules/libsodium-wrappers/dist/modules-esm/libsodium-wrappers.mjs";
45
+ import { SELLABLE_ALLOWED_TOOLSETS } from "../fly-capabilities-bridge.mjs";
45
46
  import {
46
47
  claimWithSoulObservation,
47
48
  createFlyCustomerControlClient,
@@ -696,6 +697,22 @@ const ADMIN_LIVE_SKILLS_CONFIG_KEYS = Object.freeze([
696
697
  "platform_disabled",
697
698
  ]);
698
699
 
700
+ function adminPreservedSlackToolsets(config) {
701
+ const platformToolsets = config?.platform_toolsets;
702
+ if (
703
+ !platformToolsets ||
704
+ typeof platformToolsets !== "object" ||
705
+ Array.isArray(platformToolsets) ||
706
+ !Object.prototype.hasOwnProperty.call(platformToolsets, "slack")
707
+ )
708
+ return null;
709
+ const slack = Array.isArray(platformToolsets.slack)
710
+ ? platformToolsets.slack
711
+ : [];
712
+ const allowed = new Set(SELLABLE_ALLOWED_TOOLSETS);
713
+ return [...new Set(slack.filter((name) => allowed.has(name)))].sort();
714
+ }
715
+
699
716
  function adminPreservedSkillsIdentity(profileRoot) {
700
717
  if (!existsSync(profileRoot)) return null;
701
718
  const entries = [];
@@ -725,6 +742,7 @@ function adminPreservedSkillsIdentity(profileRoot) {
725
742
  };
726
743
  if (existsSync(skillsRoot)) walk(skillsRoot);
727
744
  let disabled = {};
745
+ let slackToolsets = null;
728
746
  if (existsSync(configPath)) {
729
747
  const document = parseDocument(readFileSync(configPath, "utf8"));
730
748
  if (document.errors.length > 0) {
@@ -738,8 +756,9 @@ function adminPreservedSkillsIdentity(profileRoot) {
738
756
  ).map((key) => [key, skills[key]])
739
757
  );
740
758
  }
759
+ slackToolsets = adminPreservedSlackToolsets(document.toJS());
741
760
  }
742
- return sha256(canonicalJson({ disabled, entries }));
761
+ return sha256(canonicalJson({ disabled, entries, slackToolsets }));
743
762
  }
744
763
 
745
764
  function mergeAdminLiveSkillsConfig(sourceRoot, targetRoot) {
@@ -751,19 +770,28 @@ function mergeAdminLiveSkillsConfig(sourceRoot, targetRoot) {
751
770
  if (sourceDocument.errors.length > 0 || targetDocument.errors.length > 0) {
752
771
  throw new Error("admin_runtime_live_skills_config_rejected");
753
772
  }
754
- const sourceSkills = sourceDocument.toJS()?.skills;
755
- if (
756
- !sourceSkills ||
757
- typeof sourceSkills !== "object" ||
758
- Array.isArray(sourceSkills)
759
- ) {
760
- return;
761
- }
762
- for (const key of ADMIN_LIVE_SKILLS_CONFIG_KEYS) {
763
- if (Object.prototype.hasOwnProperty.call(sourceSkills, key)) {
764
- targetDocument.setIn(["skills", key], structuredClone(sourceSkills[key]));
773
+ const sourceConfig = sourceDocument.toJS();
774
+ const sourceSkills = sourceConfig?.skills;
775
+ if (sourceSkills !== undefined) {
776
+ if (
777
+ !sourceSkills ||
778
+ typeof sourceSkills !== "object" ||
779
+ Array.isArray(sourceSkills)
780
+ ) {
781
+ throw new Error("admin_runtime_live_skills_config_rejected");
782
+ }
783
+ for (const key of ADMIN_LIVE_SKILLS_CONFIG_KEYS) {
784
+ if (Object.prototype.hasOwnProperty.call(sourceSkills, key)) {
785
+ targetDocument.setIn(
786
+ ["skills", key],
787
+ structuredClone(sourceSkills[key])
788
+ );
789
+ }
765
790
  }
766
791
  }
792
+ const slackToolsets = adminPreservedSlackToolsets(sourceConfig);
793
+ if (slackToolsets !== null)
794
+ targetDocument.setIn(["platform_toolsets", "slack"], slackToolsets);
767
795
  atomicPrivateFile(targetPath, targetDocument.toString());
768
796
  }
769
797
 
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import {
7
+ CAPABILITIES_REQUEST_MAX_BYTES,
8
+ FlyCapabilitiesBridgeError,
9
+ executeExternalCapabilitiesBridgeOperation,
10
+ } from "./fly-capabilities-bridge.mjs";
11
+
12
+ export const FLY_CAPABILITIES_EXEC_ARGUMENT_MAX_BYTES = 96 * 1024;
13
+ export const FLY_CAPABILITIES_EXEC_ARGUMENT_MAX_COUNT = 2;
14
+
15
+ const BASE64URL_CHUNK = /^[A-Za-z0-9_-]+$/;
16
+ const ENCODED_MAX_BYTES = Math.ceil(CAPABILITIES_REQUEST_MAX_BYTES / 3) * 4;
17
+
18
+ export function decodeFlyCapabilitiesExecArguments(args) {
19
+ if (!Array.isArray(args) || args.length === 0)
20
+ throw new FlyCapabilitiesBridgeError("capabilities_request_rejected");
21
+ if (args.length > FLY_CAPABILITIES_EXEC_ARGUMENT_MAX_COUNT)
22
+ throw new FlyCapabilitiesBridgeError("capabilities_request_too_large");
23
+ let encoded = "";
24
+ for (const chunk of args) {
25
+ if (typeof chunk !== "string" || !BASE64URL_CHUNK.test(chunk))
26
+ throw new FlyCapabilitiesBridgeError("capabilities_request_rejected");
27
+ if (
28
+ Buffer.byteLength(chunk, "utf8") >
29
+ FLY_CAPABILITIES_EXEC_ARGUMENT_MAX_BYTES
30
+ )
31
+ throw new FlyCapabilitiesBridgeError("capabilities_request_too_large");
32
+ encoded += chunk;
33
+ if (encoded.length > ENCODED_MAX_BYTES)
34
+ throw new FlyCapabilitiesBridgeError("capabilities_request_too_large");
35
+ }
36
+ let bytes;
37
+ try {
38
+ bytes = Buffer.from(encoded, "base64url");
39
+ } catch {
40
+ throw new FlyCapabilitiesBridgeError("capabilities_request_rejected");
41
+ }
42
+ if (
43
+ bytes.toString("base64url") !== encoded ||
44
+ bytes.byteLength > CAPABILITIES_REQUEST_MAX_BYTES
45
+ )
46
+ throw new FlyCapabilitiesBridgeError("capabilities_request_rejected");
47
+ try {
48
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
49
+ } catch {
50
+ throw new FlyCapabilitiesBridgeError("capabilities_request_rejected");
51
+ }
52
+ }
53
+
54
+ export async function runFlyCapabilitiesBridgeExecCli() {
55
+ try {
56
+ const request = decodeFlyCapabilitiesExecArguments(process.argv.slice(2));
57
+ const result = executeExternalCapabilitiesBridgeOperation(request);
58
+ process.stdout.write(`${JSON.stringify(result)}\n`);
59
+ process.exitCode = result.ok ? 0 : 2;
60
+ } catch (error) {
61
+ process.stdout.write(
62
+ `${JSON.stringify({
63
+ ok: false,
64
+ code:
65
+ error instanceof FlyCapabilitiesBridgeError
66
+ ? error.code
67
+ : "capabilities_bridge_failed",
68
+ })}\n`
69
+ );
70
+ process.exitCode = 2;
71
+ }
72
+ }
73
+
74
+ if (
75
+ resolve(process.argv[1] ?? "") === resolve(fileURLToPath(import.meta.url))
76
+ ) {
77
+ await runFlyCapabilitiesBridgeExecCli();
78
+ }
@@ -0,0 +1,659 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import {
6
+ closeSync,
7
+ constants,
8
+ existsSync,
9
+ fstatSync,
10
+ lstatSync,
11
+ mkdirSync,
12
+ openSync,
13
+ readFileSync,
14
+ realpathSync,
15
+ renameSync,
16
+ rmSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { isAbsolute, join } from "node:path";
20
+
21
+ import {
22
+ SOUL_BRIDGE_DATA_ROOT,
23
+ discoverLiveHermesGateway,
24
+ revalidateLiveHermesGateway,
25
+ soulProfilePaths,
26
+ withProfileSoulLock,
27
+ } from "./fly-soul-bridge.mjs";
28
+ import { markHermesSnapshotDirty } from "./hermes-memory-snapshot.mjs";
29
+
30
+ export const CAPABILITIES_BRIDGE_SCHEMA_VERSION =
31
+ "sellable-agent-capabilities-bridge/v1";
32
+ export const CAPABILITIES_PROOF_SCHEMA_VERSION =
33
+ "sellable-agent-capabilities-proof/v1";
34
+ export const CAPABILITIES_REQUEST_MAX_BYTES = 16 * 1024;
35
+
36
+ // Deliberately excludes credential-bearing, external-account, platform-admin,
37
+ // media-generation, and default-off toolsets. These are the already-bundled
38
+ // core capabilities a Sellable Agent operator may narrow or restore for Slack.
39
+ export const SELLABLE_ALLOWED_TOOLSETS = Object.freeze([
40
+ "clarify",
41
+ "code_execution",
42
+ "context_engine",
43
+ "cronjob",
44
+ "delegation",
45
+ "file",
46
+ "memory",
47
+ "session_search",
48
+ "skills",
49
+ "terminal",
50
+ "todo",
51
+ ]);
52
+
53
+ const ALLOWED_TOOLSETS = new Set(SELLABLE_ALLOWED_TOOLSETS);
54
+ const PROFILE_ID = /^[a-z0-9][a-z0-9_-]{0,127}$/;
55
+ const BOUND_ID = /^[A-Za-z0-9_-]{1,128}$/;
56
+ const TOOLSET_NAME = /^[a-z][a-z0-9_]{0,127}$/;
57
+ const TOOL_NAME = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
58
+ const SHA256 = /^[a-f0-9]{64}$/;
59
+ const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,127}$/;
60
+ const PYTHON = "/opt/hermes/.venv/bin/python3";
61
+ const EXPECTED_PACKAGES = Object.freeze({
62
+ sellable: "@sellable/mcp",
63
+ "sellable-admin": "@sellable/admin-mcp",
64
+ });
65
+
66
+ /**
67
+ * @typedef {{
68
+ * enumerate: (input: any) => any,
69
+ * toggle: (input: any) => any,
70
+ * }} CapabilitiesNativeAdapter
71
+ */
72
+
73
+ /**
74
+ * @typedef {{
75
+ * profileId?: string,
76
+ * dataRoot?: string,
77
+ * nativeAdapter?: CapabilitiesNativeAdapter | null,
78
+ * packageSpecs?: Record<string, string> | null,
79
+ * markDirty?: (profileRoot: string) => void,
80
+ * lockHeld?: boolean,
81
+ * }} CapabilitiesBridgeOptions
82
+ */
83
+
84
+ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
85
+ const stableJson = (value) => {
86
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
87
+ if (value && typeof value === "object") {
88
+ return `{${Object.keys(value)
89
+ .sort()
90
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
91
+ .join(",")}}`;
92
+ }
93
+ return JSON.stringify(value);
94
+ };
95
+
96
+ export class FlyCapabilitiesBridgeError extends Error {
97
+ constructor(code) {
98
+ super(code);
99
+ this.name = "FlyCapabilitiesBridgeError";
100
+ this.code = code;
101
+ }
102
+ }
103
+
104
+ function fail(code) {
105
+ throw new FlyCapabilitiesBridgeError(code);
106
+ }
107
+
108
+ function regularFile(path, maxBytes = 2 * 1024 * 1024) {
109
+ const link = lstatSync(path);
110
+ if (
111
+ link.isSymbolicLink() ||
112
+ !link.isFile() ||
113
+ link.nlink !== 1 ||
114
+ link.size > maxBytes
115
+ ) {
116
+ fail("capabilities_config_rejected");
117
+ }
118
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
119
+ try {
120
+ const opened = fstatSync(fd);
121
+ if (
122
+ !opened.isFile() ||
123
+ opened.dev !== link.dev ||
124
+ opened.ino !== link.ino ||
125
+ opened.nlink !== 1 ||
126
+ opened.size !== link.size
127
+ ) {
128
+ fail("capabilities_config_changed");
129
+ }
130
+ return readFileSync(fd);
131
+ } finally {
132
+ closeSync(fd);
133
+ }
134
+ }
135
+
136
+ function assertExactKeys(value, allowed) {
137
+ if (!value || typeof value !== "object" || Array.isArray(value))
138
+ fail("capabilities_request_rejected");
139
+ if (Object.keys(value).some((key) => !allowed.has(key)))
140
+ fail("capabilities_request_rejected");
141
+ }
142
+
143
+ function expectedAliases(kind) {
144
+ return kind === "ADMIN" ? ["sellable", "sellable-admin"] : ["sellable"];
145
+ }
146
+
147
+ function assertRequest(request) {
148
+ const common = ["operation", "expectedRuntimeKind", "expectedWorkspaceId"];
149
+ const shapes = {
150
+ list: common,
151
+ toggle_tool: [...common, "toolsetName", "enabled", "configSha256"],
152
+ test_mcp: [...common, "serverName"],
153
+ };
154
+ const keys = shapes[request?.operation];
155
+ if (!keys) fail("capabilities_operation_rejected");
156
+ assertExactKeys(request, new Set(keys));
157
+ if (!["CUSTOMER", "ADMIN"].includes(request.expectedRuntimeKind))
158
+ fail("capabilities_runtime_kind_rejected");
159
+ if (!BOUND_ID.test(request.expectedWorkspaceId ?? ""))
160
+ fail("capabilities_workspace_rejected");
161
+ if (request.operation === "toggle_tool") {
162
+ if (
163
+ !TOOLSET_NAME.test(request.toolsetName ?? "") ||
164
+ !ALLOWED_TOOLSETS.has(request.toolsetName)
165
+ )
166
+ fail("capabilities_toolset_rejected");
167
+ if (
168
+ typeof request.enabled !== "boolean" ||
169
+ !SHA256.test(request.configSha256 ?? "")
170
+ )
171
+ fail("capabilities_cas_rejected");
172
+ }
173
+ if (
174
+ request.operation === "test_mcp" &&
175
+ !expectedAliases(request.expectedRuntimeKind).includes(request.serverName)
176
+ )
177
+ fail("capabilities_mcp_server_rejected");
178
+ return request;
179
+ }
180
+
181
+ function parsePackageSpec(spec, expectedName) {
182
+ if (typeof spec !== "string") fail("capabilities_package_identity_rejected");
183
+ const delimiter = spec.lastIndexOf("@");
184
+ const name = spec.slice(0, delimiter);
185
+ const version = spec.slice(delimiter + 1);
186
+ if (name !== expectedName || !VERSION.test(version))
187
+ fail("capabilities_package_identity_rejected");
188
+ return { packageName: name, version };
189
+ }
190
+
191
+ function boundedText(value, max) {
192
+ return typeof value === "string" && Buffer.byteLength(value, "utf8") <= max;
193
+ }
194
+
195
+ function normalizeNative(native, request, configSha256, packageSpecs) {
196
+ if (!native || !Array.isArray(native.tools) || !Array.isArray(native.servers))
197
+ fail("capabilities_native_output_rejected");
198
+ const tools = native.tools.map((row) => {
199
+ if (
200
+ !row ||
201
+ !ALLOWED_TOOLSETS.has(row.name) ||
202
+ !boundedText(row.label, 256) ||
203
+ !boundedText(row.description, 2048) ||
204
+ row.platform !== "slack" ||
205
+ typeof row.enabled !== "boolean" ||
206
+ typeof row.configured !== "boolean" ||
207
+ !Array.isArray(row.tools) ||
208
+ row.tools.length > 256 ||
209
+ row.tools.some((name) => !TOOL_NAME.test(name))
210
+ )
211
+ fail("capabilities_tool_inventory_rejected");
212
+ const names = [...new Set(row.tools)].sort();
213
+ if (names.length !== row.tools.length)
214
+ fail("capabilities_tool_inventory_rejected");
215
+ return Object.freeze({
216
+ name: row.name,
217
+ label: row.label,
218
+ description: row.description,
219
+ platform: "slack",
220
+ enabled: row.enabled,
221
+ available: row.enabled && row.configured,
222
+ configured: row.configured,
223
+ policy: "allowed",
224
+ tools: Object.freeze(names),
225
+ });
226
+ });
227
+ tools.sort((left, right) => left.name.localeCompare(right.name));
228
+ if (
229
+ stableJson(tools.map((tool) => tool.name)) !==
230
+ stableJson([...SELLABLE_ALLOWED_TOOLSETS].sort())
231
+ )
232
+ fail("capabilities_tool_inventory_rejected");
233
+
234
+ const aliases = expectedAliases(request.expectedRuntimeKind);
235
+ const mcpServers = native.servers.map((row) => {
236
+ if (
237
+ !row ||
238
+ !aliases.includes(row.name) ||
239
+ row.enabled !== true ||
240
+ row.transport !== "stdio" ||
241
+ typeof row.workspaceLocked !== "boolean" ||
242
+ typeof row.requiresWorkspaceLock !== "boolean" ||
243
+ !["healthy", "unhealthy", "unknown"].includes(row.health) ||
244
+ !Array.isArray(row.tools) ||
245
+ row.tools.length > 1024 ||
246
+ row.tools.some((name) => !TOOL_NAME.test(name)) ||
247
+ !Number.isSafeInteger(row.prompts) ||
248
+ row.prompts < 0 ||
249
+ !Number.isSafeInteger(row.resources) ||
250
+ row.resources < 0 ||
251
+ ![null, "probe_failed", "not_tested"].includes(row.errorCode)
252
+ )
253
+ fail("capabilities_mcp_inventory_rejected");
254
+ if (
255
+ row.name === "sellable" &&
256
+ (!row.workspaceLocked || !row.requiresWorkspaceLock)
257
+ )
258
+ fail("capabilities_workspace_lock_rejected");
259
+ const names = [...new Set(row.tools)].sort();
260
+ if (names.length !== row.tools.length)
261
+ fail("capabilities_mcp_inventory_rejected");
262
+ return Object.freeze({
263
+ name: row.name,
264
+ label: row.name === "sellable" ? "Sellable" : "Sellable Admin",
265
+ ...parsePackageSpec(packageSpecs[row.name], EXPECTED_PACKAGES[row.name]),
266
+ enabled: true,
267
+ managed: true,
268
+ readOnly: true,
269
+ transport: "stdio",
270
+ workspaceLocked: row.workspaceLocked,
271
+ requiresWorkspaceLock: row.requiresWorkspaceLock,
272
+ health: row.health,
273
+ tools: Object.freeze(names),
274
+ prompts: row.prompts,
275
+ resources: row.resources,
276
+ errorCode: row.errorCode,
277
+ });
278
+ });
279
+ mcpServers.sort((left, right) => left.name.localeCompare(right.name));
280
+ if (
281
+ stableJson(mcpServers.map((server) => server.name)) !== stableJson(aliases)
282
+ )
283
+ fail("capabilities_mcp_inventory_rejected");
284
+ const inventory = Object.freeze({
285
+ complete: true,
286
+ configSha256,
287
+ tools: Object.freeze(tools),
288
+ mcpServers: Object.freeze(mcpServers),
289
+ });
290
+ return Object.freeze({
291
+ ...inventory,
292
+ inventorySha256: sha256(stableJson(inventory)),
293
+ });
294
+ }
295
+
296
+ const NATIVE_SCRIPT = String.raw`
297
+ import json, sys
298
+ request = json.loads(sys.stdin.read())
299
+ from hermes_cli.config import load_config
300
+ from hermes_cli.mcp_config import _get_mcp_servers, _probe_single_server
301
+ from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS, _get_platform_tools, _save_platform_tools, _toolset_has_keys
302
+ from toolsets import resolve_toolset
303
+
304
+ def enumerate_capabilities():
305
+ allowed = request["allowedToolsets"]
306
+ config = load_config()
307
+ catalog = {name: (label, description) for name, label, description in CONFIGURABLE_TOOLSETS}
308
+ enabled = _get_platform_tools(config, "slack", include_default_mcp_servers=False)
309
+ tool_rows = []
310
+ for name in allowed:
311
+ if name not in catalog:
312
+ raise RuntimeError("missing_policy_toolset")
313
+ label, description = catalog[name]
314
+ label_parts = str(label).strip().split(None, 1)
315
+ if len(label_parts) == 2 and not any(ch.isascii() and ch.isalnum() for ch in label_parts[0]):
316
+ label = label_parts[1]
317
+ tool_rows.append({
318
+ "name": name,
319
+ "label": str(label),
320
+ "description": str(description),
321
+ "platform": "slack",
322
+ "enabled": name in enabled,
323
+ "configured": bool(_toolset_has_keys(name, config)),
324
+ "tools": sorted(set(resolve_toolset(name, include_registry=False))),
325
+ })
326
+
327
+ servers = _get_mcp_servers(config)
328
+ expected_aliases = request["expectedAliases"]
329
+ if sorted(servers.keys()) != sorted(expected_aliases):
330
+ raise RuntimeError("managed_server_inventory")
331
+ server_rows = []
332
+ for name in expected_aliases:
333
+ server = servers[name]
334
+ env = server.get("env") if isinstance(server.get("env"), dict) else {}
335
+ details = {}
336
+ try:
337
+ found = _probe_single_server(name, server, connect_timeout=12, details=details)
338
+ health = "healthy"
339
+ tools = sorted(set(str(tool_name) for tool_name, _description in found))
340
+ prompts = int(details.get("prompts", 0) or 0)
341
+ resources = int(details.get("resources", 0) or 0)
342
+ error_code = None
343
+ except BaseException:
344
+ health, tools, prompts, resources, error_code = "unhealthy", [], 0, 0, "probe_failed"
345
+ server_rows.append({
346
+ "name": name,
347
+ "enabled": server.get("enabled", True) is not False,
348
+ "transport": "http" if server.get("url") else "stdio" if server.get("command") else "unknown",
349
+ "workspaceLocked": env.get("SELLABLE_LOCK_WORKSPACE_ID") == request["expectedWorkspaceId"],
350
+ "requiresWorkspaceLock": str(env.get("SELLABLE_REQUIRE_WORKSPACE_LOCK", "")) == "1",
351
+ "health": health,
352
+ "tools": tools,
353
+ "prompts": prompts,
354
+ "resources": resources,
355
+ "errorCode": error_code,
356
+ })
357
+ return {"tools": tool_rows, "servers": server_rows}
358
+
359
+ if request["operation"] == "enumerate":
360
+ output = enumerate_capabilities()
361
+ elif request["operation"] == "toggle":
362
+ config = load_config()
363
+ enabled = set(_get_platform_tools(config, "slack", include_default_mcp_servers=False))
364
+ if request["enabled"]: enabled.add(request["name"])
365
+ else: enabled.discard(request["name"])
366
+ _save_platform_tools(config, "slack", enabled)
367
+ output = {"ok": True}
368
+ else:
369
+ raise RuntimeError("operation")
370
+ sys.stdout.write(json.dumps(output, separators=(",", ":")))
371
+ `;
372
+
373
+ function runNative(payload, paths, { python = PYTHON } = {}) {
374
+ const child = spawnSync(python, ["-c", NATIVE_SCRIPT], {
375
+ input: JSON.stringify(payload),
376
+ encoding: "utf8",
377
+ timeout: 30_000,
378
+ maxBuffer: 4 * 1024 * 1024,
379
+ env: {
380
+ HOME: paths.dataRoot,
381
+ HERMES_HOME: paths.profileRoot,
382
+ HERMES_PROFILE: paths.profileId,
383
+ SELLABLE_HERMES_PROFILE_ID: paths.profileId,
384
+ SELLABLE_AGENT_RUNTIME: "1",
385
+ PYTHONDONTWRITEBYTECODE: "1",
386
+ PATH: "/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin",
387
+ },
388
+ });
389
+ if (child.status !== 0 || child.signal || child.error)
390
+ fail("capabilities_native_call_failed");
391
+ try {
392
+ return JSON.parse(child.stdout);
393
+ } catch {
394
+ fail("capabilities_native_output_rejected");
395
+ }
396
+ }
397
+
398
+ function defaultAdapter(paths) {
399
+ return {
400
+ enumerate: ({
401
+ expectedAliases: aliases,
402
+ expectedWorkspaceId,
403
+ probeServerName,
404
+ }) =>
405
+ runNative(
406
+ {
407
+ operation: "enumerate",
408
+ allowedToolsets: SELLABLE_ALLOWED_TOOLSETS,
409
+ expectedAliases: aliases,
410
+ expectedWorkspaceId,
411
+ ...(probeServerName ? { probeServerName } : {}),
412
+ },
413
+ paths
414
+ ),
415
+ toggle: ({ name, enabled }) =>
416
+ runNative({ operation: "toggle", name, enabled }, paths),
417
+ };
418
+ }
419
+
420
+ function runtimePackageSpecs(kind, provided) {
421
+ if (provided) return provided;
422
+ return kind === "ADMIN"
423
+ ? {
424
+ sellable: process.env.SELLABLE_AGENT_PRODUCT_MCP_PACKAGE,
425
+ "sellable-admin": process.env.SELLABLE_AGENT_ADMIN_MCP_PACKAGE,
426
+ }
427
+ : { sellable: process.env.SELLABLE_AGENT_MCP_PACKAGE };
428
+ }
429
+
430
+ function atomicProof(paths, inventory) {
431
+ const root = join(paths.profileRoot, ".sellable-agent");
432
+ mkdirSync(root, { recursive: true, mode: 0o700 });
433
+ const path = join(root, "capabilities-proof.json");
434
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
435
+ writeFileSync(
436
+ temporary,
437
+ `${JSON.stringify({
438
+ schemaVersion: CAPABILITIES_PROOF_SCHEMA_VERSION,
439
+ inventorySha256: inventory.inventorySha256,
440
+ configSha256: inventory.configSha256,
441
+ })}\n`,
442
+ { mode: 0o600 }
443
+ );
444
+ renameSync(temporary, path);
445
+ }
446
+
447
+ function readInventory(request, paths, adapter, packageSpecs) {
448
+ const configPath = join(paths.profileRoot, "config.yaml");
449
+ const configSha256 = sha256(regularFile(configPath));
450
+ const aliases = expectedAliases(request.expectedRuntimeKind);
451
+ const native = adapter.enumerate({
452
+ expectedAliases: aliases,
453
+ expectedWorkspaceId: request.expectedWorkspaceId,
454
+ ...(request.operation === "test_mcp"
455
+ ? { probeServerName: request.serverName }
456
+ : {}),
457
+ });
458
+ if (sha256(regularFile(configPath)) !== configSha256)
459
+ fail("capabilities_config_changed");
460
+ return normalizeNative(native, request, configSha256, packageSpecs);
461
+ }
462
+
463
+ function executeLocked(request, paths, adapter, options) {
464
+ const packageSpecs = runtimePackageSpecs(
465
+ request.expectedRuntimeKind,
466
+ options.packageSpecs
467
+ );
468
+ const before = readInventory(request, paths, adapter, packageSpecs);
469
+ if (request.operation === "list" || request.operation === "test_mcp") {
470
+ atomicProof(paths, before);
471
+ return {
472
+ ok: true,
473
+ schemaVersion: CAPABILITIES_BRIDGE_SCHEMA_VERSION,
474
+ status: "proven",
475
+ operation: request.operation,
476
+ ...(request.operation === "test_mcp"
477
+ ? { testedServer: request.serverName }
478
+ : {}),
479
+ ...before,
480
+ };
481
+ }
482
+ if (request.configSha256 !== before.configSha256) {
483
+ return {
484
+ ok: false,
485
+ code: "capabilities_config_conflict",
486
+ configSha256: before.configSha256,
487
+ inventorySha256: before.inventorySha256,
488
+ };
489
+ }
490
+ const detail = before.tools.find((tool) => tool.name === request.toolsetName);
491
+ if (!detail) fail("capabilities_toolset_rejected");
492
+ if (detail.enabled === request.enabled) {
493
+ atomicProof(paths, before);
494
+ return {
495
+ ok: true,
496
+ schemaVersion: CAPABILITIES_BRIDGE_SCHEMA_VERSION,
497
+ status: "noop",
498
+ operation: "toggle_tool",
499
+ ...before,
500
+ detail,
501
+ };
502
+ }
503
+ const configPath = join(paths.profileRoot, "config.yaml");
504
+ const configBackup = regularFile(configPath);
505
+ const proofPath = join(
506
+ paths.profileRoot,
507
+ ".sellable-agent",
508
+ "capabilities-proof.json"
509
+ );
510
+ const proofBackup = existsSync(proofPath)
511
+ ? regularFile(proofPath, 4096)
512
+ : null;
513
+ const rollback = () => {
514
+ writeFileSync(configPath, configBackup, { mode: 0o600 });
515
+ if (proofBackup) writeFileSync(proofPath, proofBackup, { mode: 0o600 });
516
+ else rmSync(proofPath, { force: true });
517
+ };
518
+ try {
519
+ const result = adapter.toggle({
520
+ name: request.toolsetName,
521
+ enabled: request.enabled,
522
+ platform: "slack",
523
+ });
524
+ if (result?.ok !== true) fail("capabilities_native_refused");
525
+ const after = readInventory(request, paths, adapter, packageSpecs);
526
+ const afterDetail = after.tools.find(
527
+ (tool) => tool.name === request.toolsetName
528
+ );
529
+ if (
530
+ !afterDetail ||
531
+ afterDetail.enabled !== request.enabled ||
532
+ after.configSha256 === before.configSha256 ||
533
+ stableJson(after.mcpServers.map((server) => server.name)) !==
534
+ stableJson(before.mcpServers.map((server) => server.name))
535
+ )
536
+ fail("capabilities_readback_failed");
537
+ atomicProof(paths, after);
538
+ options.markDirty(paths.profileRoot);
539
+ return {
540
+ ok: true,
541
+ schemaVersion: CAPABILITIES_BRIDGE_SCHEMA_VERSION,
542
+ status: "applied",
543
+ operation: "toggle_tool",
544
+ effectId: randomUUID(),
545
+ ...after,
546
+ detail: afterDetail,
547
+ };
548
+ } catch (error) {
549
+ rollback();
550
+ try {
551
+ const restored = readInventory(request, paths, adapter, packageSpecs);
552
+ if (restored.configSha256 !== before.configSha256)
553
+ return { ok: false, code: "capabilities_rollback_unproven" };
554
+ } catch {
555
+ return { ok: false, code: "capabilities_rollback_unproven" };
556
+ }
557
+ return {
558
+ ok: false,
559
+ code: "capabilities_apply_failed_rolled_back",
560
+ reason:
561
+ error instanceof FlyCapabilitiesBridgeError
562
+ ? error.code
563
+ : "capabilities_bridge_failed",
564
+ };
565
+ }
566
+ }
567
+
568
+ /**
569
+ * @param {any} request
570
+ * @param {CapabilitiesBridgeOptions} [options]
571
+ */
572
+ export function executeCapabilitiesBridgeOperation(
573
+ request,
574
+ {
575
+ profileId = process.env.SELLABLE_HERMES_PROFILE_ID,
576
+ dataRoot = SOUL_BRIDGE_DATA_ROOT,
577
+ nativeAdapter = null,
578
+ packageSpecs = null,
579
+ markDirty = markHermesSnapshotDirty,
580
+ lockHeld = false,
581
+ } = {}
582
+ ) {
583
+ assertRequest(request);
584
+ if (typeof profileId !== "string" || !PROFILE_ID.test(profileId))
585
+ fail("capabilities_profile_id_rejected");
586
+ const paths = soulProfilePaths(profileId, { dataRoot });
587
+ const adapter = nativeAdapter ?? defaultAdapter(paths);
588
+ if (
589
+ !adapter ||
590
+ typeof adapter.enumerate !== "function" ||
591
+ typeof adapter.toggle !== "function"
592
+ )
593
+ fail("capabilities_native_adapter_rejected");
594
+ if (typeof markDirty !== "function")
595
+ fail("capabilities_dirty_marker_rejected");
596
+ const run = () =>
597
+ executeLocked(request, paths, adapter, { packageSpecs, markDirty });
598
+ return lockHeld ? run() : withProfileSoulLock(profileId, run, { dataRoot });
599
+ }
600
+
601
+ export function executeExternalCapabilitiesBridgeOperation(
602
+ request,
603
+ {
604
+ procRoot = "/proc",
605
+ dataRoot = SOUL_BRIDGE_DATA_ROOT,
606
+ nativeAdapter = null,
607
+ packageSpecs = null,
608
+ markDirty = markHermesSnapshotDirty,
609
+ callerProfileId = process.env.SELLABLE_HERMES_PROFILE_ID,
610
+ } = {}
611
+ ) {
612
+ const gateway = discoverLiveHermesGateway({ procRoot, dataRoot });
613
+ if (callerProfileId && callerProfileId !== gateway.profileId)
614
+ fail("capabilities_gateway_caller_profile_mismatch");
615
+ revalidateLiveHermesGateway(gateway, { procRoot, dataRoot });
616
+ const result = executeCapabilitiesBridgeOperation(request, {
617
+ profileId: gateway.profileId,
618
+ dataRoot,
619
+ nativeAdapter,
620
+ packageSpecs,
621
+ markDirty,
622
+ });
623
+ revalidateLiveHermesGateway(gateway, { procRoot, dataRoot });
624
+ return Object.freeze({
625
+ ...result,
626
+ profileId: gateway.profileId,
627
+ gatewayPid: gateway.pid,
628
+ gatewayStartTicks: gateway.startTicks,
629
+ });
630
+ }
631
+
632
+ export function readCapabilitiesProofObservation(profileRoot) {
633
+ try {
634
+ if (!isAbsolute(profileRoot)) return null;
635
+ const value = JSON.parse(
636
+ regularFile(
637
+ join(
638
+ realpathSync(profileRoot),
639
+ ".sellable-agent",
640
+ "capabilities-proof.json"
641
+ ),
642
+ 4096
643
+ ).toString("utf8")
644
+ );
645
+ if (
646
+ value.schemaVersion !== CAPABILITIES_PROOF_SCHEMA_VERSION ||
647
+ !SHA256.test(value.inventorySha256 ?? "") ||
648
+ !SHA256.test(value.configSha256 ?? "") ||
649
+ Object.keys(value).length !== 3
650
+ )
651
+ return null;
652
+ return Object.freeze({
653
+ inventorySha256: value.inventorySha256,
654
+ configSha256: value.configSha256,
655
+ });
656
+ } catch {
657
+ return null;
658
+ }
659
+ }
@@ -6,7 +6,7 @@ USER root
6
6
 
7
7
  ARG MCP_PACKAGE=@sellable/mcp@0.1.860
8
8
  ARG MCP_INTEGRITY=sha512-jRA1KsCV2d4Zcuo5/UmJiP8P0NaoC3ZDvUFRQOc2AMSNDVmUR6gWvtkyAiqb1lukbFWKzfVlz2/4EWzxvb7hkQ==
9
- ARG INSTALLER_PACKAGE=@sellable/install@0.1.597
9
+ ARG INSTALLER_PACKAGE=@sellable/install@0.1.598
10
10
  ARG ADMIN_MCP_PACKAGE=@sellable/admin-mcp@0.1.186-wip.phase158.20260802034727
11
11
  ARG ADMIN_MCP_INTEGRITY=sha512-qYNew5wd2IfgVcp1UYOG9QvpF/8Mx19YJ5HQtMKzvSfUf3evePjtaKsoXLf2C/GUJ5QlLlHJo84VWOI3V01+9A==
12
12
  ARG TUS_JS_CLIENT_VERSION=4.3.1
@@ -69,7 +69,7 @@ RUN --mount=type=secret,id=mcp_trust_root,required=true \
69
69
  /etc/cont-init.d/02-reconcile-profiles \
70
70
  && npm cache clean --force
71
71
 
72
- COPY mcp-context-proxy.mjs mcp-runtime-binding.mjs fly-customer-model.mjs fly-customer-worker.mjs profile-materializer.mjs hermes-bridge.mjs hermes-memory-snapshot.mjs fly-runtime-identity.mjs fly-soul-bridge.mjs fly-soul-bridge-exec.mjs fly-skills-bridge.mjs fly-skills-bridge-exec.mjs soul-artifact-validator.mjs model-auth-reconciler.mjs model-auth-canary-reseed.mjs model-health-reporter.mjs \
72
+ COPY mcp-context-proxy.mjs mcp-runtime-binding.mjs fly-customer-model.mjs fly-customer-worker.mjs profile-materializer.mjs hermes-bridge.mjs hermes-memory-snapshot.mjs fly-runtime-identity.mjs fly-soul-bridge.mjs fly-soul-bridge-exec.mjs fly-skills-bridge.mjs fly-skills-bridge-exec.mjs fly-capabilities-bridge.mjs fly-capabilities-bridge-exec.mjs soul-artifact-validator.mjs model-auth-reconciler.mjs model-auth-canary-reseed.mjs model-health-reporter.mjs \
73
73
  /usr/local/lib/sellable-agent/
74
74
  COPY hermes-memory-dirty.mjs /opt/sellable/bin/hermes-memory-dirty
75
75
  COPY hermes-memory-reconcile.sh /opt/sellable/share/hermes-memory-reconcile.sh
@@ -101,13 +101,22 @@ RUN node --input-type=module -e \
101
101
  'import("file:///usr/local/lib/sellable-agent/fly-skills-bridge-exec.mjs").then(({ runFlySkillsBridgeExecCli }) => runFlySkillsBridgeExecCli());' \
102
102
  > /usr/local/bin/sellable-skills-bridge-exec \
103
103
  && chmod 0555 /usr/local/bin/sellable-skills-bridge-exec \
104
+ && printf '%s\n' \
105
+ '#!/usr/bin/env node' \
106
+ 'import("file:///usr/local/lib/sellable-agent/fly-capabilities-bridge-exec.mjs").then(({ runFlyCapabilitiesBridgeExecCli }) => runFlyCapabilitiesBridgeExecCli());' \
107
+ > /usr/local/bin/sellable-capabilities-bridge-exec \
108
+ && chmod 0555 /usr/local/bin/sellable-capabilities-bridge-exec \
104
109
  && sha256sum /usr/local/lib/sellable-agent/fly-soul-bridge.mjs \
105
110
  > /usr/local/share/sellable-agent/soul-bridge-source.sha256 \
106
111
  && sha256sum /usr/local/lib/sellable-agent/fly-skills-bridge.mjs \
107
112
  /usr/local/lib/sellable-agent/fly-skills-bridge-exec.mjs \
108
113
  > /usr/local/share/sellable-agent/skills-bridge-source.sha256 \
114
+ && sha256sum /usr/local/lib/sellable-agent/fly-capabilities-bridge.mjs \
115
+ /usr/local/lib/sellable-agent/fly-capabilities-bridge-exec.mjs \
116
+ > /usr/local/share/sellable-agent/capabilities-bridge-source.sha256 \
109
117
  && chmod 0444 /usr/local/share/sellable-agent/soul-bridge-source.sha256 \
110
118
  /usr/local/share/sellable-agent/skills-bridge-source.sha256 \
119
+ /usr/local/share/sellable-agent/capabilities-bridge-source.sha256 \
111
120
  && chmod 0755 /etc \
112
121
  && chmod 0555 /usr/local/lib/sellable-agent/*.mjs \
113
122
  && chmod 0555 /opt/sellable/bin/hermes-memory-dirty /opt/sellable/bin/hermes-endpoint-cron \
@@ -95,7 +95,7 @@ const RUNTIME_GENERATION = Number(
95
95
  const BOOT_SESSION_ID = randomUUID();
96
96
  const HERMES_VERSION = process.env.SELLABLE_AGENT_HERMES_VERSION ?? "0.20.0";
97
97
  const INSTALLER_PACKAGE =
98
- process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.597";
98
+ process.env.SELLABLE_AGENT_INSTALLER_PACKAGE ?? "@sellable/install@0.1.598";
99
99
  const MCP_PACKAGE =
100
100
  process.env.SELLABLE_AGENT_MCP_PACKAGE ?? "@sellable/mcp@0.1.860";
101
101
 
@@ -38,7 +38,7 @@ import { installAgentHostServices } from "./service-installer.mjs";
38
38
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
39
39
  const VERSION = "sellable-agent-host-bootstrap/v1";
40
40
  const RECEIPT_VERSION = "sellable-agent-host-registration/v1";
41
- const INSTALLER_PACKAGE = "@sellable/install@0.1.597";
41
+ const INSTALLER_PACKAGE = "@sellable/install@0.1.598";
42
42
  const MCP_PACKAGE = "@sellable/mcp@0.1.860";
43
43
  const HERMES_VERSION = "0.18.0";
44
44
  const SHA256 = /^[a-f0-9]{64}$/;
@@ -1546,7 +1546,7 @@ export function compileClaimToProfileDesired(activeClaim, config) {
1546
1546
  ]) ||
1547
1547
  pinned.hermesCli !== "hermes" ||
1548
1548
  pinned.hermesVersion !== "0.18.0" ||
1549
- pinned.installerPackage !== "@sellable/install@0.1.597" ||
1549
+ pinned.installerPackage !== "@sellable/install@0.1.598" ||
1550
1550
  pinned.mcpPackage !== "@sellable/mcp@0.1.860" ||
1551
1551
  !Array.isArray(policy.toolInclude) ||
1552
1552
  policy.toolInclude.length === 0 ||
@@ -25,6 +25,7 @@ import {
25
25
  resolve,
26
26
  } from "node:path";
27
27
  import { parseDocument } from "yaml";
28
+ import { SELLABLE_ALLOWED_TOOLSETS } from "./fly-capabilities-bridge.mjs";
28
29
  import {
29
30
  FLY_CUSTOMER_MODEL_ID,
30
31
  FLY_CUSTOMER_MODEL_PROVIDER,
@@ -277,7 +278,7 @@ function validateDesired(desired) {
277
278
  desired.serviceCredentialGeneration < 1 ||
278
279
  desired.hermesCli !== "hermes" ||
279
280
  desired.hermesVersion !== "0.18.0" ||
280
- desired.installerPackage !== "@sellable/install@0.1.597" ||
281
+ desired.installerPackage !== "@sellable/install@0.1.598" ||
281
282
  desired.mcpPackage !== "@sellable/mcp@0.1.860" ||
282
283
  !Array.isArray(desired.toolInclude) ||
283
284
  desired.toolInclude.length === 0 ||
@@ -487,7 +488,7 @@ const LIVE_SKILLS_CONFIG_KEYS = Object.freeze([
487
488
  "platform_disabled",
488
489
  ]);
489
490
 
490
- function profileConfigWithoutLiveSkills(value) {
491
+ function profileConfigWithoutLiveState(value) {
491
492
  const normalized = structuredClone(value);
492
493
  if (
493
494
  normalized?.skills &&
@@ -497,6 +498,15 @@ function profileConfigWithoutLiveSkills(value) {
497
498
  for (const key of LIVE_SKILLS_CONFIG_KEYS) delete normalized.skills[key];
498
499
  if (Object.keys(normalized.skills).length === 0) delete normalized.skills;
499
500
  }
501
+ if (
502
+ normalized?.platform_toolsets &&
503
+ typeof normalized.platform_toolsets === "object" &&
504
+ !Array.isArray(normalized.platform_toolsets)
505
+ ) {
506
+ delete normalized.platform_toolsets.slack;
507
+ if (Object.keys(normalized.platform_toolsets).length === 0)
508
+ delete normalized.platform_toolsets;
509
+ }
500
510
  return normalized;
501
511
  }
502
512
 
@@ -517,12 +527,28 @@ function readProfileConfig(pathValue) {
517
527
  function normalizedProfileConfigBytes(pathValue) {
518
528
  const value = readProfileConfig(pathValue);
519
529
  return Buffer.from(
520
- `${JSON.stringify(profileConfigWithoutLiveSkills(value), null, 2)}\n`,
530
+ `${JSON.stringify(profileConfigWithoutLiveState(value), null, 2)}\n`,
521
531
  "utf8"
522
532
  );
523
533
  }
524
534
 
525
- function mergeLiveSkillsConfig(sourceRoot, targetRoot) {
535
+ function preservedSlackToolsets(config) {
536
+ const platformToolsets = config?.platform_toolsets;
537
+ if (
538
+ !platformToolsets ||
539
+ typeof platformToolsets !== "object" ||
540
+ Array.isArray(platformToolsets) ||
541
+ !Object.prototype.hasOwnProperty.call(platformToolsets, "slack")
542
+ )
543
+ return null;
544
+ const slack = Array.isArray(platformToolsets.slack)
545
+ ? platformToolsets.slack
546
+ : [];
547
+ const allowed = new Set(SELLABLE_ALLOWED_TOOLSETS);
548
+ return [...new Set(slack.filter((name) => allowed.has(name)))].sort();
549
+ }
550
+
551
+ function mergeLiveMutableConfig(sourceRoot, targetRoot) {
526
552
  const sourcePath = join(sourceRoot, "config.yaml");
527
553
  const targetPath = join(targetRoot, "config.yaml");
528
554
  if (!existsSync(sourcePath) || !existsSync(targetPath)) return;
@@ -532,27 +558,43 @@ function mergeLiveSkillsConfig(sourceRoot, targetRoot) {
532
558
  const source = readProfileConfig(sourcePath);
533
559
  const target = readProfileConfig(targetPath);
534
560
  const sourceSkills = source?.skills;
561
+ let changed = false;
535
562
  if (
536
- !sourceSkills ||
537
- typeof sourceSkills !== "object" ||
538
- Array.isArray(sourceSkills)
539
- ) {
540
- return;
563
+ sourceSkills &&
564
+ (typeof sourceSkills !== "object" || Array.isArray(sourceSkills))
565
+ )
566
+ throw new Error("live skills config rejected");
567
+ if (sourceSkills) {
568
+ const preserved = Object.fromEntries(
569
+ LIVE_SKILLS_CONFIG_KEYS.filter((key) =>
570
+ Object.prototype.hasOwnProperty.call(sourceSkills, key)
571
+ ).map((key) => [key, structuredClone(sourceSkills[key])])
572
+ );
573
+ if (Object.keys(preserved).length > 0) {
574
+ target.skills = {
575
+ ...(target.skills &&
576
+ typeof target.skills === "object" &&
577
+ !Array.isArray(target.skills)
578
+ ? target.skills
579
+ : {}),
580
+ ...preserved,
581
+ };
582
+ changed = true;
583
+ }
541
584
  }
542
- const preserved = Object.fromEntries(
543
- LIVE_SKILLS_CONFIG_KEYS.filter((key) =>
544
- Object.prototype.hasOwnProperty.call(sourceSkills, key)
545
- ).map((key) => [key, structuredClone(sourceSkills[key])])
546
- );
547
- if (Object.keys(preserved).length === 0) return;
548
- target.skills = {
549
- ...(target.skills &&
550
- typeof target.skills === "object" &&
551
- !Array.isArray(target.skills)
552
- ? target.skills
553
- : {}),
554
- ...preserved,
555
- };
585
+ const slackToolsets = preservedSlackToolsets(source);
586
+ if (slackToolsets !== null) {
587
+ target.platform_toolsets = {
588
+ ...(target.platform_toolsets &&
589
+ typeof target.platform_toolsets === "object" &&
590
+ !Array.isArray(target.platform_toolsets)
591
+ ? target.platform_toolsets
592
+ : {}),
593
+ slack: slackToolsets,
594
+ };
595
+ changed = true;
596
+ }
597
+ if (!changed) return;
556
598
  atomicWrite(
557
599
  targetPath,
558
600
  `${JSON.stringify(target, null, 2)}\n`,
@@ -560,15 +602,15 @@ function mergeLiveSkillsConfig(sourceRoot, targetRoot) {
560
602
  );
561
603
  }
562
604
 
563
- function mergeLiveSkillsState(sourceRoot, targetRoot) {
605
+ function mergeLiveMutableState(sourceRoot, targetRoot) {
564
606
  if (!existsSync(sourceRoot)) return;
565
607
  const sourceSkills = join(sourceRoot, "skills");
566
608
  const targetSkills = join(targetRoot, "skills");
567
609
  if (existsSync(sourceSkills)) mergeHermesState(sourceSkills, targetSkills);
568
- mergeLiveSkillsConfig(sourceRoot, targetRoot);
610
+ mergeLiveMutableConfig(sourceRoot, targetRoot);
569
611
  }
570
612
 
571
- function preservedSkillsConfigIdentity(profileRoot) {
613
+ function preservedLiveStateIdentity(profileRoot) {
572
614
  if (!existsSync(profileRoot)) return null;
573
615
  const entries = [];
574
616
  const skillsRoot = join(profileRoot, "skills");
@@ -592,6 +634,7 @@ function preservedSkillsConfigIdentity(profileRoot) {
592
634
  if (existsSync(skillsRoot)) walk(skillsRoot);
593
635
  const configPath = join(profileRoot, "config.yaml");
594
636
  let disabled = {};
637
+ let slackToolsets = null;
595
638
  if (existsSync(configPath)) {
596
639
  const config = readProfileConfig(configPath);
597
640
  const skills = config?.skills;
@@ -602,8 +645,9 @@ function preservedSkillsConfigIdentity(profileRoot) {
602
645
  ).map((key) => [key, skills[key]])
603
646
  );
604
647
  }
648
+ slackToolsets = preservedSlackToolsets(config);
605
649
  }
606
- return sha256(stableJson({ disabled, entries }));
650
+ return sha256(stableJson({ disabled, entries, slackToolsets }));
607
651
  }
608
652
 
609
653
  function mcpFor(desired, profileId) {
@@ -893,7 +937,7 @@ export function validateAgentProfileBundle({ profileRoot, desired }) {
893
937
  readFileSync(join(profileRoot, "sellable", "mcp.json"), "utf8")
894
938
  );
895
939
  if (
896
- stableJson(profileConfigWithoutLiveSkills(config)) !==
940
+ stableJson(profileConfigWithoutLiveState(config)) !==
897
941
  stableJson(configFor(desired, manifest.profileId))
898
942
  ) {
899
943
  return { ok: false, code: "config_drift" };
@@ -1896,14 +1940,14 @@ async function materializeAgentProfileLocked(input = {}) {
1896
1940
  revisionId: desired.revisionId,
1897
1941
  fence: desired.fence,
1898
1942
  });
1899
- const liveSkillsIdentity = preservedSkillsConfigIdentity(profileRoot);
1943
+ const liveStateIdentity = preservedLiveStateIdentity(profileRoot);
1900
1944
  renderStagedProfile(stagingRoot, desired);
1901
- mergeLiveSkillsState(profileRoot, stagingRoot);
1945
+ mergeLiveMutableState(profileRoot, stagingRoot);
1902
1946
  if (
1903
- liveSkillsIdentity !== null &&
1904
- preservedSkillsConfigIdentity(stagingRoot) !== liveSkillsIdentity
1947
+ liveStateIdentity !== null &&
1948
+ preservedLiveStateIdentity(stagingRoot) !== liveStateIdentity
1905
1949
  ) {
1906
- throw new Error("live skills identity changed during staging");
1950
+ throw new Error("live mutable state identity changed during staging");
1907
1951
  }
1908
1952
  const staged = validateAgentProfileBundle({
1909
1953
  profileRoot: stagingRoot,
@@ -2721,7 +2765,7 @@ export function materializeNativeSlackCustomerProfile({
2721
2765
  };
2722
2766
  }
2723
2767
  let soulBytes = Buffer.from(desired.soul, "utf8");
2724
- const liveSkillsIdentity = preservedSkillsConfigIdentity(activeRoot);
2768
+ const liveStateIdentity = preservedLiveStateIdentity(activeRoot);
2725
2769
  if (existsSync(activeRoot)) {
2726
2770
  const soulPath = join(activeRoot, "SOUL.md");
2727
2771
  if (existsSync(soulPath)) {
@@ -2739,12 +2783,12 @@ export function materializeNativeSlackCustomerProfile({
2739
2783
  );
2740
2784
  mergeHermesState(activeRoot, stagingRoot);
2741
2785
  repointInternalCronProfileReferences(desired, stagingRoot);
2742
- mergeLiveSkillsConfig(activeRoot, stagingRoot);
2786
+ mergeLiveMutableConfig(activeRoot, stagingRoot);
2743
2787
  if (
2744
- liveSkillsIdentity !== null &&
2745
- preservedSkillsConfigIdentity(stagingRoot) !== liveSkillsIdentity
2788
+ liveStateIdentity !== null &&
2789
+ preservedLiveStateIdentity(stagingRoot) !== liveStateIdentity
2746
2790
  ) {
2747
- throw new Error("live skills identity changed during staging");
2791
+ throw new Error("live mutable state identity changed during staging");
2748
2792
  }
2749
2793
  const staged = validateNativeSlackCustomerProfile({
2750
2794
  desired,
@@ -25,7 +25,7 @@ import { fileURLToPath } from "node:url";
25
25
  import { deriveContainedProfileId } from "./profile-materializer.mjs";
26
26
 
27
27
  export const PROVISIONING_ACTION = "PROVISION_HERMES_PROFILE";
28
- export const PINNED_INSTALL_PACKAGE = "@sellable/install@0.1.597";
28
+ export const PINNED_INSTALL_PACKAGE = "@sellable/install@0.1.598";
29
29
  export const PINNED_MCP_PACKAGE = "@sellable/mcp@0.1.860";
30
30
  const PINNED_INSTALL_VERSION = PINNED_INSTALL_PACKAGE.slice(
31
31
  PINNED_INSTALL_PACKAGE.lastIndexOf("@") + 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/install",
3
- "version": "0.1.597",
3
+ "version": "0.1.598",
4
4
  "type": "module",
5
5
  "description": "One-command installer for Sellable MCP in Claude Code, Codex, and Hermes",
6
6
  "bin": {