@debugg-ai/debugg-ai-mcp 4.2.2 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +10 -4
  2. package/dist/handlers/createEnvironmentHandler.js +10 -0
  3. package/dist/handlers/probePageHandler.js +14 -7
  4. package/dist/handlers/runTestSuiteHandler.js +6 -5
  5. package/dist/handlers/testPageChangesHandler.js +20 -10
  6. package/dist/handlers/triggerCrawlHandler.js +7 -6
  7. package/dist/handlers/updateEnvironmentHandler.js +14 -2
  8. package/dist/services/index.js +21 -0
  9. package/dist/services/ngrok/tunnelManager.js +157 -161
  10. package/dist/services/tunnel/debuggTransport.js +439 -0
  11. package/dist/services/tunnel/ngrokTransport.js +165 -0
  12. package/dist/services/tunnel/probeRegistry.js +52 -0
  13. package/dist/services/tunnel/protocol/codec.js +327 -0
  14. package/dist/services/tunnel/protocol/conformance.js +171 -0
  15. package/dist/services/tunnel/protocol/constants.js +178 -0
  16. package/dist/services/tunnel/protocol/errors.js +51 -0
  17. package/dist/services/tunnel/protocol/handshake.js +134 -0
  18. package/dist/services/tunnel/protocol/index.js +17 -0
  19. package/dist/services/tunnel/protocol/session.js +668 -0
  20. package/dist/services/tunnel/protocol/stream.js +109 -0
  21. package/dist/services/tunnel/protocol/transport.js +106 -0
  22. package/dist/services/tunnel/transport.js +15 -0
  23. package/dist/services/tunnels.js +71 -3
  24. package/dist/services/verdictAdapter.js +25 -1
  25. package/dist/tools/environment.js +5 -2
  26. package/dist/tools/testPageChanges.js +1 -1
  27. package/dist/types/index.js +29 -2
  28. package/dist/utils/authorizedCredentialHosts.js +38 -0
  29. package/dist/utils/localReachability.js +160 -3
  30. package/dist/utils/tunnelContext.js +6 -3
  31. package/dist/utils/tunnelDisposition.js +16 -1
  32. package/dist/utils/tunnelDomains.js +113 -0
  33. package/dist/utils/urlParser.js +8 -2
  34. package/package.json +3 -1
package/README.md CHANGED
@@ -92,6 +92,8 @@ Naming an account only in `description` does **not** make the agent use it — i
92
92
 
93
93
  Set `useEnvironmentCredentials: false` when a silent fallback to the default test user would invalidate the check.
94
94
 
95
+ **Cross-domain SSO?** A run only types credentials on the app's own host and its subdomains; a sign-in page on another domain is refused as `offscope_host`. Add the identity provider's host to the environment's `authorizedCredentialHosts` (see [`environment`](#environment)).
96
+
95
97
  **Checking a page that needs no login at all?** Pass `useEnvironmentCredentials: false` and name no account. That combination means exactly what it says — *do not log in* — and the run skips authentication entirely instead of hunting for a login form. Use it for public pages, marketing sites, docs, and anything pre-auth. It is also faster: on the default (`auto`) the agent will follow a "Log in" link off your page and try the environment's stored account before it evaluates anything.
96
98
 
97
99
  ##### Session reuse: why a check can report "no login form"
@@ -109,7 +111,9 @@ Results report the identity actually used, so a wrong one is visible rather than
109
111
 
110
112
  ```json
111
113
  "logins": [
112
- { "username": "qa+invitefix@example.com", "source": "task", "submitted": true, "authenticated": true }
114
+ { "username": "qa+invitefix@example.com", "source": "task", "submitted": true, "authenticated": true },
115
+ { "username": "qatest123@example.com", "source": "env_default", "submitted": false, "authenticated": false,
116
+ "reason": "offscope_host", "detail": "refused to enter credentials on auth.idp.example: not part of this run's scope …" }
113
117
  ],
114
118
  "credentialWarning": {
115
119
  "requested": "qa+invitefix@example.com",
@@ -118,7 +122,7 @@ Results report the identity actually used, so a wrong one is visible rather than
118
122
  }
119
123
  ```
