@debugg-ai/debugg-ai-mcp 6.1.5 → 6.2.1
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/CHANGELOG.md +13 -0
- package/README.md +4 -0
- package/dist/handlers/localExecutorBridge.js +152 -0
- package/dist/handlers/localProbeHandler.js +46 -0
- package/dist/handlers/probePageHandler.js +4 -0
- package/dist/handlers/testPageChangesHandler.js +37 -8
- package/dist/services/localBrowser.js +207 -0
- package/dist/services/localExecutor/credentialBox.js +43 -0
- package/dist/services/localExecutor/httpTransport.js +72 -0
- package/dist/services/localExecutor/opRunner.js +437 -0
- package/dist/services/localExecutor/ops.js +12 -0
- package/dist/services/localExecutor/session.js +125 -0
- package/dist/services/navigationGuard.js +71 -0
- package/dist/services/workflows.js +31 -1
- package/dist/tools/probePage.js +22 -0
- package/dist/tools/testPageChanges.js +8 -0
- package/dist/types/index.js +16 -0
- package/dist/utils/projectRequired.js +8 -4
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [6.2.0] — 2026-11-13
|
|
11
|
+
|
|
12
|
+
> Local click-through needs a backend with local executors enabled (platform flag `EXECUTORS_LOCAL_ENABLED`) and `DEBUGGAI_LOCAL_BROWSER=1` on the MCP server. Without both, nothing changes for existing calls.
|
|
13
|
+
|
|
14
|
+
### Added — check_app_in_browser on your local Chrome, cloud brain (`browser: "local"`) (epic m7un)
|
|
15
|
+
- New `browser: "local"` (also `headless`, `useProfile`, `allowedDomains`) runs the check in Chrome on your machine while the cloud brain decides each step. The laptop dials out, so no tunnel is needed for localhost or private apps. Opt-in: set `DEBUGGAI_LOCAL_BROWSER=1` in the MCP server environment; stdio only (not the remote HTTP/OAuth server). The platform side is flag-gated, so it needs a backend that has local executors enabled.
|
|
16
|
+
- Uses the sense/think/act brain (`flow/tools/browse`; `DEBUGGAI_LOCAL_TEMPLATE` overrides the slug). The `browser_use` App Evaluation template cannot drive a local browser and is refused by the backend for local runs.
|
|
17
|
+
- Safety: every navigation hop (redirects included) is checked against an allowlist of the target host plus `allowedDomains`/`extraOrigins`; an empty allowlist is refused. Passwords you pass are never sent to the cloud: the brain sees `{{cred:label}}` and the MCP substitutes it locally. Stored environment credentials arrive sealed to a per-session public key and are opened only on your machine. No cookies, HAR or login state are uploaded.
|
|
18
|
+
- Screenshots go to the cloud by default (the brain needs them); `DEBUGGAI_LOCAL_SCREENSHOTS=local_only` keeps them on disk and sends a 1x1 placeholder. Full captures are written under `~/.debugg-ai/captures` (0600).
|
|
19
|
+
- `useProfile` uses an MCP-owned Chrome profile (`~/.debugg-ai/chrome-profile`), never your real Chrome, and requires an allowlist. Note: it keeps persistent cookies between runs but not session cookies (Chrome drops them on close), so an app whose login is a session cookie will ask you to sign in again on the next run.
|
|
20
|
+
- Limits are configuration from the server (heartbeat, lease, in-flight concurrency), not hard-coded in the client. Retries are off for local runs (a retry needs a fresh session). `LOG_LEVEL=debug` logs each executor command.
|
|
21
|
+
- Verified against a flag-on backend: a click-through, a credentialed multi-step login with task creation, and cancel mid-run (call returns at once, Chrome closed).
|
|
22
|
+
|
|
10
23
|
### Changed — test_suite `run` retired (platform-g3am)
|
|
11
24
|
- The server removed its suite-run route, so `run` only produced a raw HTML 404. It is gone from the tool's actions and description; a call from an older client now returns an `ActionRetired` error naming `results`, `check_app_in_browser` and `trigger_crawl`, without contacting the server.
|
|
12
25
|
|
package/README.md
CHANGED
|
@@ -47,6 +47,10 @@ cause), then that your dev server answers a plain `curl http://127.0.0.1:<port>/
|
|
|
47
47
|
Verified 2026-11-10: servers bound to `127.0.0.1` only (IPv4 Caddy gateways on :4040 and :4043) probe fine via
|
|
48
48
|
`http://localhost:<port>` and `http://127.0.0.1:<port>`.
|
|
49
49
|
|
|
50
|
+
### Local Chrome capture (opt-in)
|
|
51
|
+
|
|
52
|
+
`probe_page` accepts `browser: "local"` to capture with Chrome on your own machine: no tunnel, no cloud browser, nothing uploaded (screenshots also saved under `~/.debugg-ai/captures`, mode 0600). The server owner must set `DEBUGGAI_LOCAL_BROWSER=1`; Chrome is found automatically or via `DEBUGGAI_CHROME_PATH`; `playwright-core` is an optional dependency. `useProfile: true` uses an MCP-owned persistent profile (`~/.debugg-ai/chrome-profile`, never your everyday Chrome profile): sign in once with `headless: false, loginWindowMs: 120000`, later local probes are logged in. Profile sessions require `allowedDomains` (or the owner's `DEBUGGAI_PROFILE_ALLOWED_DOMAINS`, which overrides it); top-level navigations off the list, redirect hops included, are blocked.
|
|
53
|
+
|
|
50
54
|
Get an API key at [debugg.ai](https://debugg.ai), then add to your MCP client config:
|
|
51
55
|
|
|
52
56
|
```json
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check_app_in_browser with browser:'local' (epic m7un). The cloud workflow runs as
|
|
3
|
+
* usual but its page calls are served by the Chrome on THIS machine through the
|
|
4
|
+
* Executor API v1 (sentinal docs/local-executor-api-design.md). This module owns the
|
|
5
|
+
* MCP side: create the session, hold Chrome, attach + serve commands while the
|
|
6
|
+
* workflow executes, tear down. The handler only calls prepare / begin / end.
|
|
7
|
+
*/
|
|
8
|
+
import { config } from '../config/index.js';
|
|
9
|
+
import { logger } from '../utils/logger.js';
|
|
10
|
+
import { localBrowserEnabled, LocalBrowserError, resolveAllowedDomains } from '../services/localBrowser.js';
|
|
11
|
+
import { OpRunner } from '../services/localExecutor/opRunner.js';
|
|
12
|
+
import { runExecutorSession } from '../services/localExecutor/session.js';
|
|
13
|
+
import { HttpExecutorTransport } from '../services/localExecutor/httpTransport.js';
|
|
14
|
+
import { generateSessionKeys } from '../services/localExecutor/credentialBox.js';
|
|
15
|
+
/**
|
|
16
|
+
* Passwords never go to the cloud for a local run: the workflow gets `{{cred:<label>}}`
|
|
17
|
+
* and the real value stays here. Environment-stored credentials (credentialId/role)
|
|
18
|
+
* live in the cloud and are not handled by this path yet.
|
|
19
|
+
*/
|
|
20
|
+
export function localizeCredentials(input) {
|
|
21
|
+
const secrets = {};
|
|
22
|
+
const ph = (label, password) => { secrets[label] = password; return `{{cred:${label}}}`; };
|
|
23
|
+
const out = { ...input };
|
|
24
|
+
if (input.username && input.password)
|
|
25
|
+
out.password = ph(input.username, input.password);
|
|
26
|
+
if (input.loginCredentials) {
|
|
27
|
+
out.loginCredentials = input.loginCredentials.map((c) => ({ ...c, password: ph(c.label ?? c.username, c.password) }));
|
|
28
|
+
}
|
|
29
|
+
if (input.auth?.password && input.auth.username) {
|
|
30
|
+
out.auth = { ...input.auth, password: ph(input.auth.username, input.auth.password) };
|
|
31
|
+
}
|
|
32
|
+
return { input: out, secrets };
|
|
33
|
+
}
|
|
34
|
+
export function localAllowedDomains(input, startUrl) {
|
|
35
|
+
const startHost = (() => { try {
|
|
36
|
+
return new URL(startUrl).hostname;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return '';
|
|
40
|
+
} })();
|
|
41
|
+
const extra = (input.extraOrigins ?? []).map((o) => { try {
|
|
42
|
+
return new URL(/^\d+$/.test(o) ? `http://localhost:${o}` : o).hostname;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return '';
|
|
46
|
+
} });
|
|
47
|
+
if (input.useProfile)
|
|
48
|
+
return resolveAllowedDomains(input.allowedDomains);
|
|
49
|
+
return [startHost, ...(input.allowedDomains ?? []), ...extra].filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
export async function prepareLocalRun(args) {
|
|
52
|
+
const { client, input, startUrl, secrets, signal } = args;
|
|
53
|
+
if (!localBrowserEnabled()) {
|
|
54
|
+
throw new LocalBrowserError('Local browser mode is off. Set DEBUGGAI_LOCAL_BROWSER=1 in the MCP server environment to allow it.', 'LOCAL_BROWSER_DISABLED');
|
|
55
|
+
}
|
|
56
|
+
const allowed = localAllowedDomains(input, startUrl);
|
|
57
|
+
if (allowed.length === 0) {
|
|
58
|
+
throw new LocalBrowserError('A profile session needs an allowlist of domains it may visit: pass allowedDomains or have the server owner set DEBUGGAI_PROFILE_ALLOWED_DOMAINS.', 'PROFILE_ALLOWLIST_REQUIRED');
|
|
59
|
+
}
|
|
60
|
+
const headless = input.headless !== false;
|
|
61
|
+
const screenshotsMode = process.env.DEBUGGAI_LOCAL_SCREENSHOTS === 'local_only' ? 'local_only' : 'cloud';
|
|
62
|
+
// Chrome starts while the session is being created and the workflow queued.
|
|
63
|
+
// Mutable on purpose: stored credentials are decrypted into it once the session exists.
|
|
64
|
+
const liveSecrets = { ...secrets };
|
|
65
|
+
const keys = generateSessionKeys();
|
|
66
|
+
const runnerP = OpRunner.open({ allowedDomains: allowed, headless, useProfile: !!input.useProfile, secrets: liveSecrets, screenshots: screenshotsMode, signal });
|
|
67
|
+
runnerP.catch(() => { });
|
|
68
|
+
let created;
|
|
69
|
+
try {
|
|
70
|
+
created = await client.tx.post('api/v1/executors/sessions', {
|
|
71
|
+
executor: 'local',
|
|
72
|
+
allowedDomains: allowed,
|
|
73
|
+
headless,
|
|
74
|
+
viewport: { w: 1280, h: 800 },
|
|
75
|
+
capture: { screenshots: screenshotsMode },
|
|
76
|
+
// Public half only. The private key never leaves this process.
|
|
77
|
+
executorPublicKey: keys.publicKey,
|
|
78
|
+
...(args.projectId ? { projectId: args.projectId } : {}),
|
|
79
|
+
...(input.environmentId ? { environmentId: input.environmentId } : {}),
|
|
80
|
+
...(input.credentialId ? { credentialId: input.credentialId } : {}),
|
|
81
|
+
...(input.credentialRole ? { credentialRole: input.credentialRole } : {}),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
(await runnerP.catch(() => null))?.close();
|
|
86
|
+
const status = err?.response?.status;
|
|
87
|
+
if (status === 404) {
|
|
88
|
+
throw new LocalBrowserError('The DebuggAI backend does not offer local browser execution yet (executor API not enabled for this account).', 'LOCAL_EXECUTOR_UNAVAILABLE');
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
const runner = await runnerP;
|
|
93
|
+
if (created.captureMode === 'cloud' || created.captureMode === 'local_only')
|
|
94
|
+
runner.setCaptureMode(created.captureMode);
|
|
95
|
+
if (typeof created.headless === 'boolean' && created.headless !== (headless !== false)) {
|
|
96
|
+
logger.warn(`Server runs this session with headless=${created.headless}; the local browser was already launched with headless=${headless !== false}.`);
|
|
97
|
+
}
|
|
98
|
+
for (const c of created.credentials ?? []) {
|
|
99
|
+
try {
|
|
100
|
+
const pw = keys.open(c.sealed, created.sessionId);
|
|
101
|
+
liveSecrets[c.label] = pw;
|
|
102
|
+
// The backend names the placeholder after the label OR the username depending on the resolution path; accept both.
|
|
103
|
+
if (c.username && !(c.username in liveSecrets))
|
|
104
|
+
liveSecrets[c.username] = pw;
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
logger.warn(`Could not open stored credential "${c.label}" (${err instanceof Error ? err.message : 'decrypt failed'})`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const limits = {
|
|
111
|
+
heartbeat_s: created.limits?.heartbeatS ?? 10,
|
|
112
|
+
lease_s: created.limits?.leaseS ?? 45,
|
|
113
|
+
max_inline_bytes: created.limits?.maxInlineBytes ?? 1_000_000,
|
|
114
|
+
max_batch: created.limits?.maxBatch ?? 1,
|
|
115
|
+
...(created.limits?.maxInflight ? { max_inflight: created.limits.maxInflight } : {}),
|
|
116
|
+
};
|
|
117
|
+
const transport = new HttpExecutorTransport({
|
|
118
|
+
baseUrl: config.api.baseUrl,
|
|
119
|
+
authorization: `${config.api.tokenType === 'bearer' ? 'Bearer' : 'Token'} ${config.api.key}`,
|
|
120
|
+
sessionId: created.sessionId,
|
|
121
|
+
secret: created.secret,
|
|
122
|
+
});
|
|
123
|
+
runner.setUploader((url, ct, bytes) => transport.upload(url, ct, bytes));
|
|
124
|
+
const stop = new AbortController();
|
|
125
|
+
const onAbort = () => stop.abort();
|
|
126
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
127
|
+
let running;
|
|
128
|
+
return {
|
|
129
|
+
sessionId: created.sessionId,
|
|
130
|
+
begin() {
|
|
131
|
+
running = runExecutorSession({
|
|
132
|
+
transport, runner, limits, signal: stop.signal,
|
|
133
|
+
attach: {
|
|
134
|
+
executor_version: 'debugg-ai-mcp',
|
|
135
|
+
ops_supported: OpRunner.supportedOps(),
|
|
136
|
+
browser: { name: 'chrome', headless },
|
|
137
|
+
profile: input.useProfile ? 'mcp_owned' : 'ephemeral',
|
|
138
|
+
credential_labels: Object.keys(liveSecrets),
|
|
139
|
+
},
|
|
140
|
+
}).catch((err) => { logger.warn(`local executor session failed: ${err instanceof Error ? err.message : err}`); return { ended: 'lease_lost', commandsRun: 0 }; });
|
|
141
|
+
},
|
|
142
|
+
async end() {
|
|
143
|
+
signal.removeEventListener('abort', onAbort);
|
|
144
|
+
if (!running) {
|
|
145
|
+
await runner.close();
|
|
146
|
+
return { ended: 'not_started', commandsRun: 0 };
|
|
147
|
+
}
|
|
148
|
+
stop.abort();
|
|
149
|
+
return running;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* probe_page with browser:'local' (bead jpw3.1/.2): capture each target with the
|
|
3
|
+
* user's own Chrome on this machine. No backend call, no tunnel.
|
|
4
|
+
*/
|
|
5
|
+
import { localCapture, LocalBrowserError } from '../services/localBrowser.js';
|
|
6
|
+
import { imageContentBlock } from '../utils/imageUtils.js';
|
|
7
|
+
import { logger } from '../utils/logger.js';
|
|
8
|
+
export async function localProbe(input, context) {
|
|
9
|
+
const results = [];
|
|
10
|
+
const images = [];
|
|
11
|
+
for (const t of input.targets) {
|
|
12
|
+
try {
|
|
13
|
+
const r = await localCapture({
|
|
14
|
+
url: t.url,
|
|
15
|
+
headless: input.headless,
|
|
16
|
+
useProfile: input.useProfile,
|
|
17
|
+
loginWindowMs: input.loginWindowMs,
|
|
18
|
+
allowedDomains: input.allowedDomains,
|
|
19
|
+
waitForSelector: t.waitForSelector,
|
|
20
|
+
waitForLoadState: t.waitForLoadState,
|
|
21
|
+
timeoutMs: t.timeoutMs,
|
|
22
|
+
includeHtml: input.includeHtml,
|
|
23
|
+
captureScreenshot: input.captureScreenshots,
|
|
24
|
+
signal: context.signal,
|
|
25
|
+
});
|
|
26
|
+
const { screenshotBase64, ...rest } = r;
|
|
27
|
+
results.push(rest);
|
|
28
|
+
if (screenshotBase64)
|
|
29
|
+
images.push(imageContentBlock(screenshotBase64, 'image/png'));
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (err instanceof LocalBrowserError && (err.code === 'LOCAL_BROWSER_DISABLED' || err.code === 'PLAYWRIGHT_MISSING' || err.code === 'PROFILE_ALLOWLIST_REQUIRED')) {
|
|
33
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: err.code, message: err.message }, null, 2) }], isError: true };
|
|
34
|
+
}
|
|
35
|
+
const message = err instanceof Error ? err.message.split('\n')[0] : String(err);
|
|
36
|
+
logger.warn(`probe_page local: ${t.url} failed: ${message}`);
|
|
37
|
+
results.push({ url: t.url, error: message, ...(err instanceof LocalBrowserError ? { errorCode: err.code } : {}) });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{ type: 'text', text: JSON.stringify({ browser: 'local', results }, null, 2) },
|
|
43
|
+
...images,
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -25,6 +25,7 @@ import { buildContext, findExistingTunnel, ensureTunnel, acquirePortRoute, relea
|
|
|
25
25
|
import { randomUUID } from 'node:crypto';
|
|
26
26
|
import { getCachedTemplateUuid, invalidateTemplateCache } from '../utils/handlerCaches.js';
|
|
27
27
|
import { getPageProbeTemplateSlug } from '../services/workflows.js';
|
|
28
|
+
import { localProbe } from './localProbeHandler.js';
|
|
28
29
|
import { reaggregateByOriginPath, mapConsoleSlice } from '../utils/harSummarizer.js';
|
|
29
30
|
import { fetchImageAsBase64, imageContentBlock } from '../utils/imageUtils.js';
|
|
30
31
|
const logger = new Logger({ module: 'probePageHandler' });
|
|
@@ -76,6 +77,9 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
: undefined;
|
|
80
|
+
if (input.browser === 'local') {
|
|
81
|
+
return localProbe(input, context);
|
|
82
|
+
}
|
|
79
83
|
const client = new DebuggAIServerClient(config.api.key);
|
|
80
84
|
await client.init();
|
|
81
85
|
// Bead 56kd.7: cancellation is driven by the MCP request/transport lifecycle
|
|
@@ -9,7 +9,7 @@ import { Logger } from '../utils/logger.js';
|
|
|
9
9
|
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
|
-
import { getEvalTemplateSlug } from '../services/workflows.js';
|
|
12
|
+
import { getEvalTemplateSlug, getLocalBrowseTemplateSlug } from '../services/workflows.js';
|
|
13
13
|
import { authorizeExtraOrigins, ExtraOriginError } from '../utils/extraOrigins.js';
|
|
14
14
|
import { refreshPendingArtifacts } from '../utils/artifactGrace.js';
|
|
15
15
|
import { adaptVerdict, credentialSubstitutions, summarizeLogins } from '../services/verdictAdapter.js';
|
|
@@ -23,6 +23,8 @@ import { extractLocalhostPort, rewriteLocalhostInText } from '../utils/urlParser
|
|
|
23
23
|
import { getCachedTemplateUuid, getCachedProjectUuid, invalidateTemplateCache, invalidateProjectCache, } from '../utils/handlerCaches.js';
|
|
24
24
|
import { isTransientWorkflowError, transientReasonTag } from '../utils/transientErrors.js';
|
|
25
25
|
import { Telemetry, TelemetryEvents } from '../utils/telemetry.js';
|
|
26
|
+
import { prepareLocalRun, localizeCredentials } from './localExecutorBridge.js';
|
|
27
|
+
import { LocalBrowserError } from '../services/localBrowser.js';
|
|
26
28
|
import { projectRequiredMessage, projectLookupFailedPayload } from '../utils/projectRequired.js';
|
|
27
29
|
const logger = new Logger({ module: 'testPageChangesHandler' });
|
|
28
30
|
// Bead kbxy: bounded retry on known transient backend signatures (Pydantic
|
|
@@ -136,6 +138,15 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
136
138
|
: undefined;
|
|
137
139
|
const client = new DebuggAIServerClient(config.api.key);
|
|
138
140
|
await client.init();
|
|
141
|
+
// browser:'local': Chrome on this machine does the clicking (no tunnel); passwords stay local.
|
|
142
|
+
const localMode = input.browser === 'local';
|
|
143
|
+
let localSecrets = {};
|
|
144
|
+
if (localMode) {
|
|
145
|
+
const l = localizeCredentials(input);
|
|
146
|
+
input = l.input;
|
|
147
|
+
localSecrets = l.secrets;
|
|
148
|
+
}
|
|
149
|
+
let localRun;
|
|
139
150
|
const originalUrl = resolveTargetUrl(input);
|
|
140
151
|
let ctx = buildContext(originalUrl);
|
|
141
152
|
let provision;
|
|
@@ -227,14 +238,14 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
227
238
|
// is a billed leak, and beats a backend node failing mid-run.
|
|
228
239
|
if (!projectUuid && !namedEnvironmentId && lookupError !== undefined && repoName) {
|
|
229
240
|
// The lookup FAILED — not "no project linked" (platform-y2ob.4).
|
|
230
|
-
const payload = projectLookupFailedPayload(repoName, lookupError);
|
|
241
|
+
const payload = projectLookupFailedPayload(repoName, lookupError, false);
|
|
231
242
|
logger.warn(`check_app_in_browser: ${payload.message}`);
|
|
232
243
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
233
244
|
}
|
|
234
245
|
if (!projectUuid && !namedEnvironmentId) {
|
|
235
246
|
const payload = {
|
|
236
247
|
error: 'ProjectRequired',
|
|
237
|
-
message: projectRequiredMessage(repoName),
|
|
248
|
+
message: projectRequiredMessage(repoName, false),
|
|
238
249
|
};
|
|
239
250
|
logger.warn(`check_app_in_browser: ${payload.message}`);
|
|
240
251
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
@@ -263,7 +274,10 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
263
274
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
264
275
|
}
|
|
265
276
|
}
|
|
266
|
-
if (
|
|
277
|
+
if (localMode) {
|
|
278
|
+
logger.info(`check_app_in_browser: local browser — no tunnel for ${ctx.originalUrl}`);
|
|
279
|
+
}
|
|
280
|
+
else if (config.devMode) {
|
|
267
281
|
// Dev mode: local backend can reach localhost directly — no tunnel needed.
|
|
268
282
|
logger.info(`check_app_in_browser: dev mode — using localhost URL directly: ${ctx.originalUrl}`);
|
|
269
283
|
}
|
|
@@ -375,11 +389,14 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
375
389
|
// Cache key = the dispatch slug so the cache key and the lookup can never
|
|
376
390
|
// drift apart (bug clka: the key used to be a decoupled 'app evaluation'
|
|
377
391
|
// literal while the lookup searched a different string).
|
|
378
|
-
const
|
|
379
|
-
|
|
392
|
+
const templateSlug = localMode ? getLocalBrowseTemplateSlug() : getEvalTemplateSlug();
|
|
393
|
+
const templateUuid = await getCachedTemplateUuid(templateSlug, async () => {
|
|
394
|
+
return localMode
|
|
395
|
+
? client.workflows.findTemplateBySlug(templateSlug)
|
|
396
|
+
: client.workflows.findEvaluationTemplate();
|
|
380
397
|
});
|
|
381
398
|
if (!templateUuid) {
|
|
382
|
-
throw new Error(
|
|
399
|
+
throw new Error(`${localMode ? 'Local browse' : 'App Evaluation'} workflow template not found (slug "${templateSlug}").`);
|
|
383
400
|
}
|
|
384
401
|
// --- Build context data (camelCase here — axiosTransport auto-converts to snake_case) ---
|
|
385
402
|
const contextData = {
|
|
@@ -395,6 +412,11 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
395
412
|
contextData.projectId = projectUuid;
|
|
396
413
|
}
|
|
397
414
|
contextData.headless = true; // D7: the MCP always runs headless — no opt-out.
|
|
415
|
+
if (localMode) {
|
|
416
|
+
localRun = await prepareLocalRun({ client, input, startUrl: originalUrl, secrets: localSecrets, signal: abortController.signal, projectId: projectUuid || undefined });
|
|
417
|
+
contextData.executorSessionId = localRun.sessionId;
|
|
418
|
+
contextData.goal = contextData.question; // flow/tools/browse reads `goal`
|
|
419
|
+
}
|
|
398
420
|
// Bead 56kd.6: forward the auth-precondition deep-link intent verbatim per
|
|
399
421
|
// backend contract sentinal-k8x1f.8 (contextData.auth). Thin relay — express
|
|
400
422
|
// intent + forward; the backend authenticates then navigates to deepUrl. Only
|
|
@@ -504,7 +526,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
504
526
|
// 28: Complete
|
|
505
527
|
const BACKEND_SETUP_END = 6;
|
|
506
528
|
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
|
|
507
|
-
const MAX_RETRIES = getMaxTransientRetries();
|
|
529
|
+
const MAX_RETRIES = localMode ? 0 : getMaxTransientRetries(); // a retry would need a fresh local session
|
|
508
530
|
let executeResponse;
|
|
509
531
|
let executionUuid = '';
|
|
510
532
|
let finalExecution;
|
|
@@ -539,6 +561,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
539
561
|
// the target forward; the previous attempt already reached a terminal
|
|
540
562
|
// state, so there is nothing to cancel there).
|
|
541
563
|
currentExecutionUuid = executionUuid;
|
|
564
|
+
localRun?.begin();
|
|
542
565
|
logger.info(`Execution queued: ${executionUuid}${attempt > 1 ? ` (retry ${attempt - 1}/${MAX_RETRIES})` : ''}`);
|
|
543
566
|
// The abort can fire BEFORE anything is queued — while provisioning the
|
|
544
567
|
// tunnel or resolving the template — and there is no abort check between
|
|
@@ -930,6 +953,11 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
930
953
|
return tunnelFault ? { content, isError: true } : { content };
|
|
931
954
|
}
|
|
932
955
|
catch (error) {
|
|
956
|
+
if (error instanceof LocalBrowserError) {
|
|
957
|
+
const payload = { error: error.code, message: error.message };
|
|
958
|
+
logger.warn(`check_app_in_browser (local): ${error.code}: ${error.message}`);
|
|
959
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
960
|
+
}
|
|
933
961
|
const duration = Date.now() - startTime;
|
|
934
962
|
logger.toolError('check_app_in_browser', error, duration);
|
|
935
963
|
if (error instanceof Error && (error.message.includes('not found') || error.message.includes('401'))) {
|
|
@@ -939,6 +967,7 @@ async function testPageChangesHandlerInner(input, context, rawProgressCallback)
|
|
|
939
967
|
throw handleExternalServiceError(error, 'DebuggAI', 'test execution');
|
|
940
968
|
}
|
|
941
969
|
finally {
|
|
970
|
+
await localRun?.end().catch(() => { });
|
|
942
971
|
if (requestSignal)
|
|
943
972
|
requestSignal.removeEventListener('abort', onAbort);
|
|
944
973
|
// §2.4: release this call's claim on the shared port route (no-op if we
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Chrome capture for probe_page (bead jpw3.1 / jpw3.2).
|
|
3
|
+
*
|
|
4
|
+
* Launches the user's installed Chrome through playwright-core on THIS machine
|
|
5
|
+
* and captures a page directly — no tunnel, no cloud browser, nothing uploaded.
|
|
6
|
+
* Everything stays local: screenshots are returned inline and also written under
|
|
7
|
+
* ~/.debugg-ai/captures (0600).
|
|
8
|
+
*
|
|
9
|
+
* Opt-in at the server: DEBUGGAI_LOCAL_BROWSER=1. With `useProfile`, the browser
|
|
10
|
+
* uses an MCP-owned persistent profile (~/.debugg-ai/chrome-profile, override with
|
|
11
|
+
* DEBUGGAI_CHROME_PROFILE_DIR) the user logs into once via `loginToLocalProfile`;
|
|
12
|
+
* the user's everyday Chrome profile is never touched.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { installNavigationGuard, isLoopbackHost } from './navigationGuard.js';
|
|
18
|
+
export class LocalBrowserError extends Error {
|
|
19
|
+
code;
|
|
20
|
+
constructor(message, code) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.name = 'LocalBrowserError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Host matches an allowed domain exactly or as a subdomain. */
|
|
27
|
+
export function hostAllowed(host, allowed) {
|
|
28
|
+
const h = host.toLowerCase();
|
|
29
|
+
return allowed.some((d) => {
|
|
30
|
+
const a = d.trim().toLowerCase().replace(/^\*?\./, '');
|
|
31
|
+
return a !== '' && (h === a || h.endsWith(`.${a}`));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Allowlist for a profile session (bead jpw3.3). The server owner's
|
|
36
|
+
* DEBUGGAI_PROFILE_ALLOWED_DOMAINS (comma list) is authoritative when set;
|
|
37
|
+
* otherwise the call must name allowedDomains. Never empty for a profile run.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveAllowedDomains(perCall, env = process.env) {
|
|
40
|
+
const owner = (env.DEBUGGAI_PROFILE_ALLOWED_DOMAINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
41
|
+
return owner.length > 0 ? owner : (perCall ?? []).filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
export function localBrowserEnabled(env = process.env) {
|
|
44
|
+
return env.DEBUGGAI_LOCAL_BROWSER === '1' || env.DEBUGGAI_LOCAL_BROWSER === 'true';
|
|
45
|
+
}
|
|
46
|
+
export function debuggHome(env = process.env) {
|
|
47
|
+
return env.DEBUGGAI_HOME || path.join(os.homedir(), '.debugg-ai');
|
|
48
|
+
}
|
|
49
|
+
export function profileDir(env = process.env) {
|
|
50
|
+
return env.DEBUGGAI_CHROME_PROFILE_DIR || path.join(debuggHome(env), 'chrome-profile');
|
|
51
|
+
}
|
|
52
|
+
/** Well-known Chrome locations; DEBUGGAI_CHROME_PATH wins. undefined => let playwright try channel 'chrome'. */
|
|
53
|
+
export function findChrome(env = process.env) {
|
|
54
|
+
if (env.DEBUGGAI_CHROME_PATH)
|
|
55
|
+
return env.DEBUGGAI_CHROME_PATH;
|
|
56
|
+
const candidates = process.platform === 'darwin'
|
|
57
|
+
? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium']
|
|
58
|
+
: process.platform === 'win32'
|
|
59
|
+
? [
|
|
60
|
+
`${env.PROGRAMFILES ?? 'C:\\Program Files'}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
61
|
+
`${env['PROGRAMFILES(X86)'] ?? 'C:\\Program Files (x86)'}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
62
|
+
]
|
|
63
|
+
: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
|
|
64
|
+
return candidates.find((p) => fs.existsSync(p));
|
|
65
|
+
}
|
|
66
|
+
async function loadPlaywright() {
|
|
67
|
+
try {
|
|
68
|
+
return await import('playwright-core');
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new LocalBrowserError('Local browser mode needs the optional dependency playwright-core, which is not installed. Run `npm i playwright-core` next to the MCP.', 'PLAYWRIGHT_MISSING');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function ensurePrivateDir(dir) {
|
|
75
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
76
|
+
}
|
|
77
|
+
export async function launch(opts) {
|
|
78
|
+
const pw = await loadPlaywright();
|
|
79
|
+
const executablePath = findChrome();
|
|
80
|
+
const base = { headless: opts.headless, ...(executablePath ? { executablePath } : { channel: 'chrome' }) };
|
|
81
|
+
try {
|
|
82
|
+
if (opts.useProfile) {
|
|
83
|
+
const dir = profileDir();
|
|
84
|
+
ensurePrivateDir(dir);
|
|
85
|
+
const context = await pw.chromium.launchPersistentContext(dir, { ...base, viewport: { width: 1280, height: 800 } });
|
|
86
|
+
return { context, close: () => context.close() };
|
|
87
|
+
}
|
|
88
|
+
const browser = await pw.chromium.launch(base);
|
|
89
|
+
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
90
|
+
return { context, close: () => browser.close() };
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
94
|
+
if (/ProcessSingleton|SingletonLock|profile.*in use|already running/i.test(msg)) {
|
|
95
|
+
throw new LocalBrowserError(`The MCP's Chrome profile (${profileDir()}) is already open in another Chrome window. Close it and retry.`, 'PROFILE_LOCKED');
|
|
96
|
+
}
|
|
97
|
+
if (/Executable doesn't exist|Failed to launch|chrome.*not found|distribution/i.test(msg)) {
|
|
98
|
+
throw new LocalBrowserError(`Could not start Chrome (${executablePath ?? "channel 'chrome'"}). Install Google Chrome or set DEBUGGAI_CHROME_PATH. Detail: ${msg.split('\n')[0]}`, 'CHROME_NOT_FOUND');
|
|
99
|
+
}
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Aggregate responses by origin+path (query stripped), capped. */
|
|
104
|
+
function summarize(entries) {
|
|
105
|
+
const map = new Map();
|
|
106
|
+
for (const e of entries) {
|
|
107
|
+
let key = e.url;
|
|
108
|
+
try {
|
|
109
|
+
const u = new URL(e.url);
|
|
110
|
+
key = u.origin + u.pathname;
|
|
111
|
+
}
|
|
112
|
+
catch { /* keep raw */ }
|
|
113
|
+
const row = map.get(key) ?? { url: key, count: 0, statuses: {}, totalBytes: 0 };
|
|
114
|
+
row.count++;
|
|
115
|
+
row.statuses[String(e.status)] = (row.statuses[String(e.status)] ?? 0) + 1;
|
|
116
|
+
row.totalBytes += e.bytes;
|
|
117
|
+
map.set(key, row);
|
|
118
|
+
}
|
|
119
|
+
return [...map.values()].sort((a, b) => b.count - a.count).slice(0, 50);
|
|
120
|
+
}
|
|
121
|
+
export async function localCapture(o) {
|
|
122
|
+
if (!localBrowserEnabled()) {
|
|
123
|
+
throw new LocalBrowserError('Local browser mode is off. Set DEBUGGAI_LOCAL_BROWSER=1 in the MCP server environment to allow it.', 'LOCAL_BROWSER_DISABLED');
|
|
124
|
+
}
|
|
125
|
+
const allowed = o.useProfile ? resolveAllowedDomains(o.allowedDomains) : [];
|
|
126
|
+
if (o.useProfile && allowed.length === 0) {
|
|
127
|
+
throw new LocalBrowserError('A profile session needs an allowlist of domains it may visit: pass allowedDomains (e.g. ["example.com"]) or have the server owner set DEBUGGAI_PROFILE_ALLOWED_DOMAINS.', 'PROFILE_ALLOWLIST_REQUIRED');
|
|
128
|
+
}
|
|
129
|
+
const timeout = Math.min(Math.max(o.timeoutMs ?? 10000, 1000), 30000);
|
|
130
|
+
const { context, close } = await launch({ headless: o.headless !== false, useProfile: !!o.useProfile });
|
|
131
|
+
const onAbort = () => { void close().catch(() => { }); };
|
|
132
|
+
o.signal?.addEventListener('abort', onAbort, { once: true });
|
|
133
|
+
try {
|
|
134
|
+
const page = context.pages()[0] ?? await context.newPage();
|
|
135
|
+
const blocked = [];
|
|
136
|
+
if (o.useProfile) {
|
|
137
|
+
const startHost = (() => { try {
|
|
138
|
+
return new URL(o.url).hostname;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return '';
|
|
142
|
+
} })();
|
|
143
|
+
if (!hostAllowed(startHost, allowed) && !isLoopbackHost(startHost)) {
|
|
144
|
+
throw new LocalBrowserError(`${startHost || o.url} is not in the allowlist for this profile session.`, 'DOMAIN_NOT_ALLOWED');
|
|
145
|
+
}
|
|
146
|
+
await installNavigationGuard(context, allowed, (h) => blocked.push(h));
|
|
147
|
+
}
|
|
148
|
+
const consoleErrors = [];
|
|
149
|
+
const net = [];
|
|
150
|
+
page.on('console', (m) => { if (m.type() === 'error' || m.type() === 'warning')
|
|
151
|
+
consoleErrors.push({ level: m.type(), text: m.text() }); });
|
|
152
|
+
page.on('pageerror', (e) => consoleErrors.push({ level: 'error', text: e.message }));
|
|
153
|
+
page.on('response', (r) => {
|
|
154
|
+
const len = Number(r.headers()['content-length'] ?? 0);
|
|
155
|
+
net.push({ url: r.url(), status: r.status(), bytes: Number.isFinite(len) ? len : 0 });
|
|
156
|
+
});
|
|
157
|
+
const t0 = Date.now();
|
|
158
|
+
let resp;
|
|
159
|
+
try {
|
|
160
|
+
resp = await page.goto(o.url, { waitUntil: o.waitForLoadState === 'load' ? 'load' : 'domcontentloaded', timeout });
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
if (blocked.length > 0) {
|
|
164
|
+
throw new LocalBrowserError(`Navigation to ${[...new Set(blocked)].join(', ')} was blocked: not in this profile session's allowlist.`, 'NAVIGATION_BLOCKED');
|
|
165
|
+
}
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
if (o.waitForSelector)
|
|
169
|
+
await page.waitForSelector(o.waitForSelector, { timeout: Math.max(1000, timeout - (Date.now() - t0)) });
|
|
170
|
+
else
|
|
171
|
+
await page.waitForLoadState('networkidle', { timeout: Math.min(2000, Math.max(500, timeout - (Date.now() - t0))) }).catch(() => { });
|
|
172
|
+
const result = {
|
|
173
|
+
url: o.url,
|
|
174
|
+
finalUrl: page.url() || null,
|
|
175
|
+
statusCode: resp ? resp.status() : null,
|
|
176
|
+
title: (await page.title().catch(() => '')) || null,
|
|
177
|
+
loadTimeMs: Date.now() - t0,
|
|
178
|
+
consoleErrors,
|
|
179
|
+
networkSummary: summarize(net),
|
|
180
|
+
};
|
|
181
|
+
if (blocked.length > 0)
|
|
182
|
+
result.blockedNavigations = [...new Set(blocked)];
|
|
183
|
+
if (o.loginWindowMs && o.useProfile && o.headless === false) {
|
|
184
|
+
const ms = Math.min(o.loginWindowMs, 600000);
|
|
185
|
+
await new Promise((resolve) => {
|
|
186
|
+
const t = setTimeout(resolve, ms);
|
|
187
|
+
context.once('close', () => { clearTimeout(t); resolve(); });
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (o.includeHtml)
|
|
191
|
+
result.html = await page.content();
|
|
192
|
+
if (o.captureScreenshot !== false) {
|
|
193
|
+
const buf = await page.screenshot({ type: 'png' });
|
|
194
|
+
result.screenshotBase64 = buf.toString('base64');
|
|
195
|
+
const dir = path.join(debuggHome(), 'captures');
|
|
196
|
+
ensurePrivateDir(dir);
|
|
197
|
+
const file = path.join(dir, `probe-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.png`);
|
|
198
|
+
fs.writeFileSync(file, buf, { mode: 0o600 });
|
|
199
|
+
result.screenshotPath = file;
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
o.signal?.removeEventListener('abort', onAbort);
|
|
205
|
+
await close().catch(() => { });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sealed-box credential delivery. The executor makes a fresh X25519 keypair per session
|
|
3
|
+
* and sends ONLY the public key at session creation. The server decrypts a stored
|
|
4
|
+
* credential with its own key (which never leaves the server), then re-encrypts it to
|
|
5
|
+
* this public key before it enters any queue or response. Only this process can open it.
|
|
6
|
+
*
|
|
7
|
+
* Envelope (JSON, all base64): { v:1, alg:'x25519-hkdf-sha256-aes256gcm', epk, iv, ct, tag }
|
|
8
|
+
* epk = sender's ephemeral X25519 public key, SPKI DER
|
|
9
|
+
* key = HKDF-SHA256(ikm = ECDH(epk, recipient private), salt = empty, info = 'debuggai-executor-credential-v1', 32 bytes)
|
|
10
|
+
* ct/tag = AES-256-GCM(key, iv(12 bytes), plaintext); AAD = the session id
|
|
11
|
+
*/
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
export const SEAL_ALG = 'x25519-hkdf-sha256-aes256gcm';
|
|
14
|
+
export const SEAL_INFO = 'debuggai-executor-credential-v1';
|
|
15
|
+
export function generateSessionKeys() {
|
|
16
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('x25519');
|
|
17
|
+
return {
|
|
18
|
+
publicKey: publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
|
|
19
|
+
open(env, sessionId) {
|
|
20
|
+
if (env.v !== 1 || env.alg !== SEAL_ALG)
|
|
21
|
+
throw new Error(`Unsupported credential envelope (${env.alg}).`);
|
|
22
|
+
const epk = crypto.createPublicKey({ key: Buffer.from(env.epk, 'base64'), type: 'spki', format: 'der' });
|
|
23
|
+
const shared = crypto.diffieHellman({ privateKey, publicKey: epk });
|
|
24
|
+
const key = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.alloc(0), SEAL_INFO, 32));
|
|
25
|
+
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(env.iv, 'base64'));
|
|
26
|
+
d.setAAD(Buffer.from(sessionId));
|
|
27
|
+
d.setAuthTag(Buffer.from(env.tag, 'base64'));
|
|
28
|
+
return Buffer.concat([d.update(Buffer.from(env.ct, 'base64')), d.final()]).toString('utf8');
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Reference sealer (server side lives in the platform repo; used here for tests and as the spec). */
|
|
33
|
+
export function sealTo(publicKeyB64, plaintext, sessionId) {
|
|
34
|
+
const recipient = crypto.createPublicKey({ key: Buffer.from(publicKeyB64, 'base64'), type: 'spki', format: 'der' });
|
|
35
|
+
const eph = crypto.generateKeyPairSync('x25519');
|
|
36
|
+
const shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: recipient });
|
|
37
|
+
const key = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.alloc(0), SEAL_INFO, 32));
|
|
38
|
+
const iv = crypto.randomBytes(12);
|
|
39
|
+
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
40
|
+
c.setAAD(Buffer.from(sessionId));
|
|
41
|
+
const ct = Buffer.concat([c.update(plaintext, 'utf8'), c.final()]);
|
|
42
|
+
return { v: 1, alg: SEAL_ALG, epk: eph.publicKey.export({ type: 'spki', format: 'der' }).toString('base64'), iv: iv.toString('base64'), ct: ct.toString('base64'), tag: c.getAuthTag().toString('base64') };
|
|
43
|
+
}
|