@debugg-ai/debugg-ai-mcp 4.0.0 → 4.2.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 +20 -2
- package/dist/handlers/environmentHandler.js +15 -0
- package/dist/handlers/environmentSessionsHandler.js +60 -0
- package/dist/handlers/testPageChangesHandler.js +19 -0
- package/dist/services/index.js +31 -0
- package/dist/tools/environment.js +9 -3
- package/dist/tools/testPageChanges.js +4 -0
- package/dist/types/index.js +27 -7
- package/dist/utils/confirmDestructive.js +32 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,7 +75,8 @@ Runs an AI browser agent against your app. The agent navigates, interacts, and r
|
|
|
75
75
|
| `username` | string | Username for login (ephemeral — not persisted) |
|
|
76
76
|
| `password` | string | Password for login (ephemeral — not persisted) |
|
|
77
77
|
| `loginCredentials` | array | Accounts for logins the agent hits **during** the task — `[{username, password, label?}]` |
|
|
78
|
-
| `useEnvironmentCredentials` | boolean | Default `true`. `false` forbids auto-filling the environment's stored credentials |
|
|
78
|
+
| `useEnvironmentCredentials` | boolean | Default `true`. `false` forbids auto-filling the environment's stored credentials; with no account named it means **do not log in at all** |
|
|
79
|
+
| `freshSession` | boolean | Default `false`. `true` forces a real login instead of reusing the warm session held for that account |
|
|
79
80
|
| `auth` | object | Auth precondition — `{precondition, entryUrl, deepUrl, environmentId, username, password}` |
|
|
80
81
|
| `repoName` | string | Override auto-detected git repo name (e.g. `my-org/my-repo`) |
|
|
81
82
|
|
|
@@ -89,7 +90,20 @@ Naming an account only in `description` does **not** make the agent use it — i
|
|
|
89
90
|
- `auth.username` / `auth.password` — pins the precondition login when you also use `auth.precondition: "login"`.
|
|
90
91
|
- `loginCredentials` — accounts for a login form the agent reaches **part-way through** the task. This is the one for flows like *set a password → get bounced to sign-in → log in as the account you just created*, where splitting into separate calls would lose browser state.
|
|
91
92
|
|
|
92
|
-
Set `useEnvironmentCredentials: false` when a silent fallback to the default test user would invalidate the check.
|
|
93
|
+
Set `useEnvironmentCredentials: false` when a silent fallback to the default test user would invalidate the check.
|
|
94
|
+
|
|
95
|
+
**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
|
+
|
|
97
|
+
##### Session reuse: why a check can report "no login form"
|
|
98
|
+
|
|
99
|
+
Runs don't log in every time. After a verified login the backend captures that account's session and **restores** it on the next run for the same identity, which skips the login entirely — that's why a check can legitimately come back with `submitted: false` and no login form: it was already signed in. A restored run reports itself in `logins` with `reason: "restored_session"`, so you can tell it apart from a run that genuinely found no form.
|
|
100
|
+
|
|
101
|
+
Sessions are keyed per **account**, so naming a different account never reuses somebody else's. Two ways to bypass reuse:
|
|
102
|
+
|
|
103
|
+
- `freshSession: true` on a single call — log in for real this once, then re-capture. Use it when the login flow *is* what you're checking, when you suspect the stored session is stale, or when the app's only route between personas is a logout.
|
|
104
|
+
- `environment` tool, `action: "clearSessions"` — invalidate the stored sessions so subsequent runs log in. Narrow with `username` / `credentialId`; unscoped clears require confirmation because every account on the environment then re-authenticates.
|
|
105
|
+
|
|
106
|
+
Use `action: "sessions"` to see what an environment is currently holding and whether each would be reused.
|
|
93
107
|
|
|
94
108
|
Results report the identity actually used, so a wrong one is visible rather than masquerading as a broken app:
|
|
95
109
|
|
|
@@ -165,9 +179,13 @@ Team and repo resolve by **either** uuid **or** name (case-insensitive exact mat
|
|
|
165
179
|
| `create` | `{name, url, description?, projectUuid?, credentials?}` | Created env (optionally seeds credentials) |
|
|
166
180
|
| `update` | `{uuid, name?, url?, description?, addCredentials?, updateCredentials?, removeCredentialIds?}` | Patched env; credential ops run **remove → update → add** |
|
|
167
181
|
| `delete` | `{uuid, projectUuid?, confirm?}` | Deletes env (cascades credentials) — **requires confirmation** |
|
|
182
|
+
| `sessions` | `{uuid, username?, credentialId?}` | Captured login sessions the env holds, per account, with `isUsable` and a `usableCount` |
|
|
183
|
+
| `clearSessions` | `{uuid, username?, credentialId?, confirm?}` | Invalidates them so the next run logs in for real — **unscoped clears require confirmation** |
|
|
168
184
|
|
|
169
185
|
`projectUuid` auto-resolves from the git repo when omitted. Per-cred failures surface in `credentialWarnings[]` without blocking the env op.
|
|
170
186
|
|
|
187
|
+
`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
|
+
|
|
171
189
|
### `test_suite`
|
|
172
190
|
|
|
173
191
|
| Action | Params | Result |
|
|
@@ -3,6 +3,7 @@ import { searchEnvironmentsHandler } from './searchEnvironmentsHandler.js';
|
|
|
3
3
|
import { createEnvironmentHandler } from './createEnvironmentHandler.js';
|
|
4
4
|
import { updateEnvironmentHandler } from './updateEnvironmentHandler.js';
|
|
5
5
|
import { deleteEnvironmentHandler } from './deleteEnvironmentHandler.js';
|
|
6
|
+
import { clearEnvironmentSessionsHandler, listEnvironmentSessionsHandler, } from './environmentSessionsHandler.js';
|
|
6
7
|
export async function environmentHandler(input, ctx) {
|
|
7
8
|
switch (input.action) {
|
|
8
9
|
case 'get':
|
|
@@ -23,5 +24,19 @@ export async function environmentHandler(input, ctx) {
|
|
|
23
24
|
return refusal;
|
|
24
25
|
return deleteEnvironmentHandler({ uuid: input.uuid, projectUuid: input.projectUuid }, ctx);
|
|
25
26
|
}
|
|
27
|
+
case 'sessions':
|
|
28
|
+
return listEnvironmentSessionsHandler(input, ctx);
|
|
29
|
+
case 'clearSessions': {
|
|
30
|
+
// Confirmed like a delete when it is UNSCOPED. Clearing one account's
|
|
31
|
+
// session costs that account one login; clearing an environment's costs
|
|
32
|
+
// every account on it one, which is a different size of action and should
|
|
33
|
+
// not happen because a filter was mistyped.
|
|
34
|
+
if (!input.username && !input.credentialId) {
|
|
35
|
+
const refusal = await ensureConfirmed('clearSessions', `environment ${input.uuid}`, input, ctx);
|
|
36
|
+
if (refusal)
|
|
37
|
+
return refusal;
|
|
38
|
+
}
|
|
39
|
+
return clearEnvironmentSessionsHandler(input, ctx);
|
|
40
|
+
}
|
|
26
41
|
}
|
|
27
42
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Logger } from '../utils/logger.js';
|
|
2
|
+
import { handleExternalServiceError } from '../utils/errors.js';
|
|
3
|
+
import { DebuggAIServerClient } from '../services/index.js';
|
|
4
|
+
import { config } from '../config/index.js';
|
|
5
|
+
const logger = new Logger({ module: 'environmentSessionsHandler' });
|
|
6
|
+
function ok(payload) {
|
|
7
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
8
|
+
}
|
|
9
|
+
export async function listEnvironmentSessionsHandler(input, _context) {
|
|
10
|
+
logger.toolStart('environment.sessions', { uuid: input.uuid, username: input.username });
|
|
11
|
+
try {
|
|
12
|
+
const client = new DebuggAIServerClient(config.api.key);
|
|
13
|
+
await client.init();
|
|
14
|
+
const sessions = await client.listEnvironmentSessions(input.uuid, {
|
|
15
|
+
...(input.username ? { username: input.username } : {}),
|
|
16
|
+
...(input.credentialId ? { credentialId: input.credentialId } : {}),
|
|
17
|
+
});
|
|
18
|
+
// usableCount, not just the rows: "does this environment currently hold a
|
|
19
|
+
// session that will be restored?" is the question a caller is actually
|
|
20
|
+
// asking, and an expired row still reports status 'valid'.
|
|
21
|
+
const usableCount = sessions.filter(s => s.isUsable).length;
|
|
22
|
+
return ok({
|
|
23
|
+
environmentUuid: input.uuid,
|
|
24
|
+
sessions,
|
|
25
|
+
pageInfo: { totalCount: sessions.length, usableCount },
|
|
26
|
+
note: sessions.length === 0
|
|
27
|
+
? 'No captured sessions — every run for this environment logs in for real.'
|
|
28
|
+
: `${usableCount} of ${sessions.length} session(s) would be restored instead of logging in. `
|
|
29
|
+
+ 'Use action "clearSessions" to force a real login, or pass freshSession:true on a single run.',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
throw handleExternalServiceError(error, 'DebuggAI', 'environment.sessions');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function clearEnvironmentSessionsHandler(input, _context) {
|
|
37
|
+
logger.toolStart('environment.clearSessions', { uuid: input.uuid, username: input.username });
|
|
38
|
+
try {
|
|
39
|
+
const client = new DebuggAIServerClient(config.api.key);
|
|
40
|
+
await client.init();
|
|
41
|
+
const filters = {
|
|
42
|
+
...(input.username ? { username: input.username } : {}),
|
|
43
|
+
...(input.credentialId ? { credentialId: input.credentialId } : {}),
|
|
44
|
+
};
|
|
45
|
+
const { invalidated } = await client.clearEnvironmentSessions(input.uuid, filters);
|
|
46
|
+
const scope = input.username ?? input.credentialId ?? 'all accounts';
|
|
47
|
+
logger.info(`environment.clearSessions: invalidated ${invalidated} session(s) for ${scope}`);
|
|
48
|
+
return ok({
|
|
49
|
+
environmentUuid: input.uuid,
|
|
50
|
+
invalidated,
|
|
51
|
+
scope,
|
|
52
|
+
note: invalidated === 0
|
|
53
|
+
? 'Nothing to clear — no usable captured session matched.'
|
|
54
|
+
: 'The next run for this identity will perform a real login and re-capture.',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
throw handleExternalServiceError(error, 'DebuggAI', 'environment.clearSessions');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Executes the App Evaluation Workflow via the 4-step pattern:
|
|
4
4
|
* find template → execute → poll → result
|
|
5
5
|
*/
|
|
6
|
+
import { meansDoNotLogIn, } from '../types/index.js';
|
|
6
7
|
import { config } from '../config/index.js';
|
|
7
8
|
import { Logger } from '../utils/logger.js';
|
|
8
9
|
import { handleExternalServiceError } from '../utils/errors.js';
|
|
@@ -419,6 +420,24 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
419
420
|
if (input.useEnvironmentCredentials === false) {
|
|
420
421
|
env.useEnvironmentCredentials = false;
|
|
421
422
|
}
|
|
423
|
+
// sentinal-oj7dp.3: opting out of the environment's credentials WITHOUT naming
|
|
424
|
+
// an account means "do not log in" — say so in the language the backend already
|
|
425
|
+
// speaks instead of leaving auth_mode on its 'auto' default. On 'auto' the auth
|
|
426
|
+
// subworkflow hunts for a login on a page that needs none: measured 2026-08-18,
|
|
427
|
+
// a public-homepage check spent 38 of its 111 seconds following the site's login
|
|
428
|
+
// link to a sibling host, submitting the environment's default credential three
|
|
429
|
+
// times, and parking the browser on a login screen the run was then graded
|
|
430
|
+
// against (execution 2c787273). no_auth short-circuits that cleanly and marks
|
|
431
|
+
// the run auth.skipped, which is explicitly NOT an auth failure (sentinal-76f8y.12).
|
|
432
|
+
if (meansDoNotLogIn(input)) {
|
|
433
|
+
contextData.auth_mode = 'no_auth';
|
|
434
|
+
}
|
|
435
|
+
// Same rule for the session opt-out: send it only when it IS one, so the
|
|
436
|
+
// default (reuse a warm session when one exists for this account) is
|
|
437
|
+
// expressed by absence rather than by an explicit false.
|
|
438
|
+
if (input.freshSession === true) {
|
|
439
|
+
env.freshSession = true;
|
|
440
|
+
}
|
|
422
441
|
// --- Execute ---
|
|
423
442
|
// Log the SHAPE of env, never its secrets. It now carries per-account
|
|
424
443
|
// passwords (taskCredentials), and this log line is not run through the
|
package/dist/services/index.js
CHANGED
|
@@ -253,6 +253,37 @@ export class DebuggAIServerClient {
|
|
|
253
253
|
throw new Error('Client not initialized — call init() first');
|
|
254
254
|
await this.tx.delete(`api/v1/projects/${projectUuid}/environments/${envUuid}/`);
|
|
255
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Captured authenticated sessions held for an environment (sentinal-cs1hn.5).
|
|
258
|
+
*
|
|
259
|
+
* The backend keeps a warm session per account and restores it to skip login.
|
|
260
|
+
* That cache decides WHO a run signs in as, so it needs to be inspectable —
|
|
261
|
+
* until it was, a run reusing the wrong account's session looked identical to
|
|
262
|
+
* a run that simply found no login form.
|
|
263
|
+
*
|
|
264
|
+
* The flat route, not the nested one: this is company-scoped server-side and
|
|
265
|
+
* needs no projectUuid, so a caller holding only an environment UUID can ask.
|
|
266
|
+
* Never returns the session contents — a session cookie is a bearer credential.
|
|
267
|
+
*/
|
|
268
|
+
async listEnvironmentSessions(envUuid, filters = {}) {
|
|
269
|
+
if (!this.tx)
|
|
270
|
+
throw new Error('Client not initialized — call init() first');
|
|
271
|
+
const response = await this.tx.get(`api/v1/environments/${envUuid}/sessions/`, filters);
|
|
272
|
+
return Array.isArray(response) ? response : (response?.results ?? []);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Invalidate captured sessions so the next run logs in for real.
|
|
276
|
+
*
|
|
277
|
+
* Narrow with ``username`` / ``credentialId``; omit both to reset the whole
|
|
278
|
+
* environment. The backend marks rows invalid rather than deleting them, so
|
|
279
|
+
* reuse stops immediately while the capture history stays readable.
|
|
280
|
+
*/
|
|
281
|
+
async clearEnvironmentSessions(envUuid, filters = {}) {
|
|
282
|
+
if (!this.tx)
|
|
283
|
+
throw new Error('Client not initialized — call init() first');
|
|
284
|
+
const response = await this.tx.delete(`api/v1/environments/${envUuid}/sessions/`, { params: filters });
|
|
285
|
+
return { invalidated: response?.invalidated ?? 0 };
|
|
286
|
+
}
|
|
256
287
|
/**
|
|
257
288
|
* Fetch a single environment by UUID. Throws AxiosError with status 404 if not found.
|
|
258
289
|
*/
|
|
@@ -14,7 +14,11 @@ const DESCRIPTION = `Manage environments (and their login credentials) under a p
|
|
|
14
14
|
- "list" {projectUuid?, q?, page?, pageSize?} → paginated environments. projectUuid auto-resolves from the git repo if omitted.
|
|
15
15
|
- "create" {name, url, description?, projectUuid?, credentials?} → create an env, optionally seeding credentials.
|
|
16
16
|
- "update" {uuid, name?, url?, description?, addCredentials?, updateCredentials?, removeCredentialIds?} → patch env + manage credentials.
|
|
17
|
-
- "delete" {uuid, projectUuid?, confirm?} → delete env (DESTRUCTIVE; requires confirmation)
|
|
17
|
+
- "delete" {uuid, projectUuid?, confirm?} → delete env (DESTRUCTIVE; requires confirmation).
|
|
18
|
+
- "sessions" {uuid, username?, credentialId?} → captured login sessions this env is holding, and whether each would be reused.
|
|
19
|
+
- "clearSessions" {uuid, username?, credentialId?, confirm?} → invalidate them so the next run logs in for real.
|
|
20
|
+
|
|
21
|
+
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.`;
|
|
18
22
|
export function buildEnvironmentTool() {
|
|
19
23
|
return {
|
|
20
24
|
name: 'environment',
|
|
@@ -24,7 +28,7 @@ export function buildEnvironmentTool() {
|
|
|
24
28
|
inputSchema: {
|
|
25
29
|
type: 'object',
|
|
26
30
|
properties: {
|
|
27
|
-
action: { type: 'string', enum: ['get', 'list', 'create', 'update', 'delete'], description: 'Operation to perform.' },
|
|
31
|
+
action: { type: 'string', enum: ['get', 'list', 'create', 'update', 'delete', 'sessions', 'clearSessions'], description: 'Operation to perform.' },
|
|
28
32
|
uuid: { type: 'string', description: '[get/update/delete] Environment UUID.' },
|
|
29
33
|
projectUuid: { type: 'string', description: 'Target project (defaults to git auto-detect).' },
|
|
30
34
|
q: { type: 'string', description: '[list] Free-text search over env name.' },
|
|
@@ -37,7 +41,9 @@ export function buildEnvironmentTool() {
|
|
|
37
41
|
addCredentials: { type: 'array', items: CRED_ITEM, description: '[update] Add credentials.' },
|
|
38
42
|
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.' },
|
|
39
43
|
removeCredentialIds: { type: 'array', items: { type: 'string' }, description: '[update] Delete credentials by UUID.' },
|
|
40
|
-
|
|
44
|
+
username: { type: 'string', description: '[sessions/clearSessions] Narrow to one account. Matched case-insensitively.' },
|
|
45
|
+
credentialId: { type: 'string', description: '[sessions/clearSessions] Narrow to one stored credential by UUID.' },
|
|
46
|
+
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.' },
|
|
41
47
|
},
|
|
42
48
|
required: ['action'],
|
|
43
49
|
// No top-level oneOf/anyOf/allOf: the Anthropic tool input_schema rejects
|
|
@@ -102,6 +102,10 @@ export function buildTestPageChangesTool(ctx) {
|
|
|
102
102
|
type: "boolean",
|
|
103
103
|
description: "Default true. Set false to forbid the agent from ever auto-filling the environment's stored credentials — it signs in only as an account this call named (username/password, credentialId, credentialRole, loginCredentials, or auth.username), or not at all. Use when a run must prove a SPECIFIC account's experience and a silent fallback to the default test user would invalidate it."
|
|
104
104
|
},
|
|
105
|
+
freshSession: {
|
|
106
|
+
type: "boolean",
|
|
107
|
+
description: "Default false. Set true to force a REAL login instead of reusing the warm session the backend keeps per account. Use when the login flow itself is what you're checking, when you suspect the stored session is stale, or when the app's only route between personas is a logout. Costs one login; the run re-captures afterwards, so later runs stay fast."
|
|
108
|
+
},
|
|
105
109
|
repoName: {
|
|
106
110
|
type: "string",
|
|
107
111
|
description: "GitHub repository name (e.g. 'my-org/my-repo'). Auto-detected from the current git repo — only provide this if you want to run against a different project than the one you're in."
|
package/dist/types/index.js
CHANGED
|
@@ -59,16 +59,30 @@ export const TestPageChangesInputSchema = z.object({
|
|
|
59
59
|
// Opt out of the environment's stored credentials entirely: the agent signs
|
|
60
60
|
// in only as an account this call named, or not at all.
|
|
61
61
|
useEnvironmentCredentials: z.boolean().optional(),
|
|
62
|
+
// Force a REAL login instead of reusing a captured session (sentinal-cs1hn.4).
|
|
63
|
+
// The backend keeps a warm authenticated session per account and restores it to
|
|
64
|
+
// skip login; that is right for speed and wrong when the login itself is what
|
|
65
|
+
// you are checking, or when the app's only route between personas is a logout.
|
|
66
|
+
freshSession: z.boolean().optional(),
|
|
62
67
|
// Auth-precondition deep-link intent (bead 56kd.6) — "log in THEN go to X".
|
|
63
68
|
auth: AuthPreconditionSchema.optional(),
|
|
64
|
-
}).refine((v) => !(v.useEnvironmentCredentials === false
|
|
65
|
-
&& !v.username && !v.credentialId && !v.credentialRole
|
|
66
|
-
&& !(v.loginCredentials && v.loginCredentials.length > 0)
|
|
67
|
-
&& !v.auth?.username), {
|
|
68
|
-
message: 'useEnvironmentCredentials:false leaves the run with no way to authenticate. '
|
|
69
|
-
+ 'Pass username/password, credentialId, credentialRole, or loginCredentials alongside it.',
|
|
70
|
-
path: ['useEnvironmentCredentials'],
|
|
71
69
|
});
|
|
70
|
+
/**
|
|
71
|
+
* True when the caller opted out of the environment's credentials AND named no
|
|
72
|
+
* account of their own — i.e. "do not log in at all".
|
|
73
|
+
*
|
|
74
|
+
* This used to be a hard validation error ("leaves the run with no way to
|
|
75
|
+
* authenticate"), which made the single most common check — "is my PUBLIC page
|
|
76
|
+
* still up?" — the one thing the tool refused to express. It is not an error; it
|
|
77
|
+
* is an instruction, and the backend already has the enum for it
|
|
78
|
+
* (auth_mode: no_auth, sentinal-i7fnc). See sentinal-oj7dp.3.
|
|
79
|
+
*/
|
|
80
|
+
export function meansDoNotLogIn(v) {
|
|
81
|
+
return v.useEnvironmentCredentials === false
|
|
82
|
+
&& !v.username && !v.credentialId && !v.credentialRole
|
|
83
|
+
&& !(v.loginCredentials && v.loginCredentials.length > 0)
|
|
84
|
+
&& !v.auth?.username;
|
|
85
|
+
}
|
|
72
86
|
export const TriggerCrawlInputSchema = z.object({
|
|
73
87
|
url: z.preprocess(normalizeUrl, z.string().url('Invalid URL. Pass a full URL like "http://localhost:3000" or "https://example.com". Localhost URLs are auto-tunneled to the remote browser.')),
|
|
74
88
|
projectUuid: z.string().uuid().optional(),
|
|
@@ -307,6 +321,12 @@ export const EnvironmentInputSchema = z.discriminatedUnion('action', [
|
|
|
307
321
|
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(),
|
|
308
322
|
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(),
|
|
309
323
|
z.object({ action: z.literal('delete'), uuid: z.string().uuid(), projectUuid: z.string().uuid().optional(), confirm: z.boolean().optional() }).strict(),
|
|
324
|
+
// Captured authenticated sessions (sentinal-cs1hn.5). The backend holds a warm
|
|
325
|
+
// session per account and restores it to skip login; these two actions make that
|
|
326
|
+
// cache visible and clearable instead of a thing that silently decides who a run
|
|
327
|
+
// signs in as.
|
|
328
|
+
z.object({ action: z.literal('sessions'), uuid: z.string().uuid(), projectUuid: z.string().uuid().optional(), username: z.string().min(1).optional(), credentialId: z.string().uuid().optional() }).strict(),
|
|
329
|
+
z.object({ action: z.literal('clearSessions'), uuid: z.string().uuid(), projectUuid: z.string().uuid().optional(), username: z.string().min(1).optional(), credentialId: z.string().uuid().optional(), confirm: z.boolean().optional() }).strict(),
|
|
310
330
|
]);
|
|
311
331
|
export const TestSuiteInputSchema = z.discriminatedUnion('action', [
|
|
312
332
|
z.object({ action: z.literal('list'), ...projectIdentifier, search: z.string().optional(), page: _page, pageSize: z.number().int().min(1).max(100).optional() }).strict(),
|
|
@@ -10,11 +10,33 @@
|
|
|
10
10
|
* epic — that epic only has to populate `ctx.elicit`; the confirm-arg path here
|
|
11
11
|
* ships standalone.
|
|
12
12
|
*/
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Action names treated as destructive.
|
|
15
|
+
*
|
|
16
|
+
* `clearSessions` is here for BLAST RADIUS, not permanence: invalidating captured
|
|
17
|
+
* sessions is recoverable (the next run logs in and re-captures), but an unscoped
|
|
18
|
+
* clear makes EVERY account on an environment re-authenticate, and that should not
|
|
19
|
+
* happen because a `username` filter was mistyped. The caller-side guard only asks
|
|
20
|
+
* when the call is unscoped — see environmentHandler.
|
|
21
|
+
*/
|
|
22
|
+
export const DESTRUCTIVE_ACTIONS = new Set(['delete', 'clearSessions']);
|
|
15
23
|
export function isDestructiveAction(action) {
|
|
16
24
|
return DESTRUCTIVE_ACTIONS.has(action);
|
|
17
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* How each destructive action is described when we ask. `delete` keeps its exact
|
|
28
|
+
* pre-existing wording so its behaviour (and tests) are untouched.
|
|
29
|
+
*/
|
|
30
|
+
const PROMPTS = {
|
|
31
|
+
delete: { verb: 'Delete', noun: 'Deletion', consequence: 'This cannot be undone.' },
|
|
32
|
+
clearSessions: {
|
|
33
|
+
verb: 'Clear all captured login sessions for',
|
|
34
|
+
noun: 'Clearing sessions',
|
|
35
|
+
// Deliberately NOT "cannot be undone" — that would be false, and a guard that
|
|
36
|
+
// overstates the stakes trains people to click through it.
|
|
37
|
+
consequence: 'Every account on it will have to log in again on its next run.',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
18
40
|
function refusal(error, message) {
|
|
19
41
|
return {
|
|
20
42
|
content: [{ type: 'text', text: JSON.stringify({ error, message }, null, 2) }],
|
|
@@ -29,20 +51,24 @@ function refusal(error, message) {
|
|
|
29
51
|
export async function ensureConfirmed(action, label, input, ctx) {
|
|
30
52
|
if (!isDestructiveAction(action))
|
|
31
53
|
return null;
|
|
54
|
+
const { verb, noun, consequence } = PROMPTS[action] ?? PROMPTS.delete;
|
|
32
55
|
if (ctx.elicit) {
|
|
33
56
|
const res = await ctx.elicit({
|
|
34
|
-
message:
|
|
57
|
+
message: `${verb} ${label}? ${consequence}`,
|
|
35
58
|
requestedSchema: {
|
|
36
59
|
type: 'object',
|
|
37
|
-
properties: {
|
|
60
|
+
properties: {
|
|
61
|
+
confirm: { type: 'boolean', description: `Confirm: ${verb.toLowerCase()} ${label}` },
|
|
62
|
+
},
|
|
38
63
|
required: ['confirm'],
|
|
39
64
|
},
|
|
40
65
|
});
|
|
41
66
|
if (res.action === 'accept' && res.content?.confirm === true)
|
|
42
67
|
return null;
|
|
43
|
-
return refusal('confirmation_declined',
|
|
68
|
+
return refusal('confirmation_declined', `${noun} of ${label} was not confirmed.`);
|
|
44
69
|
}
|
|
45
70
|
if (input.confirm === true)
|
|
46
71
|
return null;
|
|
47
|
-
return refusal('confirmation_required', `Refusing to
|
|
72
|
+
return refusal('confirmation_required', `Refusing to ${verb.toLowerCase()} ${label} without confirmation. `
|
|
73
|
+
+ 'Pass confirm:true, or use an elicitation-capable client.');
|
|
48
74
|
}
|