@xns-cloud/relayer-mcp 0.4.0 → 0.5.1

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/README.md CHANGED
@@ -46,13 +46,13 @@ No separate install step required.
46
46
  | 1 | `check_prerequisites` | Verify Docker (local or remote), ports (8888, 9000), an existing installation, disk, and network connectivity. |
47
47
  | 2 | `register_account` | Register an XNS account (email + password) via Console2. |
48
48
  | 3 | `check_email_verified` | Poll email verification status (15s interval, 30-min timeout). |
49
- | 4 | `install_relayer` | Write the bundled `docker-compose.yml` + `.env` (`releases.scpri.me/xns-relayer:beta-latest` — the pre-release beta channel, anonymous pull, no `docker login`) and start the containers. **Fresh installs only** — see [Fresh installs vs. existing deployments](#fresh-installs-vs-existing-deployments). The user authors nothing; `compose_url` is an optional override (e.g. the full channel bundle with monitoring: `https://releases.scpri.me/relayer/beta/docker-compose.yml`). |
50
- | 5 | `check_relayer_health` | Poll UI, S3, and HostIO health (10s interval, 300s timeout). Targets the Docker host automatically. |
49
+ | 4 | `install_relayer` | Fetch the canonical beta channel bundle — relayer + Prometheus/Grafana monitoring stack (`https://releases.scpri.me/relayer/beta/docker-compose.yml`, anonymous pull, no `docker login`) — write the `.env`, and start the containers. Falls back to a bundled service-parity copy if the fetch fails. **Fresh installs only** — see [Fresh installs vs. existing deployments](#fresh-installs-vs-existing-deployments). The user authors nothing; `compose_url` is an optional override for custom installs. |
50
+ | 5 | `check_relayer_health` | Poll UI, S3, HostIO, and the monitoring sidecars (10s interval, 300s timeout). A missing monitoring stack reports as degraded without blocking the flow. Targets the Docker host automatically. |
51
51
  | 6 | `start_claim` | Initiate a claim session — returns a URL for browser confirmation. |
52
52
  | 7 | `check_claim_status` | Poll claim state (STATE_1 / STATE_2 / STATE_3). |
53
53
  | 8 | `get_host_tags` | Retrieve available host tags for VPD configuration. |
54
54
  | 9 | `configure_vpd` | Set data/parity host selection via CEL expressions. |
55
- | 10 | `verify_storage` | Round-trip S3 test (create bucket, put object, get object) against the S3 gateway on port 9000. |
55
+ | 10 | `verify_storage` | Round-trip S3 test (create bucket, put object, get object) against the S3 gateway on port 9000. **Requires fullaccess credentials** (admin key pair from the Relayer UI IAM page — not a read-only or bucket-scoped key). Test bucket is cleaned up automatically. If auto-detection cannot reach the host, pass `endpoint` with an explicit IP (e.g. `http://192.168.1.100:9000`). |
56
56
  | 11 | `setup_cli_credentials` | Provision S3 IAM credentials and write `~/.xns/credentials` so the XNS CLI works without further configuration. |
57
57
 
58
58
  ## Onboarding Flow
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xns-cloud/relayer-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "MCP server for XNS Relayer onboarding — drives a complete Relayer setup conversationally over stdio, npx-ready",
5
5
  "author": "SCP Corp",
6
6
  "license": "Apache-2.0",
@@ -47,7 +47,9 @@
47
47
  "jest": "^30.4.2"
48
48
  },
49
49
  "jest": {
50
- "testMatch": ["**/src/__tests__/**/*.test.js"],
50
+ "testMatch": [
51
+ "**/src/__tests__/**/*.test.js"
52
+ ],
51
53
  "testEnvironment": "node",
52
54
  "forceExit": true
53
55
  }
@@ -5,6 +5,8 @@ const {
5
5
  CreateBucketCommand,
6
6
  PutObjectCommand,
7
7
  GetObjectCommand,
8
+ DeleteObjectCommand,
9
+ DeleteBucketCommand,
8
10
  } = require('@aws-sdk/client-s3');
9
11
 
10
12
  /**
@@ -65,6 +67,23 @@ function createS3Client(options = {}) {
65
67
  return resp.Body.transformToString();
66
68
  },
67
69
 
70
+ /**
71
+ * @param {string} bucket
72
+ * @param {string} key
73
+ * @returns {Promise<void>}
74
+ */
75
+ async deleteObject(bucket, key) {
76
+ await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
77
+ },
78
+
79
+ /**
80
+ * @param {string} bucket
81
+ * @returns {Promise<void>}
82
+ */
83
+ async deleteBucket(bucket) {
84
+ await client.send(new DeleteBucketCommand({ Bucket: bucket }));
85
+ },
86
+
68
87
  /** Expose the raw client for advanced use */
69
88
  client,
70
89
  };
