@debugg-ai/debugg-ai-mcp 4.2.2 → 4.3.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/testPageChangesHandler.js +12 -4
- package/dist/handlers/updateEnvironmentHandler.js +14 -2
- package/dist/services/index.js +21 -0
- 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/package.json +1 -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
|
}
|
|
@@ -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';
|
|
@@ -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
|
|
@@ -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
|
/**
|
|
@@ -27,10 +27,34 @@ export const KNOWN_OUTCOMES = ['pass', 'fail', 'inconclusive', 'error', 'timeout
|
|
|
27
27
|
const KNOWN = new Set(KNOWN_OUTCOMES);
|
|
28
28
|
/** Credential sources that mean "the caller named this account for this run". */
|
|
29
29
|
const CALLER_SPECIFIED_SOURCES = new Set(['task', 'explicit', 'credential_id']);
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* True when this login's credential CAME FROM the environment rather than a
|
|
32
|
+
* named account. Says nothing about whether it was ever used — a refused
|
|
33
|
+
* `offscope_host` skip carries an env source too. See credentialSubstitutions.
|
|
34
|
+
*/
|
|
31
35
|
export function isEnvironmentDefault(login) {
|
|
32
36
|
return !!login.source && !CALLER_SPECIFIED_SOURCES.has(login.source);
|
|
33
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* The logins that really did substitute an environment default for an account
|
|
40
|
+
* the caller named (bead b5x6). All three must hold:
|
|
41
|
+
* - the credential came from the environment (isEnvironmentDefault);
|
|
42
|
+
* - it was actually submitted — a refused or skipped login typed nothing, so
|
|
43
|
+
* "signed in with an environment default" would be false (client runs
|
|
44
|
+
* 2d4970a6, 146f081f);
|
|
45
|
+
* - it names an account, and not one the caller asked for — the environment's
|
|
46
|
+
* stored credential can BE the requested account, and a warning that lists
|
|
47
|
+
* X as both requested and used contradicts itself (client run 146f081f).
|
|
48
|
+
* Identities compare trimmed and case-insensitively (they are emails).
|
|
49
|
+
*/
|
|
50
|
+
export function credentialSubstitutions(logins, namedIdentities) {
|
|
51
|
+
const norm = (s) => s.trim().toLowerCase();
|
|
52
|
+
const named = new Set(namedIdentities.filter((u) => typeof u === 'string' && u.trim() !== '').map(norm));
|
|
53
|
+
return (logins ?? []).filter((l) => isEnvironmentDefault(l)
|
|
54
|
+
&& l.submitted === true
|
|
55
|
+
&& typeof l.username === 'string' && l.username.trim() !== ''
|
|
56
|
+
&& !named.has(norm(l.username)));
|
|
57
|
+
}
|
|
34
58
|
/**
|
|
35
59
|
* Map a workflow execution onto the MCP relay verdict. Never throws.
|
|
36
60
|
*/
|
|
@@ -12,12 +12,14 @@ const CRED_ITEM = {
|
|
|
12
12
|
const DESCRIPTION = `Manage environments (and their login credentials) under a project. Pass an "action":
|
|
13
13
|
- "get" {uuid, projectUuid?} → one environment with credentials inline (passwords never returned).
|
|
14
14
|
- "list" {projectUuid?, q?, page?, pageSize?} → paginated environments. projectUuid auto-resolves from the git repo if omitted.
|
|
15
|
-
- "create" {name, url, description?, projectUuid?, credentials?} → create an env, optionally seeding credentials.
|
|
16
|
-
- "update" {uuid, name?, url?, description?, addCredentials?, updateCredentials?, removeCredentialIds?} → patch env + manage credentials.
|
|
15
|
+
- "create" {name, url, description?, projectUuid?, credentials?, authorizedCredentialHosts?} → create an env, optionally seeding credentials.
|
|
16
|
+
- "update" {uuid, name?, url?, description?, addCredentials?, updateCredentials?, removeCredentialIds?, authorizedCredentialHosts?} → patch env + manage credentials.
|
|
17
17
|
- "delete" {uuid, projectUuid?, confirm?} → delete env (DESTRUCTIVE; requires confirmation).
|
|
18
18
|
- "sessions" {uuid, username?, credentialId?} → captured login sessions this env is holding, and whether each would be reused.
|
|
19
19
|
- "clearSessions" {uuid, username?, credentialId?, confirm?} → invalidate them so the next run logs in for real.
|
|
20
20
|
|
|
21
|
+
CROSS-DOMAIN SSO: a run only types this environment's credentials on the app's own host (and its subdomains); a login on any other host is refused as offscope_host. If your identity provider lives on a different domain than the app, add the IdP host to authorizedCredentialHosts — bare hostnames like "auth.example.com", no scheme/path/port/wildcard. The response echoes the saved list; if it reports authorizedCredentialHostsWarning, the server did not persist it.
|
|
22
|
+
|
|
21
23
|
SESSIONS: runs reuse a warm authenticated session per account instead of logging in every time. That is why a check can report "no login form" — it was already signed in. Use "sessions" to see whose session is held, "clearSessions" to drop it, or pass freshSession:true on a single check_app_in_browser call to bypass reuse without clearing anything.`;
|
|
22
24
|
export function buildEnvironmentTool() {
|
|
23
25
|
return {
|
|
@@ -41,6 +43,7 @@ export function buildEnvironmentTool() {
|
|
|
41
43
|
addCredentials: { type: 'array', items: CRED_ITEM, description: '[update] Add credentials.' },
|
|
42
44
|
updateCredentials: { type: 'array', items: { type: 'object', properties: { uuid: { type: 'string' }, label: { type: 'string' }, username: { type: 'string' }, password: { type: 'string' }, role: { type: 'string' } }, required: ['uuid'], additionalProperties: false }, description: '[update] Patch credentials by UUID.' },
|
|
43
45
|
removeCredentialIds: { type: 'array', items: { type: 'string' }, description: '[update] Delete credentials by UUID.' },
|
|
46
|
+
authorizedCredentialHosts: { type: 'array', items: { type: 'string' }, description: "[create/update] Hosts where the run may enter this environment's credentials besides the app's own host, e.g. your identity provider for cross-domain SSO. Bare hostnames only (auth.example.com). On update this REPLACES the list; [] clears it." },
|
|
44
47
|
username: { type: 'string', description: '[sessions/clearSessions] Narrow to one account. Matched case-insensitively.' },
|
|
45
48
|
credentialId: { type: 'string', description: '[sessions/clearSessions] Narrow to one stored credential by UUID.' },
|
|
46
49
|
confirm: { type: 'boolean', description: '[delete/clearSessions] Set true to confirm (when the client cannot prompt). clearSessions only needs it when no username/credentialId narrows it.' },
|
|
@@ -112,7 +112,7 @@ export function buildTestPageChangesTool(ctx) {
|
|
|
112
112
|
},
|
|
113
113
|
auth: {
|
|
114
114
|
type: "object",
|
|
115
|
-
description: "Optional auth-precondition for a 'log in THEN deep-navigate' check. Set precondition:'login' to authenticate first, then land on deepUrl. Use this instead of hoping the agent signs itself in at a login wall. Pass username/password here to pin WHICH account it authenticates as; omit them to use the environment's default credential.",
|
|
115
|
+
description: "Optional auth-precondition for a 'log in THEN deep-navigate' check. Set precondition:'login' to authenticate first, then land on deepUrl. Use this instead of hoping the agent signs itself in at a login wall. Pass username/password here to pin WHICH account it authenticates as; omit them to use the environment's default credential. Cross-domain SSO (sign-in page on a different domain than the app): add that IdP host to the environment's authorizedCredentialHosts (environment tool), or the login there is refused as offscope_host.",
|
|
116
116
|
properties: {
|
|
117
117
|
environmentId: {
|
|
118
118
|
type: "string",
|
package/dist/types/index.js
CHANGED
|
@@ -126,12 +126,38 @@ const CredentialSeedSchema = z.object({
|
|
|
126
126
|
password: z.string().min(1, 'password is required'),
|
|
127
127
|
role: z.string().min(1).optional(),
|
|
128
128
|
}).strict();
|
|
129
|
+
/**
|
|
130
|
+
* One entry of an environment's authorizedCredentialHosts (bead q4d4): a bare
|
|
131
|
+
* hostname where a run may type this environment's credentials besides the
|
|
132
|
+
* app's own host — typically the identity provider of a cross-domain SSO app.
|
|
133
|
+
* The backend matches it against a URL's lowercased hostname, exactly, so a
|
|
134
|
+
* scheme, path, port or wildcard could never match anything; reject them here
|
|
135
|
+
* with the reason instead of storing a host that silently does nothing.
|
|
136
|
+
*/
|
|
137
|
+
const HOSTNAME_RE = /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))*$/;
|
|
138
|
+
const AuthorizedCredentialHostSchema = z.string().trim().toLowerCase().superRefine((h, ctx) => {
|
|
139
|
+
const bad = (message) => ctx.addIssue({ code: z.ZodIssueCode.custom, message });
|
|
140
|
+
if (h === '')
|
|
141
|
+
return bad('host must not be empty');
|
|
142
|
+
if (h.includes('://'))
|
|
143
|
+
return bad(`'${h}' has a scheme — pass the bare hostname (e.g. auth.example.com), not a URL`);
|
|
144
|
+
if (h.includes('/'))
|
|
145
|
+
return bad(`'${h}' has a path — pass the bare hostname only (e.g. auth.example.com)`);
|
|
146
|
+
if (h.includes('*'))
|
|
147
|
+
return bad(`'${h}' is a wildcard — list each host explicitly (subdomains of the app's own host are already in scope)`);
|
|
148
|
+
if (h.includes(':'))
|
|
149
|
+
return bad(`'${h}' has a port — pass the bare hostname; hosts are matched without a port`);
|
|
150
|
+
if (h.length > 253 || !HOSTNAME_RE.test(h))
|
|
151
|
+
return bad(`'${h}' is not a valid hostname`);
|
|
152
|
+
});
|
|
153
|
+
const AuthorizedCredentialHostsSchema = z.array(AuthorizedCredentialHostSchema).max(50, 'at most 50 hosts');
|
|
129
154
|
export const CreateEnvironmentInputSchema = z.object({
|
|
130
155
|
name: z.string().min(1, 'name is required'),
|
|
131
156
|
url: z.string().url('url is required for standard environments'),
|
|
132
157
|
description: z.string().optional(),
|
|
133
158
|
projectUuid: z.string().uuid().optional(),
|
|
134
159
|
credentials: z.array(CredentialSeedSchema).optional(),
|
|
160
|
+
authorizedCredentialHosts: AuthorizedCredentialHostsSchema.optional(),
|
|
135
161
|
}).strict();
|
|
136
162
|
const CredentialUpdateSchema = z.object({
|
|
137
163
|
uuid: z.string().uuid(),
|
|
@@ -149,6 +175,7 @@ export const UpdateEnvironmentInputSchema = z.object({
|
|
|
149
175
|
addCredentials: z.array(CredentialSeedSchema).optional(),
|
|
150
176
|
updateCredentials: z.array(CredentialUpdateSchema).optional(),
|
|
151
177
|
removeCredentialIds: z.array(z.string().uuid()).optional(),
|
|
178
|
+
authorizedCredentialHosts: AuthorizedCredentialHostsSchema.optional(),
|
|
152
179
|
}).strict();
|
|
153
180
|
export const DeleteEnvironmentInputSchema = z.object({
|
|
154
181
|
uuid: z.string().uuid(),
|
|
@@ -326,8 +353,8 @@ export const ProjectInputSchema = z.discriminatedUnion('action', [
|
|
|
326
353
|
export const EnvironmentInputSchema = z.discriminatedUnion('action', [
|
|
327
354
|
z.object({ action: z.literal('get'), uuid: z.string().uuid(), projectUuid: z.string().uuid().optional() }).strict(),
|
|
328
355
|
z.object({ action: z.literal('list'), projectUuid: z.string().uuid().optional(), q: z.string().min(1).optional(), page: _page, pageSize: _pageSize }).strict(),
|
|
329
|
-
z.object({ action: z.literal('create'), name: z.string().min(1), url: z.string().url('url is required for standard environments'), description: z.string().optional(), projectUuid: z.string().uuid().optional(), credentials: z.array(CredentialSeedSchema).optional() }).strict(),
|
|
330
|
-
z.object({ action: z.literal('update'), uuid: z.string().uuid(), name: z.string().min(1).optional(), url: z.string().url().optional(), description: z.string().optional(), projectUuid: z.string().uuid().optional(), addCredentials: z.array(CredentialSeedSchema).optional(), updateCredentials: z.array(CredentialUpdateSchema).optional(), removeCredentialIds: z.array(z.string().uuid()).optional() }).strict(),
|
|
356
|
+
z.object({ action: z.literal('create'), name: z.string().min(1), url: z.string().url('url is required for standard environments'), description: z.string().optional(), projectUuid: z.string().uuid().optional(), credentials: z.array(CredentialSeedSchema).optional(), authorizedCredentialHosts: AuthorizedCredentialHostsSchema.optional() }).strict(),
|
|
357
|
+
z.object({ action: z.literal('update'), uuid: z.string().uuid(), name: z.string().min(1).optional(), url: z.string().url().optional(), description: z.string().optional(), projectUuid: z.string().uuid().optional(), addCredentials: z.array(CredentialSeedSchema).optional(), updateCredentials: z.array(CredentialUpdateSchema).optional(), removeCredentialIds: z.array(z.string().uuid()).optional(), authorizedCredentialHosts: AuthorizedCredentialHostsSchema.optional() }).strict(),
|
|
331
358
|
z.object({ action: z.literal('delete'), uuid: z.string().uuid(), projectUuid: z.string().uuid().optional(), confirm: z.boolean().optional() }).strict(),
|
|
332
359
|
// Captured authenticated sessions (sentinal-cs1hn.5). The backend holds a warm
|
|
333
360
|
// session per account and restores it to skip login; these two actions make that
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Echo check for an environment's authorizedCredentialHosts (bead q4d4).
|
|
3
|
+
*
|
|
4
|
+
* The MCP ships this field before every backend accepts it (sentinal-oj7dp.23).
|
|
5
|
+
* A backend that does not know the field ignores it and still answers 200, so a
|
|
6
|
+
* create/update "succeeds" whether or not the hosts were saved. The only proof
|
|
7
|
+
* is the response echoing them back — so when it doesn't, say so loudly instead
|
|
8
|
+
* of letting the caller believe cross-domain SSO is now configured.
|
|
9
|
+
*/
|
|
10
|
+
const norm = (hosts) => new Set(hosts.map((h) => h.trim().toLowerCase()));
|
|
11
|
+
/**
|
|
12
|
+
* Compare the requested hosts with the backend's echo. Returns a warning when
|
|
13
|
+
* they differ (as sets, case-insensitively — the backend normalizes), or
|
|
14
|
+
* undefined when the echo confirms them. `op` shapes the advice: a create has
|
|
15
|
+
* already created the environment, so a retry of create would duplicate it.
|
|
16
|
+
*/
|
|
17
|
+
export function checkAuthorizedCredentialHostsEcho(requested, returned, op) {
|
|
18
|
+
const echoed = Array.isArray(returned) ? returned : null;
|
|
19
|
+
if (echoed) {
|
|
20
|
+
const want = norm(requested);
|
|
21
|
+
const got = norm(echoed);
|
|
22
|
+
if (want.size === got.size && [...want].every((h) => got.has(h)))
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const hosts = requested.length > 0 ? requested.join(', ') : '(an empty list)';
|
|
26
|
+
const why = echoed
|
|
27
|
+
? `The backend did not persist authorizedCredentialHosts as requested: it holds [${echoed.join(', ')}], not [${requested.join(', ')}].`
|
|
28
|
+
: 'The backend did not persist authorizedCredentialHosts: it is not yet supported on this DebuggAI server ' +
|
|
29
|
+
'(the response did not echo the hosts back).';
|
|
30
|
+
const applied = op === 'create'
|
|
31
|
+
? 'The environment itself WAS created, with any credentials listed here — do not re-run create; ' +
|
|
32
|
+
'set the hosts later with environment {action:"update"}.'
|
|
33
|
+
: 'Everything else in this update was applied.';
|
|
34
|
+
const consequence = requested.length > 0
|
|
35
|
+
? ` Until the hosts are saved, check_app_in_browser will still refuse to enter credentials on ${hosts} (offscope_host).`
|
|
36
|
+
: '';
|
|
37
|
+
return { requested, returned: echoed, message: `${why} ${applied}${consequence}` };
|
|
38
|
+
}
|