mercury-agent 0.4.7 → 0.4.9
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/container/Dockerfile.power +1 -1
- package/docs/auth/dashboard.md +28 -28
- package/docs/container-lifecycle.md +4 -4
- package/examples/extensions/voice-synth/index.ts +94 -94
- package/examples/extensions/voice-transcribe/scripts/transcribe.py +1 -1
- package/package.json +1 -1
- package/resources/templates/mercury.example.yaml +1 -1
- package/src/adapters/whatsapp.ts +635 -632
- package/src/agent/container-runner.ts +3 -1
- package/src/agent/model-capabilities.ts +231 -231
- package/src/bridges/discord.ts +178 -178
- package/src/bridges/slack.ts +179 -179
- package/src/bridges/teams.ts +162 -162
- package/src/bridges/telegram.ts +579 -579
- package/src/cli/mercury.ts +2551 -2536
- package/src/cli/whatsapp-auth.ts +263 -260
- package/src/config.ts +316 -316
- package/src/core/permissions.ts +196 -196
- package/src/core/router.ts +191 -191
- package/src/core/routes/chat.ts +175 -175
- package/src/core/routes/dashboard.ts +2491 -2491
- package/src/core/routes/messages.ts +37 -37
- package/src/core/routes/mutes.ts +95 -95
- package/src/core/routes/roles.ts +135 -135
- package/src/core/runtime.ts +1140 -1140
- package/src/core/task-scheduler.ts +139 -139
- package/src/extensions/catalog.ts +117 -117
- package/src/extensions/hooks.ts +161 -161
- package/src/extensions/installer.ts +306 -306
- package/src/extensions/loader.ts +271 -271
- package/src/extensions/permission-guard.ts +52 -52
- package/src/server.ts +391 -391
- package/src/storage/db.ts +1625 -1625
- package/src/storage/pi-auth.ts +95 -95
- package/src/tts/azure.ts +52 -52
- package/src/tts/synthesize.ts +133 -133
package/src/extensions/loader.ts
CHANGED
|
@@ -1,271 +1,271 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Extension discovery, loading, and registry.
|
|
3
|
-
*
|
|
4
|
-
* Scans `.mercury/extensions/` for directories with index.ts,
|
|
5
|
-
* loads them, validates, and builds a registry.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import fs from "node:fs";
|
|
9
|
-
import path from "node:path";
|
|
10
|
-
import { fileURLToPath } from "node:url";
|
|
11
|
-
import type { Logger } from "../logger.js";
|
|
12
|
-
import type { Db } from "../storage/db.js";
|
|
13
|
-
import { MercuryExtensionAPIImpl } from "./api.js";
|
|
14
|
-
import type { ConfigRegistry } from "./config-registry.js";
|
|
15
|
-
import { RESERVED_EXTENSION_NAMES } from "./reserved.js";
|
|
16
|
-
import type {
|
|
17
|
-
EventHandler,
|
|
18
|
-
ExtensionMeta,
|
|
19
|
-
JobDef,
|
|
20
|
-
MercuryEvents,
|
|
21
|
-
} from "./types.js";
|
|
22
|
-
|
|
23
|
-
/** Extension names must be alphanumeric + hyphens. */
|
|
24
|
-
const VALID_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
25
|
-
|
|
26
|
-
const __loaderDir = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
-
/** Root of the mercury-agent package (parent of `src/`). */
|
|
28
|
-
const MERCURY_PACKAGE_ROOT = path.resolve(__loaderDir, "../..");
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Ensure `<extensionsDir>/node_modules/mercury-agent` resolves to this framework
|
|
32
|
-
* package so extensions can `import ... from "mercury-agent"` (e.g. `mercury-agent/tts`).
|
|
33
|
-
*/
|
|
34
|
-
function ensurePackageLink(extensionsDir: string): void {
|
|
35
|
-
const expectedRoot = path.resolve(MERCURY_PACKAGE_ROOT);
|
|
36
|
-
const nmDir = path.join(extensionsDir, "node_modules");
|
|
37
|
-
const linkPath = path.join(nmDir, "mercury-agent");
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
if (fs.existsSync(linkPath) && fs.realpathSync(linkPath) === expectedRoot) {
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
} catch {
|
|
44
|
-
/* broken symlink or unreadable — replace */
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (fs.lstatSync(linkPath, { throwIfNoEntry: false }) !== undefined) {
|
|
48
|
-
fs.rmSync(linkPath, { recursive: true, force: true });
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
fs.mkdirSync(nmDir, { recursive: true });
|
|
52
|
-
const linkType = process.platform === "win32" ? "junction" : "dir";
|
|
53
|
-
fs.symlinkSync(expectedRoot, linkPath, linkType);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export class ExtensionRegistry {
|
|
57
|
-
private readonly extensions = new Map<string, ExtensionMeta>();
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Load all extensions from one or more directories.
|
|
61
|
-
* The first directory is the primary (user extensions),
|
|
62
|
-
* additional directories are for built-in extensions shipped with Mercury.
|
|
63
|
-
*
|
|
64
|
-
* `envOverride` replaces `process.env` for credential gate checks only —
|
|
65
|
-
* used by the pre-build endpoint to simulate the target container's env.
|
|
66
|
-
*/
|
|
67
|
-
async loadAll(
|
|
68
|
-
extensionsDir: string,
|
|
69
|
-
db: Db,
|
|
70
|
-
log: Logger,
|
|
71
|
-
configRegistry?: ConfigRegistry | null,
|
|
72
|
-
extraDirs: string[] = [],
|
|
73
|
-
envOverride?: Record<string, string>,
|
|
74
|
-
): Promise<void> {
|
|
75
|
-
const dirs = [extensionsDir, ...extraDirs];
|
|
76
|
-
for (const dir of dirs) {
|
|
77
|
-
await this.loadFromDir(
|
|
78
|
-
dir,
|
|
79
|
-
db,
|
|
80
|
-
log,
|
|
81
|
-
configRegistry ?? undefined,
|
|
82
|
-
envOverride,
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
private async loadFromDir(
|
|
88
|
-
extensionsDir: string,
|
|
89
|
-
db: Db,
|
|
90
|
-
log: Logger,
|
|
91
|
-
configRegistry?: ConfigRegistry,
|
|
92
|
-
envOverride?: Record<string, string>,
|
|
93
|
-
): Promise<void> {
|
|
94
|
-
if (!fs.existsSync(extensionsDir)) {
|
|
95
|
-
log.debug(`Extensions directory not found: ${extensionsDir}`);
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
ensurePackageLink(extensionsDir);
|
|
100
|
-
|
|
101
|
-
const entries = fs.readdirSync(extensionsDir, { withFileTypes: true });
|
|
102
|
-
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
103
|
-
|
|
104
|
-
for (const entry of entries) {
|
|
105
|
-
if (!entry.isDirectory()) continue;
|
|
106
|
-
|
|
107
|
-
const name = entry.name;
|
|
108
|
-
if (name === "node_modules") continue;
|
|
109
|
-
|
|
110
|
-
const extDir = path.join(extensionsDir, name);
|
|
111
|
-
|
|
112
|
-
// Validate name format
|
|
113
|
-
if (!VALID_NAME_RE.test(name)) {
|
|
114
|
-
log.warn(
|
|
115
|
-
`Skipping extension "${name}": invalid name (must be lowercase alphanumeric + hyphens)`,
|
|
116
|
-
);
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Check reserved names
|
|
121
|
-
if (RESERVED_EXTENSION_NAMES.has(name)) {
|
|
122
|
-
throw new Error(`Extension "${name}" conflicts with built-in command`);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Check for index.ts
|
|
126
|
-
const indexPath = path.join(extDir, "index.ts");
|
|
127
|
-
if (!fs.existsSync(indexPath)) {
|
|
128
|
-
log.warn(
|
|
129
|
-
`Skipping extension "${name}": no index.ts found in ${extDir}`,
|
|
130
|
-
);
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Skip if already loaded (user extensions take precedence over built-in)
|
|
135
|
-
if (this.extensions.has(name)) {
|
|
136
|
-
log.debug(`Skipping duplicate extension "${name}" (already loaded)`);
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
try {
|
|
141
|
-
const meta = await loadExtension(name, extDir, indexPath, db);
|
|
142
|
-
|
|
143
|
-
// Credential gate: skip connection extensions whose credential env var is unset.
|
|
144
|
-
// When envOverride is provided (pre-build simulation), check it instead of process.env.
|
|
145
|
-
const credVar = meta.connection?.credentialEnvVar;
|
|
146
|
-
const envToCheck = envOverride ?? process.env;
|
|
147
|
-
if (credVar && !envToCheck[credVar]) {
|
|
148
|
-
log.debug(
|
|
149
|
-
`Skipping extension "${name}": connection credential env var ${credVar} is not set`,
|
|
150
|
-
);
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Register extension config keys in the config registry
|
|
155
|
-
if (configRegistry) {
|
|
156
|
-
for (const [key, def] of meta.configs) {
|
|
157
|
-
configRegistry.register(name, key, def);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
this.extensions.set(name, meta);
|
|
161
|
-
log.info(`Loaded extension: ${name}`);
|
|
162
|
-
} catch (err) {
|
|
163
|
-
log.error(
|
|
164
|
-
`Failed to load extension "${name}": ${err instanceof Error ? err.message : String(err)}`,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Get an extension by name. */
|
|
171
|
-
get(name: string): ExtensionMeta | undefined {
|
|
172
|
-
return this.extensions.get(name);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** List all loaded extensions. */
|
|
176
|
-
list(): ExtensionMeta[] {
|
|
177
|
-
return [...this.extensions.values()];
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Get extensions that declare a CLI. */
|
|
181
|
-
getCliExtensions(): ExtensionMeta[] {
|
|
182
|
-
return this.list().filter((ext) => ext.clis.length > 0);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** Get all env var source names claimed by extensions (for passthrough filtering). */
|
|
186
|
-
getClaimedEnvSources(): Set<string> {
|
|
187
|
-
const sources = new Set<string>();
|
|
188
|
-
for (const ext of this.extensions.values()) {
|
|
189
|
-
for (const envDef of ext.envVars) {
|
|
190
|
-
sources.add(envDef.from);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return sources;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Get all hook handlers for a specific event, across all extensions. */
|
|
197
|
-
getHookHandlers<E extends keyof MercuryEvents>(event: E): EventHandler<E>[] {
|
|
198
|
-
const handlers: EventHandler<E>[] = [];
|
|
199
|
-
for (const ext of this.extensions.values()) {
|
|
200
|
-
const extHandlers = ext.hooks.get(event);
|
|
201
|
-
if (extHandlers) {
|
|
202
|
-
handlers.push(...(extHandlers as EventHandler<E>[]));
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return handlers;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** Get all jobs across all extensions. */
|
|
209
|
-
getJobs(): Array<{ extension: string; name: string; def: JobDef }> {
|
|
210
|
-
const jobs: Array<{ extension: string; name: string; def: JobDef }> = [];
|
|
211
|
-
for (const ext of this.extensions.values()) {
|
|
212
|
-
for (const [name, def] of ext.jobs) {
|
|
213
|
-
jobs.push({ extension: ext.name, name, def });
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
return jobs;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Number of loaded extensions. */
|
|
220
|
-
get size(): number {
|
|
221
|
-
return this.extensions.size;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Load a single extension: import its index.ts, run the setup function,
|
|
227
|
-
* and return the collected metadata.
|
|
228
|
-
*/
|
|
229
|
-
async function loadExtension(
|
|
230
|
-
name: string,
|
|
231
|
-
extDir: string,
|
|
232
|
-
indexPath: string,
|
|
233
|
-
db: Db,
|
|
234
|
-
): Promise<ExtensionMeta> {
|
|
235
|
-
const mod = await import(indexPath);
|
|
236
|
-
const setup = mod.default;
|
|
237
|
-
|
|
238
|
-
if (typeof setup !== "function") {
|
|
239
|
-
throw new Error(
|
|
240
|
-
`Extension "${name}": index.ts must export a default function`,
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const api = new MercuryExtensionAPIImpl(name, extDir, db);
|
|
245
|
-
await setup(api);
|
|
246
|
-
const meta = api.getMeta();
|
|
247
|
-
|
|
248
|
-
if (meta.connection) {
|
|
249
|
-
if (!meta.permission) {
|
|
250
|
-
throw new Error(
|
|
251
|
-
`Extension "${name}": connection() requires permission() — credentials must be gated`,
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
const { credentialEnvVar, statusCheck } = meta.connection;
|
|
255
|
-
if (!credentialEnvVar && !statusCheck) {
|
|
256
|
-
throw new Error(
|
|
257
|
-
`Extension "${name}": connection requires at least one of credentialEnvVar or statusCheck`,
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
if (credentialEnvVar) {
|
|
261
|
-
const declared = meta.envVars.some((e) => e.from === credentialEnvVar);
|
|
262
|
-
if (!declared) {
|
|
263
|
-
throw new Error(
|
|
264
|
-
`Extension "${name}": connection.credentialEnvVar "${credentialEnvVar}" is not declared via mercury.env()`,
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return meta;
|
|
271
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Extension discovery, loading, and registry.
|
|
3
|
+
*
|
|
4
|
+
* Scans `.mercury/extensions/` for directories with index.ts,
|
|
5
|
+
* loads them, validates, and builds a registry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import type { Logger } from "../logger.js";
|
|
12
|
+
import type { Db } from "../storage/db.js";
|
|
13
|
+
import { MercuryExtensionAPIImpl } from "./api.js";
|
|
14
|
+
import type { ConfigRegistry } from "./config-registry.js";
|
|
15
|
+
import { RESERVED_EXTENSION_NAMES } from "./reserved.js";
|
|
16
|
+
import type {
|
|
17
|
+
EventHandler,
|
|
18
|
+
ExtensionMeta,
|
|
19
|
+
JobDef,
|
|
20
|
+
MercuryEvents,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
|
|
23
|
+
/** Extension names must be alphanumeric + hyphens. */
|
|
24
|
+
const VALID_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
25
|
+
|
|
26
|
+
const __loaderDir = path.dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
/** Root of the mercury-agent package (parent of `src/`). */
|
|
28
|
+
const MERCURY_PACKAGE_ROOT = path.resolve(__loaderDir, "../..");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ensure `<extensionsDir>/node_modules/mercury-agent` resolves to this framework
|
|
32
|
+
* package so extensions can `import ... from "mercury-agent"` (e.g. `mercury-agent/tts`).
|
|
33
|
+
*/
|
|
34
|
+
function ensurePackageLink(extensionsDir: string): void {
|
|
35
|
+
const expectedRoot = path.resolve(MERCURY_PACKAGE_ROOT);
|
|
36
|
+
const nmDir = path.join(extensionsDir, "node_modules");
|
|
37
|
+
const linkPath = path.join(nmDir, "mercury-agent");
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
if (fs.existsSync(linkPath) && fs.realpathSync(linkPath) === expectedRoot) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
/* broken symlink or unreadable — replace */
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (fs.lstatSync(linkPath, { throwIfNoEntry: false }) !== undefined) {
|
|
48
|
+
fs.rmSync(linkPath, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
fs.mkdirSync(nmDir, { recursive: true });
|
|
52
|
+
const linkType = process.platform === "win32" ? "junction" : "dir";
|
|
53
|
+
fs.symlinkSync(expectedRoot, linkPath, linkType);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class ExtensionRegistry {
|
|
57
|
+
private readonly extensions = new Map<string, ExtensionMeta>();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Load all extensions from one or more directories.
|
|
61
|
+
* The first directory is the primary (user extensions),
|
|
62
|
+
* additional directories are for built-in extensions shipped with Mercury.
|
|
63
|
+
*
|
|
64
|
+
* `envOverride` replaces `process.env` for credential gate checks only —
|
|
65
|
+
* used by the pre-build endpoint to simulate the target container's env.
|
|
66
|
+
*/
|
|
67
|
+
async loadAll(
|
|
68
|
+
extensionsDir: string,
|
|
69
|
+
db: Db,
|
|
70
|
+
log: Logger,
|
|
71
|
+
configRegistry?: ConfigRegistry | null,
|
|
72
|
+
extraDirs: string[] = [],
|
|
73
|
+
envOverride?: Record<string, string>,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const dirs = [extensionsDir, ...extraDirs];
|
|
76
|
+
for (const dir of dirs) {
|
|
77
|
+
await this.loadFromDir(
|
|
78
|
+
dir,
|
|
79
|
+
db,
|
|
80
|
+
log,
|
|
81
|
+
configRegistry ?? undefined,
|
|
82
|
+
envOverride,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async loadFromDir(
|
|
88
|
+
extensionsDir: string,
|
|
89
|
+
db: Db,
|
|
90
|
+
log: Logger,
|
|
91
|
+
configRegistry?: ConfigRegistry,
|
|
92
|
+
envOverride?: Record<string, string>,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
if (!fs.existsSync(extensionsDir)) {
|
|
95
|
+
log.debug(`Extensions directory not found: ${extensionsDir}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
ensurePackageLink(extensionsDir);
|
|
100
|
+
|
|
101
|
+
const entries = fs.readdirSync(extensionsDir, { withFileTypes: true });
|
|
102
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
103
|
+
|
|
104
|
+
for (const entry of entries) {
|
|
105
|
+
if (!entry.isDirectory()) continue;
|
|
106
|
+
|
|
107
|
+
const name = entry.name;
|
|
108
|
+
if (name === "node_modules") continue;
|
|
109
|
+
|
|
110
|
+
const extDir = path.join(extensionsDir, name);
|
|
111
|
+
|
|
112
|
+
// Validate name format
|
|
113
|
+
if (!VALID_NAME_RE.test(name)) {
|
|
114
|
+
log.warn(
|
|
115
|
+
`Skipping extension "${name}": invalid name (must be lowercase alphanumeric + hyphens)`,
|
|
116
|
+
);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check reserved names
|
|
121
|
+
if (RESERVED_EXTENSION_NAMES.has(name)) {
|
|
122
|
+
throw new Error(`Extension "${name}" conflicts with built-in command`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check for index.ts
|
|
126
|
+
const indexPath = path.join(extDir, "index.ts");
|
|
127
|
+
if (!fs.existsSync(indexPath)) {
|
|
128
|
+
log.warn(
|
|
129
|
+
`Skipping extension "${name}": no index.ts found in ${extDir}`,
|
|
130
|
+
);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Skip if already loaded (user extensions take precedence over built-in)
|
|
135
|
+
if (this.extensions.has(name)) {
|
|
136
|
+
log.debug(`Skipping duplicate extension "${name}" (already loaded)`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const meta = await loadExtension(name, extDir, indexPath, db);
|
|
142
|
+
|
|
143
|
+
// Credential gate: skip connection extensions whose credential env var is unset.
|
|
144
|
+
// When envOverride is provided (pre-build simulation), check it instead of process.env.
|
|
145
|
+
const credVar = meta.connection?.credentialEnvVar;
|
|
146
|
+
const envToCheck = envOverride ?? process.env;
|
|
147
|
+
if (credVar && !envToCheck[credVar]) {
|
|
148
|
+
log.debug(
|
|
149
|
+
`Skipping extension "${name}": connection credential env var ${credVar} is not set`,
|
|
150
|
+
);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Register extension config keys in the config registry
|
|
155
|
+
if (configRegistry) {
|
|
156
|
+
for (const [key, def] of meta.configs) {
|
|
157
|
+
configRegistry.register(name, key, def);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
this.extensions.set(name, meta);
|
|
161
|
+
log.info(`Loaded extension: ${name}`);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
log.error(
|
|
164
|
+
`Failed to load extension "${name}": ${err instanceof Error ? err.message : String(err)}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Get an extension by name. */
|
|
171
|
+
get(name: string): ExtensionMeta | undefined {
|
|
172
|
+
return this.extensions.get(name);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** List all loaded extensions. */
|
|
176
|
+
list(): ExtensionMeta[] {
|
|
177
|
+
return [...this.extensions.values()];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Get extensions that declare a CLI. */
|
|
181
|
+
getCliExtensions(): ExtensionMeta[] {
|
|
182
|
+
return this.list().filter((ext) => ext.clis.length > 0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Get all env var source names claimed by extensions (for passthrough filtering). */
|
|
186
|
+
getClaimedEnvSources(): Set<string> {
|
|
187
|
+
const sources = new Set<string>();
|
|
188
|
+
for (const ext of this.extensions.values()) {
|
|
189
|
+
for (const envDef of ext.envVars) {
|
|
190
|
+
sources.add(envDef.from);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return sources;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Get all hook handlers for a specific event, across all extensions. */
|
|
197
|
+
getHookHandlers<E extends keyof MercuryEvents>(event: E): EventHandler<E>[] {
|
|
198
|
+
const handlers: EventHandler<E>[] = [];
|
|
199
|
+
for (const ext of this.extensions.values()) {
|
|
200
|
+
const extHandlers = ext.hooks.get(event);
|
|
201
|
+
if (extHandlers) {
|
|
202
|
+
handlers.push(...(extHandlers as EventHandler<E>[]));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return handlers;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Get all jobs across all extensions. */
|
|
209
|
+
getJobs(): Array<{ extension: string; name: string; def: JobDef }> {
|
|
210
|
+
const jobs: Array<{ extension: string; name: string; def: JobDef }> = [];
|
|
211
|
+
for (const ext of this.extensions.values()) {
|
|
212
|
+
for (const [name, def] of ext.jobs) {
|
|
213
|
+
jobs.push({ extension: ext.name, name, def });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return jobs;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Number of loaded extensions. */
|
|
220
|
+
get size(): number {
|
|
221
|
+
return this.extensions.size;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Load a single extension: import its index.ts, run the setup function,
|
|
227
|
+
* and return the collected metadata.
|
|
228
|
+
*/
|
|
229
|
+
async function loadExtension(
|
|
230
|
+
name: string,
|
|
231
|
+
extDir: string,
|
|
232
|
+
indexPath: string,
|
|
233
|
+
db: Db,
|
|
234
|
+
): Promise<ExtensionMeta> {
|
|
235
|
+
const mod = await import(indexPath);
|
|
236
|
+
const setup = mod.default;
|
|
237
|
+
|
|
238
|
+
if (typeof setup !== "function") {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Extension "${name}": index.ts must export a default function`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const api = new MercuryExtensionAPIImpl(name, extDir, db);
|
|
245
|
+
await setup(api);
|
|
246
|
+
const meta = api.getMeta();
|
|
247
|
+
|
|
248
|
+
if (meta.connection) {
|
|
249
|
+
if (!meta.permission) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Extension "${name}": connection() requires permission() — credentials must be gated`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
const { credentialEnvVar, statusCheck } = meta.connection;
|
|
255
|
+
if (!credentialEnvVar && !statusCheck) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Extension "${name}": connection requires at least one of credentialEnvVar or statusCheck`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (credentialEnvVar) {
|
|
261
|
+
const declared = meta.envVars.some((e) => e.from === credentialEnvVar);
|
|
262
|
+
if (!declared) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Extension "${name}": connection.credentialEnvVar "${credentialEnvVar}" is not declared via mercury.env()`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return meta;
|
|
271
|
+
}
|
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mercury Permission Guard — pi extension (runs inside container)
|
|
3
|
-
*
|
|
4
|
-
* Blocks direct bash invocation of extension CLIs that the caller
|
|
5
|
-
* doesn't have permission to use. This prevents bypassing Mercury's
|
|
6
|
-
* RBAC by calling CLIs directly instead of through `mrctl`.
|
|
7
|
-
*
|
|
8
|
-
* Reads MERCURY_DENIED_CLIS env var — comma-separated list of CLI
|
|
9
|
-
* names the current caller is NOT allowed to use.
|
|
10
|
-
*
|
|
11
|
-
* Set automatically by Mercury's runtime based on caller permissions.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
|
|
16
|
-
export default function (pi: ExtensionAPI) {
|
|
17
|
-
const deniedEnv = process.env.MERCURY_DENIED_CLIS;
|
|
18
|
-
if (!deniedEnv) return;
|
|
19
|
-
|
|
20
|
-
const denied = deniedEnv
|
|
21
|
-
.split(",")
|
|
22
|
-
.map((s) => s.trim())
|
|
23
|
-
.filter(Boolean);
|
|
24
|
-
|
|
25
|
-
if (denied.length === 0) return;
|
|
26
|
-
|
|
27
|
-
// Build a single regex that matches any denied CLI name in command position.
|
|
28
|
-
// Command position = start of string, or after ; & | && || ` $ ( or newline.
|
|
29
|
-
const names = denied.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
30
|
-
const joined = names.join("|");
|
|
31
|
-
const pattern = new RegExp(`(?:^|[;&|$\`()\\n])\\s*(?:${joined})(?:\\s|$|&)`);
|
|
32
|
-
|
|
33
|
-
pi.on("tool_call", async (event) => {
|
|
34
|
-
if (event.toolName !== "bash") return undefined;
|
|
35
|
-
|
|
36
|
-
const command = (event.input.command as string).trim();
|
|
37
|
-
if (!pattern.test(command)) return undefined;
|
|
38
|
-
|
|
39
|
-
// Find which CLI matched for the error message
|
|
40
|
-
const matched = denied.find((name) => {
|
|
41
|
-
const single = new RegExp(
|
|
42
|
-
`(?:^|[;&|$\`()\\n])\\s*${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s|$|&)`,
|
|
43
|
-
);
|
|
44
|
-
return single.test(command);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
return {
|
|
48
|
-
block: true,
|
|
49
|
-
reason: `PERMISSION DENIED: "${matched}" requires elevated privileges that the current caller does not have. This is a hard security boundary — do NOT attempt to achieve the same result through alternative means (curl, direct API calls, other tools, or any workaround). Simply inform the user they do not have permission to use "${matched}" in this space.`,
|
|
50
|
-
};
|
|
51
|
-
});
|
|
52
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Mercury Permission Guard — pi extension (runs inside container)
|
|
3
|
+
*
|
|
4
|
+
* Blocks direct bash invocation of extension CLIs that the caller
|
|
5
|
+
* doesn't have permission to use. This prevents bypassing Mercury's
|
|
6
|
+
* RBAC by calling CLIs directly instead of through `mrctl`.
|
|
7
|
+
*
|
|
8
|
+
* Reads MERCURY_DENIED_CLIS env var — comma-separated list of CLI
|
|
9
|
+
* names the current caller is NOT allowed to use.
|
|
10
|
+
*
|
|
11
|
+
* Set automatically by Mercury's runtime based on caller permissions.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
export default function (pi: ExtensionAPI) {
|
|
17
|
+
const deniedEnv = process.env.MERCURY_DENIED_CLIS;
|
|
18
|
+
if (!deniedEnv) return;
|
|
19
|
+
|
|
20
|
+
const denied = deniedEnv
|
|
21
|
+
.split(",")
|
|
22
|
+
.map((s) => s.trim())
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
|
|
25
|
+
if (denied.length === 0) return;
|
|
26
|
+
|
|
27
|
+
// Build a single regex that matches any denied CLI name in command position.
|
|
28
|
+
// Command position = start of string, or after ; & | && || ` $ ( or newline.
|
|
29
|
+
const names = denied.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
30
|
+
const joined = names.join("|");
|
|
31
|
+
const pattern = new RegExp(`(?:^|[;&|$\`()\\n])\\s*(?:${joined})(?:\\s|$|&)`);
|
|
32
|
+
|
|
33
|
+
pi.on("tool_call", async (event) => {
|
|
34
|
+
if (event.toolName !== "bash") return undefined;
|
|
35
|
+
|
|
36
|
+
const command = (event.input.command as string).trim();
|
|
37
|
+
if (!pattern.test(command)) return undefined;
|
|
38
|
+
|
|
39
|
+
// Find which CLI matched for the error message
|
|
40
|
+
const matched = denied.find((name) => {
|
|
41
|
+
const single = new RegExp(
|
|
42
|
+
`(?:^|[;&|$\`()\\n])\\s*${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s|$|&)`,
|
|
43
|
+
);
|
|
44
|
+
return single.test(command);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
block: true,
|
|
49
|
+
reason: `PERMISSION DENIED: "${matched}" requires elevated privileges that the current caller does not have. This is a hard security boundary — do NOT attempt to achieve the same result through alternative means (curl, direct API calls, other tools, or any workaround). Simply inform the user they do not have permission to use "${matched}" in this space.`,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
}
|