@@ -1,29 +1,38 @@
1
- # Bundled by @xns-cloud/relayer-mcp — the released XNS Relayer install.
2
- # Source of truth: the beta release channel on the XNS releases registry
3
- # (releases.scpri.me — anonymous pull, no docker login). The full channel bundle
4
- # (relayer + monitoring stack) lives at
5
- # https://releases.scpri.me/relayer/beta/docker-compose.yml; this bundled file is
6
- # the minimal relayer-only install. NEVER point this at Docker Hub scprime/*
7
- # those are production fleet tags and are not a release channel.
1
+ # Bundled by @xns-cloud/relayer-mcp — OFFLINE FALLBACK for the released install.
2
+ # Source of truth: the beta channel bundle on the XNS releases registry
3
+ # https://releases.scpri.me/relayer/beta/docker-compose.yml (versioned in the
4
+ # deploy repo, shipped by `deploy.py promote`). install_relayer fetches that URL
5
+ # by default and only writes this copy when the fetch fails, so this file MUST
6
+ # keep service parity with the channel bundle: relayer + the monitoring stack
7
+ # (Prometheus + Grafana + node-exporter) that powers the dashboards under
8
+ # Monitoring in the web UI. The jest contract tests enforce the parity.
9
+ #
10
+ # NEVER point an image at Docker Hub scprime/* — those are production fleet
11
+ # tags, not a release channel. The one allowed upstream image is
12
+ # prom/node-exporter (public, multi-arch, version-pinned — matches the channel
13
+ # bundle; nothing to rebuild).
8
14
  #
9
15
  # Pre-release: pinned to :beta-latest so beta testers exercise the current
10
- # Relayer agentically. Flip this one tag to :stable-latest at general release.
16
+ # Relayer agentically. Flip these tags to :stable-latest at general release.
11
17
  #
12
- # A first-time user no longer hand-writes this file or the .env — install_relayer
18
+ # A first-time user never hand-writes this file or the .env — install_relayer
13
19
  # writes both. Differences from the channel compose, by design:
14
- # - data lives in ./data (relative to this file) instead of a named volume,
15
- # so the same file works on Windows, macOS, and Linux and the data is easy
16
- # to find next to the compose.
20
+ # - relayer data lives in ./data (relative to this file) instead of a named
21
+ # volume, so the same file works on Windows, macOS, and Linux and the data
22
+ # is easy to find next to the compose.
17
23
  # - SMB ports 139/445 are intentionally NOT exposed: 445 is held by Windows
18
24
  # SMB on consumer hosts and would fail the install. S3 onboarding uses :9000.
19
25
  # - pull_policy: always so `docker compose up -d` fetches the current beta
20
- # image without a separate `docker compose pull` step.
26
+ # images without a separate `docker compose pull` step.
21
27
  services:
22
28
  xns:
29
+ # container_name is load-bearing: the baked-in prometheus.yml scrapes the
30
+ # relayer by this name, and the install preflight keys on it.
23
31
  container_name: xns-relayer
24
32
  image: releases.scpri.me/xns-relayer:beta-latest
25
33
  pull_policy: always
26
34
  restart: unless-stopped
35
+ privileged: true # samba / fuse / disk management (matches channel compose)
27
36
  volumes:
28
37
  - ./data:/relayer
29
38
  ports:
@@ -31,3 +40,38 @@ services:
31
40
  - ${S3_PORT:-9000}:9000
32
41
  env_file:
33
42
  - .env
