mcp-google-multi 5.4.1-alpha.2 → 6.0.0-alpha.2
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 +21 -16
- package/dist/accounts.d.ts +44 -5
- package/dist/accounts.js +267 -25
- package/dist/auth.d.ts +11 -4
- package/dist/auth.js +85 -78
- package/dist/client-config.d.ts +90 -0
- package/dist/client-config.js +282 -0
- package/dist/client.js +10 -4
- package/dist/config-file.d.ts +38 -0
- package/dist/config-file.js +117 -0
- package/dist/discover.js +57 -3
- package/dist/doctor.d.ts +86 -0
- package/dist/doctor.js +384 -0
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +52 -0
- package/dist/executor.d.ts +2 -0
- package/dist/executor.js +2 -1
- package/dist/fanout.js +8 -5
- package/dist/fs-atomic.d.ts +3 -0
- package/dist/fs-atomic.js +123 -0
- package/dist/http-config.d.ts +35 -0
- package/dist/http-config.js +113 -0
- package/dist/http-transport.d.ts +59 -0
- package/dist/http-transport.js +269 -0
- package/dist/identity.d.ts +24 -0
- package/dist/identity.js +19 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +199 -18
- package/dist/master-key.d.ts +53 -0
- package/dist/master-key.js +242 -0
- package/dist/mcp-token.d.ts +81 -0
- package/dist/mcp-token.js +176 -0
- package/dist/migrate-config.d.ts +7 -0
- package/dist/migrate-config.js +103 -0
- package/dist/migrate-tokens.js +4 -3
- package/dist/net-tuning.d.ts +8 -0
- package/dist/net-tuning.js +25 -0
- package/dist/oauth-as.d.ts +52 -0
- package/dist/oauth-as.js +457 -0
- package/dist/oauth-consent.d.ts +28 -0
- package/dist/oauth-consent.js +109 -0
- package/dist/open-url.d.ts +1 -0
- package/dist/open-url.js +30 -0
- package/dist/reauth-hint.d.ts +2 -0
- package/dist/reauth-hint.js +15 -0
- package/dist/registry-transfer.d.ts +41 -0
- package/dist/registry-transfer.js +219 -0
- package/dist/registry.d.ts +27 -1
- package/dist/registry.js +117 -6
- package/dist/scope-catalog.d.ts +19 -0
- package/dist/scope-catalog.js +182 -0
- package/dist/scope-observability.d.ts +78 -0
- package/dist/scope-observability.js +192 -0
- package/dist/services.js +1 -1
- package/dist/setup-prompt.d.ts +9 -0
- package/dist/setup-prompt.js +59 -0
- package/dist/ssrf-guard.d.ts +48 -0
- package/dist/ssrf-guard.js +242 -0
- package/dist/token-store.js +23 -125
- package/dist/tools/_errors.d.ts +8 -2
- package/dist/tools/_errors.js +97 -6
- package/dist/tools/account-wizard.d.ts +33 -0
- package/dist/tools/account-wizard.js +284 -0
- package/dist/tools/accounts-tool.d.ts +8 -5
- package/dist/tools/accounts-tool.js +38 -15
- package/dist/tools/admin.js +2 -2
- package/dist/tools/calendar.js +2 -2
- package/dist/tools/chat.js +2 -2
- package/dist/tools/contacts.d.ts +40 -0
- package/dist/tools/contacts.js +135 -2
- package/dist/tools/docs.d.ts +3 -0
- package/dist/tools/docs.js +60 -9
- package/dist/tools/drive.d.ts +3 -0
- package/dist/tools/drive.js +60 -8
- package/dist/tools/forms.js +4 -2
- package/dist/tools/generated/_shared.d.ts +1 -3
- package/dist/tools/generated/_shared.js +3 -3
- package/dist/tools/generated/admin.js +175 -132
- package/dist/tools/generated/appsmarket.js +6 -2
- package/dist/tools/generated/calendar.js +45 -27
- package/dist/tools/generated/chat.js +81 -47
- package/dist/tools/generated/classroom.js +131 -104
- package/dist/tools/generated/cloudidentity.js +73 -61
- package/dist/tools/generated/cloudsearch.js +59 -49
- package/dist/tools/generated/contacts.js +22 -14
- package/dist/tools/generated/drive.js +51 -35
- package/dist/tools/generated/driveactivity.js +5 -1
- package/dist/tools/generated/drivelabels.js +30 -25
- package/dist/tools/generated/forms.js +7 -3
- package/dist/tools/generated/gmail.js +73 -58
- package/dist/tools/generated/groupsmigration.js +5 -1
- package/dist/tools/generated/groupssettings.js +7 -3
- package/dist/tools/generated/keep.js +12 -7
- package/dist/tools/generated/licensing.js +11 -7
- package/dist/tools/generated/meet.js +20 -13
- package/dist/tools/generated/postmaster.js +8 -4
- package/dist/tools/generated/reseller.js +22 -17
- package/dist/tools/generated/script.js +26 -16
- package/dist/tools/generated/sheets.js +11 -7
- package/dist/tools/generated/tasks.js +6 -2
- package/dist/tools/generated/vault.js +38 -33
- package/dist/tools/generated/workspaceevents.js +12 -7
- package/dist/tools/gmail-mime.d.ts +38 -8
- package/dist/tools/gmail-mime.js +75 -71
- package/dist/tools/gmail.d.ts +31 -0
- package/dist/tools/gmail.js +483 -99
- package/dist/tools/google-api.js +7 -4
- package/dist/tools/meet.js +2 -2
- package/dist/tools/searchconsole.js +2 -2
- package/dist/tools/sheets.js +6 -2
- package/dist/tools/slides.js +4 -2
- package/dist/tools/tasks.js +2 -2
- package/dist/types.d.ts +1 -1
- package/dist/write-control.d.ts +24 -1
- package/dist/write-control.js +26 -1
- package/package.json +26 -7
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { atomicWriteFileSync, withFileLock } from './fs-atomic.js';
|
|
6
|
+
export const CONFIG_VERSION = 1;
|
|
7
|
+
export function configDir(env = process.env) {
|
|
8
|
+
return path.join(env.XDG_CONFIG_HOME || path.join(homedir(), '.config'), 'mcp-google-multi');
|
|
9
|
+
}
|
|
10
|
+
export function configFilePath(env = process.env) {
|
|
11
|
+
return path.join(configDir(env), 'config.json');
|
|
12
|
+
}
|
|
13
|
+
// Alias charset is load-bearing security, not cosmetics: the alias becomes a
|
|
14
|
+
// tokenDir path segment (encPath), so this guard blocks path traversal.
|
|
15
|
+
export const ALIAS_RE = /^[a-zA-Z0-9_-]+$/;
|
|
16
|
+
const accountEntrySchema = z.strictObject({
|
|
17
|
+
email: z.string().min(1),
|
|
18
|
+
scopeProfile: z.string().optional(),
|
|
19
|
+
admin: z.boolean().optional(),
|
|
20
|
+
});
|
|
21
|
+
// .strict() everywhere: any unknown or secret-shaped key (clientSecret,
|
|
22
|
+
// masterKey, jwtKey, clientId) fails validation, structurally enforcing
|
|
23
|
+
// secrets-env-only. scopeProfiles/defaultAccount/discovery/toolsets are part
|
|
24
|
+
// of the frozen envelope; their consumers land in later slices.
|
|
25
|
+
const RESERVED_ALIASES = ['__proto__', 'constructor', 'prototype'];
|
|
26
|
+
const configSchema = z.strictObject({
|
|
27
|
+
version: z.number().int(),
|
|
28
|
+
accounts: z.record(z.string().regex(ALIAS_RE), accountEntrySchema).optional(),
|
|
29
|
+
scopeProfiles: z
|
|
30
|
+
.record(z.string().regex(ALIAS_RE), z.strictObject({
|
|
31
|
+
bundles: z.array(z.string()),
|
|
32
|
+
admin: z.boolean().optional(),
|
|
33
|
+
includesBase: z.boolean().optional(),
|
|
34
|
+
}))
|
|
35
|
+
.optional(),
|
|
36
|
+
defaultAccount: z.string().optional(),
|
|
37
|
+
discovery: z.enum(['lazy', 'curated', 'eager']).optional(),
|
|
38
|
+
toolsets: z.string().optional(),
|
|
39
|
+
});
|
|
40
|
+
export function failStartup(slug, message) {
|
|
41
|
+
process.stderr.write(`${slug}: ${message}\n`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
export class ConfigFileError extends Error {
|
|
45
|
+
slug;
|
|
46
|
+
constructor(slug, message) {
|
|
47
|
+
super(`${slug}: ${message}`);
|
|
48
|
+
this.slug = slug;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Load + validate config.json. Returns null when the file does not exist.
|
|
53
|
+
* onInvalid 'exit' is for boot/CLI only; runtime reload paths use 'throw' so a
|
|
54
|
+
* mid-edit or corrupt file can NEVER kill a running server (and so lock
|
|
55
|
+
* finally-cleanup still runs).
|
|
56
|
+
*/
|
|
57
|
+
export function loadConfigFile(filePath = configFilePath(), onInvalid = 'exit') {
|
|
58
|
+
let raw;
|
|
59
|
+
try {
|
|
60
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
if (e.code === 'ENOENT')
|
|
64
|
+
return null;
|
|
65
|
+
throw e;
|
|
66
|
+
}
|
|
67
|
+
function fail(slug, message) {
|
|
68
|
+
if (onInvalid === 'throw')
|
|
69
|
+
throw new ConfigFileError(slug, message);
|
|
70
|
+
failStartup(slug, message);
|
|
71
|
+
}
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(raw);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
fail('E_CONFIG_INVALID', `${filePath} is not valid JSON.`);
|
|
78
|
+
}
|
|
79
|
+
const version = parsed?.version;
|
|
80
|
+
if (typeof version === 'number' && version > CONFIG_VERSION) {
|
|
81
|
+
fail('E_CONFIG_VERSION_UNSUPPORTED', `${filePath} has version ${version}; this build understands up to ${CONFIG_VERSION}. Upgrade mcp-google-multi.`);
|
|
82
|
+
}
|
|
83
|
+
const result = configSchema.safeParse(parsed);
|
|
84
|
+
if (!result.success) {
|
|
85
|
+
const issue = result.error.issues[0];
|
|
86
|
+
fail('E_CONFIG_INVALID', `${filePath}: ${issue.path.join('.') || '(root)'}: ${issue.message}. Secrets never belong in config.json.`);
|
|
87
|
+
}
|
|
88
|
+
// Post-schema because zod's key regex passes these; a __proto__ record key
|
|
89
|
+
// is a prototype-pollution foothold, and Object.prototype member names break
|
|
90
|
+
// plain-object lookups downstream.
|
|
91
|
+
const data = result.data;
|
|
92
|
+
for (const [section, keys] of [
|
|
93
|
+
['accounts', Object.keys(data.accounts ?? {})],
|
|
94
|
+
['scopeProfiles', Object.keys(data.scopeProfiles ?? {})],
|
|
95
|
+
]) {
|
|
96
|
+
const reserved = keys.find((k) => RESERVED_ALIASES.includes(k));
|
|
97
|
+
if (reserved) {
|
|
98
|
+
fail('E_CONFIG_INVALID', `${filePath}: ${section}: reserved name "${reserved}".`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return data;
|
|
102
|
+
}
|
|
103
|
+
export function writeConfigFile(config, filePath = configFilePath()) {
|
|
104
|
+
atomicWriteFileSync(filePath, `${JSON.stringify(config, null, 2)}\n`, 0o600);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Read-latest-under-lock mutation: acquire the lock, re-read the current file,
|
|
108
|
+
* apply the change to that (never a stale in-memory copy), write atomically.
|
|
109
|
+
*/
|
|
110
|
+
export function mutateConfigFile(fn, filePath = configFilePath()) {
|
|
111
|
+
return withFileLock(filePath, () => {
|
|
112
|
+
const current = loadConfigFile(filePath, 'throw') ?? { version: CONFIG_VERSION };
|
|
113
|
+
const next = fn(structuredClone(current));
|
|
114
|
+
writeConfigFile(next, filePath);
|
|
115
|
+
return next;
|
|
116
|
+
}, 'E_CONFIG_LOCK_TIMEOUT (config.json)');
|
|
117
|
+
}
|
package/dist/discover.js
CHANGED
|
@@ -5,14 +5,68 @@ function opVocabulary(registry, service) {
|
|
|
5
5
|
return [...new Set(ops)].join(', ');
|
|
6
6
|
}
|
|
7
7
|
export function registerDiscoverTools(registry, policy) {
|
|
8
|
+
const registerMeta = registry.registerMeta;
|
|
9
|
+
// Agent-controllable runtime expansion (D3 refinement): reveal the whole
|
|
10
|
+
// curated quality layer for heavy Google work, collapse to reclaim the
|
|
11
|
+
// name/schema context budget after. stdio-only semantics — over stateless
|
|
12
|
+
// HTTP the mode is forced curated and these are no-ops.
|
|
13
|
+
registerMeta('discover_all', {
|
|
14
|
+
description: 'Reveal ALL curated Google tools at once (instead of per-service discovery). ' +
|
|
15
|
+
'Use when starting substantial Google work so the shaped, high-quality tools are ' +
|
|
16
|
+
'directly callable; prefer them over google_api_call. Pair with discover_reset when done.',
|
|
17
|
+
inputSchema: {},
|
|
18
|
+
_meta: { 'anthropic/alwaysLoad': true },
|
|
19
|
+
}, async () => {
|
|
20
|
+
const changed = registry.expand();
|
|
21
|
+
const counts = registry.visibleCount();
|
|
22
|
+
return {
|
|
23
|
+
content: [
|
|
24
|
+
{
|
|
25
|
+
type: 'text',
|
|
26
|
+
text: JSON.stringify({
|
|
27
|
+
expanded: changed,
|
|
28
|
+
visibleTools: counts.eager + counts.revealed,
|
|
29
|
+
note: changed
|
|
30
|
+
? 'Curated tools are now advertised. Generated long-tail tools still appear per-service via {service}_discover.'
|
|
31
|
+
: 'Surface was already expanded (or the configured mode already advertises curated tools).',
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
registerMeta('discover_reset', {
|
|
38
|
+
description: 'Collapse the tool surface back to the configured default (lean meta-tools-only under the ' +
|
|
39
|
+
'default lazy mode), reclaiming context budget after heavy Google work. All tools remain ' +
|
|
40
|
+
'callable by name after collapsing.',
|
|
41
|
+
inputSchema: {},
|
|
42
|
+
_meta: { 'anthropic/alwaysLoad': true },
|
|
43
|
+
}, async () => {
|
|
44
|
+
const changed = registry.collapse();
|
|
45
|
+
const counts = registry.visibleCount();
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
type: 'text',
|
|
50
|
+
text: JSON.stringify({
|
|
51
|
+
collapsed: changed,
|
|
52
|
+
visibleTools: counts.eager + counts.revealed,
|
|
53
|
+
note: 'Hidden tools stay callable by name (graceful dispatch); re-expand any time with discover_all.',
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
});
|
|
8
59
|
for (const service of registry.services()) {
|
|
9
|
-
|
|
10
|
-
description: `List the available ${service} operations.
|
|
11
|
-
|
|
60
|
+
registerMeta(`${service}_discover`, {
|
|
61
|
+
description: `List the available ${service} operations. ` +
|
|
62
|
+
(registry.mode === 'lazy'
|
|
63
|
+
? `Operational ${service} tools are hidden until discovered — call this first, then call the tool you need by name. `
|
|
64
|
+
: `Returns the ${service} catalog (and reveals any still-hidden ${service} tools). `) +
|
|
12
65
|
`Operations: ${opVocabulary(registry, service)}.`,
|
|
13
66
|
inputSchema: {
|
|
14
67
|
query: z.string().optional().describe('Keyword to filter the returned operations'),
|
|
15
68
|
},
|
|
69
|
+
_meta: { 'anthropic/alwaysLoad': true },
|
|
16
70
|
}, async ({ query }) => {
|
|
17
71
|
const operations = registry.catalog(service, query);
|
|
18
72
|
registry.reveal(service);
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ToolRegistry } from './registry.js';
|
|
2
|
+
import { getAccountSet } from './accounts.js';
|
|
3
|
+
import { type AccountHealth } from './tools/accounts-tool.js';
|
|
4
|
+
import { peekMasterKeyProvenance } from './master-key.js';
|
|
5
|
+
export type Verdict = 'ok' | 'warn' | 'fail' | 'unknown';
|
|
6
|
+
export interface DiagnosticSection {
|
|
7
|
+
id: number;
|
|
8
|
+
title: string;
|
|
9
|
+
verdict: Verdict;
|
|
10
|
+
lines: string[];
|
|
11
|
+
/** copy-pasteable remediation (gh-CLI style), attached only on warn/fail. */
|
|
12
|
+
hint?: string;
|
|
13
|
+
slug?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface DiagnosticsReport {
|
|
16
|
+
verdict: Verdict;
|
|
17
|
+
sections: DiagnosticSection[];
|
|
18
|
+
}
|
|
19
|
+
/** One API-enablement probe outcome for a single service (section 6). */
|
|
20
|
+
export interface ApiProbeResult {
|
|
21
|
+
service: string;
|
|
22
|
+
/** discovery API id used for the per-API console deep-link, e.g. "gmail". */
|
|
23
|
+
api: string;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
/** true only for accessNotConfigured / SERVICE_DISABLED (the actionable case). */
|
|
26
|
+
notEnabled?: boolean;
|
|
27
|
+
message?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface DiagnosticsDeps {
|
|
30
|
+
nodeVersion: string;
|
|
31
|
+
env: Record<string, string | undefined>;
|
|
32
|
+
cwd: string;
|
|
33
|
+
accountSet: () => ReturnType<typeof getAccountSet> | null;
|
|
34
|
+
accountHealth: (alias: string) => AccountHealth;
|
|
35
|
+
masterKeyProvenance: () => ReturnType<typeof peekMasterKeyProvenance>;
|
|
36
|
+
anyTokensExist: (aliases: string[]) => boolean;
|
|
37
|
+
fileExists: (p: string) => boolean;
|
|
38
|
+
/** Optional live section-6 probe; when absent the section reports `unknown`
|
|
39
|
+
* (spec: a section that cannot run is unknown, not FAIL). */
|
|
40
|
+
probeApi?: (alias: string) => Promise<ApiProbeResult[]>;
|
|
41
|
+
}
|
|
42
|
+
/** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
|
|
43
|
+
export declare function apiEnableLink(api: string): string;
|
|
44
|
+
/** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
|
|
45
|
+
export declare function overallVerdict(sections: DiagnosticSection[]): Verdict;
|
|
46
|
+
export declare function runDiagnostics(deps?: DiagnosticsDeps): Promise<DiagnosticsReport>;
|
|
47
|
+
/** doctor CLI (brew-doctor style): glyph-coded human report. */
|
|
48
|
+
export declare function renderDoctorText(report: DiagnosticsReport): string;
|
|
49
|
+
/** Redacted, paste-ready bug report (BR8): verdicts + provenance labels + token
|
|
50
|
+
* statuses + slugs; NO token values, secrets, keys, or full email addresses. */
|
|
51
|
+
export declare function renderReport(report: DiagnosticsReport, version: string): string;
|
|
52
|
+
/** Exit non-zero when any section FAILs; with strict, WARN counts too. */
|
|
53
|
+
export declare function exitCodeFor(report: DiagnosticsReport, strict: boolean): number;
|
|
54
|
+
/** Agent-callable structured health report (read-only). Mirrors `doctor`'s
|
|
55
|
+
* engine; NOT alwaysLoad (the agent asks for it when diagnosing). */
|
|
56
|
+
export declare function registerDiagnoseTool(registry: ToolRegistry): void;
|
|
57
|
+
export interface ResetOptions {
|
|
58
|
+
account?: string;
|
|
59
|
+
regenerateKey: boolean;
|
|
60
|
+
yes: boolean;
|
|
61
|
+
}
|
|
62
|
+
export interface ResetPlan {
|
|
63
|
+
wipeAliases: string[];
|
|
64
|
+
regenerateKey: boolean;
|
|
65
|
+
}
|
|
66
|
+
export type ResetPlanResult = {
|
|
67
|
+
ok: true;
|
|
68
|
+
plan: ResetPlan;
|
|
69
|
+
reauth: string[];
|
|
70
|
+
} | {
|
|
71
|
+
ok: false;
|
|
72
|
+
slug: string;
|
|
73
|
+
message: string;
|
|
74
|
+
};
|
|
75
|
+
/** Pure planner (unit-testable): decide which token files to wipe and whether
|
|
76
|
+
* key regeneration is safe. Refuses --regenerate-key while any account outside
|
|
77
|
+
* the wipe set still holds an encrypted token (a fresh key would brick it). */
|
|
78
|
+
export declare function planReset(opts: ResetOptions, state: {
|
|
79
|
+
aliases: string[];
|
|
80
|
+
tokenPresent: (a: string) => boolean;
|
|
81
|
+
}): ResetPlanResult;
|
|
82
|
+
/** CLI entry for `mcp-google-multi reset` (flags: --account, --regenerate-key,
|
|
83
|
+
* --yes). Confirmation-gated; config.json is always kept. */
|
|
84
|
+
export declare function runResetCli(argv: string[]): Promise<number>;
|
|
85
|
+
/** CLI entry for `mcp-google-multi doctor` (flags: --json, --strict, --report). */
|
|
86
|
+
export declare function runDoctorCli(argv: string[], version: string): Promise<number>;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as readline from 'node:readline';
|
|
4
|
+
import { getAccountSet } from './accounts.js';
|
|
5
|
+
import { deriveAccountHealth } from './tools/accounts-tool.js';
|
|
6
|
+
import { peekMasterKeyProvenance, deleteMasterKeyMaterial } from './master-key.js';
|
|
7
|
+
import { hasToken } from './token-store.js';
|
|
8
|
+
import { configDir } from './config-file.js';
|
|
9
|
+
const MIN_NODE_MAJOR = 22;
|
|
10
|
+
const DEFAULT_DEPS = {
|
|
11
|
+
nodeVersion: process.versions.node,
|
|
12
|
+
env: process.env,
|
|
13
|
+
cwd: process.cwd(),
|
|
14
|
+
accountSet: () => {
|
|
15
|
+
try {
|
|
16
|
+
return getAccountSet();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
accountHealth: (alias) => deriveAccountHealth(alias),
|
|
23
|
+
masterKeyProvenance: () => peekMasterKeyProvenance(),
|
|
24
|
+
anyTokensExist: (aliases) => aliases.some((a) => hasToken(a)),
|
|
25
|
+
fileExists: fs.existsSync,
|
|
26
|
+
};
|
|
27
|
+
/** Console deep-link to enable one API (section-6 hint, error taxonomy B10). */
|
|
28
|
+
export function apiEnableLink(api) {
|
|
29
|
+
return `https://console.cloud.google.com/apis/library/${api}.googleapis.com`;
|
|
30
|
+
}
|
|
31
|
+
function transportsFrom(env) {
|
|
32
|
+
return (env.MCP_TRANSPORT ?? 'stdio')
|
|
33
|
+
.split(',')
|
|
34
|
+
.map((s) => s.trim().toLowerCase())
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
}
|
|
37
|
+
const LEGACY_ENV_KEYS = ['GOOGLE_ACCOUNTS', 'GOOGLE_OPTIONAL_SCOPES', 'GOOGLE_ADMIN_ACCOUNTS'];
|
|
38
|
+
function sectionRuntime(deps) {
|
|
39
|
+
const major = Number.parseInt(deps.nodeVersion.split('.')[0] ?? '0', 10);
|
|
40
|
+
if (!Number.isFinite(major) || major < MIN_NODE_MAJOR) {
|
|
41
|
+
return {
|
|
42
|
+
id: 1,
|
|
43
|
+
title: 'Runtime',
|
|
44
|
+
verdict: 'fail',
|
|
45
|
+
slug: 'E_NODE_TOO_OLD',
|
|
46
|
+
lines: [`Node.js ${deps.nodeVersion} (requires >= ${MIN_NODE_MAJOR})`],
|
|
47
|
+
hint: `Upgrade to Node ${MIN_NODE_MAJOR} LTS or newer.`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { id: 1, title: 'Runtime', verdict: 'ok', lines: [`Node.js ${deps.nodeVersion} (>= ${MIN_NODE_MAJOR})`] };
|
|
51
|
+
}
|
|
52
|
+
function sectionConfig(deps, set) {
|
|
53
|
+
const lines = [];
|
|
54
|
+
let verdict = 'ok';
|
|
55
|
+
let hint;
|
|
56
|
+
let slug;
|
|
57
|
+
if (!set || set.aliases.length === 0) {
|
|
58
|
+
return {
|
|
59
|
+
id: 2,
|
|
60
|
+
title: 'Config',
|
|
61
|
+
verdict: 'fail',
|
|
62
|
+
slug: 'E_NO_ACCOUNTS_CONFIGURED',
|
|
63
|
+
lines: ['No accounts configured.'],
|
|
64
|
+
hint: 'Add an account: run `npx mcp-google-multi migrate-config` or set GOOGLE_ACCOUNTS.',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const cfgPath = path.join(configDir(), 'config.json');
|
|
68
|
+
lines.push(deps.fileExists(cfgPath) ? `config.json present (${set.aliases.length} account(s))` : `config.json absent — accounts sourced from env (${set.aliases.length})`);
|
|
69
|
+
const legacyEnv = LEGACY_ENV_KEYS.filter((k) => deps.env[k]);
|
|
70
|
+
if (legacyEnv.length > 0) {
|
|
71
|
+
verdict = 'warn';
|
|
72
|
+
slug = 'E_LEGACY_ENV';
|
|
73
|
+
lines.push(`Legacy env in effect: ${legacyEnv.join(', ')}`);
|
|
74
|
+
hint = 'Fold legacy env into config.json: `npx mcp-google-multi migrate-config`.';
|
|
75
|
+
}
|
|
76
|
+
// Legacy .env in CWD / package root shadows the ~/.config location.
|
|
77
|
+
for (const dir of [deps.cwd]) {
|
|
78
|
+
const envFile = path.join(dir, '.env');
|
|
79
|
+
if (deps.fileExists(envFile)) {
|
|
80
|
+
if (verdict === 'ok')
|
|
81
|
+
verdict = 'warn';
|
|
82
|
+
lines.push(`Legacy .env found at ${envFile}`);
|
|
83
|
+
const target = path.join(configDir(), '.env');
|
|
84
|
+
hint = (hint ? `${hint} ` : '') + `Move it: \`mv ${envFile} ${target}\`.`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { id: 2, title: 'Config', verdict, lines, ...(hint ? { hint } : {}), ...(slug ? { slug } : {}) };
|
|
88
|
+
}
|
|
89
|
+
function sectionKeys(deps, aliases) {
|
|
90
|
+
const provenance = deps.masterKeyProvenance();
|
|
91
|
+
const tokensExist = deps.anyTokensExist(aliases);
|
|
92
|
+
if (provenance === 'unprovisioned' && tokensExist) {
|
|
93
|
+
return {
|
|
94
|
+
id: 3,
|
|
95
|
+
title: 'Keys',
|
|
96
|
+
verdict: 'fail',
|
|
97
|
+
slug: 'E_MASTER_KEY_MISSING_TOKENS_EXIST',
|
|
98
|
+
lines: ['MASTER_KEY is unprovisioned but encrypted tokens exist — they cannot be decrypted.'],
|
|
99
|
+
hint: 'Restore the original MASTER_KEY, or `npx mcp-google-multi reset` and re-auth.',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const line = provenance === 'unprovisioned'
|
|
103
|
+
? 'MASTER_KEY: unprovisioned (generated on first use)'
|
|
104
|
+
: `MASTER_KEY provenance: ${provenance}`;
|
|
105
|
+
return { id: 3, title: 'Keys', verdict: 'ok', lines: [line] };
|
|
106
|
+
}
|
|
107
|
+
function sectionsTokensAndScopes(deps, aliases) {
|
|
108
|
+
const tokenLines = [];
|
|
109
|
+
const scopeLines = [];
|
|
110
|
+
let tokenVerdict = 'ok';
|
|
111
|
+
let scopeVerdict = 'ok';
|
|
112
|
+
let tokenHint;
|
|
113
|
+
let scopeHint;
|
|
114
|
+
let sawMissing = false;
|
|
115
|
+
for (const alias of aliases) {
|
|
116
|
+
const h = deps.accountHealth(alias);
|
|
117
|
+
const s = h.token.status;
|
|
118
|
+
tokenLines.push(`${alias} (${h.email}): ${s}${h.token.expiryDate ? ` — expires ${h.token.expiryDate}` : ''}`);
|
|
119
|
+
if (s === 'missing' || s === 'needs_reauth' || s === 'decrypt_error') {
|
|
120
|
+
tokenVerdict = 'fail';
|
|
121
|
+
if (s === 'missing')
|
|
122
|
+
sawMissing = true;
|
|
123
|
+
if (h.token.hint)
|
|
124
|
+
tokenHint = h.token.hint;
|
|
125
|
+
}
|
|
126
|
+
else if (s === 'expired_refreshable' && tokenVerdict === 'ok') {
|
|
127
|
+
// refreshes transparently on next use — not a failure.
|
|
128
|
+
tokenLines[tokenLines.length - 1] += ' (auto-refreshes on use)';
|
|
129
|
+
}
|
|
130
|
+
const r = h.scopes;
|
|
131
|
+
if (r.requestable.length > 0) {
|
|
132
|
+
if (scopeVerdict === 'ok')
|
|
133
|
+
scopeVerdict = 'warn';
|
|
134
|
+
scopeLines.push(`${alias}: ${r.callable.length} callable, ${r.requestable.length} requested-not-granted`);
|
|
135
|
+
scopeHint = `Re-auth to grant missing scopes: \`npx mcp-google-multi auth --account ${alias}\`.`;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
scopeLines.push(`${alias}: ${r.callable.length} callable, all profile scopes granted`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return [
|
|
142
|
+
{ id: 4, title: 'Tokens', verdict: tokenVerdict, lines: tokenLines, ...(tokenHint ? { hint: tokenHint } : {}), ...(tokenVerdict === 'fail' ? { slug: sawMissing ? 'E_AUTH_REQUIRED' : 'E_REAUTH_REQUIRED' } : {}) },
|
|
143
|
+
{ id: 5, title: 'Scopes', verdict: scopeVerdict, lines: scopeLines, ...(scopeHint ? { hint: scopeHint } : {}), ...(scopeVerdict === 'warn' ? { slug: 'E_SCOPE_NOT_GRANTED' } : {}) },
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
async function sectionApiEnablement(deps, aliases) {
|
|
147
|
+
if (!deps.probeApi) {
|
|
148
|
+
return { id: 6, title: 'API enablement', verdict: 'unknown', lines: ['Probe not run (no live account probe configured).'] };
|
|
149
|
+
}
|
|
150
|
+
// Probe on the first alias with a live token; can't probe without one.
|
|
151
|
+
const healthy = aliases.find((a) => {
|
|
152
|
+
const st = deps.accountHealth(a).token.status;
|
|
153
|
+
return st === 'ok' || st === 'expired_refreshable';
|
|
154
|
+
});
|
|
155
|
+
if (!healthy) {
|
|
156
|
+
return { id: 6, title: 'API enablement', verdict: 'unknown', lines: ['No authenticated account to probe with.'] };
|
|
157
|
+
}
|
|
158
|
+
let results;
|
|
159
|
+
try {
|
|
160
|
+
results = await deps.probeApi(healthy);
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
// Network / transient: WARN with the target, never crash the report.
|
|
164
|
+
return { id: 6, title: 'API enablement', verdict: 'warn', lines: [`Probe could not complete: ${e?.message ?? e}`] };
|
|
165
|
+
}
|
|
166
|
+
const disabled = results.filter((r) => r.notEnabled);
|
|
167
|
+
const lines = results.map((r) => `${r.service}: ${r.ok ? 'enabled' : r.notEnabled ? 'NOT ENABLED' : `unknown (${r.message ?? 'error'})`}`);
|
|
168
|
+
if (disabled.length > 0) {
|
|
169
|
+
return {
|
|
170
|
+
id: 6,
|
|
171
|
+
title: 'API enablement',
|
|
172
|
+
verdict: 'fail',
|
|
173
|
+
slug: 'E_API_NOT_ENABLED',
|
|
174
|
+
lines,
|
|
175
|
+
hint: disabled.map((r) => `Enable ${r.service}: ${apiEnableLink(r.api)}`).join('\n'),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return { id: 6, title: 'API enablement', verdict: 'ok', lines: lines.length ? lines : ['(probed account, all enabled)'] };
|
|
179
|
+
}
|
|
180
|
+
function sectionHttpDeferred(deps) {
|
|
181
|
+
if (!transportsFrom(deps.env).includes('http'))
|
|
182
|
+
return null;
|
|
183
|
+
return {
|
|
184
|
+
id: 7,
|
|
185
|
+
title: 'HTTP',
|
|
186
|
+
verdict: 'unknown',
|
|
187
|
+
lines: ['HTTP diagnostics (MCP_PUBLIC_URL canonicalization, MCP_OWNER_EMAILS, PRM/AS-metadata self-fetch) land with the OAuth authorization server (not yet built).'],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const RANK = { ok: 0, unknown: 0, warn: 1, fail: 2 };
|
|
191
|
+
/** Roll section verdicts to an overall verdict. `unknown` never worsens it. */
|
|
192
|
+
export function overallVerdict(sections) {
|
|
193
|
+
let worst = 'ok';
|
|
194
|
+
for (const s of sections) {
|
|
195
|
+
if (RANK[s.verdict] > RANK[worst])
|
|
196
|
+
worst = s.verdict;
|
|
197
|
+
}
|
|
198
|
+
return worst;
|
|
199
|
+
}
|
|
200
|
+
export async function runDiagnostics(deps = DEFAULT_DEPS) {
|
|
201
|
+
const sections = [];
|
|
202
|
+
sections.push(sectionRuntime(deps));
|
|
203
|
+
const set = deps.accountSet();
|
|
204
|
+
sections.push(sectionConfig(deps, set));
|
|
205
|
+
const aliases = set?.aliases ?? [];
|
|
206
|
+
sections.push(sectionKeys(deps, aliases));
|
|
207
|
+
if (aliases.length > 0) {
|
|
208
|
+
const [tokens, scopes] = sectionsTokensAndScopes(deps, aliases);
|
|
209
|
+
sections.push(tokens, scopes);
|
|
210
|
+
sections.push(await sectionApiEnablement(deps, aliases));
|
|
211
|
+
}
|
|
212
|
+
const http = sectionHttpDeferred(deps);
|
|
213
|
+
if (http)
|
|
214
|
+
sections.push(http);
|
|
215
|
+
return { verdict: overallVerdict(sections), sections };
|
|
216
|
+
}
|
|
217
|
+
// --- skins -----------------------------------------------------------------
|
|
218
|
+
const GLYPH = { ok: '✔', warn: '!', fail: '✖', unknown: '·' };
|
|
219
|
+
/** doctor CLI (brew-doctor style): glyph-coded human report. */
|
|
220
|
+
export function renderDoctorText(report) {
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const s of report.sections) {
|
|
223
|
+
out.push(`${GLYPH[s.verdict]} ${s.id}. ${s.title} [${s.verdict.toUpperCase()}]`);
|
|
224
|
+
for (const l of s.lines)
|
|
225
|
+
out.push(` ${l}`);
|
|
226
|
+
if (s.hint)
|
|
227
|
+
for (const hl of s.hint.split('\n'))
|
|
228
|
+
out.push(` → ${hl}`);
|
|
229
|
+
}
|
|
230
|
+
out.push('');
|
|
231
|
+
out.push(`Overall: ${report.verdict.toUpperCase()}`);
|
|
232
|
+
return out.join('\n');
|
|
233
|
+
}
|
|
234
|
+
/** Mask the local-part of an email so a report never carries a full address (BR8). */
|
|
235
|
+
function maskEmails(text) {
|
|
236
|
+
return text.replace(/([A-Za-z0-9._%+-])[A-Za-z0-9._%+-]*(@[A-Za-z0-9.-]+)/g, '$1***$2');
|
|
237
|
+
}
|
|
238
|
+
/** Redacted, paste-ready bug report (BR8): verdicts + provenance labels + token
|
|
239
|
+
* statuses + slugs; NO token values, secrets, keys, or full email addresses. */
|
|
240
|
+
export function renderReport(report, version) {
|
|
241
|
+
const out = [`mcp-google-multi doctor report (v${version})`, `overall: ${report.verdict}`, ''];
|
|
242
|
+
for (const s of report.sections) {
|
|
243
|
+
out.push(`[${s.id}] ${s.title}: ${s.verdict}${s.slug ? ` (${s.slug})` : ''}`);
|
|
244
|
+
for (const l of s.lines)
|
|
245
|
+
out.push(` ${maskEmails(l)}`);
|
|
246
|
+
}
|
|
247
|
+
return out.join('\n');
|
|
248
|
+
}
|
|
249
|
+
/** Exit non-zero when any section FAILs; with strict, WARN counts too. */
|
|
250
|
+
export function exitCodeFor(report, strict) {
|
|
251
|
+
if (report.verdict === 'fail')
|
|
252
|
+
return 1;
|
|
253
|
+
if (strict && report.verdict === 'warn')
|
|
254
|
+
return 1;
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
/** Agent-callable structured health report (read-only). Mirrors `doctor`'s
|
|
258
|
+
* engine; NOT alwaysLoad (the agent asks for it when diagnosing). */
|
|
259
|
+
export function registerDiagnoseTool(registry) {
|
|
260
|
+
registry.registerTool('diagnose', {
|
|
261
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
262
|
+
description: 'Health report for this server: runtime, config, keys, per-account token status, scope grants, and API enablement. Read-only; returns copy-pasteable fixes for anything wrong. Call this to diagnose auth/config failures.',
|
|
263
|
+
inputSchema: {},
|
|
264
|
+
}, async () => {
|
|
265
|
+
try {
|
|
266
|
+
const result = await runDiagnostics();
|
|
267
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'diagnose_failed', message: e?.message ?? String(e) }) }], isError: true };
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
/** Pure planner (unit-testable): decide which token files to wipe and whether
|
|
275
|
+
* key regeneration is safe. Refuses --regenerate-key while any account outside
|
|
276
|
+
* the wipe set still holds an encrypted token (a fresh key would brick it). */
|
|
277
|
+
export function planReset(opts, state) {
|
|
278
|
+
if (opts.account && !state.aliases.includes(opts.account)) {
|
|
279
|
+
return { ok: false, slug: 'E_VALIDATION', message: `Unknown account "${opts.account}". Known: ${state.aliases.join(', ') || '(none)'}.` };
|
|
280
|
+
}
|
|
281
|
+
const wipeAliases = opts.account ? [opts.account] : [...state.aliases];
|
|
282
|
+
if (opts.regenerateKey) {
|
|
283
|
+
const remaining = state.aliases.filter((a) => !wipeAliases.includes(a) && state.tokenPresent(a));
|
|
284
|
+
if (remaining.length > 0) {
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
slug: 'E_KEY_REGEN_BLOCKED',
|
|
288
|
+
message: `Refusing --regenerate-key: ${remaining.length} account(s) still hold encrypted tokens (${remaining.join(', ')}). Wipe all accounts (omit --account) or drop --regenerate-key.`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const reauth = wipeAliases.filter((a) => state.tokenPresent(a));
|
|
293
|
+
return { ok: true, plan: { wipeAliases, regenerateKey: opts.regenerateKey }, reauth };
|
|
294
|
+
}
|
|
295
|
+
function parseResetOptions(argv) {
|
|
296
|
+
const at = argv.indexOf('--account');
|
|
297
|
+
return {
|
|
298
|
+
account: at >= 0 ? argv[at + 1] : undefined,
|
|
299
|
+
regenerateKey: argv.includes('--regenerate-key'),
|
|
300
|
+
yes: argv.includes('--yes'),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
async function confirmTty(prompt) {
|
|
304
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
305
|
+
try {
|
|
306
|
+
const answer = await new Promise((resolve) => rl.question(prompt, resolve));
|
|
307
|
+
return answer.trim().toLowerCase() === 'yes';
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
rl.close();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** CLI entry for `mcp-google-multi reset` (flags: --account, --regenerate-key,
|
|
314
|
+
* --yes). Confirmation-gated; config.json is always kept. */
|
|
315
|
+
export async function runResetCli(argv) {
|
|
316
|
+
const opts = parseResetOptions(argv);
|
|
317
|
+
const set = (() => { try {
|
|
318
|
+
return getAccountSet();
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return null;
|
|
322
|
+
} })();
|
|
323
|
+
const aliases = set?.aliases ?? [];
|
|
324
|
+
const planned = planReset(opts, { aliases, tokenPresent: (a) => hasToken(a) });
|
|
325
|
+
if (!planned.ok) {
|
|
326
|
+
console.error(`${planned.slug}: ${planned.message}`);
|
|
327
|
+
return 1;
|
|
328
|
+
}
|
|
329
|
+
const { plan, reauth } = planned;
|
|
330
|
+
const summary = `About to wipe tokens for: ${plan.wipeAliases.join(', ') || '(no accounts)'}` +
|
|
331
|
+
(plan.regenerateKey ? ' AND regenerate the MASTER_KEY' : '') + '. config.json is kept.';
|
|
332
|
+
if (!opts.yes) {
|
|
333
|
+
if (!process.stdin.isTTY) {
|
|
334
|
+
console.error(`E_INPUT_REQUIRED: ${summary}\nRe-run with --yes to confirm (non-interactive).`);
|
|
335
|
+
return 1;
|
|
336
|
+
}
|
|
337
|
+
const ok = await confirmTty(`${summary}\nType 'yes' to confirm: `);
|
|
338
|
+
if (!ok) {
|
|
339
|
+
console.error('confirmation_declined: nothing was changed.');
|
|
340
|
+
return 1;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Execute: delete each alias's encrypted token file (config.json untouched).
|
|
344
|
+
let wiped = 0;
|
|
345
|
+
for (const alias of plan.wipeAliases) {
|
|
346
|
+
const encPath = set?.configs[alias]?.encPath;
|
|
347
|
+
if (!encPath)
|
|
348
|
+
continue;
|
|
349
|
+
try {
|
|
350
|
+
fs.unlinkSync(encPath);
|
|
351
|
+
wiped++;
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// already gone
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
console.log(`Wiped ${wiped} token file(s).`);
|
|
358
|
+
if (plan.regenerateKey) {
|
|
359
|
+
const res = deleteMasterKeyMaterial();
|
|
360
|
+
console.log(`MASTER_KEY material removed (file: ${res.file}, keychain: ${res.keychain}).`);
|
|
361
|
+
if (res.env)
|
|
362
|
+
console.log('Note: MASTER_KEY is still set in the environment; unset it to let a fresh key generate.');
|
|
363
|
+
}
|
|
364
|
+
if (reauth.length > 0) {
|
|
365
|
+
console.log('\nNext, re-authenticate each wiped account:');
|
|
366
|
+
for (const alias of reauth)
|
|
367
|
+
console.log(` npx mcp-google-multi auth --account ${alias}`);
|
|
368
|
+
}
|
|
369
|
+
return 0;
|
|
370
|
+
}
|
|
371
|
+
/** CLI entry for `mcp-google-multi doctor` (flags: --json, --strict, --report). */
|
|
372
|
+
export async function runDoctorCli(argv, version) {
|
|
373
|
+
const json = argv.includes('--json');
|
|
374
|
+
const strict = argv.includes('--strict');
|
|
375
|
+
const report = argv.includes('--report');
|
|
376
|
+
const result = await runDiagnostics();
|
|
377
|
+
if (json)
|
|
378
|
+
console.log(JSON.stringify(result, null, 2));
|
|
379
|
+
else if (report)
|
|
380
|
+
console.log(renderReport(result, version));
|
|
381
|
+
else
|
|
382
|
+
console.log(renderDoctorText(result));
|
|
383
|
+
return exitCodeFor(result, strict);
|
|
384
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface EnvLoadResult {
|
|
2
|
+
loaded: string[];
|
|
3
|
+
searched: string[];
|
|
4
|
+
}
|
|
5
|
+
/** Path overrides are for tests only; production callers pass nothing. */
|
|
6
|
+
export interface EnvLoadPaths {
|
|
7
|
+
cwd?: string;
|
|
8
|
+
packageRoot?: string;
|
|
9
|
+
configDir?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function loadEnvFiles(paths?: EnvLoadPaths): EnvLoadResult;
|