120
124
 
121
- `source` is `task` | `explicit` | `credential_id` (an account you named) or `env` | `env_default` (the environment's stored account). `credentialWarning` appears only when you named an account and an environment default was used anyway. `loginError` appears when a named account could not be resolved and the run declined to substitute a different one.
125
+ `source` is `task` | `explicit` | `credential_id` (an account you named) or `env` | `env_default` (the environment's stored account). `submitted` is true only when credentials were actually typed and submitted; `reason` says what happened (e.g. `offscope_host`, `restored_session`), and `detail`, when present, is a human-readable explanation of it — for an `offscope_host` refusal it names the host and how to authorize it. `credentialWarning` appears only when you named an account and an environment default for a **different** account was actually submitted — never for a login that was refused or skipped. `loginError` appears when a named account could not be resolved and the run declined to substitute a different one.
122
126
 
123
127
  Every successful run returns a `browserSession` block alongside the screenshot — presigned S3 URLs for the captured **HAR** (full network trace) and **console log** (every JS console message). Use them to detect refetch loops, hydration errors, and other runtime issues that pass type-checks and unit tests:
124
128
 
@@ -176,14 +180,16 @@ Team and repo resolve by **either** uuid **or** name (case-insensitive exact mat
176
180
  |--------|--------|--------|
177
181
  | `get` | `{uuid, projectUuid?}` | Env with credentials inlined (passwords never returned) |
178
182
  | `list` | `{projectUuid?, q?, page?, pageSize?}` | Paginated envs, each with a credentials array |
179
- | `create` | `{name, url, description?, projectUuid?, credentials?}` | Created env (optionally seeds credentials) |
180
- | `update` | `{uuid, name?, url?, description?, addCredentials?, updateCredentials?, removeCredentialIds?}` | Patched env; credential ops run **remove → update → add** |
183
+ | `create` | `{name, url, description?, projectUuid?, credentials?, authorizedCredentialHosts?}` | Created env (optionally seeds credentials) |
184
+ | `update` | `{uuid, name?, url?, description?, addCredentials?, updateCredentials?, removeCredentialIds?, authorizedCredentialHosts?}` | Patched env; credential ops run **remove → update → add** |
181
185
  | `delete` | `{uuid, projectUuid?, confirm?}` | Deletes env (cascades credentials) — **requires confirmation** |
182
186
  | `sessions` | `{uuid, username?, credentialId?}` | Captured login sessions the env holds, per account, with `isUsable` and a `usableCount` |
183
187
  | `clearSessions` | `{uuid, username?, credentialId?, confirm?}` | Invalidates them so the next run logs in for real — **unscoped clears require confirmation** |
184
188
 
185
189
  `projectUuid` auto-resolves from the git repo when omitted. Per-cred failures surface in `credentialWarnings[]` without blocking the env op.
186
190
 
191
+ `authorizedCredentialHosts` lists hosts where a run may enter this environment's credentials besides the app's own host — **for cross-domain SSO, add the IdP host here** (e.g. `["auth.example.com"]`). Bare hostnames only: no scheme, path, port or wildcard (subdomains of the app's host are already in scope). On `update` it replaces the list; `[]` clears it. `get`/`list` return it when the server supports it. The response echoes the saved list; if the server did not persist it (older servers ignore the field), the result carries an `authorizedCredentialHostsWarning` saying so instead of a silent success.
192
+
187
193
  `sessions` / `clearSessions` manage the warm authenticated sessions the backend reuses to skip login (see [Session reuse](#session-reuse-why-a-check-can-report-no-login-form)). Session contents are never returned — a session cookie is a bearer credential. `clearSessions` marks sessions invalid rather than deleting the rows, so reuse stops immediately while the capture history stays readable.
188
194
 
189
195
  ### `test_suite`
@@ -3,6 +3,7 @@ import { handleExternalServiceError } from '../utils/errors.js';
3
3
  import { DebuggAIServerClient } from '../services/index.js';
4
4
  import { config } from '../config/index.js';
5
5
  import { detectRepoName } from '../utils/gitContext.js';
6
+ import { checkAuthorizedCredentialHostsEcho } from '../utils/authorizedCredentialHosts.js';
6
7
  const logger = new Logger({ module: 'createEnvironmentHandler' });
7
8
  export async function createEnvironmentHandler(input, _context) {
8
9
  const start = Date.now();
@@ -38,6 +39,7 @@ export async function createEnvironmentHandler(input, _context) {
38
39
  name: input.name,
39
40
  url: input.url,
40
41
  description: input.description,
42
+ authorizedCredentialHosts: input.authorizedCredentialHosts,
41
43
  });
42
44
  const payload = {
43
45
  created: true,
@@ -77,6 +79,14 @@ export async function createEnvironmentHandler(input, _context) {
77
79
  if (warnings.length > 0)
78
80
  payload.credentialWarnings = warnings;
79
81
  }
82
+ // Bead q4d4: only the echo proves the backend kept the hosts.
83
+ if (input.authorizedCredentialHosts !== undefined) {
84
+ const hostsWarning = checkAuthorizedCredentialHostsEcho(input.authorizedCredentialHosts, env.authorizedCredentialHosts, 'create');
85
+ if (hostsWarning) {
86
+ payload.authorizedCredentialHostsWarning = hostsWarning;
87
+ logger.warn(`create_environment: ${hostsWarning.message}`);
88
+ }
89
+ }
80
90
  logger.toolComplete('create_environment', Date.now() - start);
81
91
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
82
92
  }
@@ -104,7 +104,10 @@ export async function probePageHandler(input, context, rawProgressCallback) {
104
104
  // Per-target tunnel contexts. Index aligns with input.targets[].
105
105
  const targetContexts = [];
106
106
  // Tunnel keys we provisioned this call (for cleanup if creation fails after key acquired).
107
- const acquiredKeyIds = [];
107
+ // The provisions this call acquired, in target order. Provisions rather than
108
+ // bare key ids because revoking follows the transport the backend picked
109
+ // (ngrok keys and debugg tunnels have different endpoints).
110
+ const acquiredProvisions = [];
108
111
  // Progress budget: 1 pre-flight + 1 template + 1 execute + N per-target captures + 1 done
109
112
  const TOTAL_STEPS = 3 + input.targets.length + 1;
110
113
  let progressStep = 0;
@@ -157,10 +160,10 @@ export async function probePageHandler(input, context, rawProgressCallback) {
157
160
  throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
158
161
  `(Detail: ${msg})${diag}`);
159
162
  }
160
- acquiredKeyIds.push(tunnel.keyId);
163
+ acquiredProvisions.push(tunnel);
161
164
  let tunneled;
162
165
  try {
163
- tunneled = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
166
+ tunneled = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.tunnels.revoke(tunnel), tunnel);
164
167
  }
165
168
  catch (tunnelError) {
166
169
  const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
@@ -463,11 +466,15 @@ export async function probePageHandler(input, context, rawProgressCallback) {
463
466
  // Tunnels intentionally NOT torn down — reuse pattern (bead vwd) +
464
467
  // 55-min idle auto-shutoff. Revoke only orphaned keys (we acquired the
465
468
  // key but tunnel creation failed before ensureTunnel completed).
466
- for (let i = 0; i < acquiredKeyIds.length; i++) {
467
- const keyId = acquiredKeyIds[i];
469
+ // NOTE (bead debugg_ai_mcp-xkoh.9): `tc` is undefined when ensureTunnel
470
+ // threw, so this condition does not fire on the very path it describes.
471
+ // That is a pre-existing defect with its own bead and is deliberately NOT
472
+ // fixed here; only the endpoint the revoke goes to changed.
473
+ for (let i = 0; i < acquiredProvisions.length; i++) {
474
+ const provision = acquiredProvisions[i];
468
475
  const tc = targetContexts[i];
469
- if (tc && !tc.tunnelId && keyId) {
470
- client.revokeNgrokKey(keyId).catch(err => logger.warn(`Failed to revoke unused ngrok key ${keyId}: ${err}`));
476
+ if (tc && !tc.tunnelId && provision) {
477
+ client.tunnels.revoke(provision).catch(err => logger.warn(`Failed to revoke unused tunnel ${provision.tunnelId}: ${err}`));
471
478
  }
472
479
  }
473
480
  }
@@ -18,7 +18,7 @@ export async function runTestSuiteHandler(input, _context) {
18
18
  logger.toolStart('run_test_suite', input);
19
19
  const client = new DebuggAIServerClient(config.api.key);
20
20
  await client.init();
21
- let acquiredKeyId = null;
21
+ let acquiredProvision = null;
22
22
  let tunnelId;
23
23
  // Used ONLY to scope the defensive sanitizeResponseUrls call below (bead
24
24
  // debugg_ai_mcp-6cfv.7) — never fed into acquirePortRoute/PortLock. This
@@ -79,10 +79,10 @@ export async function runTestSuiteHandler(input, _context) {
79
79
  const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
80
80
  return errorResp('TunnelProvisionFailed', `Failed to provision tunnel for ${input.targetUrl}. (Detail: ${msg})${diag}`);
81
81
  }
82
- acquiredKeyId = tunnel.keyId;
82
+ acquiredProvision = tunnel;
83
83
  let dedicated;
84
84
  try {
85
- dedicated = await tunnelManager.acquireDedicatedTunnel(ctx.originalUrl, tunnel.tunnelKey, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
85
+ dedicated = await tunnelManager.acquireDedicatedTunnel(ctx.originalUrl, tunnel.tunnelKey, tunnel.keyId, () => client.tunnels.revoke(tunnel), tunnel);
86
86
  }
87
87
  catch (tunnelError) {
88
88
  const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
@@ -142,8 +142,9 @@ export async function runTestSuiteHandler(input, _context) {
142
142
  finally {
143
143
  // Tunnels are NOT torn down — reuse pattern + 55-min idle auto-shutoff.
144
144
  // Only revoke an orphaned key (acquired but tunnel creation failed).
145
- if (acquiredKeyId && !tunnelId) {
146
- client.revokeNgrokKey(acquiredKeyId).catch((err) => logger.warn(`Failed to revoke unused ngrok key ${acquiredKeyId}: ${err}`));
145
+ if (acquiredProvision && !tunnelId) {
146
+ const orphan = acquiredProvision;
147
+ client.tunnels.revoke(orphan).catch((err) => logger.warn(`Failed to revoke unused tunnel ${orphan.tunnelId}: ${err}`));
147
148
  }
148
149
  }
149
150
  }
@@ -10,7 +10,7 @@ import { handleExternalServiceError } from '../utils/errors.js';
10
10
  import { fetchImageAsBase64, imageContentBlock, resourceLinkBlock, artifactResourceLinks } from '../utils/imageUtils.js';
11
11
  import { DebuggAIServerClient } from '../services/index.js';
12
12
  import { getEvalTemplateSlug } from '../services/workflows.js';
13
- import { adaptVerdict, isEnvironmentDefault } from '../services/verdictAdapter.js';
13
+ import { adaptVerdict, credentialSubstitutions } from '../services/verdictAdapter.js';
14
14
  import { TunnelProvisionError } from '../services/tunnels.js';
15
15
  import { resolveTargetUrl, buildContext, findExistingTunnel, ensureTunnel, acquirePortRoute, releasePortRoute, sanitizeResponseUrls, touchTunnelById, retargetAuxiliaryUrl, } from '../utils/tunnelContext.js';
16
16
  import { randomUUID } from 'node:crypto';
@@ -135,7 +135,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
135
135
  await client.init();
136
136
  const originalUrl = resolveTargetUrl(input);
137
137
  let ctx = buildContext(originalUrl);
138
- let keyId;
138
+ let provision;
139
139
  // Cancellation is driven by the MCP request/transport lifecycle, not
140
140
  // process.stdin. The SDK aborts context.signal when the client cancels the
141
141
  // call OR the transport closes — e.g. an HTTP client drops the connection.
@@ -249,7 +249,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
249
249
  `Make sure your dev server is running on the specified port and try again. ` +
250
250
  `(Detail: ${msg})${diag}`);
251
251
  }
252
- keyId = tunnel.keyId;
252
+ provision = tunnel;
253
253
  try {
254
254
  ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
255
255
  }
@@ -309,7 +309,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
309
309
  // owned path already revokes it; if we kept the tunnel, the key is that
310
310
  // live tunnel's own credential and revoking it would kill what we just
311
311
  // decided to preserve.
312
- keyId = undefined;
312
+ provision = undefined;
313
313
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
314
314
  }
315
315
  }
@@ -794,20 +794,28 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
794
794
  responsePayload.logins = verdict.logins;
795
795
  if (verdict.loginError)
796
796
  responsePayload.loginError = verdict.loginError;
797
+ // Bead b5x6: only a SUBMITTED env-default login under a DIFFERENT account
798
+ // counts. A refused/skipped login (offscope_host, no form) typed nothing,
799
+ // and the env's stored credential may be the very account that was named —
800
+ // warning on either states something that did not happen.
797
801
  const requestedIdentity = input.username
798
802
  ?? input.auth?.username
799
803
  ?? input.loginCredentials?.[0]?.username;
800
- const substituted = (verdict.logins ?? []).filter(isEnvironmentDefault);
804
+ const substituted = credentialSubstitutions(verdict.logins, [
805
+ input.username,
806
+ input.auth?.username,
807
+ ...(input.loginCredentials ?? []).map(c => c.username),
808
+ ]);
801
809
  if (requestedIdentity && substituted.length > 0) {
802
810
  responsePayload.credentialWarning = {
803
811
  requested: requestedIdentity,
804
- used: substituted.map(l => l.username).filter(Boolean),
812
+ used: [...new Set(substituted.map(l => l.username))],
805
813
  message: `This run signed in with an environment default credential even though ` +
806
814
  `'${requestedIdentity}' was specified. Treat a login failure here as a ` +
807
815
  `credential-resolution problem, not an application failure.`,
808
816
  };
809
817
  logger.warn(`check_app_in_browser: requested identity '${requestedIdentity}' but the run used ` +
810
- `environment-default credential(s): ${substituted.map(l => l.username).join(', ')}`);
818
+ `environment-default credential(s): ${responsePayload.credentialWarning.used.join(', ')}`);
811
819
  }
812
820
  // Bug z15n: OUR tunnel died mid-run, so this 'fail' describes our error page,
813
821
  // not the user's app. Relay it as a distinct, retryable infrastructure class
@@ -985,9 +993,11 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
985
993
  // subsequent calls to the same port and auto-shutoffs after 55 min idle.
986
994
  // Process-exit cleanup happens via stopAllTunnels() in the SIGINT/SIGTERM
987
995
  // handlers in index.ts.
988
- if (!ctx.tunnelId && keyId) {
989
- // Provisioned a key but tunnel creation failed — revoke the orphaned key.
990
- client.revokeNgrokKey(keyId).catch(err => logger.warn(`Failed to revoke unused ngrok key ${keyId}: ${err}`));
996
+ if (!ctx.tunnelId && provision) {
997
+ // Provisioned a tunnel but creation failed — revoke it, through whichever
998
+ // endpoint its transport uses.
999
+ const orphan = provision;
1000
+ client.tunnels.revoke(orphan).catch(err => logger.warn(`Failed to revoke unused tunnel ${orphan.tunnelId}: ${err}`));
991
1001
  }
992
1002
  }
993
1003
  }
@@ -60,7 +60,7 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
60
60
  await client.init();
61
61
  const originalUrl = resolveTargetUrl(input);
62
62
  let ctx = buildContext(originalUrl);
63
- let keyId;
63
+ let provision;
64
64
  // Bead 56kd.7: cancellation is driven by the MCP request/transport lifecycle
65
65
  // (context.signal), NOT process.stdin. The SDK aborts context.signal when the
66
66
  // client cancels the call OR the transport closes — e.g. an HTTP client drops
@@ -123,8 +123,8 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
123
123
  `The remote browser needs a secure tunnel to reach your local dev server. ` +
124
124
  `(Detail: ${msg})${diag}`);
125
125
  }
126
- keyId = tunnel.keyId;
127
- ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
126
+ provision = tunnel;
127
+ ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.tunnels.revoke(tunnel), tunnel);
128
128
  }
129
129
  // §2.4: acquire this session's shared Caddy route for our port BEFORE
130
130
  // probing/dispatching — trigger_crawl polls (pollExecution below) for
@@ -169,7 +169,7 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
169
169
  // owned path already revokes it; if we kept the tunnel, the key is that
170
170
  // live tunnel's own credential and revoking it would kill what we just
171
171
  // decided to preserve.
172
- keyId = undefined;
172
+ provision = undefined;
173
173
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
174
174
  }
175
175
  }