43
+
44
+ # Monitoring stack — powers the dashboards under Monitoring in the web UI.
45
+ # Not host-exposed: Grafana is reached only through the relayer's /grafana
46
+ # reverse proxy and the in-network Prometheus scrape.
47
+ prometheus:
48
+ image: releases.scpri.me/relayer-prometheus:beta-latest
49
+ pull_policy: always
50
+ restart: unless-stopped
51
+ volumes:
52
+ - prometheus_data:/prometheus
53
+
54
+ grafana:
55
+ image: releases.scpri.me/relayer-grafana:beta-latest
56
+ pull_policy: always
57
+ restart: unless-stopped
58
+ environment:
59
+ - GF_AUTH_ANONYMOUS_ENABLED=true
60
+ - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
61
+ - GF_SERVER_SERVE_FROM_SUB_PATH=true
62
+ - GF_SERVER_ROOT_URL=%(protocol)s://%(domain)s/grafana
63
+ - GF_SECURITY_ALLOW_EMBEDDING=true
64
+ volumes:
65
+ - grafana_data:/var/lib/grafana
66
+
67
+ node-exporter:
68
+ image: prom/node-exporter:v1.11.1
69
+ restart: unless-stopped
70
+ command:
71
+ - --path.rootfs=/host
72
+ volumes:
73
+ - /:/host:ro # read-only host metrics for the Node Health page
74
+
75
+ volumes:
76
+ prometheus_data:
77
+ grafana_data:
@@ -53,7 +53,7 @@ module.exports = function registerCheckRelayerHealth(server, options = {}) {
53
53
 
54
54
  server.tool(
55
55
  'check_relayer_health',
56
- 'Check the health of all Relayer services: UI (port 8888), S3 gateway (port 9000), and HostIO. Polls every 10 seconds for up to 300 seconds. Reports each component status individually and names any unhealthy component. Targets the machine the Docker daemon runs on (auto-detected from the Docker context — supports remote ssh:// Docker hosts); pass host to override. Note: HostIO health status is unknown until OIDC authentication is completed.',
56
+ 'Check the health of all Relayer services: UI (port 8888), S3 gateway (port 9000), HostIO, and the monitoring sidecars (Prometheus + Grafana containers). Polls every 10 seconds for up to 300 seconds. Reports each component status individually and names any unhealthy component; a missing monitoring stack reports as degraded (dashboards empty) without blocking the install flow. Targets the machine the Docker daemon runs on (auto-detected from the Docker context — supports remote ssh:// Docker hosts); pass host to override. Note: HostIO health status is unknown until OIDC authentication is completed.',
57
57
  {
58
58
  poll: z.boolean().optional().default(true).describe('If true (default), poll until healthy or timeout. If false, check once.'),
59
59
  host: z.string().trim().min(1).optional().describe('Hostname/IP where the Relayer containers run. Default: auto-detected from the Docker context (localhost, or the remote host for ssh:// / tcp:// contexts).'),
@@ -104,6 +104,25 @@ module.exports = function registerCheckRelayerHealth(server, options = {}) {
104
104
  components.hostio = { healthy: null, status: null, note: 'HostIO health unknown — authentication not yet completed' };
105
105
  }
106
106
 
107
+ // Monitoring sidecars (Prometheus + Grafana) — the channel
108
+ // bundle ships them; without them the dashboards under
109
+ // Monitoring in the web UI are dead. Absence DEGRADES the
110
+ // install (surfaced + named) but never blocks the core flow
111
+ // — claim/onboarding doesn't depend on monitoring.
112
+ const [prometheus, grafana] = await Promise.all([
113
+ docker.isContainerRunning('prometheus'),
114
+ docker.isContainerRunning('grafana'),
115
+ ]);
116
+ const monitoringHealthy = prometheus === true && grafana === true;
117
+ components.monitoring = {
118
+ healthy: monitoringHealthy,
119
+ prometheus,
120
+ grafana,
121
+ ...(monitoringHealthy ? {} : {
122
+ note: 'Monitoring stack (Prometheus/Grafana) is not running — the dashboards under Monitoring in the web UI will be empty. The channel bundle compose includes both; re-run docker compose up -d with the channel bundle to add them.',
123
+ }),
124
+ };
125
+
107
126
  // Healthy if UI and S3 are up. HostIO null (unknown) does NOT block.
108
127
  const coreHealthy = components.ui.healthy === true && components.s3.healthy === true;
109
128
  const allKnownHealthy = coreHealthy && components.hostio.healthy === true;
@@ -116,16 +135,24 @@ module.exports = function registerCheckRelayerHealth(server, options = {}) {
116
135
  components,
117
136
  healthy: coreHealthy,
118
137
  all_healthy: allKnownHealthy,
138
+ // JSON.stringify drops undefined — degraded only appears when true.
139
+ degraded: monitoringHealthy ? undefined : true,
119
140
  unhealthy: unhealthy.length > 0 ? unhealthy : undefined,
120
141
  target_host: target,
121
142
  remote_docker: dockerHost.remote === true || undefined,
122
143
  };
123
144
  };
124
145
 
146
+ // Degraded (monitoring down) is appended to ANY healthy message —
147
+ // success stays true, the gap is named instead of swallowed.
148
+ const degradedSuffix = (r) => (r.degraded
149
+ ? ' DEGRADED: monitoring stack (Prometheus/Grafana) is not running — Monitoring dashboards will be empty.'
150
+ : '');
151
+
125
152
  if (!poll) {
126
153
  const result = await checkOnce();
127
154
  const message = result.healthy
128
- ? 'Relayer core services (UI + S3) are healthy.' + (result.all_healthy ? ' HostIO is also healthy.' : ' HostIO status is pending authentication.')
155
+ ? 'Relayer core services (UI + S3) are healthy.' + (result.all_healthy ? ' HostIO is also healthy.' : ' HostIO status is pending authentication.') + degradedSuffix(result)
129
156
  : `Relayer is not yet healthy. Unhealthy: ${result.unhealthy.join(', ')}.`;
130
157
 
131
158
  return {
@@ -163,9 +190,9 @@ module.exports = function registerCheckRelayerHealth(server, options = {}) {
163
190
  };
164
191
  }
165
192
 
166
- const message = result.all_healthy
193
+ const message = (result.all_healthy
167
194
  ? 'All Relayer services are healthy (UI, S3, HostIO). Ready for claim.'
168
- : 'Relayer core services (UI + S3) are healthy. HostIO status pending authentication. Proceed to start_claim.';
195
+ : 'Relayer core services (UI + S3) are healthy. HostIO status pending authentication. Proceed to start_claim.') + degradedSuffix(result);
169
196
 
170
197
  return {
171
198
  content: [{
@@ -4,8 +4,14 @@ const { z } = require('zod');
4
4
  const path = require('path');
5
5
  const { createDockerUtil } = require('../lib/dockerUtil');
6
6
 
7
- // Bundled released-install template (ships in the npm package; package.json
8
- // `files: ["src/"]` covers it). This is the documented xns.tech install.
7
+ // Canonical released install the full beta channel bundle (relayer +
8
+ // monitoring stack). Versioned in the deploy repo, shipped to web01 by
9
+ // `deploy.py promote`, served login-free. THE default install source.
10
+ const CHANNEL_COMPOSE_URL = 'https://releases.scpri.me/relayer/beta/docker-compose.yml';
11
+
12
+ // Bundled OFFLINE FALLBACK template (ships in the npm package; package.json
13
+ // `files: ["src/"]` covers it). Written only when the channel fetch fails;
14
+ // kept service-parity with the channel bundle by the jest contract tests.
9
15
  const TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'docker-compose.yml');
10
16
 
11
17
  // container_name in the bundled compose. Docker container names are unique
@@ -19,11 +25,12 @@ const CONTAINER_NAME = 'xns-relayer';
19
25
  * TP-30: uses execFile, no shell (security non-negotiable).
20
26
  *
21
27
  * A first-time user has never heard of a Relayer and cannot supply a compose
22
- * file or a .env. So by default this tool WRITES both for them the bundled
23
- * released compose (releases.scpri.me/xns-relayer:beta-latest, the pre-release
24
- * beta channel; anonymous pull, no docker login) plus a .env carrying the two
25
- * ports. The full channel bundle (relayer + monitoring stack) is published at
26
- * https://releases.scpri.me/relayer/beta/docker-compose.yml for compose_url.
28
+ * file or a .env. So by default this tool authors both for them: it fetches
29
+ * the CANONICAL channel bundle (relayer + Prometheus + Grafana + node-exporter
30
+ * the monitoring stack powers the dashboards under Monitoring in the web UI)
31
+ * and writes a .env carrying the two ports. If the fetch fails (offline,
32
+ * registry hiccup), the bundled service-parity template is the fallback — the
33
+ * install still completes and the response says it fell back.
27
34
  *
28
35
  * `compose_url` stays as an optional override for internal/custom installs;
29
36
  * when given, the old download-a-URL behaviour is preserved.
@@ -32,10 +39,11 @@ module.exports = function registerInstallRelayer(server, options = {}) {
32
39
  const docker = options.dockerUtil || createDockerUtil(options);
33
40
  const _execFile = options.execFile;
34
41
  const fsp = options.fs || require('fs').promises;
42
+ const channelComposeUrl = options.channelComposeUrl || CHANNEL_COMPOSE_URL;
35
43
 
36
44
  server.tool(
37
45
  'install_relayer',
38
- 'Install and start the XNS Relayer. By default writes the bundled docker-compose.yml (releases.scpri.me/xns-relayer:beta-latest — the pre-release beta channel, anonymous pull) and a .env, then runs docker compose up -d — the user does NOT need to author any file. Pass compose_url only to override with a custom compose (e.g. the full channel bundle with monitoring at https://releases.scpri.me/relayer/beta/docker-compose.yml).',
46
+ 'Install and start the XNS Relayer. By default fetches the canonical beta channel bundle relayer + the Prometheus/Grafana monitoring stack — from releases.scpri.me (anonymous pull) and writes a .env, then runs docker compose up -d — the user does NOT need to author any file. Falls back to a bundled copy of the bundle if the fetch fails. Pass compose_url only to override with a custom compose.',
39
47
  {
40
48
  install_path: z.string().optional().default('/opt/xns-relayer').describe('Directory to install the compose file into'),
41
49
  ui_port: z.number().int().positive().optional().default(8888).describe('Host port for the Relayer admin/customer UI (container 8888)'),
@@ -77,18 +85,33 @@ module.exports = function registerInstallRelayer(server, options = {}) {
77
85
  });
78
86
  });
79
87
 
88
+ const fetchCompose = (url) => new Promise((resolve, reject) => {
89
+ execFileFn('curl', ['-fsSL', '-o', composePath, url], { timeout: 60000 }, (err) => {
90
+ if (err) return reject(new Error(`Failed to download compose file: ${err.message}`));
91
+ resolve();
92
+ });
93
+ });
94
+
95
+ let source;
96
+ let note;
80
97
  if (compose_url) {
81
98
  // Override path: download a custom compose (execFile, no shell).
82
- await new Promise((resolve, reject) => {
83
- execFileFn('curl', ['-fsSL', '-o', composePath, compose_url], { timeout: 60000 }, (err) => {
84
- if (err) return reject(new Error(`Failed to download compose file: ${err.message}`));
85
- resolve();
86
- });
87
- });
99
+ await fetchCompose(compose_url);
100
+ source = 'compose_url';
88
101
  } else {
89
- // Default path: write the bundled released compose + .env for the user.
90
- const template = await fsp.readFile(TEMPLATE_PATH, 'utf8');
91
- await fsp.writeFile(composePath, template);
102
+ // Default path: fetch the canonical channel bundle (relayer +
103
+ // monitoring stack); fall back to the bundled service-parity
104
+ // template only when the fetch fails. Either way, author the
105
+ // .env so the user never writes a file.
106
+ try {
107
+ await fetchCompose(channelComposeUrl);
108
+ source = 'channel';
109
+ } catch (fetchErr) {
110
+ const template = await fsp.readFile(TEMPLATE_PATH, 'utf8');
111
+ await fsp.writeFile(composePath, template);
112
+ source = 'bundled-fallback';
113
+ note = `Channel bundle fetch failed (${fetchErr.message}) — fell back to the bundled compose. Same services; re-running install later is not required.`;
114
+ }
92
115
  await fsp.writeFile(envPath, `UI_PORT=${ui_port}\nS3_PORT=${s3_port}\n`);
93
116
  }
94
117
 
@@ -108,7 +131,8 @@ module.exports = function registerInstallRelayer(server, options = {}) {
108
131
  message: 'XNS Relayer containers are starting. Use check_relayer_health to monitor when all services are ready.',
109
132
  compose_path: composePath,
110
133
  install_path,
111
- source: compose_url ? 'compose_url' : 'bundled',
134
+ source,
135
+ ...(note ? { note } : {}),
112
136
  }, null, 2),
113
137
  }],
114
138
  };
@@ -8,6 +8,10 @@ const { createDockerUtil } = require('../lib/dockerUtil');
8
8
  * Tool 10: verify_storage
9
9
  * AC-20: targets the S3 gateway on port 9000 plain HTTP; reports endpoint.
10
10
  * AC-21: verify fail → names the failing step (CreateBucket/PutObject/GetObject).
11
+ * W3-AC1: cleanup in finally — always removes mcp-verify-* bucket/object; failure
12
+ * is non-fatal (noted in response, never flips pass→fail).
13
+ * W3-AC2: fullaccess credential requirement stated in description and error output.
14
+ * W3-AC3: explicit-IP hint when endpoint was auto-detected.
11
15
  * R6: S3 credentials must be provided (from relayer-ui IAM key-mgmt flow).
12
16
  *
13
17
  * Performs a round-trip verification: CreateBucket → PutObject → GetObject → compare.
@@ -20,22 +24,26 @@ module.exports = function registerVerifyStorage(server, options = {}) {
20
24
 
21
25
  server.tool(
22
26
  'verify_storage',
23
- 'Verify the S3-compatible storage gateway is working by performing a round-trip test: create a test bucket, upload a small object, download it, and compare. By default targets port 9000 on the machine the Docker daemon runs on (auto-detected from the Docker context — supports remote ssh:// Docker hosts); pass endpoint to override. You must provide S3 access credentials these can be found in the Relayer configuration or generated via the Relayer UI.',
27
+ 'Verify the S3-compatible storage gateway is working by performing a round-trip test: create a test bucket, upload a small object, download it, and compare. IMPORTANT: you must supply fullaccess credentials — the admin key pair created via the Relayer UI IAM page (not a read-only or bucket-scoped key). By default targets port 9000 on the machine the Docker daemon runs on (auto-detected from the Docker context — supports remote ssh:// Docker hosts); pass endpoint to override with an explicit IP when auto-detection cannot reach the host.',
24
28
  {
25
- access_key_id: z.string().describe('S3 access key ID'),
26
- secret_access_key: z.string().describe('S3 secret access key'),
27
- endpoint: z.string().trim().url().optional().describe('S3 endpoint URL. Default: http://{docker-host}:9000, where {docker-host} is auto-detected from the Docker context.'),
29
+ access_key_id: z.string().describe('S3 access key ID (fullaccess credentials from the Relayer UI IAM page)'),
30
+ secret_access_key: z.string().describe('S3 secret access key (fullaccess credentials from the Relayer UI IAM page)'),
31
+ endpoint: z.string().trim().url().optional().describe('S3 endpoint URL. Default: http://{docker-host}:9000, where {docker-host} is auto-detected from the Docker context. Pass an explicit IP (e.g. http://192.168.1.100:9000) when auto-detection cannot reach the host.'),
28
32
  },
29
33
  async ({ access_key_id, secret_access_key, endpoint }) => {
30
34
  const testBucket = `mcp-verify-${Date.now()}`;
31
35
  const testKey = 'verify-test.txt';
32
36
  const testContent = `relayer-mcp verification ${Date.now()}`;
33
37
  let currentStep = 'init';
38
+ let endpointAutoDetected = false;
39
+
40
+ let verifyResult = null;
34
41
 
35
42
  try {
36
43
  if (!endpoint) {
37
44
  const { host } = await docker.getDockerHost();
38
45
  endpoint = `http://${host}:9000`;
46
+ endpointAutoDetected = true;
39
47
  }
40
48
  const s3 = _createS3Client({
41
49
  endpoint,
@@ -58,42 +66,83 @@ module.exports = function registerVerifyStorage(server, options = {}) {
58
66
  // Step 4: Compare
59
67
  currentStep = 'Compare';
60
68
  if (retrieved !== testContent) {
61
- return {
62
- content: [{
63
- type: 'text',
64
- text: JSON.stringify({
65
- success: false,
66
- error: 'Storage verification failed at Compare: uploaded content does not match downloaded content. The S3 gateway may have data integrity issues.',
67
- endpoint,
68
- failing_step: 'Compare',
69
- }),
70
- }],
71
- isError: true,
69
+ verifyResult = {
70
+ success: false,
71
+ error: 'Storage verification failed at Compare: uploaded content does not match downloaded content. The S3 gateway may have data integrity issues.',
72
+ endpoint,
73
+ failing_step: 'Compare',
74
+ };
75
+ } else {
76
+ const explicitIpHint = endpointAutoDetected
77
+ ? `If auto-detection cannot reach the host, pass an explicit endpoint: endpoint option (e.g. "http://<ip>:9000").`
78
+ : null;
79
+
80
+ verifyResult = {
81
+ success: true,
82
+ message: `S3 storage verification passed. Successfully created bucket, uploaded object, and verified download at ${endpoint}.`,
83
+ endpoint,
84
+ test_bucket: testBucket,
85
+ ...(explicitIpHint ? { note: explicitIpHint } : {}),
72
86
  };
73
87
  }
74
88
 
89
+ // Cleanup: remove test object and bucket (W3-AC1)
90
+ try {
91
+ await s3.deleteObject(testBucket, testKey);
92
+ await s3.deleteBucket(testBucket);
93
+ } catch (cleanupErr) {
94
+ // Non-fatal: note it but never flip the result
95
+ verifyResult.cleanup_warning = `Test bucket cleanup failed (${cleanupErr.message}). The bucket "${testBucket}" may remain — remove it manually via the Relayer UI.`;
96
+ }
97
+
75
98
  return {
76
99
  content: [{
77
100
  type: 'text',
78
- text: JSON.stringify({
79
- success: true,
80
- message: `S3 storage verification passed. Successfully created bucket, uploaded object, and verified download at ${endpoint}.`,
81
- endpoint,
82
- test_bucket: testBucket,
83
- }, null, 2),
101
+ text: JSON.stringify(verifyResult, null, 2),
84
102
  }],
103
+ ...(verifyResult.success ? {} : { isError: true }),
85
104
  };
86
105
  } catch (err) {
106
+ // Attempt cleanup even on error (W3-AC1)
107
+ let cleanupWarning;
108
+ if (currentStep !== 'init' && currentStep !== 'CreateBucket') {
109
+ // Bucket was created before the failure — try to clean up
110
+ try {
111
+ const s3Cleanup = _createS3Client({
112
+ endpoint,
113
+ accessKeyId: access_key_id,
114
+ secretAccessKey: secret_access_key,
115
+ });
116
+ await s3Cleanup.deleteObject(testBucket, testKey).catch(() => {});
117
+ await s3Cleanup.deleteBucket(testBucket);
118
+ } catch (cleanupErr) {
119
+ cleanupWarning = `Test bucket cleanup failed (${cleanupErr.message}). The bucket "${testBucket}" may remain — remove it manually.`;
120
+ }
121
+ }
122
+
123
+ const isCredentialError = /AccessDenied|InvalidAccessKeyId|SignatureDoesNotMatch|Forbidden|401|403/i.test(err.message);
124
+ const credentialHint = isCredentialError
125
+ ? ' Ensure you are using fullaccess credentials from the Relayer UI IAM page (not a read-only or bucket-scoped key).'
126
+ : '';
127
+
128
+ const explicitIpHint = endpointAutoDetected
129
+ ? ` If the endpoint cannot be reached, pass an explicit IP via the endpoint option (e.g. "http://<ip>:9000").`
130
+ : '';
131
+
87
132
  // AC-21: name the failing step
133
+ const errorPayload = {
134
+ success: false,
135
+ error: `Storage verification failed at ${currentStep}: ${err.message}.${credentialHint}${explicitIpHint}`,
136
+ endpoint,
137
+ failing_step: currentStep,
138
+ ...(isCredentialError ? { credential_requirement: 'fullaccess credentials required — use the admin key pair from the Relayer UI IAM page.' } : {}),
139
+ ...(cleanupWarning ? { cleanup_warning: cleanupWarning } : {}),
140
+ };
141
+
88
142
  return {
89
143
  content: [{
90
144
  type: 'text',
91
- text: JSON.stringify({
92
- success: false,
93
- error: `Storage verification failed at ${currentStep}: ${err.message}`,
94
- endpoint,
95
- failing_step: currentStep,
96
- }),
145
+ text: JSON.stringify(errorPayload),
97
146
  }],
98
147
  isError: true,
99
148
  };