@hydraharness/harness-plugin-runtime 0.1.1-rc.5

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.
@@ -0,0 +1,1838 @@
1
+ /** Shared, immutable runtime for imported OpenAI/Codex and Claude plugin bundles. */
2
+ import { execFile } from 'node:child_process';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { copyFile, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { Service } from '@hydraharness/cordis';
8
+ import { withFileLock, writeFileAtomic } from '@hydraharness/harness-atomic-write';
9
+ import { resolveHydraHome } from '@hydraharness/harness-home-paths';
10
+ import { apply as applyCodexHooks, inject as codexHooksInject } from '@hydraharness/harness-hooks-codex';
11
+ import { parseCodexConfig } from '@hydraharness/harness-hooks-codex/config';
12
+ import { createUserMessage } from '@hydraharness/harness-llm';
13
+ import { apply as applyMcpClient, inject as mcpClientInject, publicToolName } from '@hydraharness/harness-mcp-client';
14
+ import { BUNDLED_SKILL_RANK, parseSkillDocument, } from '@hydraharness/harness-skill';
15
+ import { parse as parseToml } from 'smol-toml';
16
+ import { parse as parseYaml } from 'yaml';
17
+ /** Services required by the imported-plugin runtime. */
18
+ export const inject = ['skills', 'commands', 'tools'];
19
+ const REGISTRY_VERSION = 1;
20
+ const MAX_PACKAGE_BYTES = 64 * 1024 * 1024;
21
+ const MAX_FILE_BYTES = 8 * 1024 * 1024;
22
+ const MAX_FILES = 4_000;
23
+ const MAX_MANIFEST_BYTES = 256 * 1024;
24
+ const MAX_MARKETPLACE_BYTES = 1024 * 1024;
25
+ const GIT_TIMEOUT_MS = 30_000;
26
+ const PLUGIN_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/u;
27
+ const COMMAND_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
28
+ // oxlint-disable-next-line @stylistic/max-len
29
+ const VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
30
+ const GITHUB_SHORTHAND = /^([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)$/u;
31
+ const SCP_GIT_SOURCE = /^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[A-Za-z0-9._~/-]+$/u;
32
+ const AGENT_PLUGIN_SCHEMA_URI = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json';
33
+ const AGENT_PLUGIN_SCHEMA_PREFIX = 'https://agent-plugins.org/schemas/';
34
+ const PLUGIN_COMMAND_HINT = '[list|import <folder-or-git-source>|install <plugin[@marketplace]|folder-or-git-source>|marketplace add <folder-or-git-source>|marketplace list|marketplace remove <source>|info <name>|enable <name>|disable <name>|trust <name>|untrust <name>|remove <name>]';
35
+ /** Plugin root resolver and manifest parser. It never executes plugin files. */
36
+ export class PluginManifestLoader {
37
+ /**
38
+ * Validate and discover one bundle without executing it.
39
+ * @param root - Candidate plugin-root directory.
40
+ * @param identity - Source-qualified identity used to namespace MCP servers.
41
+ * @returns Parsed manifest and discovered component definitions.
42
+ */
43
+ async load(root, identity = 'plugin') {
44
+ const checkedRoot = await validateBundleTree(root);
45
+ const manifestLocation = await pluginManifestPath(checkedRoot);
46
+ const manifest = parseManifest(await readBoundedJson(manifestLocation.path, MAX_MANIFEST_BYTES, 'plugin manifest'), manifestLocation.format);
47
+ if (manifest.format === 'claude')
48
+ await rejectUnsupportedClaudeComponents(checkedRoot);
49
+ const agentMetadata = await discoverOpenAiMetadata(checkedRoot);
50
+ const skills = await discoverSkills(checkedRoot, manifest, agentMetadata);
51
+ const commands = await discoverCommands(checkedRoot);
52
+ const mcp = await discoverMcp(checkedRoot, manifest, identity);
53
+ const hooks = await discoverHooks(checkedRoot, manifest);
54
+ const apps = await discoverApps(checkedRoot, manifest);
55
+ return {
56
+ root: checkedRoot,
57
+ manifest,
58
+ skills,
59
+ commands,
60
+ mcp,
61
+ hooks,
62
+ ...hooks.length === 0 ? {} : { hookDigest: digest(hooks.map(hook => hook.raw)) },
63
+ ...apps === undefined ? {} : { apps },
64
+ ...agentMetadata === undefined ? {} : { agentMetadata },
65
+ };
66
+ }
67
+ }
68
+ /** Durable registry and immutable bundle store beneath one Hydra home directory. */
69
+ export class PluginStore {
70
+ home;
71
+ /** Immutable versioned plugin-bundle root. */
72
+ cacheRoot;
73
+ /** Persistent writable plugin-data root. */
74
+ dataRoot;
75
+ /** Durable source-qualified plugin registry path. */
76
+ registryPath;
77
+ manifests = new PluginManifestLoader();
78
+ constructor(home = resolveHydraHome()) {
79
+ this.home = home;
80
+ this.cacheRoot = join(home, 'plugins', 'cache');
81
+ this.dataRoot = join(home, 'plugins', 'data');
82
+ this.registryPath = join(home, 'plugins', 'registry.json');
83
+ }
84
+ /**
85
+ * List every stored entry by source-qualified identity.
86
+ * @returns Every stored entry by source-qualified identity.
87
+ */
88
+ async list() {
89
+ return new Map(Object.entries((await this.readRegistry()).plugins));
90
+ }
91
+ /**
92
+ * Read one stored plugin entry.
93
+ * @param identity - Source-qualified plugin identity.
94
+ * @returns Stored entry, or undefined when absent.
95
+ */
96
+ async get(identity) {
97
+ return (await this.readRegistry()).plugins[identity];
98
+ }
99
+ /**
100
+ * Validate, stage, and activate one imported source.
101
+ * @param source - Local, Git, or marketplace source.
102
+ * @returns Source-qualified identity of the activated plugin.
103
+ */
104
+ async install(source) {
105
+ const material = await materializeSource(source);
106
+ try {
107
+ const preliminary = await this.manifests.load(material.root);
108
+ const identity = pluginIdentity(preliminary.manifest.name, material.source.sourceId);
109
+ // The source is treated as untrusted twice: once before copying and again
110
+ // after staging, so a local source cannot race a checked installation.
111
+ await this.ensureRegistryDirectory();
112
+ return await withFileLock(this.registryPath, async () => {
113
+ const stageParent = join(this.cacheRoot, material.source.sourceId, preliminary.manifest.name);
114
+ const stage = join(stageParent, `.staging-${randomUUID()}`);
115
+ const target = join(stageParent, preliminary.manifest.version);
116
+ try {
117
+ await copyTree(material.root, stage);
118
+ const staged = await this.manifests.load(stage, identity);
119
+ if (staged.manifest.name !== preliminary.manifest.name || staged.manifest.version !== preliminary.manifest.version) {
120
+ throw new Error('plugin runtime: plugin manifest changed while staging');
121
+ }
122
+ const registry = await this.readRegistry();
123
+ const duplicate = Object.entries(registry.plugins)
124
+ .find(([candidateIdentity, candidate]) => candidateIdentity !== identity && candidate.name === staged.manifest.name);
125
+ if (duplicate !== undefined) {
126
+ throw new Error(`plugin runtime: ${staged.manifest.name} is already installed from another source as ${duplicate[0]}; remove it before importing this source`);
127
+ }
128
+ await mkdir(stageParent, { recursive: true, mode: 0o700 });
129
+ if (!await exists(target))
130
+ await rename(stage, target);
131
+ const previous = registry.plugins[identity];
132
+ registry.plugins[identity] = {
133
+ name: staged.manifest.name,
134
+ source: material.source,
135
+ activeVersion: staged.manifest.version,
136
+ versions: {
137
+ ...previous?.versions,
138
+ [staged.manifest.version]: { installedAt: new Date().toISOString() },
139
+ },
140
+ enabled: previous?.enabled ?? false,
141
+ ...previous?.hookTrustDigest === undefined ? {} : { hookTrustDigest: previous.hookTrustDigest },
142
+ mcp: initializeMcpState(staged.mcp, previous?.mcp),
143
+ };
144
+ await this.writeRegistry(registry);
145
+ await mkdir(join(this.dataRoot, material.source.sourceId, staged.manifest.name), {
146
+ recursive: true, mode: 0o700,
147
+ });
148
+ return identity;
149
+ }
150
+ finally {
151
+ await rm(stage, { recursive: true, force: true });
152
+ }
153
+ }, { waitMs: GIT_TIMEOUT_MS });
154
+ }
155
+ finally {
156
+ await material.dispose();
157
+ }
158
+ }
159
+ /**
160
+ * Mutate one durable entry while holding the registry lock.
161
+ * @param identity - Source-qualified plugin identity.
162
+ * @param mutation - In-place mutation applied before atomic persistence.
163
+ * @returns After the registry is persisted.
164
+ */
165
+ async update(identity, mutation) {
166
+ await this.ensureRegistryDirectory();
167
+ await withFileLock(this.registryPath, async () => {
168
+ const registry = await this.readRegistry();
169
+ const entry = registry.plugins[identity];
170
+ if (entry === undefined)
171
+ throw new Error(`plugin runtime: imported plugin ${identity} is not installed`);
172
+ mutation(entry);
173
+ await this.writeRegistry(registry);
174
+ });
175
+ }
176
+ /**
177
+ * Remove one registry entry and its owned bundle and data directories.
178
+ * @param identity - Source-qualified plugin identity.
179
+ * @returns Removed entry, or undefined when absent.
180
+ */
181
+ async remove(identity) {
182
+ await this.ensureRegistryDirectory();
183
+ return await withFileLock(this.registryPath, async () => {
184
+ const registry = await this.readRegistry();
185
+ const entry = registry.plugins[identity];
186
+ if (entry === undefined)
187
+ return undefined;
188
+ const { [identity]: _removed, ...remainingPlugins } = registry.plugins;
189
+ registry.plugins = remainingPlugins;
190
+ await this.writeRegistry(registry);
191
+ const cache = join(this.cacheRoot, entry.source.sourceId, entry.name);
192
+ const data = join(this.dataRoot, entry.source.sourceId, entry.name);
193
+ assertStoreDescendant(this.cacheRoot, cache);
194
+ assertStoreDescendant(this.dataRoot, data);
195
+ await rm(cache, { recursive: true, force: true });
196
+ await rm(data, { recursive: true, force: true });
197
+ return entry;
198
+ });
199
+ }
200
+ /**
201
+ * Resolve the active immutable bundle path.
202
+ * @param entry - Stored plugin entry.
203
+ * @returns Active version directory.
204
+ */
205
+ bundlePath(entry) {
206
+ return join(this.cacheRoot, entry.source.sourceId, entry.name, entry.activeVersion);
207
+ }
208
+ /**
209
+ * Resolve the persistent writable data path.
210
+ * @param entry - Stored plugin entry.
211
+ * @returns Plugin data directory.
212
+ */
213
+ dataPath(entry) {
214
+ return join(this.dataRoot, entry.source.sourceId, entry.name);
215
+ }
216
+ /**
217
+ * Load one active stored bundle.
218
+ * @param entry - Stored plugin entry.
219
+ * @param identity - Source-qualified plugin identity.
220
+ * @returns Parsed active bundle.
221
+ */
222
+ async load(entry, identity) {
223
+ return await this.manifests.load(this.bundlePath(entry), identity);
224
+ }
225
+ async readRegistry() {
226
+ try {
227
+ const raw = JSON.parse(await readFile(this.registryPath, 'utf8'));
228
+ if (!isRecord(raw) || raw.version !== REGISTRY_VERSION || !isRecord(raw.plugins)) {
229
+ throw new Error('plugin runtime: registry has an unsupported format');
230
+ }
231
+ return raw;
232
+ }
233
+ catch (error) {
234
+ if (isMissing(error))
235
+ return { version: REGISTRY_VERSION, plugins: {} };
236
+ throw error;
237
+ }
238
+ }
239
+ async writeRegistry(registry) {
240
+ await writeFileAtomic(this.registryPath, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600, dirMode: 0o700 });
241
+ }
242
+ async ensureRegistryDirectory() {
243
+ await mkdir(dirname(this.registryPath), { recursive: true, mode: 0o700 });
244
+ }
245
+ }
246
+ /** Lightweight views over the durable records; the runtime owns live components. */
247
+ export class PluginRegistry {
248
+ store;
249
+ constructor(store) {
250
+ this.store = store;
251
+ }
252
+ /**
253
+ * List every stored entry by source-qualified identity.
254
+ * @returns Every stored entry by source-qualified identity.
255
+ */
256
+ list() { return this.store.list(); }
257
+ /**
258
+ * Read one stored plugin entry.
259
+ * @param identity - Source-qualified plugin identity.
260
+ * @returns Stored entry, or undefined when absent.
261
+ */
262
+ get(identity) { return this.store.get(identity); }
263
+ }
264
+ /** Explicit names for the runtime's independent trust and component registries. */
265
+ export class PluginTrustStore {
266
+ store;
267
+ constructor(store) {
268
+ this.store = store;
269
+ }
270
+ /**
271
+ * Record trust for one exact hook definition digest.
272
+ * @param identity - Source-qualified plugin identity.
273
+ * @param definitionDigest - Current canonical hook-definition digest.
274
+ * @returns After trust persistence completes.
275
+ */
276
+ async trust(identity, definitionDigest) {
277
+ await this.store.update(identity, (entry) => { entry.hookTrustDigest = definitionDigest; });
278
+ }
279
+ /**
280
+ * Remove recorded hook trust.
281
+ * @param identity - Source-qualified plugin identity.
282
+ * @returns After trust removal persists.
283
+ */
284
+ async untrust(identity) {
285
+ await this.store.update(identity, (entry) => { delete entry.hookTrustDigest; });
286
+ }
287
+ }
288
+ /** Public lifecycle facade over the shared imported-plugin runtime. */
289
+ export class PluginLifecycleManager {
290
+ runtime;
291
+ constructor(runtime) {
292
+ this.runtime = runtime;
293
+ }
294
+ /**
295
+ * Enable and load one plugin.
296
+ * @param identity - Source-qualified plugin identity.
297
+ * @returns Refreshed installed-plugin projection.
298
+ */
299
+ enable(identity) { return this.runtime.enable(identity); }
300
+ /**
301
+ * Disable and unload one plugin.
302
+ * @param identity - Source-qualified plugin identity.
303
+ * @returns Refreshed installed-plugin projection.
304
+ */
305
+ disable(identity) { return this.runtime.disable(identity); }
306
+ /**
307
+ * Unload one plugin's live components.
308
+ * @param identity - Source-qualified plugin identity.
309
+ * @returns After owned components quiesce.
310
+ */
311
+ unload(identity) { return this.runtime.unload(identity); }
312
+ }
313
+ /** Shared `@hydraharness/harness-base` service for every imported bundle, not a Web-only facility. */
314
+ export class ImportedPluginRuntime extends Service {
315
+ static inject = inject;
316
+ /** Component effects belong to the Host service, independent of a method caller's context. */
317
+ owner;
318
+ store;
319
+ /** Read-only facade over durable imported-plugin records. */
320
+ registry;
321
+ /** Hook-definition trust record manager. */
322
+ trust;
323
+ /** Enablement and teardown facade. */
324
+ lifecycle;
325
+ live = new Map();
326
+ initialEnabled = new Map();
327
+ startup = new Map();
328
+ mutationTail = Promise.resolve();
329
+ constructor(ctx, config = {}) {
330
+ super(ctx, 'importedPlugins');
331
+ this.owner = ctx;
332
+ this.store = new PluginStore(resolveHydraHome(config.hydraHome));
333
+ this.registry = new PluginRegistry(this.store);
334
+ this.trust = new PluginTrustStore(this.store);
335
+ this.lifecycle = new PluginLifecycleManager(this);
336
+ }
337
+ async [Service.init]() {
338
+ this.ctx.effect(() => () => this.dispose(), 'plugin-runtime.dispose');
339
+ this.ctx.on('tools/pre-execute', (exec, next) => this.approveMcpTool(exec, next));
340
+ this.ctx.commands.register({
341
+ name: 'plugin',
342
+ description: 'Manage imported OpenAI/Codex-compatible plugin bundles',
343
+ input: { hint: PLUGIN_COMMAND_HINT },
344
+ handler: async ({ rawInput }) => this.command(rawInput),
345
+ });
346
+ const entries = await this.store.list();
347
+ for (const [identity, entry] of entries) {
348
+ this.initialEnabled.set(identity, entry.enabled);
349
+ if (entry.enabled)
350
+ await this.load(identity, entry);
351
+ }
352
+ }
353
+ /**
354
+ * Return every imported plugin's current source-qualified runtime view.
355
+ * @returns Current installed-plugin projection.
356
+ */
357
+ async list() {
358
+ const entries = await this.store.list();
359
+ return { plugins: await Promise.all([...entries].sort(([left], [right]) => left.localeCompare(right))
360
+ .map(async ([identity, entry]) => await this.view(identity, entry))) };
361
+ }
362
+ /**
363
+ * Return one installed plugin view.
364
+ * @param identityOrName - Source-qualified identity or an unambiguous plugin name.
365
+ * @returns Current installed-plugin projection.
366
+ */
367
+ async info(identityOrName) {
368
+ const [identity, entry] = await this.resolve(identityOrName);
369
+ return await this.view(identity, entry);
370
+ }
371
+ /**
372
+ * Stage one local, Git, or marketplace source.
373
+ * @param source - Source to import.
374
+ * @returns Refreshed installed-plugin projection.
375
+ */
376
+ import(source) {
377
+ return this.enqueue(async () => {
378
+ const identity = await this.store.install(source);
379
+ const entry = await this.require(identity);
380
+ if (entry.enabled)
381
+ await this.reload(identity, entry);
382
+ return await this.list();
383
+ });
384
+ }
385
+ /**
386
+ * Enable and load one plugin.
387
+ * @param identityOrName - Plugin identity or unambiguous name.
388
+ * @returns Refreshed installed-plugin projection.
389
+ */
390
+ enable(identityOrName) {
391
+ return this.enqueue(async () => {
392
+ const [identity] = await this.resolve(identityOrName);
393
+ await this.store.update(identity, (entry) => { entry.enabled = true; });
394
+ await this.load(identity, await this.require(identity));
395
+ return await this.list();
396
+ });
397
+ }
398
+ /**
399
+ * Disable and unload one plugin.
400
+ * @param identityOrName - Plugin identity or unambiguous name.
401
+ * @returns Refreshed installed-plugin projection.
402
+ */
403
+ disable(identityOrName) {
404
+ return this.enqueue(async () => {
405
+ const [identity] = await this.resolve(identityOrName);
406
+ await this.unload(identity);
407
+ await this.store.update(identity, (entry) => { entry.enabled = false; });
408
+ return await this.list();
409
+ });
410
+ }
411
+ /**
412
+ * Trust one plugin's current hook definition.
413
+ * @param identityOrName - Plugin identity or unambiguous name.
414
+ * @returns Refreshed installed-plugin projection.
415
+ */
416
+ trustHooks(identityOrName) {
417
+ return this.enqueue(async () => {
418
+ const [identity, entry] = await this.resolve(identityOrName);
419
+ const loaded = await this.store.load(entry, identity);
420
+ if (loaded.hookDigest === undefined)
421
+ throw new Error(`plugin runtime: ${identity} has no hooks to trust`);
422
+ await this.trust.trust(identity, loaded.hookDigest);
423
+ if (entry.enabled)
424
+ await this.reload(identity, await this.require(identity));
425
+ return await this.list();
426
+ });
427
+ }
428
+ /**
429
+ * Revoke one plugin's hook trust.
430
+ * @param identityOrName - Plugin identity or unambiguous name.
431
+ * @returns Refreshed installed-plugin projection.
432
+ */
433
+ untrustHooks(identityOrName) {
434
+ return this.enqueue(async () => {
435
+ const [identity, entry] = await this.resolve(identityOrName);
436
+ await this.trust.untrust(identity);
437
+ if (entry.enabled)
438
+ await this.reload(identity, await this.require(identity));
439
+ return await this.list();
440
+ });
441
+ }
442
+ /**
443
+ * Unload and remove one plugin with its owned store paths.
444
+ * @param identityOrName - Plugin identity or unambiguous name.
445
+ * @returns Refreshed installed-plugin projection.
446
+ */
447
+ remove(identityOrName) {
448
+ return this.enqueue(async () => {
449
+ const [identity] = await this.resolve(identityOrName);
450
+ await this.unload(identity);
451
+ await this.store.remove(identity);
452
+ return await this.list();
453
+ });
454
+ }
455
+ /**
456
+ * Set one plugin MCP server's lifecycle state.
457
+ * @param identityOrName - Owning plugin identity or unambiguous name.
458
+ * @param server - Manifest MCP server name.
459
+ * @param enabled - Desired server state.
460
+ * @returns Refreshed installed-plugin projection.
461
+ */
462
+ /* jscpd:ignore-start -- MCP lifecycle setters intentionally share queue and reload handling. */
463
+ async setMcpServerEnabled(identityOrName, server, enabled) {
464
+ return this.enqueue(async () => {
465
+ const [identity] = await this.resolve(identityOrName);
466
+ await this.store.update(identity, (entry) => {
467
+ const state = entry.mcp[server];
468
+ if (!Object.hasOwn(entry.mcp, server) || state === undefined)
469
+ throw new Error(`plugin runtime: MCP server ${server} is not declared`);
470
+ state.enabled = enabled;
471
+ entry.mcp[server] = state;
472
+ });
473
+ const entry = await this.require(identity);
474
+ if (entry.enabled)
475
+ await this.reload(identity, entry);
476
+ return await this.list();
477
+ });
478
+ }
479
+ /**
480
+ * Set one MCP tool's approval mode.
481
+ * @param identityOrName - Owning plugin identity or unambiguous name.
482
+ * @param server - Manifest MCP server name.
483
+ * @param tool - Raw MCP tool name or host-qualified public tool name.
484
+ * @param approval - Independent per-tool approval mode.
485
+ * @returns Refreshed installed-plugin projection.
486
+ */
487
+ async setMcpToolApproval(identityOrName, server, tool, approval) {
488
+ return this.enqueue(async () => {
489
+ const [identity] = await this.resolve(identityOrName);
490
+ await this.store.update(identity, (entry) => {
491
+ const state = entry.mcp[server];
492
+ if (!Object.hasOwn(entry.mcp, server) || state === undefined)
493
+ throw new Error(`plugin runtime: MCP server ${server} is not declared`);
494
+ state.toolApproval[tool] = approval;
495
+ entry.mcp[server] = state;
496
+ });
497
+ return await this.list();
498
+ });
499
+ }
500
+ /* jscpd:ignore-end */
501
+ /**
502
+ * Unload every live component owned by one plugin.
503
+ * @param identity - Installed plugin identity.
504
+ * @returns After skills, hooks, and MCP fibers quiesce.
505
+ */
506
+ async unload(identity) {
507
+ const component = this.live.get(identity);
508
+ if (component === undefined)
509
+ return;
510
+ this.live.delete(identity);
511
+ component.skillDispose?.();
512
+ for (const dispose of [...component.commandDisposes].reverse())
513
+ dispose();
514
+ const fibers = [...component.mcpFibers.values(), component.hookFiber].filter((fiber) => fiber !== undefined);
515
+ for (const fiber of fibers)
516
+ void fiber.dispose();
517
+ await Promise.all(fibers.map(async (fiber) => {
518
+ await fiber.await().catch(() => { });
519
+ }));
520
+ this.startup.delete(identity);
521
+ }
522
+ async load(identity, entry) {
523
+ if (this.live.has(identity))
524
+ return;
525
+ const loaded = await this.store.load(entry, identity);
526
+ const component = { commandDisposes: [], mcpFibers: new Map() };
527
+ this.live.set(identity, component);
528
+ try {
529
+ const skillDispose = this.registerSkills(identity, loaded.skills);
530
+ if (skillDispose !== undefined)
531
+ component.skillDispose = skillDispose;
532
+ this.registerCommands(identity, loaded.commands, component.commandDisposes);
533
+ await this.startMcp(identity, entry, loaded, component);
534
+ if (loaded.hookDigest !== undefined && entry.hookTrustDigest === loaded.hookDigest) {
535
+ component.hookFiber = await this.startHooks(identity, entry, loaded);
536
+ }
537
+ }
538
+ catch (error) {
539
+ await this.unload(identity);
540
+ throw error;
541
+ }
542
+ }
543
+ async reload(identity, entry) {
544
+ await this.unload(identity);
545
+ await this.load(identity, entry);
546
+ }
547
+ registerSkills(identity, skills) {
548
+ if (skills.length === 0)
549
+ return undefined;
550
+ return this.owner.skills.registerProvider(() => ({
551
+ name: `codex-plugin:${identity}`,
552
+ list: (_options) => {
553
+ return Promise.resolve(skills.map(skill => ({
554
+ name: qualifiedSkillName(identity, skill.rawName),
555
+ aliases: [skill.rawName],
556
+ description: skill.description,
557
+ ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
558
+ invocation: skill.invocation,
559
+ source: `codex-plugin:${identity}`,
560
+ provider: `codex-plugin:${identity}`,
561
+ rank: BUNDLED_SKILL_RANK,
562
+ locator: skill,
563
+ path: skill.path,
564
+ resourceBase: { kind: 'directory', path: skill.directory },
565
+ metadata: { pluginIdentity: identity, skillName: skill.rawName },
566
+ })));
567
+ },
568
+ get: async (candidate) => {
569
+ const skill = candidate.locator;
570
+ const parsed = parseSkillDocument(await readBoundedText(skill.path, MAX_FILE_BYTES, 'skill file'));
571
+ if (parsed.name !== skill.rawName)
572
+ return undefined;
573
+ return {
574
+ name: candidate.name,
575
+ description: parsed.description,
576
+ ...parsed.whenToUse === undefined ? {} : { whenToUse: parsed.whenToUse },
577
+ invocation: {
578
+ ...parsed.invocation,
579
+ modelInvocable: parsed.invocation.modelInvocable && skill.openAiModelInvocable,
580
+ },
581
+ source: `codex-plugin:${identity}`,
582
+ provider: `codex-plugin:${identity}`,
583
+ resourceBase: { kind: 'directory', path: skill.directory },
584
+ path: skill.path,
585
+ metadata: { pluginIdentity: identity, skillName: skill.rawName },
586
+ content: parsed.content,
587
+ };
588
+ },
589
+ }));
590
+ }
591
+ registerCommands(identity, commands, disposes) {
592
+ for (const command of commands) {
593
+ try {
594
+ disposes.push(this.owner.commands.register({
595
+ name: command.name,
596
+ description: command.description,
597
+ input: { hint: command.inputHint ?? '[arguments]' },
598
+ handler: ({ agent, rawInput }) => {
599
+ agent.followup(createUserMessage({
600
+ content: [{ type: 'text', text: renderCommandPrompt(command.prompt, rawInput) }],
601
+ source: { kind: 'plugin', plugin: `codex-plugin:${identity}` },
602
+ }));
603
+ return { kind: 'success' };
604
+ },
605
+ }));
606
+ }
607
+ catch (error) {
608
+ if (!(error instanceof Error) || !error.message.includes('is already registered'))
609
+ throw error;
610
+ this.ctx.logger.warn(`plugin runtime: command "/${command.name}" from ${identity} ignored because a command with that name is already registered`);
611
+ }
612
+ }
613
+ }
614
+ async startMcp(identity, entry, loaded, component) {
615
+ const states = new Map();
616
+ this.startup.set(identity, states);
617
+ const pending = [];
618
+ for (const server of loaded.mcp) {
619
+ // Imported MCP servers are opt-in: enabling the bundle must not start a
620
+ // local command or remote connection before the user reviews it.
621
+ if (entry.mcp[server.name]?.enabled !== true)
622
+ continue;
623
+ states.set(server.name, 'starting');
624
+ const fiber = this.owner.plugin({
625
+ name: `imported-mcp:${identity}:${server.name}`,
626
+ inject: mcpClientInject,
627
+ apply: applyMcpClient,
628
+ }, server.config);
629
+ component.mcpFibers.set(server.name, fiber);
630
+ pending.push(Promise.resolve(fiber).then(() => { states.set(server.name, 'started'); }, async () => {
631
+ states.set(server.name, 'failed');
632
+ component.mcpFibers.delete(server.name);
633
+ try {
634
+ await fiber.dispose();
635
+ }
636
+ catch {
637
+ // The failed fiber is already inactive; no further cleanup is possible here.
638
+ }
639
+ }));
640
+ }
641
+ await Promise.all(pending);
642
+ }
643
+ async startHooks(identity, entry, loaded) {
644
+ const configPath = join(this.store.dataPath(entry), `hooks-${loaded.hookDigest}.json`);
645
+ const hookConfig = mergeHookDefinitions(loaded.hooks);
646
+ // Windows profiles use PowerShell; expand known plugin variables without embedding path bytes as shell code.
647
+ const text = JSON.stringify(hookConfig, (key, value) => key === 'command' && typeof value === 'string' && process.platform === 'win32'
648
+ ? value.replaceAll(/\$\{((?:CLAUDE_)?PLUGIN_(?:ROOT|DATA))\}/gu, '${env:$1}') : value);
649
+ await writeFileAtomic(configPath, `${text}\n`, { mode: 0o600, dirMode: 0o700 });
650
+ return this.owner.plugin({
651
+ name: `imported-hooks:${identity}`,
652
+ inject: codexHooksInject,
653
+ apply: applyCodexHooks,
654
+ }, {
655
+ configPath,
656
+ env: {
657
+ PLUGIN_ROOT: loaded.root,
658
+ PLUGIN_DATA: this.store.dataPath(entry),
659
+ CLAUDE_PLUGIN_ROOT: loaded.root,
660
+ CLAUDE_PLUGIN_DATA: this.store.dataPath(entry),
661
+ },
662
+ });
663
+ }
664
+ async view(identity, entry) {
665
+ const loaded = await this.store.load(entry, identity);
666
+ const hookTrustState = loaded.hookDigest === undefined
667
+ ? 'not-applicable'
668
+ : loaded.hookDigest === entry.hookTrustDigest ? 'trusted' : 'pending';
669
+ const started = this.startup.get(identity);
670
+ return {
671
+ identity: identity,
672
+ name: entry.name,
673
+ ...loaded.manifest.description === undefined ? {} : { description: loaded.manifest.description },
674
+ ...loaded.manifest.application === undefined ? {} : { application: loaded.manifest.application },
675
+ version: entry.activeVersion,
676
+ source: entry.source,
677
+ pluginRoot: loaded.root,
678
+ dataPath: this.store.dataPath(entry),
679
+ enabled: entry.enabled,
680
+ initialEnabled: this.initialEnabled.get(identity) ?? false,
681
+ lifecycle: this.live.has(identity) ? 'loaded' : entry.enabled ? 'enabled' : 'disabled',
682
+ hookTrustState,
683
+ ...loaded.hookDigest === undefined ? {} : { hookDefinitionDigest: loaded.hookDigest },
684
+ skills: loaded.skills.map(skill => skill.rawName),
685
+ mcpServers: loaded.mcp.map(server => ({
686
+ name: server.name,
687
+ enabled: entry.mcp[server.name]?.enabled === true,
688
+ startupState: started?.get(server.name) ?? 'not-started',
689
+ authenticationState: server.config.transport === 'stdio' ? 'not-applicable' : 'unknown',
690
+ defaultToolsApprovalMode: entry.mcp[server.name]?.defaultToolsApprovalMode ?? 'ask',
691
+ toolApproval: entry.mcp[server.name]?.toolApproval ?? {},
692
+ tools: this.ctx.tools.schemas().map(tool => tool.name)
693
+ .filter(name => name.startsWith(`mcp__${mcpServerName(identity, server.name)}__`)).sort(),
694
+ })),
695
+ hooks: loaded.hooks.map(hook => hook.label),
696
+ ...loaded.agentMetadata === undefined ? {} : { agentMetadata: loaded.agentMetadata },
697
+ ...loaded.apps === undefined ? {} : { appMappings: Object.keys(loaded.apps).sort() },
698
+ installationStatus: 'installed',
699
+ };
700
+ }
701
+ async resolve(identityOrName) {
702
+ const value = identityOrName.trim();
703
+ const entries = await this.store.list();
704
+ const exact = entries.get(value);
705
+ if (exact !== undefined)
706
+ return [value, exact];
707
+ const matches = [...entries].filter(([, entry]) => entry.name === value);
708
+ if (matches.length === 1)
709
+ return matches[0];
710
+ if (matches.length > 1)
711
+ throw new Error(`plugin runtime: ${value} exists in multiple sources; use <name>@<source-id>`);
712
+ throw new Error(`plugin runtime: imported plugin ${value} is not installed`);
713
+ }
714
+ async require(identity) {
715
+ const entry = await this.store.get(identity);
716
+ if (entry === undefined)
717
+ throw new Error(`plugin runtime: imported plugin ${identity} is not installed`);
718
+ return entry;
719
+ }
720
+ enqueue(task) {
721
+ const run = this.mutationTail.then(task);
722
+ this.mutationTail = run.then(() => undefined, () => undefined);
723
+ return run;
724
+ }
725
+ async approveMcpTool(exec, next) {
726
+ if (!exec.name.startsWith('mcp__'))
727
+ return await next();
728
+ for (const [identity, component] of this.live) {
729
+ const matching = [...component.mcpFibers.keys()]
730
+ .find(name => exec.name.startsWith(`mcp__${mcpServerName(identity, name)}__`));
731
+ if (matching === undefined)
732
+ continue;
733
+ const entry = await this.require(identity);
734
+ const state = entry.mcp[matching];
735
+ const override = Object.entries(state?.toolApproval ?? {})
736
+ .find(([raw]) => raw === exec.name || publicToolName(mcpServerName(identity, matching), raw) === exec.name);
737
+ const toolName = override?.[0] ?? exec.name;
738
+ const approval = override?.[1] ?? state?.defaultToolsApprovalMode ?? 'ask';
739
+ if (approval === 'allow')
740
+ return await next();
741
+ if (approval === 'deny')
742
+ return { kind: 'deny', reason: `MCP tool ${toolName} is disabled by its plugin policy` };
743
+ return { kind: 'ask', reason: `MCP tool ${toolName} requires approval` };
744
+ }
745
+ return await next();
746
+ }
747
+ marketplaceInventory() {
748
+ // Cross-plugin lookup: the inventory service lives in another composition
749
+ // plane, so the Context merge cannot type it here.
750
+ const inventory = this.ctx.get('pluginInventory');
751
+ return inventory === undefined || inventory === null ? undefined : inventory;
752
+ }
753
+ requireMarketplaceInventory() {
754
+ const inventory = this.marketplaceInventory();
755
+ if (inventory === undefined)
756
+ throw new Error('plugin runtime: plugin inventory is unavailable');
757
+ return inventory;
758
+ }
759
+ async resolveMarketplace(name) {
760
+ const trimmed = name.trim();
761
+ if (isExplicitSource(trimmed))
762
+ return trimmed;
763
+ const inventory = this.marketplaceInventory();
764
+ if (inventory === undefined)
765
+ throw new Error(`plugin runtime: unknown marketplace ${trimmed}`);
766
+ const { marketplaces } = await inventory.listMarketplaces();
767
+ const key = marketplaceRepoKey(trimmed);
768
+ const matches = marketplaces.filter(entry => entry.source === trimmed || marketplaceRepoKey(entry.source) === key);
769
+ if (matches.length === 1) {
770
+ /* v8 ignore next -- filter produced exactly one marketplace with a required source. */
771
+ return matches[0]?.source ?? trimmed;
772
+ }
773
+ if (matches.length > 1)
774
+ throw new Error(`plugin runtime: marketplace ${trimmed} matches multiple sources`);
775
+ throw new Error(`plugin runtime: unknown marketplace ${trimmed}`);
776
+ }
777
+ async marketplaceCommand(input) {
778
+ const [verb, ...rest] = input.split(/\s+/u).filter(part => part !== '');
779
+ const argument = rest.join(' ');
780
+ switch (verb) {
781
+ case 'add': {
782
+ if (argument === '')
783
+ return usage();
784
+ const inventory = this.marketplaceInventory();
785
+ if (inventory === undefined) {
786
+ await this.import(argument);
787
+ return { kind: 'success', text: 'Plugin imported. It is disabled until explicitly enabled.' };
788
+ }
789
+ await inventory.addMarketplace({ source: argument });
790
+ return { kind: 'success', text: 'Marketplace added. Install a plugin with /plugin install <name>@<marketplace>.' };
791
+ }
792
+ case 'list': {
793
+ const { marketplaces } = await this.requireMarketplaceInventory().listMarketplaces();
794
+ return {
795
+ kind: 'success',
796
+ text: marketplaces.length === 0
797
+ ? 'No plugin marketplaces.'
798
+ : marketplaces.map(entry => `${entry.source} ${entry.status ?? 'ready'}${entry.enabled === false ? ' disabled' : ''}`).join('\n'),
799
+ };
800
+ }
801
+ case 'remove': {
802
+ if (argument === '')
803
+ return usage();
804
+ await this.requireMarketplaceInventory().removeMarketplace(await this.resolveMarketplace(argument));
805
+ return { kind: 'success', text: 'Marketplace removed.' };
806
+ }
807
+ default:
808
+ return usage();
809
+ }
810
+ }
811
+ async installCommand(argument) {
812
+ if (argument === '')
813
+ return usage();
814
+ const qualified = /^([A-Za-z0-9][A-Za-z0-9._-]{0,99})@(.+)$/u.exec(argument);
815
+ let request;
816
+ if (isExplicitSource(argument)) {
817
+ request = argument;
818
+ }
819
+ else if (qualified !== null) {
820
+ /* v8 ignore next -- both regex capture groups require at least one character. */
821
+ const marketplace = qualified[2] ?? '';
822
+ /* v8 ignore next -- both regex capture groups require at least one character. */
823
+ const plugin = qualified[1] ?? '';
824
+ request = { source: await this.resolveMarketplace(marketplace), plugin };
825
+ }
826
+ else {
827
+ const entries = [...await this.store.list()].filter(([, entry]) => entry.name === argument);
828
+ if (entries.length === 1) {
829
+ /* v8 ignore next -- the filtered map contains exactly one entry tuple. */
830
+ const [entryId] = entries[0] ?? [];
831
+ /* v8 ignore if -- every map entry tuple starts with its string key. */
832
+ if (entryId === undefined)
833
+ throw new Error(`plugin runtime: imported plugin ${argument} is not installed`);
834
+ await this.enable(entryId);
835
+ return { kind: 'success', text: `Plugin ${argument} enabled.` };
836
+ }
837
+ const inventory = this.marketplaceInventory();
838
+ const marketplaces = inventory === undefined ? [] : (await inventory.listMarketplaces()).marketplaces;
839
+ if (marketplaces.length !== 1)
840
+ throw new Error(`plugin runtime: imported plugin ${argument} is not installed`);
841
+ const marketplace = marketplaces[0];
842
+ /* v8 ignore if -- the preceding length check requires exactly one marketplace. */
843
+ if (marketplace === undefined)
844
+ throw new Error(`plugin runtime: imported plugin ${argument} is not installed`);
845
+ request = { source: marketplace.source, plugin: argument };
846
+ }
847
+ const snapshot = await this.import(request);
848
+ const name = snapshot.plugins[0]?.name;
849
+ if (name === undefined)
850
+ throw new Error('plugin runtime: import produced no plugin');
851
+ await this.enable(name);
852
+ return { kind: 'success', text: `Plugin ${name} enabled.` };
853
+ }
854
+ async command(rawInput) {
855
+ const input = rawInput.trim();
856
+ if (input === '' || input === 'list') {
857
+ const plugins = (await this.list()).plugins;
858
+ return { kind: 'success', text: plugins.length === 0 ? 'No imported plugins.' : plugins
859
+ .map(plugin => `${plugin.identity} ${plugin.version} ${plugin.enabled ? 'enabled' : 'disabled'}${plugin.hookTrustState === 'pending' ? ' (hook review pending)' : ''}`)
860
+ .join('\n') };
861
+ }
862
+ const [verb] = input.split(/\s+/u);
863
+ /* v8 ignore next -- splitting a non-empty input always yields a first word. */
864
+ const argument = input.slice(verb?.length ?? 0).trim();
865
+ try {
866
+ switch (verb) {
867
+ case 'import':
868
+ if (argument === '')
869
+ return usage();
870
+ await this.import(argument);
871
+ return { kind: 'success', text: 'Plugin imported. It is disabled until explicitly enabled.' };
872
+ case 'install':
873
+ return await this.installCommand(argument);
874
+ case 'marketplace':
875
+ return await this.marketplaceCommand(argument);
876
+ case 'info': return argument === '' ? usage() : { kind: 'success', text: renderInfo(await this.info(argument)) };
877
+ case 'enable':
878
+ if (argument === '')
879
+ return usage();
880
+ await this.enable(argument);
881
+ return { kind: 'success', text: 'Plugin enabled.' };
882
+ case 'disable':
883
+ if (argument === '')
884
+ return usage();
885
+ await this.disable(argument);
886
+ return { kind: 'success', text: 'Plugin disabled and unloaded.' };
887
+ case 'trust':
888
+ if (argument === '')
889
+ return usage();
890
+ await this.trustHooks(argument);
891
+ return { kind: 'success', text: 'Current hook definitions trusted; no MCP/tool approval changed.' };
892
+ case 'untrust':
893
+ if (argument === '')
894
+ return usage();
895
+ await this.untrustHooks(argument);
896
+ return { kind: 'success', text: 'Hook trust removed.' };
897
+ case 'remove':
898
+ if (argument === '')
899
+ return usage();
900
+ await this.remove(argument);
901
+ return { kind: 'success', text: 'Plugin removed.' };
902
+ default: return usage();
903
+ }
904
+ }
905
+ catch (error) {
906
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) };
907
+ }
908
+ }
909
+ async dispose() {
910
+ for (const identity of [...this.live.keys()])
911
+ await this.unload(identity);
912
+ }
913
+ }
914
+ export const name = 'plugin-runtime';
915
+ export default ImportedPluginRuntime;
916
+ function usage() {
917
+ return { kind: 'error', text: `Usage: /plugin ${PLUGIN_COMMAND_HINT}` };
918
+ }
919
+ function isExplicitSource(value) {
920
+ if (GITHUB_SHORTHAND.test(value) || SCP_GIT_SOURCE.test(value))
921
+ return true;
922
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//u.test(value) || isAbsolute(value))
923
+ return true;
924
+ return !value.includes('@') && (value.includes('/') || value.includes('\\'));
925
+ }
926
+ function marketplaceRepoKey(source) {
927
+ const withoutGit = source.replace(/\.git$/iu, '');
928
+ const parts = withoutGit.split(/[/\\:]/u).filter(part => part.length > 0);
929
+ const last = parts.at(-1);
930
+ /* v8 ignore next -- persisted marketplace sources always retain a host or path segment */
931
+ return (last ?? source).toLowerCase();
932
+ }
933
+ function renderInfo(plugin) {
934
+ return [
935
+ `${plugin.identity} ${plugin.version}`,
936
+ `source: ${plugin.source.source}`,
937
+ `enabled: ${String(plugin.enabled)}; hooks: ${plugin.hookTrustState}`,
938
+ `skills: ${plugin.skills.join(', ') || 'none'}`,
939
+ `MCP: ${plugin.mcpServers.map(server => `${server.name}=${server.enabled ? server.startupState : 'disabled'}`).join(', ') || 'none'}`,
940
+ ].join('\n');
941
+ }
942
+ function pluginIdentity(name, sourceId) { return `${name}@${sourceId}`; }
943
+ function digest(value) {
944
+ return createHash('sha256').update(stableJson(value)).digest('hex');
945
+ }
946
+ function sourceId(value) { return digest(value).slice(0, 16); }
947
+ function qualifiedSkillName(identity, name) {
948
+ return `plugin-${digest(identity).slice(0, 12)}-${name}`.replace(/[^a-z0-9-]/giu, '-').toLowerCase();
949
+ }
950
+ function renderCommandPrompt(prompt, rawInput) {
951
+ const argumentsText = rawInput.trim();
952
+ const positional = argumentsText === '' ? [] : argumentsText.split(/\s+/u);
953
+ return prompt
954
+ .replaceAll('{{args}}', argumentsText)
955
+ .replaceAll('$ARGUMENTS', argumentsText)
956
+ .replaceAll(/\$(\d+)\b/gu, (_match, index) => positional[Number(index) - 1] ?? '');
957
+ }
958
+ function mcpServerName(identity, name) {
959
+ return `p${digest(identity).slice(0, 10)}-${digest(name).slice(0, 10)}`;
960
+ }
961
+ async function materializeSource(input) {
962
+ const request = typeof input === 'string' ? { source: input } : input;
963
+ const source = request.source.trim();
964
+ if (source === '' || source.length > 2_048 || /[\u0000-\u001F\u007F]/u.test(source)) {
965
+ throw new Error('plugin runtime: plugin source is invalid');
966
+ }
967
+ const local = resolve(source);
968
+ try {
969
+ if ((await stat(local)).isDirectory()) {
970
+ const base = await realpath(local);
971
+ const marketplacePlugin = await marketplaceGitPlugin(base, request);
972
+ if (marketplacePlugin !== undefined) {
973
+ return await materializeMarketplaceGitPlugin('marketplace-local', base, undefined, marketplacePlugin);
974
+ }
975
+ const selected = await selectPluginRoot(base, request);
976
+ const marketplace = !await isDirectPluginRoot(base, request);
977
+ const descriptor = {
978
+ kind: marketplace ? 'marketplace-local' : 'local',
979
+ source: base,
980
+ sourceId: sourceId({ kind: marketplace ? 'marketplace-local' : 'local', source: base, path: request.path, plugin: request.plugin }),
981
+ ...request.path === undefined ? {} : { path: request.path },
982
+ ...marketplace ? { marketplace: base } : {},
983
+ };
984
+ return { root: selected, source: descriptor, dispose: async () => { } };
985
+ }
986
+ }
987
+ catch (error) {
988
+ if (!isMissing(error))
989
+ throw error;
990
+ }
991
+ const remote = normalizeGitSource(source);
992
+ const temp = await mkdirTemp('hydra-imported-plugin-');
993
+ const checkout = join(temp, 'repository');
994
+ try {
995
+ await runGit(temp, ['clone', '--depth', '1', '--filter=blob:none', '--', remote, checkout], request.ref);
996
+ const marketplacePlugin = await marketplaceGitPlugin(checkout, request);
997
+ if (marketplacePlugin !== undefined) {
998
+ const nested = await materializeMarketplaceGitPlugin('marketplace-git', remote, request.ref, marketplacePlugin);
999
+ return {
1000
+ ...nested,
1001
+ dispose: async () => {
1002
+ await nested.dispose();
1003
+ await rm(temp, { recursive: true, force: true });
1004
+ },
1005
+ };
1006
+ }
1007
+ const selected = await selectPluginRoot(checkout, request);
1008
+ const marketplace = !await isDirectPluginRoot(checkout, request);
1009
+ const descriptor = {
1010
+ kind: marketplace ? 'marketplace-git' : 'git',
1011
+ source: remote,
1012
+ sourceId: sourceId({ kind: marketplace ? 'marketplace-git' : 'git', source: remote, ref: request.ref, path: request.path, plugin: request.plugin }),
1013
+ ...request.ref === undefined ? {} : { ref: request.ref },
1014
+ ...request.path === undefined ? {} : { path: request.path },
1015
+ ...marketplace ? { marketplace: remote } : {},
1016
+ };
1017
+ return { root: selected, source: descriptor, dispose: async () => { await rm(temp, { recursive: true, force: true }); } };
1018
+ }
1019
+ catch (error) {
1020
+ await rm(temp, { recursive: true, force: true });
1021
+ throw error;
1022
+ }
1023
+ }
1024
+ async function mkdirTemp(prefix) {
1025
+ const { mkdtemp } = await import('node:fs/promises');
1026
+ return await mkdtemp(join(tmpdir(), prefix));
1027
+ }
1028
+ function normalizeGitSource(source) {
1029
+ const shorthand = GITHUB_SHORTHAND.exec(source);
1030
+ if (shorthand !== null) {
1031
+ /* v8 ignore next -- GITHUB_SHORTHAND requires a non-empty repository capture. */
1032
+ return `https://github.com/${shorthand[1]}/${(shorthand[2] ?? '').replace(/\.git$/u, '')}.git`;
1033
+ }
1034
+ if (/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[A-Za-z0-9._~/-]+$/u.test(source))
1035
+ return source;
1036
+ let parsed;
1037
+ try {
1038
+ parsed = new URL(source);
1039
+ }
1040
+ catch {
1041
+ throw new Error('plugin runtime: source must be an existing folder, GitHub shorthand, or HTTPS/SSH Git URL');
1042
+ }
1043
+ if ((parsed.protocol !== 'https:' && parsed.protocol !== 'ssh:') || parsed.username !== '' || parsed.password !== '' || parsed.search !== '' || parsed.hash !== '') {
1044
+ throw new Error('plugin runtime: remote source must be credential-free HTTPS or SSH Git URL');
1045
+ }
1046
+ return parsed.href;
1047
+ }
1048
+ function runGit(cwd, args, ref) {
1049
+ if (ref !== undefined && (ref.length === 0 || ref.length > 255 || ref.startsWith('-') || /[\u0000-\u0020\u007F]/u.test(ref))) {
1050
+ return Promise.reject(new Error('plugin runtime: Git ref is invalid'));
1051
+ }
1052
+ const cloneArgs = ref === undefined ? args : [...args.slice(0, -3), '--branch', ref, ...args.slice(-3)];
1053
+ const env = {};
1054
+ for (const [key, value] of Object.entries(process.env))
1055
+ if (!/(?:KEY|SECRET|TOKEN|PASSWORD)/iu.test(key))
1056
+ env[key] = value;
1057
+ env.GCM_INTERACTIVE = 'Never';
1058
+ env.GIT_TERMINAL_PROMPT = '0';
1059
+ return new Promise((resolvePromise, reject) => {
1060
+ execFile('git', ['-c', 'protocol.ext.allow=never', '-c', 'protocol.file.allow=never', ...cloneArgs], {
1061
+ cwd, env, timeout: GIT_TIMEOUT_MS, maxBuffer: MAX_MARKETPLACE_BYTES, windowsHide: true,
1062
+ }, (error) => {
1063
+ if (error === null) {
1064
+ resolvePromise();
1065
+ }
1066
+ else {
1067
+ reject(new Error('plugin runtime: Git import failed', { cause: error }));
1068
+ }
1069
+ });
1070
+ });
1071
+ }
1072
+ async function selectPluginRoot(root, request) {
1073
+ const candidate = request.path === undefined ? root : await existingInside(root, request.path, 'plugin path');
1074
+ const direct = await hasPluginManifest(candidate);
1075
+ if (direct)
1076
+ return candidate;
1077
+ const selected = await selectMarketplacePlugin(candidate, request.plugin);
1078
+ const location = marketplaceLocalPluginPath(selected);
1079
+ if (location === undefined)
1080
+ throw new Error('plugin runtime: marketplace plugin requires a relative path or source');
1081
+ const pluginRoot = await existingInside(candidate, location, 'marketplace plugin path');
1082
+ if (!await hasPluginManifest(pluginRoot))
1083
+ throw new Error('plugin runtime: marketplace entry is not a Codex plugin root');
1084
+ return pluginRoot;
1085
+ }
1086
+ async function materializeMarketplaceGitPlugin(kind, marketplace, marketplaceRef, plugin) {
1087
+ const nested = await materializeSource({
1088
+ source: plugin.source,
1089
+ ...plugin.path === undefined ? {} : { path: plugin.path },
1090
+ ...plugin.ref === undefined ? {} : { ref: plugin.ref },
1091
+ });
1092
+ return {
1093
+ root: nested.root,
1094
+ source: {
1095
+ kind,
1096
+ source: marketplace,
1097
+ sourceId: sourceId({
1098
+ kind, marketplace, marketplaceRef, plugin: plugin.selector, source: plugin.source, path: plugin.path, ref: plugin.ref,
1099
+ }),
1100
+ ...plugin.ref === undefined ? {} : { ref: plugin.ref },
1101
+ ...plugin.path === undefined ? {} : { path: plugin.path },
1102
+ marketplace,
1103
+ },
1104
+ dispose: nested.dispose,
1105
+ };
1106
+ }
1107
+ async function marketplaceGitPlugin(root, request) {
1108
+ const candidate = request.path === undefined ? root : await existingInside(root, request.path, 'plugin path');
1109
+ const direct = await hasPluginManifest(candidate);
1110
+ if (direct)
1111
+ return undefined;
1112
+ const marketplacePath = await optionalMarketplaceManifestPath(candidate);
1113
+ if (marketplacePath === undefined)
1114
+ return undefined;
1115
+ const selected = await selectMarketplacePlugin(candidate, request.plugin, marketplacePath);
1116
+ const source = isRecord(selected.source) ? selected.source : undefined;
1117
+ if (source === undefined || source.source === 'local')
1118
+ return undefined;
1119
+ const remote = typeof source.url === 'string' ? source.url : typeof source.source === 'string' && source.source !== 'git-subdir'
1120
+ ? source.source : undefined;
1121
+ if (remote === undefined)
1122
+ throw new Error('plugin runtime: marketplace Git plugin requires source.url');
1123
+ const path = typeof source.path === 'string' ? source.path : undefined;
1124
+ const ref = typeof source.ref === 'string' ? source.ref : typeof source.sha === 'string' ? source.sha : undefined;
1125
+ return {
1126
+ source: remote,
1127
+ ...path === undefined ? {} : { path },
1128
+ ...ref === undefined ? {} : { ref },
1129
+ selector: typeof selected.id === 'string' ? selected.id : string(selected.name),
1130
+ };
1131
+ }
1132
+ async function selectMarketplacePlugin(root, plugin, knownPath) {
1133
+ const marketplace = await readBoundedJson(knownPath ?? await marketplaceManifestPath(root), MAX_MARKETPLACE_BYTES, 'marketplace manifest');
1134
+ if (!isRecord(marketplace) || !Array.isArray(marketplace.plugins))
1135
+ throw new Error('plugin runtime: marketplace.json requires a plugins array');
1136
+ const plugins = marketplace.plugins.filter(isRecord);
1137
+ const selected = plugin === undefined
1138
+ ? plugins.length === 1 ? plugins[0] : undefined
1139
+ : plugins.find(entry => entry.name === plugin || entry.id === plugin);
1140
+ if (selected === undefined)
1141
+ throw new Error('plugin runtime: select a marketplace plugin by name');
1142
+ return selected;
1143
+ }
1144
+ function marketplaceLocalPluginPath(selected) {
1145
+ const source = isRecord(selected.source) ? selected.source : undefined;
1146
+ return typeof selected.path === 'string' ? selected.path
1147
+ : typeof selected.source === 'string' ? selected.source
1148
+ : typeof selected.directory === 'string' ? selected.directory
1149
+ : source?.source === 'local' && typeof source.path === 'string' ? source.path : undefined;
1150
+ }
1151
+ async function marketplaceManifestPath(root) {
1152
+ const path = await optionalMarketplaceManifestPath(root);
1153
+ if (path !== undefined)
1154
+ return path;
1155
+ throw new Error('plugin runtime: marketplace.json is missing');
1156
+ }
1157
+ async function optionalMarketplaceManifestPath(root) {
1158
+ for (const path of ['.agents/plugins/marketplace.json', '.agents/plugins/api_marketplace.json', 'marketplace.json']) {
1159
+ if (await exists(join(root, path)))
1160
+ return await existingInside(root, path, 'marketplace manifest');
1161
+ }
1162
+ }
1163
+ async function isDirectPluginRoot(root, request) {
1164
+ const candidate = request.path === undefined ? root : resolveInside(root, request.path, 'plugin path');
1165
+ return await hasPluginManifest(candidate);
1166
+ }
1167
+ async function hasPluginManifest(root) {
1168
+ return await findManifestLocation(root) !== undefined;
1169
+ }
1170
+ async function pluginManifestPath(root) {
1171
+ const location = await findManifestLocation(root);
1172
+ if (location !== undefined)
1173
+ return location;
1174
+ throw new Error('plugin runtime: plugin manifest is missing (expected .codex-plugin/plugin.json, .claude-plugin/plugin.json, or plugin.json)');
1175
+ }
1176
+ async function findManifestLocation(root) {
1177
+ const portablePath = join(root, 'plugin.json');
1178
+ if (await exists(portablePath)) {
1179
+ const info = await lstat(portablePath);
1180
+ if (!info.isFile() || info.isSymbolicLink())
1181
+ throw new Error('plugin runtime: root plugin.json must be a regular file');
1182
+ let raw;
1183
+ try {
1184
+ raw = JSON.parse(await readBoundedText(portablePath, MAX_MANIFEST_BYTES, 'plugin manifest'));
1185
+ }
1186
+ catch (error) {
1187
+ if (!await exists(join(root, '.codex-plugin', 'plugin.json')))
1188
+ throw error;
1189
+ raw = undefined;
1190
+ }
1191
+ const schema = isRecord(raw) && typeof raw.$schema === 'string' ? raw.$schema : undefined;
1192
+ if (schema === AGENT_PLUGIN_SCHEMA_URI) {
1193
+ return { path: await existingInside(root, 'plugin.json', 'plugin manifest'), format: 'portable' };
1194
+ }
1195
+ if (schema?.startsWith(AGENT_PLUGIN_SCHEMA_PREFIX)) {
1196
+ throw new Error(`plugin runtime: unsupported Agent Plugins schema ${JSON.stringify(schema)}`);
1197
+ }
1198
+ }
1199
+ const legacyPath = join(root, '.codex-plugin', 'plugin.json');
1200
+ if (await exists(legacyPath)) {
1201
+ return { path: await existingInside(root, '.codex-plugin/plugin.json', 'plugin manifest'), format: 'legacy' };
1202
+ }
1203
+ const claudePath = join(root, '.claude-plugin', 'plugin.json');
1204
+ if (await exists(claudePath)) {
1205
+ return { path: await existingInside(root, '.claude-plugin/plugin.json', 'plugin manifest'), format: 'claude' };
1206
+ }
1207
+ if (await exists(join(root, '.claude-plugin'))) {
1208
+ throw new Error('plugin runtime: Claude plugin manifest is missing (expected .claude-plugin/plugin.json)');
1209
+ }
1210
+ return undefined;
1211
+ }
1212
+ function parseManifest(raw, format) {
1213
+ if (format === 'portable')
1214
+ return parsePortableManifest(raw);
1215
+ if (format === 'claude')
1216
+ return parseClaudeManifest(raw);
1217
+ if (!isRecord(raw) || !PLUGIN_NAME.test(string(raw.name)) || !VERSION.test(string(raw.version))) {
1218
+ throw new Error('plugin runtime: plugin manifest requires a valid name and semantic version');
1219
+ }
1220
+ /* jscpd:ignore-start -- portable and legacy manifests share metadata validation. */
1221
+ for (const key of ['description', 'application', 'homepage', 'repository', 'license']) {
1222
+ if (raw[key] !== undefined && typeof raw[key] !== 'string')
1223
+ throw new Error(`plugin runtime: manifest ${key} must be a string`);
1224
+ }
1225
+ if (raw.keywords !== undefined && (!Array.isArray(raw.keywords) || raw.keywords.some(value => typeof value !== 'string'))) {
1226
+ throw new Error('plugin runtime: manifest keywords must be a string array');
1227
+ }
1228
+ if (raw.author !== undefined && typeof raw.author !== 'string' && !isRecord(raw.author)) {
1229
+ throw new Error('plugin runtime: manifest author must be a string or object');
1230
+ }
1231
+ /* jscpd:ignore-end */
1232
+ if (raw.interface !== undefined && !isRecord(raw.interface))
1233
+ throw new Error('plugin runtime: manifest interface must be an object');
1234
+ validateManifestPaths(raw);
1235
+ return { ...raw, format: 'legacy' };
1236
+ }
1237
+ function parseClaudeManifest(raw) {
1238
+ if (!isRecord(raw))
1239
+ throw new Error('plugin runtime: Claude plugin manifest must be an object');
1240
+ const name = string(raw.name).trim();
1241
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(name)) {
1242
+ throw new Error(`plugin runtime: invalid Claude plugin name ${JSON.stringify(name)}`);
1243
+ }
1244
+ const version = raw.version === undefined ? 'unknown' : string(raw.version).trim();
1245
+ if (!isSafeVersionDirectory(version)) {
1246
+ throw new Error('plugin runtime: Claude plugin version is not a safe directory name');
1247
+ }
1248
+ for (const key of ['displayName', 'description', 'application', 'homepage', 'repository', 'license']) {
1249
+ if (raw[key] !== undefined && typeof raw[key] !== 'string')
1250
+ throw new Error(`plugin runtime: Claude plugin manifest ${key} must be a string`);
1251
+ }
1252
+ if (raw.keywords !== undefined && (!Array.isArray(raw.keywords) || raw.keywords.some(value => typeof value !== 'string'))) {
1253
+ throw new Error('plugin runtime: Claude plugin manifest keywords must be a string array');
1254
+ }
1255
+ if (raw.author !== undefined && !isRecord(raw.author)) {
1256
+ throw new Error('plugin runtime: Claude plugin manifest author must be an object');
1257
+ }
1258
+ for (const key of ['commands', 'agents', 'lspServers', 'monitors', 'outputStyles', 'themes', 'settings', 'dependencies', 'bin', 'apps', 'interface']) {
1259
+ if (raw[key] !== undefined)
1260
+ throw new Error(`plugin runtime: Claude plugin field ${key} is unsupported`);
1261
+ }
1262
+ validateManifestPaths(raw);
1263
+ const components = {};
1264
+ if (raw.skills !== undefined)
1265
+ components.skills = raw.skills;
1266
+ if (raw.mcpServers !== undefined)
1267
+ components.mcpServers = raw.mcpServers;
1268
+ if (raw.hooks !== undefined)
1269
+ components.hooks = raw.hooks;
1270
+ return {
1271
+ format: 'claude', name, version,
1272
+ ...typeof raw.description === 'string' ? { description: raw.description } : {},
1273
+ ...typeof raw.application === 'string' ? { application: raw.application } : {},
1274
+ ...isRecord(raw.author) ? { author: raw.author } : {},
1275
+ ...typeof raw.homepage === 'string' ? { homepage: raw.homepage } : {},
1276
+ ...typeof raw.repository === 'string' ? { repository: raw.repository } : {},
1277
+ ...typeof raw.license === 'string' ? { license: raw.license } : {},
1278
+ ...Array.isArray(raw.keywords) ? { keywords: raw.keywords } : {},
1279
+ ...components,
1280
+ };
1281
+ }
1282
+ function parsePortableManifest(raw) {
1283
+ if (!isRecord(raw) || typeof raw.$schema !== 'string' || raw.$schema !== AGENT_PLUGIN_SCHEMA_URI) {
1284
+ throw new Error(`plugin runtime: root plugin.json requires Agent Plugins schema ${AGENT_PLUGIN_SCHEMA_URI}`);
1285
+ }
1286
+ const name = string(raw.name).trim();
1287
+ if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(name) || name.includes('..') || name.includes('--')) {
1288
+ throw new Error(`plugin runtime: invalid Agent Plugins name ${JSON.stringify(name)}`);
1289
+ }
1290
+ const version = raw.version === undefined ? '1.0.0' : string(raw.version).trim();
1291
+ if (!isSafeVersionDirectory(version)) {
1292
+ throw new Error('plugin runtime: portable plugin version is not a safe directory name');
1293
+ }
1294
+ for (const key of ['description', 'application', 'homepage', 'repository', 'license']) {
1295
+ if (raw[key] !== undefined && typeof raw[key] !== 'string')
1296
+ throw new Error(`plugin runtime: manifest ${key} must be a string`);
1297
+ }
1298
+ if (raw.keywords !== undefined && (!Array.isArray(raw.keywords) || raw.keywords.some(value => typeof value !== 'string'))) {
1299
+ throw new Error('plugin runtime: manifest keywords must be a string array');
1300
+ }
1301
+ if (raw.author !== undefined && typeof raw.author !== 'string' && !isRecord(raw.author)) {
1302
+ throw new Error('plugin runtime: manifest author must be a string or object');
1303
+ }
1304
+ const extension = isRecord(raw.extensions) && isRecord(raw.extensions['com.openai'])
1305
+ ? raw.extensions['com.openai'] : undefined;
1306
+ const presentation = extension?.interface ?? raw.interface;
1307
+ if (presentation !== undefined && !isRecord(presentation))
1308
+ throw new Error('plugin runtime: manifest interface must be an object');
1309
+ // Agent Plugins keeps skills and MCP at the portable manifest root. Codex
1310
+ // extensions add host-specific apps, hooks, and interface metadata.
1311
+ const component = (key) => extension?.[key] ?? raw[key];
1312
+ const optional = {};
1313
+ if (typeof raw.description === 'string')
1314
+ optional.description = raw.description;
1315
+ if (typeof raw.application === 'string')
1316
+ optional.application = raw.application;
1317
+ if (typeof raw.author === 'string' || isRecord(raw.author))
1318
+ optional.author = raw.author;
1319
+ if (typeof raw.homepage === 'string')
1320
+ optional.homepage = raw.homepage;
1321
+ if (typeof raw.repository === 'string')
1322
+ optional.repository = raw.repository;
1323
+ if (typeof raw.license === 'string')
1324
+ optional.license = raw.license;
1325
+ if (Array.isArray(raw.keywords))
1326
+ optional.keywords = raw.keywords;
1327
+ const skills = raw.skills === undefined ? './skills' : raw.skills;
1328
+ const mcpServers = raw.mcpServers === undefined ? './mcp.json' : raw.mcpServers;
1329
+ const manifest = {
1330
+ format: 'portable', name, version, ...optional,
1331
+ skills, mcpServers,
1332
+ ...component('apps') === undefined ? {} : { apps: component('apps') },
1333
+ ...component('hooks') === undefined ? {} : { hooks: component('hooks') },
1334
+ ...isRecord(presentation) ? { interface: presentation } : {},
1335
+ };
1336
+ validateManifestPaths(manifest);
1337
+ return manifest;
1338
+ }
1339
+ function isSafeVersionDirectory(version) {
1340
+ return /^[a-z0-9][a-z0-9._+-]*$/iu.test(version)
1341
+ && !version.endsWith('.')
1342
+ && !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(version);
1343
+ }
1344
+ function validateManifestPaths(manifest) {
1345
+ for (const key of ['skills', 'mcpServers', 'apps', 'hooks']) {
1346
+ const value = manifest[key];
1347
+ if (typeof value === 'string')
1348
+ assertRelativePluginPath(value, `manifest ${key}`);
1349
+ if (Array.isArray(value))
1350
+ for (const item of value)
1351
+ if (typeof item === 'string')
1352
+ assertRelativePluginPath(item, `manifest ${key}`);
1353
+ }
1354
+ const ui = isRecord(manifest.interface) ? manifest.interface : undefined;
1355
+ if (ui === undefined)
1356
+ return;
1357
+ for (const key of ['composerIcon', 'logo']) {
1358
+ if (typeof ui[key] === 'string')
1359
+ assertRelativePluginPath(ui[key], `manifest interface.${key}`);
1360
+ }
1361
+ if (ui.screenshots !== undefined) {
1362
+ for (const path of stringArray(ui.screenshots, 'manifest interface.screenshots'))
1363
+ assertRelativePluginPath(path, 'manifest interface.screenshots');
1364
+ }
1365
+ }
1366
+ async function rejectUnsupportedClaudeComponents(root) {
1367
+ for (const path of ['agents', 'output-styles', 'themes', 'monitors', 'bin', '.lsp.json', 'settings.json']) {
1368
+ if (await exists(join(root, path)))
1369
+ throw new Error(`plugin runtime: Claude plugin component ${path} is unsupported`);
1370
+ }
1371
+ }
1372
+ /** Read the optional OpenAI presentation/policy document beside a plugin or skill. */
1373
+ async function discoverOpenAiMetadata(root) {
1374
+ const relativePath = 'agents/openai.yaml';
1375
+ const path = join(root, relativePath);
1376
+ if (!await exists(path))
1377
+ return undefined;
1378
+ const info = await lstat(path);
1379
+ if (!info.isFile() || info.isSymbolicLink())
1380
+ throw new Error(`plugin runtime: ${relativePath} must be a regular file`);
1381
+ let parsed;
1382
+ try {
1383
+ parsed = parseYaml(await readBoundedText(path, MAX_MANIFEST_BYTES, relativePath));
1384
+ }
1385
+ catch (error) {
1386
+ throw new Error(`plugin runtime: ${relativePath} is invalid YAML`, { cause: error });
1387
+ }
1388
+ if (!isRecord(parsed))
1389
+ throw new Error(`plugin runtime: ${relativePath} must contain a YAML object`);
1390
+ const presentation = parsed.interface;
1391
+ if (presentation !== undefined && !isRecord(presentation)) {
1392
+ throw new Error(`plugin runtime: ${relativePath} interface must be an object`);
1393
+ }
1394
+ const policy = parsed.policy;
1395
+ if (policy !== undefined && !isRecord(policy)) {
1396
+ throw new Error(`plugin runtime: ${relativePath} policy must be an object`);
1397
+ }
1398
+ const values = {};
1399
+ for (const [source, target] of [
1400
+ ['display_name', 'displayName'],
1401
+ ['short_description', 'shortDescription'],
1402
+ ['icon_small', 'iconSmall'],
1403
+ ['icon_large', 'iconLarge'],
1404
+ ['brand_color', 'brandColor'],
1405
+ ['default_prompt', 'defaultPrompt'],
1406
+ ]) {
1407
+ const value = presentation?.[source];
1408
+ if (value === undefined)
1409
+ continue;
1410
+ if (typeof value !== 'string' || value.trim() === '')
1411
+ throw new Error(`plugin runtime: ${relativePath} interface.${source} must be a non-empty string`);
1412
+ if (source === 'icon_small' || source === 'icon_large')
1413
+ assertRelativePluginPath(value, `${relativePath} interface.${source}`);
1414
+ values[target] = value;
1415
+ }
1416
+ const allowImplicitInvocation = policy?.allow_implicit_invocation;
1417
+ if (allowImplicitInvocation !== undefined) {
1418
+ if (typeof allowImplicitInvocation !== 'boolean')
1419
+ throw new Error(`plugin runtime: ${relativePath} policy.allow_implicit_invocation must be a boolean`);
1420
+ values.allowImplicitInvocation = allowImplicitInvocation;
1421
+ }
1422
+ if (parsed.dependencies !== undefined && !isRecord(parsed.dependencies)) {
1423
+ throw new Error(`plugin runtime: ${relativePath} dependencies must be an object`);
1424
+ }
1425
+ return Object.keys(values).length === 0 ? undefined : values;
1426
+ }
1427
+ async function discoverSkills(root, manifest, pluginAgentMetadata) {
1428
+ const paths = stringPaths(manifest.skills);
1429
+ if (paths.length === 0) {
1430
+ if (await exists(join(root, 'skills')))
1431
+ paths.push('skills');
1432
+ else if (manifest.format === 'claude' && await exists(join(root, 'SKILL.md')))
1433
+ paths.push('SKILL.md');
1434
+ }
1435
+ const skills = [];
1436
+ for (const path of paths) {
1437
+ if (manifest.format === 'portable' && path === './skills' && !await exists(join(root, path)))
1438
+ continue;
1439
+ const location = await existingInside(root, path, 'skill path');
1440
+ const info = await lstat(location);
1441
+ const files = info.isDirectory()
1442
+ ? (await readdir(location, { withFileTypes: true })).filter(entry => entry.isDirectory()).map(entry => join(location, entry.name, 'SKILL.md'))
1443
+ : [location];
1444
+ for (const file of files) {
1445
+ if (basename(file) !== 'SKILL.md' || !await exists(file))
1446
+ continue;
1447
+ const resolved = await existingInside(root, relative(root, file), 'skill file');
1448
+ const parsed = parseSkillDocument(await readBoundedText(resolved, MAX_FILE_BYTES, 'skill file'));
1449
+ const agentMetadata = await discoverOpenAiMetadata(dirname(resolved));
1450
+ const openAiModelInvocable = pluginAgentMetadata?.allowImplicitInvocation !== false
1451
+ && agentMetadata?.allowImplicitInvocation !== false;
1452
+ skills.push({
1453
+ rawName: parsed.name,
1454
+ description: parsed.description,
1455
+ invocation: {
1456
+ ...parsed.invocation,
1457
+ modelInvocable: parsed.invocation.modelInvocable && openAiModelInvocable,
1458
+ },
1459
+ ...parsed.whenToUse === undefined ? {} : { whenToUse: parsed.whenToUse },
1460
+ path: resolved, directory: dirname(resolved),
1461
+ ...agentMetadata === undefined ? {} : { agentMetadata },
1462
+ openAiModelInvocable,
1463
+ });
1464
+ }
1465
+ }
1466
+ return skills.sort((left, right) => left.rawName.localeCompare(right.rawName));
1467
+ }
1468
+ async function discoverCommands(root) {
1469
+ const directory = join(root, 'commands');
1470
+ if (!await exists(directory))
1471
+ return [];
1472
+ const info = await lstat(directory);
1473
+ if (!info.isDirectory() || info.isSymbolicLink())
1474
+ throw new Error('plugin runtime: commands must be a real directory');
1475
+ const commands = [];
1476
+ const entries = (await readdir(directory, { withFileTypes: true }))
1477
+ .filter(entry => entry.isFile() && (extname(entry.name) === '.toml' || extname(entry.name) === '.md'))
1478
+ .sort((left, right) => left.name.localeCompare(right.name));
1479
+ const tomlNames = new Set(entries.filter(entry => extname(entry.name) === '.toml')
1480
+ .map(entry => entry.name.slice(0, -'.toml'.length)));
1481
+ for (const entry of entries) {
1482
+ const extension = extname(entry.name);
1483
+ const name = entry.name.slice(0, -extension.length);
1484
+ if (extension === '.md' && tomlNames.has(name))
1485
+ continue;
1486
+ if (!COMMAND_NAME.test(name))
1487
+ throw new Error(`plugin runtime: command filename "${entry.name}" must use lower kebab-case`);
1488
+ const path = await existingInside(root, join('commands', entry.name), 'command');
1489
+ if (extension === '.md') {
1490
+ commands.push(parseMarkdownCommand(name, await readBoundedText(path, MAX_FILE_BYTES, 'command'), entry.name));
1491
+ continue;
1492
+ }
1493
+ let definition;
1494
+ try {
1495
+ definition = parseToml(await readBoundedText(path, MAX_FILE_BYTES, 'command'));
1496
+ }
1497
+ catch (error) {
1498
+ throw new Error(`plugin runtime: command "${entry.name}" is invalid TOML`, { cause: error });
1499
+ }
1500
+ if (!isRecord(definition) || typeof definition.description !== 'string' || definition.description.trim() === ''
1501
+ || typeof definition.prompt !== 'string' || definition.prompt.trim() === '') {
1502
+ throw new Error(`plugin runtime: command "${entry.name}" requires non-empty string description and prompt`);
1503
+ }
1504
+ commands.push({ name, description: definition.description, prompt: definition.prompt });
1505
+ }
1506
+ return commands.sort((left, right) => left.name.localeCompare(right.name));
1507
+ }
1508
+ /** Parse one Codex/Claude-style Markdown command without executing metadata. */
1509
+ function parseMarkdownCommand(name, raw, filename) {
1510
+ const match = /^---\r?\n([\s\S]*?\r?\n)---(?:\r?\n|$)/u.exec(raw);
1511
+ let metadata = {};
1512
+ let prompt = raw;
1513
+ if (match !== null) {
1514
+ try {
1515
+ /* v8 ignore next -- the frontmatter regex always captures its YAML body. */
1516
+ const parsed = parseYaml(match[1] ?? '');
1517
+ if (!isRecord(parsed))
1518
+ throw new Error('frontmatter must be a YAML object');
1519
+ metadata = parsed;
1520
+ }
1521
+ catch (error) {
1522
+ throw new Error(`plugin runtime: command "${filename}" has invalid YAML frontmatter`, { cause: error });
1523
+ }
1524
+ prompt = raw.slice(match[0].length);
1525
+ }
1526
+ prompt = prompt.trim();
1527
+ if (prompt === '')
1528
+ throw new Error(`plugin runtime: command "${filename}" requires a non-empty Markdown body`);
1529
+ const description = metadata.description === undefined
1530
+ ? firstMarkdownLine(prompt)
1531
+ : typeof metadata.description === 'string' ? metadata.description.trim() : '';
1532
+ if (description === '')
1533
+ throw new Error(`plugin runtime: command "${filename}" requires a non-empty string description`);
1534
+ const hint = metadata['argument-hint'];
1535
+ if (hint !== undefined && (typeof hint !== 'string' || hint.trim() === '')) {
1536
+ throw new Error(`plugin runtime: command "${filename}" argument-hint must be a non-empty string`);
1537
+ }
1538
+ return {
1539
+ name,
1540
+ description,
1541
+ prompt,
1542
+ ...typeof hint === 'string' ? { inputHint: hint.trim() } : {},
1543
+ };
1544
+ }
1545
+ function firstMarkdownLine(prompt) {
1546
+ /* v8 ignore next -- split always returns at least one line. */
1547
+ const line = prompt.split(/\r?\n/u)[0] ?? '';
1548
+ return line.replace(/^#{1,6}\s+/u, '').trim();
1549
+ }
1550
+ async function discoverMcp(root, manifest, identity) {
1551
+ const documents = [];
1552
+ const sources = stringPaths(manifest.mcpServers);
1553
+ if (manifest.mcpServers === undefined && await exists(join(root, '.mcp.json')))
1554
+ sources.push('.mcp.json');
1555
+ for (const path of sources) {
1556
+ if (manifest.format === 'portable' && path === './mcp.json' && !await exists(join(root, path)))
1557
+ continue;
1558
+ documents.push(await readBoundedJson(await existingInside(root, path, 'MCP configuration'), MAX_MANIFEST_BYTES, 'MCP configuration'));
1559
+ }
1560
+ if (isRecord(manifest.mcpServers))
1561
+ documents.push(manifest.mcpServers);
1562
+ const result = [];
1563
+ for (const document of documents) {
1564
+ const map = normalizeMcpMap(document);
1565
+ for (const [name, definition] of Object.entries(map)) {
1566
+ if (!isRecord(definition))
1567
+ throw new Error(`plugin runtime: MCP server ${name} must be an object`);
1568
+ result.push({ name, config: normalizeMcpConfig(root, identity, name, definition) });
1569
+ }
1570
+ }
1571
+ const seen = new Set();
1572
+ for (const server of result) {
1573
+ if (seen.has(server.name))
1574
+ throw new Error(`plugin runtime: duplicate MCP server ${server.name}`);
1575
+ seen.add(server.name);
1576
+ }
1577
+ return result;
1578
+ }
1579
+ function normalizeMcpMap(raw) {
1580
+ if (!isRecord(raw))
1581
+ throw new Error('plugin runtime: MCP configuration must be an object');
1582
+ if (isRecord(raw.mcp_servers))
1583
+ return raw.mcp_servers;
1584
+ if (isRecord(raw.mcpServers))
1585
+ return raw.mcpServers;
1586
+ return raw;
1587
+ }
1588
+ function normalizeMcpConfig(root, identity, name, raw) {
1589
+ const serverName = mcpServerName(identity, name);
1590
+ const type = raw.type === undefined ? undefined : string(raw.type).trim().toLowerCase();
1591
+ if (raw.type !== undefined && type !== 'stdio' && type !== 'http' && type !== 'streamable_http' && type !== 'streamable-http') {
1592
+ throw new Error(`plugin runtime: MCP server ${name} has unsupported transport type`);
1593
+ }
1594
+ const isHttp = type === 'http' || type === 'streamable_http' || type === 'streamable-http' || (type === undefined && typeof raw.url === 'string');
1595
+ if (isHttp) {
1596
+ if (typeof raw.url !== 'string' || raw.url.trim() === '')
1597
+ throw new Error(`plugin runtime: MCP server ${name} requires url for HTTP transport`);
1598
+ if (raw.oauth !== undefined || raw.oauth_resource !== undefined) {
1599
+ throw new Error(`plugin runtime: MCP server ${name} OAuth metadata is not supported by the local MCP client`);
1600
+ }
1601
+ const bearerTokenEnvVar = raw.bearer_token_env_var === undefined ? undefined : environmentVariableName(raw.bearer_token_env_var, `MCP ${name}.bearer_token_env_var`);
1602
+ return {
1603
+ transport: 'streamable-http', serverName, url: raw.url,
1604
+ headers: stringRecord(raw.headers ?? raw.http_headers, `MCP ${name}.headers`),
1605
+ ...bearerTokenEnvVar === undefined ? {} : { bearerTokenEnvVar },
1606
+ toolCallTimeoutMs: 60_000, failOnStartupError: true,
1607
+ };
1608
+ }
1609
+ if (typeof raw.command !== 'string' || raw.command.trim() === '')
1610
+ throw new Error(`plugin runtime: MCP server ${name} requires command or url`);
1611
+ if (raw.bearer_token_env_var !== undefined || raw.oauth !== undefined || raw.oauth_resource !== undefined) {
1612
+ throw new Error(`plugin runtime: MCP server ${name} authentication metadata requires HTTP transport`);
1613
+ }
1614
+ const cwd = raw.cwd === undefined ? root : resolveInside(root, string(raw.cwd), `MCP ${name}.cwd`);
1615
+ return {
1616
+ transport: 'stdio', serverName, command: raw.command,
1617
+ args: raw.args === undefined ? [] : stringArray(raw.args, `MCP ${name}.args`),
1618
+ env: stringRecord(raw.env, `MCP ${name}.env`), cwd, toolCallTimeoutMs: 60_000, failOnStartupError: true,
1619
+ };
1620
+ }
1621
+ async function discoverHooks(root, manifest) {
1622
+ const hooks = [];
1623
+ if (manifest.hooks === undefined) {
1624
+ for (const entry of ['hooks/hooks.json', 'hooks.json']) {
1625
+ if (!await exists(join(root, entry)))
1626
+ continue;
1627
+ hooks.push({ label: entry, raw: record(await readBoundedJson(await existingInside(root, entry, 'hooks configuration'), MAX_MANIFEST_BYTES, 'hooks configuration'), 'hooks configuration') });
1628
+ }
1629
+ }
1630
+ const value = manifest.hooks;
1631
+ const entries = Array.isArray(value) ? value : value === undefined ? [] : [value];
1632
+ for (const [index, entry] of entries.entries()) {
1633
+ if (typeof entry === 'string') {
1634
+ const path = await existingInside(root, entry, 'hooks configuration');
1635
+ hooks.push({ label: entry, raw: record(await readBoundedJson(path, MAX_MANIFEST_BYTES, 'hooks configuration'), 'hooks configuration') });
1636
+ }
1637
+ else if (isRecord(entry)) {
1638
+ hooks.push({ label: `manifest hooks ${String(index + 1)}`, raw: entry });
1639
+ }
1640
+ else {
1641
+ throw new Error('plugin runtime: manifest hooks must be paths or objects');
1642
+ }
1643
+ }
1644
+ for (const hook of hooks) {
1645
+ const parsed = parseCodexConfig(hook.raw);
1646
+ if (Object.keys(parsed.config).length === 0) {
1647
+ throw new Error(`plugin runtime: ${hook.label} contains no supported synchronous command hooks`);
1648
+ }
1649
+ }
1650
+ return hooks;
1651
+ }
1652
+ async function discoverApps(root, manifest) {
1653
+ const mappings = {};
1654
+ if (await exists(join(root, '.app.json'))) {
1655
+ Object.assign(mappings, record(await readBoundedJson(await existingInside(root, '.app.json', '.app.json'), MAX_MANIFEST_BYTES, '.app.json'), '.app.json'));
1656
+ }
1657
+ if (typeof manifest.apps === 'string' || Array.isArray(manifest.apps)) {
1658
+ for (const path of stringPaths(manifest.apps)) {
1659
+ Object.assign(mappings, record(await readBoundedJson(await existingInside(root, path, 'app mapping'), MAX_MANIFEST_BYTES, 'app mapping'), 'app mapping'));
1660
+ }
1661
+ }
1662
+ else if (isRecord(manifest.apps)) {
1663
+ Object.assign(mappings, manifest.apps);
1664
+ }
1665
+ return Object.keys(mappings).length === 0 ? undefined : mappings;
1666
+ }
1667
+ function mergeHookDefinitions(definitions) {
1668
+ const hooks = {};
1669
+ for (const definition of definitions) {
1670
+ const source = isRecord(definition.raw.hooks) ? definition.raw.hooks : definition.raw;
1671
+ for (const [event, groups] of Object.entries(source)) {
1672
+ if (!Array.isArray(groups))
1673
+ continue;
1674
+ // Array.isArray narrows the entry value to any[]; re-root it in unknown
1675
+ // before spreading so the stored hook group keeps a checked type.
1676
+ hooks[event] = [...hooks[event] ?? [], ...groups];
1677
+ }
1678
+ }
1679
+ return { hooks };
1680
+ }
1681
+ function initializeMcpState(definitions, old) {
1682
+ return Object.fromEntries(definitions.map(({ name }) => [name, old?.[name] ?? {
1683
+ // New MCP components require an explicit user enablement decision.
1684
+ enabled: false, defaultToolsApprovalMode: 'ask', toolApproval: {},
1685
+ }]));
1686
+ }
1687
+ async function validateBundleTree(root) {
1688
+ const metadata = await lstat(root);
1689
+ if (!metadata.isDirectory() || metadata.isSymbolicLink())
1690
+ throw new Error('plugin runtime: plugin root must be a real directory');
1691
+ const canonical = await realpath(root);
1692
+ let total = 0;
1693
+ let count = 0;
1694
+ const walk = async (directory) => {
1695
+ const actual = await realpath(directory);
1696
+ if (!isInside(canonical, actual))
1697
+ throw new Error('plugin runtime: symbolic link or junction escapes plugin root');
1698
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
1699
+ const path = join(directory, entry.name);
1700
+ const info = await lstat(path);
1701
+ if (info.isSymbolicLink())
1702
+ throw new Error(`plugin runtime: symbolic links are not allowed (${path})`);
1703
+ count += 1;
1704
+ if (count > MAX_FILES)
1705
+ throw new Error(`plugin runtime: plugin contains more than ${String(MAX_FILES)} files`);
1706
+ if (info.isDirectory())
1707
+ await walk(path);
1708
+ else if (info.isFile()) {
1709
+ if (info.size > MAX_FILE_BYTES)
1710
+ throw new Error(`plugin runtime: file exceeds ${String(MAX_FILE_BYTES)} bytes`);
1711
+ total += info.size;
1712
+ if (total > MAX_PACKAGE_BYTES)
1713
+ throw new Error(`plugin runtime: package exceeds ${String(MAX_PACKAGE_BYTES)} bytes`);
1714
+ }
1715
+ else
1716
+ throw new Error(`plugin runtime: unsupported filesystem entry (${path})`);
1717
+ }
1718
+ };
1719
+ await walk(canonical);
1720
+ return canonical;
1721
+ }
1722
+ async function copyTree(source, destination) {
1723
+ await mkdir(destination, { recursive: true, mode: 0o700 });
1724
+ for (const entry of await readdir(source, { withFileTypes: true })) {
1725
+ const from = join(source, entry.name);
1726
+ const to = join(destination, entry.name);
1727
+ const info = await lstat(from);
1728
+ if (info.isSymbolicLink())
1729
+ throw new Error('plugin runtime: symbolic links are not allowed');
1730
+ if (info.isDirectory())
1731
+ await copyTree(from, to);
1732
+ else if (info.isFile())
1733
+ await copyFile(from, to);
1734
+ else
1735
+ throw new Error('plugin runtime: unsupported filesystem entry');
1736
+ }
1737
+ }
1738
+ function assertRelativePluginPath(path, label) {
1739
+ if (path.length === 0 || path.includes('\0') || isAbsolute(path) || /^[A-Za-z]:/u.test(path) || /^\\\\/u.test(path) || path.startsWith('/') || path.startsWith('\\')) {
1740
+ throw new Error(`plugin runtime: ${label} must be relative to the plugin root`);
1741
+ }
1742
+ const normalized = path.replaceAll('\\', '/').replace(/\/+$/u, '');
1743
+ const segments = normalized.split('/');
1744
+ if (segments.some(segment => segment === '..' || segment === ''))
1745
+ throw new Error(`plugin runtime: ${label} cannot traverse outside the plugin root`);
1746
+ }
1747
+ function resolveInside(root, path, label) {
1748
+ assertRelativePluginPath(path, label);
1749
+ return resolve(root, path);
1750
+ }
1751
+ async function existingInside(root, path, label) {
1752
+ const candidate = resolveInside(root, path, label);
1753
+ const info = await lstat(candidate);
1754
+ if (info.isSymbolicLink())
1755
+ throw new Error(`plugin runtime: ${label} must not be a symbolic link`);
1756
+ const actual = await realpath(candidate);
1757
+ if (!isInside(await realpath(root), actual))
1758
+ throw new Error(`plugin runtime: ${label} escapes plugin root`);
1759
+ return actual;
1760
+ }
1761
+ function assertStoreDescendant(root, path) {
1762
+ if (!isInside(resolve(root), resolve(path)))
1763
+ throw new Error('plugin runtime: refusing to remove outside the plugin store');
1764
+ }
1765
+ function isInside(root, path) {
1766
+ const child = relative(resolve(root), resolve(path));
1767
+ return child === '' || (!child.startsWith(`..${sep}`) && child !== '..' && !isAbsolute(child));
1768
+ }
1769
+ async function readBoundedJson(path, limit, label) {
1770
+ return JSON.parse(await readBoundedText(path, limit, label));
1771
+ }
1772
+ async function readBoundedText(path, limit, label) {
1773
+ const info = await lstat(path);
1774
+ if (!info.isFile() || info.isSymbolicLink() || info.size > limit)
1775
+ throw new Error(`plugin runtime: ${label} is invalid or too large`);
1776
+ const text = await readFile(path, 'utf8');
1777
+ if (Buffer.byteLength(text) > limit)
1778
+ throw new Error(`plugin runtime: ${label} is too large`);
1779
+ return text;
1780
+ }
1781
+ function string(value) { return typeof value === 'string' ? value : ''; }
1782
+ function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); }
1783
+ function record(value, label) {
1784
+ if (!isRecord(value))
1785
+ throw new Error(`plugin runtime: ${label} must be an object`);
1786
+ return value;
1787
+ }
1788
+ function stringArray(value, label) {
1789
+ // Validate against unknown[] first: Array.isArray on unknown yields any[],
1790
+ // whose spread/index would leak any into the returned strings.
1791
+ const items = Array.isArray(value) ? value : [];
1792
+ if (!Array.isArray(value) || items.some(item => typeof item !== 'string'))
1793
+ throw new Error(`plugin runtime: ${label} must be a string array`);
1794
+ return items.filter((item) => typeof item === 'string');
1795
+ }
1796
+ function stringRecord(value, label) {
1797
+ if (value === undefined)
1798
+ return {};
1799
+ if (!isRecord(value) || Object.values(value).some(item => typeof item !== 'string'))
1800
+ throw new Error(`plugin runtime: ${label} must be a string map`);
1801
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, item]));
1802
+ }
1803
+ function environmentVariableName(value, label) {
1804
+ if (typeof value !== 'string' || value.length === 0 || /[=\u0000-\u001F\u007F]/u.test(value))
1805
+ throw new Error(`plugin runtime: ${label} must be a valid environment variable name`);
1806
+ return value;
1807
+ }
1808
+ function stringPaths(value) {
1809
+ if (value === undefined || isRecord(value))
1810
+ return [];
1811
+ const paths = typeof value === 'string' ? [value] : stringArray(value, 'manifest paths');
1812
+ for (const path of paths)
1813
+ assertRelativePluginPath(path, 'manifest path');
1814
+ return paths;
1815
+ }
1816
+ function isMissing(error) { return error?.code === 'ENOENT' || error?.code === 'ENOTDIR'; }
1817
+ async function exists(path) {
1818
+ try {
1819
+ await lstat(path);
1820
+ return true;
1821
+ }
1822
+ catch (error) {
1823
+ if (isMissing(error))
1824
+ return false;
1825
+ throw error;
1826
+ }
1827
+ }
1828
+ function stableJson(value) {
1829
+ if (Array.isArray(value))
1830
+ return `[${value.map(stableJson).join(',')}]`;
1831
+ if (isRecord(value))
1832
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
1833
+ // JSON.stringify returns undefined for functions/symbols at runtime even
1834
+ // though its type says string; fall back to null like JSON does.
1835
+ /* oxlint-disable-next-line typescript/no-unnecessary-condition -- load-bearing: stringify returns undefined for function/symbol leaves */
1836
+ return JSON.stringify(value) ?? 'null';
1837
+ }
1838
+ //# sourceMappingURL=index.js.map