@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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debugg tunnel wire protocol v1 — one multiplexed stream as a node Duplex.
|
|
3
|
+
*
|
|
4
|
+
* A TunnelStream is the byte pipe for exactly one inbound browser connection.
|
|
5
|
+
* Both ends pipe it straight at a socket, so being a real Duplex (and honouring
|
|
6
|
+
* backpressure in both directions) is the whole point:
|
|
7
|
+
*
|
|
8
|
+
* server: socket.pipe(stream).pipe(socket)
|
|
9
|
+
* client: stream.pipe(net.connect(caddyLoopbackPort)).pipe(stream)
|
|
10
|
+
*
|
|
11
|
+
* Writes become DATA frames of at most MAX_DATA_PAYLOAD, gated by the stream's
|
|
12
|
+
* remaining credit. end() sends END. destroy() sends RST.
|
|
13
|
+
*
|
|
14
|
+
* All protocol state lives in the session; this class only forwards. The one
|
|
15
|
+
* piece of cleverness is the read() override, which is how the session learns
|
|
16
|
+
* how many bytes the application has actually consumed and therefore how much
|
|
17
|
+
* credit it may give back.
|
|
18
|
+
*/
|
|
19
|
+
import { Duplex } from 'node:stream';
|
|
20
|
+
import { ErrorCode, MAX_DATA_PAYLOAD } from './constants.js';
|
|
21
|
+
export class TunnelStream extends Duplex {
|
|
22
|
+
/** The stream id the server allocated. */
|
|
23
|
+
id;
|
|
24
|
+
/**
|
|
25
|
+
* Whitelisted OPEN metadata. Informational only — NEVER a dial target
|
|
26
|
+
* (see the SSRF note on bead debugg_ai_mcp-xkoh.1.2).
|
|
27
|
+
*/
|
|
28
|
+
metadata;
|
|
29
|
+
host;
|
|
30
|
+
pendingResetCode;
|
|
31
|
+
pushedTotal = 0;
|
|
32
|
+
reportedConsumed = 0;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
super({
|
|
35
|
+
// One frame's worth of writable buffer: enough to keep the wire busy,
|
|
36
|
+
// little enough that a piped producer is held back rather than drained.
|
|
37
|
+
writableHighWaterMark: MAX_DATA_PAYLOAD,
|
|
38
|
+
readableHighWaterMark: MAX_DATA_PAYLOAD,
|
|
39
|
+
allowHalfOpen: true,
|
|
40
|
+
});
|
|
41
|
+
this.id = options.id;
|
|
42
|
+
this.metadata = options.metadata;
|
|
43
|
+
this.host = options.host;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Abort both directions with RST(code). Defaults to ErrorCode.RESET. Use
|
|
47
|
+
* ErrorCode.UPSTREAM_UNREACHABLE when the local target could not be dialled,
|
|
48
|
+
* so the server can render DEBUGG_TUNNEL_UPSTREAM_REFUSED.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately does not raise a local 'error': the side that chose to reset
|
|
51
|
+
* already knows, and an unhandled 'error' on a Duplex throws.
|
|
52
|
+
*/
|
|
53
|
+
reset(code = ErrorCode.RESET) {
|
|
54
|
+
this.pendingResetCode = code;
|
|
55
|
+
this.destroy();
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Hand the session's inbound bytes to the readable side.
|
|
59
|
+
*
|
|
60
|
+
* Never call push() directly: in flowing mode node delivers a pushed chunk
|
|
61
|
+
* straight to the 'data' listener without it ever entering the buffer or going
|
|
62
|
+
* through read(), so consumption has to be reconciled right here. Missing it
|
|
63
|
+
* means credit is never returned and a transfer larger than one window
|
|
64
|
+
* deadlocks — which is exactly what it did.
|
|
65
|
+
*/
|
|
66
|
+
deliver(chunk) {
|
|
67
|
+
this.pushedTotal += chunk.length;
|
|
68
|
+
const accepted = this.push(chunk);
|
|
69
|
+
this.syncConsumed();
|
|
70
|
+
return accepted;
|
|
71
|
+
}
|
|
72
|
+
/** The peer half-closed: no more inbound bytes on this stream. */
|
|
73
|
+
deliverEnd() {
|
|
74
|
+
this.push(null);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Bytes that have left the readable buffer are bytes the application consumed,
|
|
78
|
+
* and those are what the session may grant back as WINDOW. Credit is never
|
|
79
|
+
* given for bytes still sitting in the buffer.
|
|
80
|
+
*/
|
|
81
|
+
syncConsumed() {
|
|
82
|
+
const consumed = this.pushedTotal - this.readableLength;
|
|
83
|
+
const delta = consumed - this.reportedConsumed;
|
|
84
|
+
if (delta <= 0)
|
|
85
|
+
return;
|
|
86
|
+
this.reportedConsumed = consumed;
|
|
87
|
+
this.host.streamConsumed(this, delta);
|
|
88
|
+
}
|
|
89
|
+
read(size) {
|
|
90
|
+
const chunk = super.read(size);
|
|
91
|
+
this.syncConsumed();
|
|
92
|
+
return chunk;
|
|
93
|
+
}
|
|
94
|
+
_read() {
|
|
95
|
+
// Nothing to pull: the session pushes as DATA frames arrive, and flow
|
|
96
|
+
// control — not this callback — is what limits how much can be in flight.
|
|
97
|
+
}
|
|
98
|
+
_write(chunk, encoding, cb) {
|
|
99
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
|
|
100
|
+
this.host.streamWrite(this, buf, cb);
|
|
101
|
+
}
|
|
102
|
+
_final(cb) {
|
|
103
|
+
this.host.streamFinal(this, cb);
|
|
104
|
+
}
|
|
105
|
+
_destroy(err, cb) {
|
|
106
|
+
this.host.streamDestroy(this, err, this.pendingResetCode);
|
|
107
|
+
cb(err);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debugg tunnel wire protocol v1 — transport seam.
|
|
3
|
+
*
|
|
4
|
+
* TunnelSession never touches a websocket directly. It talks to a FrameTransport,
|
|
5
|
+
* which is what lets the unit tests drive a session frame by frame (and with fake
|
|
6
|
+
* timers) while the integration tests run the same session over a real `ws`
|
|
7
|
+
* socket. `ws` is referenced only structurally here, so this module keeps its
|
|
8
|
+
* "node: built-ins only" property and stays embeddable in the tunnel server.
|
|
9
|
+
*/
|
|
10
|
+
/** RFC 6455: the connection dropped without a close handshake. */
|
|
11
|
+
const ABNORMAL_CLOSURE = 1006;
|
|
12
|
+
/** ws hands out Buffer, ArrayBuffer, or an array of Buffers for a fragmented message. */
|
|
13
|
+
function toBytes(data) {
|
|
14
|
+
if (Buffer.isBuffer(data))
|
|
15
|
+
return data;
|
|
16
|
+
if (data instanceof ArrayBuffer)
|
|
17
|
+
return new Uint8Array(data);
|
|
18
|
+
if (ArrayBuffer.isView(data))
|
|
19
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
20
|
+
if (Array.isArray(data) && data.every((part) => Buffer.isBuffer(part)))
|
|
21
|
+
return Buffer.concat(data);
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
function reasonToString(reason) {
|
|
25
|
+
if (typeof reason === 'string')
|
|
26
|
+
return reason;
|
|
27
|
+
if (Buffer.isBuffer(reason))
|
|
28
|
+
return reason.toString('utf8');
|
|
29
|
+
if (reason instanceof ArrayBuffer)
|
|
30
|
+
return Buffer.from(reason).toString('utf8');
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Adapt a ws WebSocket (client or server side) to a FrameTransport.
|
|
35
|
+
*
|
|
36
|
+
* Responsibilities: normalise inbound data to a Uint8Array, reject text messages
|
|
37
|
+
* as invalid, and make sure the session is told exactly once that the socket is
|
|
38
|
+
* gone — whichever of 'close' and 'error' happens to fire, and in whichever
|
|
39
|
+
* order.
|
|
40
|
+
*/
|
|
41
|
+
export function webSocketTransport(ws) {
|
|
42
|
+
let handlers;
|
|
43
|
+
let closed = false;
|
|
44
|
+
let socketError;
|
|
45
|
+
const reportClose = (code, reason) => {
|
|
46
|
+
if (closed)
|
|
47
|
+
return;
|
|
48
|
+
closed = true;
|
|
49
|
+
handlers?.onClose(code, reason);
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
send(frame, cb) {
|
|
53
|
+
if (closed) {
|
|
54
|
+
cb(new Error('websocket is closed'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
ws.send(frame, (err) => cb(err ?? null));
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
cb(err);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
close(code, reason) {
|
|
65
|
+
try {
|
|
66
|
+
ws.close(code, reason);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* already closing; the close event still settles the session */
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
terminate() {
|
|
73
|
+
try {
|
|
74
|
+
ws.terminate();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
/* nothing left to tear down */
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
setHandlers(next) {
|
|
81
|
+
handlers = next;
|
|
82
|
+
ws.on('message', (data, isBinary) => {
|
|
83
|
+
if (closed)
|
|
84
|
+
return;
|
|
85
|
+
if (!isBinary) {
|
|
86
|
+
next.onInvalidMessage('text message');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const bytes = toBytes(data);
|
|
90
|
+
if (!bytes) {
|
|
91
|
+
next.onInvalidMessage('message was not binary data');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
next.onFrame(bytes);
|
|
95
|
+
});
|
|
96
|
+
ws.on('error', (err) => {
|
|
97
|
+
// ws emits 'close' after 'error' for a live socket, so hold the reason
|
|
98
|
+
// and let the close path report it — unless no close ever arrives.
|
|
99
|
+
socketError = err;
|
|
100
|
+
});
|
|
101
|
+
ws.on('close', (code, reason) => {
|
|
102
|
+
reportClose(code || ABNORMAL_CLOSURE, reasonToString(reason) || socketError?.message || '');
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TunnelTransport — the one step of tunnel creation that differs between ngrok
|
|
3
|
+
* and the debugg tunnel server.
|
|
4
|
+
*
|
|
5
|
+
* STUB (bead debugg_ai_mcp-xkoh.5.3, phase 2.1). Types and signatures only; the
|
|
6
|
+
* implementations arrive in phase 4.1 (debugg_ai_mcp-xkoh.5.7).
|
|
7
|
+
*
|
|
8
|
+
* Everything above this seam is unchanged: handlers, utils/tunnelContext.ts,
|
|
9
|
+
* the per-session Caddy instance, PortLock, URL rewriting, the retry ladder,
|
|
10
|
+
* the idle timer and the session keying all behave exactly as they do today
|
|
11
|
+
* (docs/debugg-tunnel-server-design-2026-09-19.md §5). TunnelManager picks an
|
|
12
|
+
* implementation from the provision response and calls it where it calls
|
|
13
|
+
* ngrok.connect() today.
|
|
14
|
+
*/
|
|
15
|
+
export {};
|
package/dist/services/tunnels.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Called before executeWorkflow so the tunnel URL is known before execution starts.
|
|
5
5
|
*/
|
|
6
6
|
import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
|
|
7
|
+
import { isValidTunnelDomain, registerTunnelDomain, } from '../utils/tunnelDomains.js';
|
|
7
8
|
/**
|
|
8
9
|
* Typed error thrown by provision() when the backend/ngrok path fails.
|
|
9
10
|
* Carries diagnostic fields a retry wrapper (bead 7nx) can use to decide
|
|
@@ -80,11 +81,29 @@ export function classifyProvisionError(err) {
|
|
|
80
81
|
}
|
|
81
82
|
const DEFAULT_BACKOFF_MS = [500, 1500, 3000];
|
|
82
83
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
84
|
+
/** The transports this client can actually drive, best first. */
|
|
85
|
+
export const SUPPORTED_TRANSPORTS = Object.freeze(['debugg', 'ngrok']);
|
|
86
|
+
/** A relay URL must be wss, except against a loopback backend (local dev, tests). */
|
|
87
|
+
function isAcceptableRelayUrl(relayUrl) {
|
|
88
|
+
let url;
|
|
89
|
+
try {
|
|
90
|
+
url = new URL(relayUrl);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (url.protocol === 'wss:')
|
|
96
|
+
return true;
|
|
97
|
+
if (url.protocol !== 'ws:')
|
|
98
|
+
return false;
|
|
99
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
100
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
101
|
+
}
|
|
83
102
|
export const createTunnelsService = (tx) => {
|
|
84
103
|
async function provision(purpose = 'workflow') {
|
|
85
104
|
let response;
|
|
86
105
|
try {
|
|
87
|
-
response = await tx.post('api/v1/tunnels/', { purpose });
|
|
106
|
+
response = await tx.post('api/v1/tunnels/', { purpose, transports: [...SUPPORTED_TRANSPORTS] });
|
|
88
107
|
}
|
|
89
108
|
catch (err) {
|
|
90
109
|
throw classifyProvisionError(err);
|
|
@@ -95,12 +114,61 @@ export const createTunnelsService = (tx) => {
|
|
|
95
114
|
retryable: false,
|
|
96
115
|
});
|
|
97
116
|
}
|
|
98
|
-
|
|
117
|
+
// No `transport` means an old backend that predates negotiation, which only
|
|
118
|
+
// ever hands out ngrok keys.
|
|
119
|
+
const transport = (response.transport ?? 'ngrok');
|
|
120
|
+
if (!SUPPORTED_TRANSPORTS.includes(transport)) {
|
|
121
|
+
throw new TunnelProvisionError({
|
|
122
|
+
message: `Tunnel provisioning selected transport "${response.transport}", which this client cannot speak ` +
|
|
123
|
+
`(it offered ${SUPPORTED_TRANSPORTS.join(', ')}). Update @debugg-ai/debugg-ai-mcp.`,
|
|
124
|
+
retryable: false,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const result = {
|
|
99
128
|
tunnelId: response.tunnelId,
|
|
100
129
|
tunnelKey: response.tunnelKey,
|
|
101
130
|
keyId: response.keyId,
|
|
102
131
|
expiresAt: response.expiresAt,
|
|
132
|
+
transport,
|
|
103
133
|
};
|
|
134
|
+
if (transport === 'debugg') {
|
|
135
|
+
// Every one of these is non-retryable: retrying cannot turn a malformed
|
|
136
|
+
// response into a usable one, and TunnelManager must never be handed a
|
|
137
|
+
// debugg token with nowhere to send it.
|
|
138
|
+
if (!response.relayUrl || !response.tunnelDomain) {
|
|
139
|
+
throw new TunnelProvisionError({
|
|
140
|
+
message: 'Tunnel provisioning selected the debugg transport but omitted relayUrl or tunnelDomain',
|
|
141
|
+
retryable: false,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (!isAcceptableRelayUrl(response.relayUrl)) {
|
|
145
|
+
throw new TunnelProvisionError({
|
|
146
|
+
message: `Tunnel provisioning returned a relayUrl that is not wss (${response.relayUrl}); ` +
|
|
147
|
+
'refusing to send the tunnel key over it',
|
|
148
|
+
retryable: false,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (!isValidTunnelDomain(response.tunnelDomain)) {
|
|
152
|
+
throw new TunnelProvisionError({
|
|
153
|
+
message: `Tunnel provisioning returned an unusable tunnelDomain (${response.tunnelDomain})`,
|
|
154
|
+
retryable: false,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// Registering it here is what stops a tunnel URL leaking: everything that
|
|
158
|
+
// recognises or rewrites a tunnel hostname reads this same registry, so a
|
|
159
|
+
// domain the backend moves to is covered the moment it is handed to us.
|
|
160
|
+
registerTunnelDomain(response.tunnelDomain, 'debugg');
|
|
161
|
+
result.relayUrl = response.relayUrl;
|
|
162
|
+
result.tunnelDomain = response.tunnelDomain;
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
async function revoke(provisionInfo) {
|
|
167
|
+
if (provisionInfo.transport === 'debugg') {
|
|
168
|
+
await tx.post(`api/v1/tunnels/${provisionInfo.tunnelId}/revoke/`, {});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
await tx.post('api/v1/ngrok/revoke/', { ngrokKeyId: provisionInfo.keyId });
|
|
104
172
|
}
|
|
105
173
|
async function provisionWithRetry(opts = {}) {
|
|
106
174
|
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
@@ -144,5 +212,5 @@ export const createTunnelsService = (tx) => {
|
|
|
144
212
|
retryable: false,
|
|
145
213
|
});
|
|
146
214
|
}
|
|
147
|
-
return { provision, provisionWithRetry };
|
|
215
|
+
return { provision, provisionWithRetry, revoke };
|
|
148
216
|
};
|
|
@@ -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
|
+
}
|