@@ -363,8 +363,9 @@ export async function triggerCrawlHandler(input, context, rawProgressCallback) {
363
363
  releasePortRoute(ctx);
364
364
  // Tunnel intentionally NOT torn down (reuse path per bead vwd).
365
365
  // If tunnel creation failed after key provision, revoke the orphaned key.
366
- if (!ctx.tunnelId && keyId) {
367
- client.revokeNgrokKey(keyId).catch(err => logger.warn(`Failed to revoke unused ngrok key ${keyId}: ${err}`));
366
+ if (!ctx.tunnelId && provision) {
367
+ const orphan = provision;
368
+ client.tunnels.revoke(orphan).catch(err => logger.warn(`Failed to revoke unused tunnel ${orphan.tunnelId}: ${err}`));
368
369
  }
369
370
  }
370
371
  }
@@ -3,6 +3,7 @@ import { handleExternalServiceError } from '../utils/errors.js';
3
3
  import { DebuggAIServerClient } from '../services/index.js';
4
4
  import { config } from '../config/index.js';
5
5
  import { detectRepoName } from '../utils/gitContext.js';
6
+ import { checkAuthorizedCredentialHostsEcho } from '../utils/authorizedCredentialHosts.js';
6
7
  const logger = new Logger({ module: 'updateEnvironmentHandler' });
