@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.
- package/README.md +10 -4
- package/dist/handlers/createEnvironmentHandler.js +10 -0
- package/dist/handlers/probePageHandler.js +14 -7
- package/dist/handlers/runTestSuiteHandler.js +6 -5
- package/dist/handlers/testPageChangesHandler.js +20 -10
- package/dist/handlers/triggerCrawlHandler.js +7 -6
- package/dist/handlers/updateEnvironmentHandler.js +14 -2
- package/dist/services/index.js +21 -0
- package/dist/services/ngrok/tunnelManager.js +157 -161
- package/dist/services/tunnel/debuggTransport.js +439 -0
- package/dist/services/tunnel/ngrokTransport.js +165 -0
- package/dist/services/tunnel/probeRegistry.js +52 -0
- package/dist/services/tunnel/protocol/codec.js +327 -0
- package/dist/services/tunnel/protocol/conformance.js +171 -0
- package/dist/services/tunnel/protocol/constants.js +178 -0
- package/dist/services/tunnel/protocol/errors.js +51 -0
- package/dist/services/tunnel/protocol/handshake.js +134 -0
- package/dist/services/tunnel/protocol/index.js +17 -0
- package/dist/services/tunnel/protocol/session.js +668 -0
- package/dist/services/tunnel/protocol/stream.js +109 -0
- package/dist/services/tunnel/protocol/transport.js +106 -0
- package/dist/services/tunnel/transport.js +15 -0
- package/dist/services/tunnels.js +71 -3
- package/dist/services/verdictAdapter.js +25 -1
- package/dist/tools/environment.js +5 -2
- package/dist/tools/testPageChanges.js +1 -1
- package/dist/types/index.js +29 -2
- package/dist/utils/authorizedCredentialHosts.js +38 -0
- package/dist/utils/localReachability.js +160 -3
- package/dist/utils/tunnelContext.js +6 -3
- package/dist/utils/tunnelDisposition.js +16 -1
- package/dist/utils/tunnelDomains.js +113 -0
- package/dist/utils/urlParser.js +8 -2
- 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
|
|
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
|
-
|
|
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
|
-
|
|
163
|
+
acquiredProvisions.push(tunnel);
|
|
161
164
|
let tunneled;
|
|
162
165
|
try {
|
|
163
|
-
tunneled = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.
|
|
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
|
-
|
|
467
|
-
|
|
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 &&
|
|
470
|
-
client.
|
|
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
|
|
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
|
-
|
|
82
|
+
acquiredProvision = tunnel;
|
|
83
83
|
let dedicated;
|
|
84
84
|
try {
|
|
85
|
-
dedicated = await tunnelManager.acquireDedicatedTunnel(ctx.originalUrl, tunnel.tunnelKey, tunnel.keyId, () => client.
|
|
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 (
|
|
146
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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): ${
|
|
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 &&
|
|
989
|
-
// Provisioned a
|
|
990
|
-
|
|
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
|
|
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
|
-
|
|
127
|
-
ctx = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.
|
|
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
|
-
|
|
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 &&
|
|
367
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/services/index.js
CHANGED
|
@@ -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
|
/**
|