7
8
  function notFound(uuid, context) {
8
9
  return {
@@ -27,7 +28,7 @@ export async function updateEnvironmentHandler(input, _context) {
27
28
  const start = Date.now();
28
29
  logger.toolStart('update_environment', {
29
30
  uuid: input.uuid,
30
- hasEnvPatch: !!(input.name || input.url || input.description),
31
+ hasEnvPatch: !!(input.name || input.url || input.description || input.authorizedCredentialHosts),
31
32
  addCount: input.addCredentials?.length ?? 0,
32
33
  updateCount: input.updateCredentials?.length ?? 0,
33
34
  removeCount: input.removeCredentialIds?.length ?? 0,
@@ -47,12 +48,16 @@ export async function updateEnvironmentHandler(input, _context) {
47
48
  projectUuid = project.uuid;
48
49
  }
49
50
  // ── Env field patch (only if any env field is present) ──────────────────
50
- const hasEnvPatch = input.name !== undefined || input.url !== undefined || input.description !== undefined;
51
+ // authorizedCredentialHosts counts: an update that sets ONLY it must still
52
+ // PATCH, or it would return a silent no-op (bead q4d4).
53
+ const hasEnvPatch = input.name !== undefined || input.url !== undefined
54
+ || input.description !== undefined || input.authorizedCredentialHosts !== undefined;
51
55
  let environment = null;
52
56
  if (hasEnvPatch) {
53
57
  try {
54
58
  environment = await client.updateEnvironment(projectUuid, input.uuid, {
55
59
  name: input.name, url: input.url, description: input.description,
60
+ authorizedCredentialHosts: input.authorizedCredentialHosts,
56
61
  });
57
62
  }
58
63
  catch (err) {
@@ -128,6 +133,13 @@ export async function updateEnvironmentHandler(input, _context) {
128
133
  payload.removedCredentialIds = removedCredentialIds;
129
134
  if (warnings.length > 0)
130
135
  payload.credentialWarnings = warnings;
136
+ if (input.authorizedCredentialHosts !== undefined) {
137
+ const hostsWarning = checkAuthorizedCredentialHostsEcho(input.authorizedCredentialHosts, environment?.authorizedCredentialHosts, 'update');
138
+ if (hostsWarning) {
139
+ payload.authorizedCredentialHostsWarning = hostsWarning;
140
+ logger.warn(`update_environment: ${hostsWarning.message}`);
141
+ }
142
+ }
131
143
  logger.toolComplete('update_environment', Date.now() - start);
132
144
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
133
145
  }
@@ -29,6 +29,17 @@ class DebuggTransport extends AxiosTransport {
29
29
  });
30
30
  }
31
31
  }
32
+ /**
33
+ * authorizedCredentialHosts from an environment response (bead q4d4), or {} when
34
+ * the backend sent none. OMITTED rather than defaulted to [] — a backend that
35
+ * does not support the field yet must not read as "this env trusts no extra
36
+ * hosts", and the create/update handlers rely on its absence to detect that.
37
+ */
38
+ function pickAuthorizedCredentialHosts(e) {
39
+ return Array.isArray(e?.authorizedCredentialHosts)
40
+ ? { authorizedCredentialHosts: e.authorizedCredentialHosts }
41
+ : {};
42
+ }
32
43
  export class DebuggAIServerClient {
33
44
  userApiKey;
34
45
  tx;
@@ -222,6 +233,7 @@ export class DebuggAIServerClient {
222
233
  name: e.name,
223
234
  url: e.url || e.activeUrl || '',
224
235
  isActive: e.isActive,
236
+ ...pickAuthorizedCredentialHosts(e),
225
237
  })),
226
238
  };
227
239
  }
@@ -237,12 +249,16 @@ export class DebuggAIServerClient {
237
249
  body.url = input.url;
238
250
  if (input.description)
239
251
  body.description = input.description;
252
+ // Wire: authorized_credential_hosts (snake_cased by the transport).
253
+ if (input.authorizedCredentialHosts !== undefined)
254
+ body.authorizedCredentialHosts = input.authorizedCredentialHosts;
240
255
  const response = await this.tx.post(`api/v1/projects/${projectUuid}/environments/`, body);
241
256
  return {
242
257
  uuid: response.uuid,
243
258
  name: response.name,
244
259
  url: response.url || response.activeUrl || '',
245
260
  isActive: response.isActive,
261
+ ...pickAuthorizedCredentialHosts(response),
246
262
  };
247
263
  }
248
264
  /**
@@ -301,6 +317,7 @@ export class DebuggAIServerClient {
301
317
  activeUrl: e.activeUrl ?? null,
302
318
  timestamp: e.timestamp,
303
319
  lastMod: e.lastMod,
320
+ ...pickAuthorizedCredentialHosts(e),
304
321
  };
305
322
  }
306
323
  /**
@@ -316,6 +333,9 @@ export class DebuggAIServerClient {
316
333
  body.url = patch.url;
317
334
  if (patch.description !== undefined)
318
335
  body.description = patch.description;
336
+ // Wire: authorized_credential_hosts. [] is meaningful (clears the list).
337
+ if (patch.authorizedCredentialHosts !== undefined)
338
+ body.authorizedCredentialHosts = patch.authorizedCredentialHosts;
319
339
  const e = await this.tx.patch(`api/v1/projects/${projectUuid}/environments/${envUuid}/`, body);
320
340
  return {
321
341
  uuid: envUuid, // echo from input; backend PATCH response omits it
@@ -324,6 +344,7 @@ export class DebuggAIServerClient {
324
344
  isActive: e.isActive,
325
345
  description: e.description ?? null,
326
346
  endpointType: e.endpointType,
347
+ ...pickAuthorizedCredentialHosts(e),
327
348
  };
328
349
  }
329
350
  /**