release-skill 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,1365 @@
1
+ /**
2
+ * Plugin marketplace adapter for release-skill.
3
+ *
4
+ * Validates generated Claude/Codex plugin manifests and installable content.
5
+ * Uses `execFile` to call `node` for manifest validation. Never uses `exec`,
6
+ * `execSync`, or `shell: true`.
7
+ *
8
+ * Marketplace install actions only require
9
+ * `context.isolatedConsumerWritesAuthorized === true`; they write to
10
+ * isolated consumer directories, not to remote services.
11
+ *
12
+ * @module adapters/plugin-marketplace
13
+ */
14
+
15
+ import { execFile as execFileCb } from 'node:child_process';
16
+ import { promisify } from 'node:util';
17
+ import { readFile, stat, mkdir, writeFile, rename, readdir, rm, realpath, lstat } from 'node:fs/promises';
18
+ import { join, resolve, relative, isAbsolute, basename } from 'node:path';
19
+
20
+ import {
21
+ ActionType,
22
+ ActionStatus,
23
+ createResult,
24
+ assertWritesAuthorized,
25
+ assertIsolatedConsumerWritesAuthorized,
26
+ matchObservation,
27
+ } from './contract.mjs';
28
+
29
+ import { createHash } from 'node:crypto';
30
+ import { computeFrozenSnapshot, resolveFrozenPath } from '../snapshot/frozen.mjs';
31
+
32
+ const execFile = promisify(execFileCb);
33
+
34
+ const NAME = 'plugin-marketplace';
35
+
36
+ async function writeEvidenceAtomic(filePath, value) {
37
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
38
+ try {
39
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
40
+ await rename(tempPath, filePath);
41
+ } catch (err) {
42
+ await rm(tempPath, { force: true }).catch(() => {});
43
+ throw err;
44
+ }
45
+ }
46
+
47
+ const SUPPORTED_TYPES = [
48
+ ActionType.PLUGIN_MANIFEST_VALIDATE,
49
+ ActionType.PLUGIN_INSTALL_CHECK,
50
+ ActionType.CLAUDE_MARKETPLACE_INSTALL,
51
+ ActionType.CODEX_MARKETPLACE_INSTALL,
52
+ ];
53
+
54
+ /** Safe identifier pattern: lowercase alphanumeric, hyphens, dots, underscores. */
55
+ const SAFE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
56
+
57
+ /** Safe repo pattern: owner/repo with alphanumeric, hyphens, dots, underscores. */
58
+ const SAFE_REPO_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\/[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
59
+
60
+ /**
61
+ * Strict semver pattern: supports prerelease and build metadata.
62
+ * Matches: 1.0.0, 1.0.0-beta.1, 1.0.0-rc.1+build.123
63
+ */
64
+ const STRICT_SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
65
+
66
+ /**
67
+ * Validate a Git ref for injection safety.
68
+ * Rejects: backslash, //, leading/trailing /, trailing ., .lock, @{, standalone @,
69
+ * .., control characters, option-like values.
70
+ *
71
+ * @param {string} ref
72
+ * @returns {{ valid: boolean, error: string|null }}
73
+ */
74
+ function validateSafeRef(ref) {
75
+ if (!ref || typeof ref !== 'string') {
76
+ return { valid: false, error: 'ref is required' };
77
+ }
78
+ if (/[\x00-\x1f]/.test(ref)) {
79
+ return { valid: false, error: 'ref contains control characters' };
80
+ }
81
+ if (ref.startsWith('-')) {
82
+ return { valid: false, error: `ref must not start with '-': "${ref}"` };
83
+ }
84
+ if (ref.includes('\\')) {
85
+ return { valid: false, error: 'ref contains backslash' };
86
+ }
87
+ if (ref.includes('//')) {
88
+ return { valid: false, error: 'ref contains //' };
89
+ }
90
+ if (ref.startsWith('/') || ref.endsWith('/')) {
91
+ return { valid: false, error: 'ref must not start or end with /' };
92
+ }
93
+ if (ref.endsWith('.')) {
94
+ return { valid: false, error: 'ref must not end with .' };
95
+ }
96
+ if (ref.endsWith('.lock')) {
97
+ return { valid: false, error: 'ref must not end with .lock' };
98
+ }
99
+ if (ref.includes('@{')) {
100
+ return { valid: false, error: 'ref contains @{' };
101
+ }
102
+ if (ref === '@') {
103
+ return { valid: false, error: 'ref must not be standalone @' };
104
+ }
105
+ if (ref.includes('..')) {
106
+ return { valid: false, error: 'ref contains ..' };
107
+ }
108
+ if (/[;|&`$(){}]/.test(ref)) {
109
+ return { valid: false, error: 'ref contains shell metacharacters' };
110
+ }
111
+ // Must match safe alphanumeric pattern
112
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(ref)) {
113
+ return { valid: false, error: `unsafe ref: "${ref}"` };
114
+ }
115
+ return { valid: true, error: null };
116
+ }
117
+
118
+ /**
119
+ * Validate marketplace install parameters for injection-safe values.
120
+ *
121
+ * @param {object} params - The action parameters.
122
+ * @returns {{ valid: boolean, error: string|null }}
123
+ */
124
+ function validateMarketplaceParams(params) {
125
+ if (!params || typeof params !== 'object') {
126
+ return { valid: false, error: 'parameters must be an object' };
127
+ }
128
+ const { consumer, plugin, marketplace, repo, version, entrySkill } = params;
129
+ if (!['claude', 'codex'].includes(consumer)) {
130
+ return { valid: false, error: `invalid consumer: "${consumer}"` };
131
+ }
132
+ if (!plugin || !SAFE_ID_RE.test(plugin)) {
133
+ return { valid: false, error: `unsafe plugin identifier: "${plugin}"` };
134
+ }
135
+ if (!marketplace || !SAFE_ID_RE.test(marketplace)) {
136
+ return { valid: false, error: `unsafe marketplace identifier: "${marketplace}"` };
137
+ }
138
+ if (!repo || !SAFE_REPO_RE.test(repo)) {
139
+ return { valid: false, error: `unsafe repo identifier: "${repo}"` };
140
+ }
141
+ if (!version || !STRICT_SEMVER_RE.test(version)) {
142
+ return { valid: false, error: `unsafe version (must be valid semver): "${version}"` };
143
+ }
144
+ if (!entrySkill || !SAFE_ID_RE.test(entrySkill)) {
145
+ return { valid: false, error: `unsafe entrySkill: "${entrySkill}"` };
146
+ }
147
+ return { valid: true, error: null };
148
+ }
149
+
150
+
151
+ /**
152
+ * Run a CLI command using execFile (never shell: true).
153
+ */
154
+ async function run(cmd, args, options = {}) {
155
+ return execFile(cmd, args, {
156
+ shell: false,
157
+ encoding: 'utf8',
158
+ timeout: 30_000,
159
+ ...options,
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Validate that a manifest file exists and contains required fields.
165
+ *
166
+ * @param {string} manifestPath - Absolute path to the manifest JSON file.
167
+ * @param {string[]} requiredFields - Fields that must be present.
168
+ * @returns {Promise<{ valid: boolean, manifest: Object|null, missing: string[], error: string|null }>}
169
+ */
170
+ async function validateManifestFile(manifestPath, requiredFields) {
171
+ try {
172
+ const content = await readFile(manifestPath, 'utf8');
173
+ const manifest = JSON.parse(content);
174
+
175
+ const missing = requiredFields.filter((f) => !(f in manifest));
176
+
177
+ return {
178
+ valid: missing.length === 0,
179
+ manifest,
180
+ missing,
181
+ error: missing.length > 0 ? `Missing required fields: ${missing.join(', ')}` : null,
182
+ };
183
+ } catch (err) {
184
+ return {
185
+ valid: false,
186
+ manifest: null,
187
+ missing: requiredFields,
188
+ error: `Failed to read manifest: ${err.message}`,
189
+ };
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Check that required files exist in a directory.
195
+ *
196
+ * @param {string} dir - Absolute path to check.
197
+ * @param {string[]} requiredFiles - File paths relative to dir.
198
+ * @returns {Promise<{ allPresent: boolean, missing: string[] }>}
199
+ */
200
+ async function checkRequiredFiles(dir, requiredFiles) {
201
+ const missing = [];
202
+ for (const file of requiredFiles) {
203
+ try {
204
+ await stat(resolve(dir, file));
205
+ } catch {
206
+ missing.push(file);
207
+ }
208
+ }
209
+ return { allPresent: missing.length === 0, missing };
210
+ }
211
+
212
+ /**
213
+ * Create the plugin-marketplace adapter.
214
+ *
215
+ * @param {Object} [deps]
216
+ * @param {typeof run} [deps.exec] - Injectable exec function for testing.
217
+ * @returns {import('./contract.mjs').Adapter}
218
+ */
219
+ export function createPluginMarketplaceAdapter(deps = {}) {
220
+ const exec = deps.exec ?? run;
221
+
222
+ return Object.freeze({
223
+ name: NAME,
224
+ actionTypes: SUPPORTED_TYPES,
225
+
226
+ /**
227
+ * Preflight: read-only checks before execution.
228
+ * Fail-closed: snapshotPath, ref, manifestDigest are required for
229
+ * marketplace install actions.
230
+ */
231
+ async preflight(action, context) {
232
+ const { actionType } = action;
233
+
234
+ try {
235
+ if (actionType === ActionType.PLUGIN_MANIFEST_VALIDATE) {
236
+ const manifestPath = action.manifestPath;
237
+ if (!manifestPath) {
238
+ return createResult({
239
+ actionType,
240
+ status: ActionStatus.PREFLIGHT_FAILED,
241
+ error: 'manifestPath is required',
242
+ });
243
+ }
244
+
245
+ // Read-only check: manifest file exists and is parseable
246
+ const result = await validateManifestFile(manifestPath, [
247
+ 'name',
248
+ 'version',
249
+ 'description',
250
+ ]);
251
+
252
+ if (!result.valid) {
253
+ return createResult({
254
+ actionType,
255
+ status: ActionStatus.PREFLIGHT_FAILED,
256
+ error: result.error,
257
+ });
258
+ }
259
+
260
+ return createResult({
261
+ actionType,
262
+ status: ActionStatus.PREFLIGHT_PASSED,
263
+ });
264
+ }
265
+
266
+ if (actionType === ActionType.PLUGIN_INSTALL_CHECK) {
267
+ const pluginDir = action.pluginDir;
268
+ if (!pluginDir) {
269
+ return createResult({
270
+ actionType,
271
+ status: ActionStatus.PREFLIGHT_FAILED,
272
+ error: 'pluginDir is required',
273
+ });
274
+ }
275
+
276
+ // Check directory exists
277
+ try {
278
+ const s = await stat(pluginDir);
279
+ if (!s.isDirectory()) {
280
+ return createResult({
281
+ actionType,
282
+ status: ActionStatus.PREFLIGHT_FAILED,
283
+ error: `pluginDir is not a directory: ${pluginDir}`,
284
+ });
285
+ }
286
+ } catch {
287
+ return createResult({
288
+ actionType,
289
+ status: ActionStatus.PREFLIGHT_FAILED,
290
+ error: `pluginDir does not exist: ${pluginDir}`,
291
+ });
292
+ }
293
+
294
+ return createResult({
295
+ actionType,
296
+ status: ActionStatus.PREFLIGHT_PASSED,
297
+ });
298
+ }
299
+
300
+ // Marketplace install preflight: fail-closed validation
301
+ if (
302
+ actionType === ActionType.CLAUDE_MARKETPLACE_INSTALL ||
303
+ actionType === ActionType.CODEX_MARKETPLACE_INSTALL
304
+ ) {
305
+ // 1. Validate all parameters for injection safety
306
+ const validation = validateMarketplaceParams(action);
307
+ if (!validation.valid) {
308
+ return createResult({
309
+ actionType,
310
+ status: ActionStatus.PREFLIGHT_FAILED,
311
+ error: validation.error,
312
+ });
313
+ }
314
+
315
+ // 2. ref is required and must be safe
316
+ const ref = action.ref;
317
+ if (!ref) {
318
+ return createResult({
319
+ actionType,
320
+ status: ActionStatus.PREFLIGHT_FAILED,
321
+ error: 'ref is required for marketplace install',
322
+ });
323
+ }
324
+ const refValidation = validateSafeRef(ref);
325
+ if (!refValidation.valid) {
326
+ return createResult({
327
+ actionType,
328
+ status: ActionStatus.PREFLIGHT_FAILED,
329
+ error: refValidation.error,
330
+ });
331
+ }
332
+
333
+ // 3. snapshotPath is required
334
+ const snapshotPath = action.snapshotPath;
335
+ if (!snapshotPath) {
336
+ return createResult({
337
+ actionType,
338
+ status: ActionStatus.PREFLIGHT_FAILED,
339
+ error: 'snapshotPath is required for marketplace install',
340
+ });
341
+ }
342
+
343
+ // 4. manifestDigest is required
344
+ const manifestDigest = action.manifestDigest;
345
+ if (!manifestDigest || typeof manifestDigest !== 'string') {
346
+ return createResult({
347
+ actionType,
348
+ status: ActionStatus.PREFLIGHT_FAILED,
349
+ error: 'manifestDigest is required for marketplace install',
350
+ });
351
+ }
352
+ if (!/^[a-f0-9]{64}$/.test(manifestDigest)) {
353
+ return createResult({
354
+ actionType,
355
+ status: ActionStatus.PREFLIGHT_FAILED,
356
+ error: `manifestDigest must be a 64-char lowercase hex string`,
357
+ });
358
+ }
359
+
360
+ // 5. Validate context (root and runDir required)
361
+ if (!context?.root) {
362
+ return createResult({
363
+ actionType,
364
+ status: ActionStatus.PREFLIGHT_FAILED,
365
+ error: 'context.root is required for marketplace install',
366
+ });
367
+ }
368
+ if (!context.runDir) {
369
+ return createResult({
370
+ actionType,
371
+ status: ActionStatus.PREFLIGHT_FAILED,
372
+ error: 'context.runDir is required for marketplace install',
373
+ });
374
+ }
375
+
376
+ // 6. Verify frozen snapshot exists and contains required marketplace files
377
+ const consumer = action.consumer;
378
+ let snapshotDirReal;
379
+ try {
380
+ snapshotDirReal = await resolveFrozenPath(context.root, snapshotPath, 'frozen snapshot path');
381
+ } catch (frozenErr) {
382
+ return createResult({
383
+ actionType,
384
+ status: ActionStatus.PREFLIGHT_FAILED,
385
+ error: `frozen snapshot validation failed: ${frozenErr.message}`,
386
+ });
387
+ }
388
+
389
+ // Verify marketplace files exist
390
+ const manifestRelative = consumer === 'claude'
391
+ ? '.claude-plugin/plugin.json'
392
+ : '.codex-plugin/plugin.json';
393
+ const marketplaceRelative = consumer === 'claude'
394
+ ? '.claude-plugin/marketplace.json'
395
+ : '.agents/plugins/marketplace.json';
396
+
397
+ const manifestPath = resolve(snapshotDirReal, manifestRelative);
398
+ const marketplacePath = resolve(snapshotDirReal, marketplaceRelative);
399
+
400
+ const manifestResult = await validateManifestFile(manifestPath, ['name', 'version']);
401
+ if (!manifestResult.valid) {
402
+ return createResult({
403
+ actionType,
404
+ status: ActionStatus.PREFLIGHT_FAILED,
405
+ error: `frozen snapshot ${manifestRelative} invalid: ${manifestResult.error}`,
406
+ });
407
+ }
408
+
409
+ // marketplace.json must exist and have root name (no root version required)
410
+ const marketplaceResult = await validateManifestFile(marketplacePath, ['name']);
411
+ if (!marketplaceResult.valid) {
412
+ return createResult({
413
+ actionType,
414
+ status: ActionStatus.PREFLIGHT_FAILED,
415
+ error: `frozen snapshot ${marketplaceRelative} invalid: ${marketplaceResult.error}`,
416
+ });
417
+ }
418
+
419
+ // Root name must equal action.marketplace
420
+ if (marketplaceResult.manifest.name !== action.marketplace) {
421
+ return createResult({
422
+ actionType,
423
+ status: ActionStatus.PREFLIGHT_FAILED,
424
+ error: `marketplace.json name "${marketplaceResult.manifest.name}" does not match action marketplace "${action.marketplace}"`,
425
+ });
426
+ }
427
+
428
+ // plugins[] must exist with exactly one entry matching action.plugin
429
+ const plugins = marketplaceResult.manifest.plugins;
430
+ if (!Array.isArray(plugins)) {
431
+ return createResult({
432
+ actionType,
433
+ status: ActionStatus.PREFLIGHT_FAILED,
434
+ error: `${marketplaceRelative} must have a plugins[] array`,
435
+ });
436
+ }
437
+ const pluginEntry = plugins.filter((p) => p.name === action.plugin);
438
+ if (pluginEntry.length !== 1) {
439
+ return createResult({
440
+ actionType,
441
+ status: ActionStatus.PREFLIGHT_FAILED,
442
+ error: `expected exactly one plugins[] entry with name "${action.plugin}", found ${pluginEntry.length}`,
443
+ });
444
+ }
445
+ const entry = pluginEntry[0];
446
+
447
+ // Entry source must be "./" (Claude: string, Codex: object with path "./")
448
+ if (consumer === 'claude') {
449
+ if (entry.source !== './') {
450
+ return createResult({
451
+ actionType,
452
+ status: ActionStatus.PREFLIGHT_FAILED,
453
+ error: `Claude marketplace plugin entry source must be "./", got "${entry.source}"`,
454
+ });
455
+ }
456
+ } else {
457
+ if (entry.source?.source !== 'local' || entry.source?.path !== './') {
458
+ return createResult({
459
+ actionType,
460
+ status: ActionStatus.PREFLIGHT_FAILED,
461
+ error: `Codex marketplace plugin entry source must be {source:"local",path:"./"}, got ${JSON.stringify(entry.source)}`,
462
+ });
463
+ }
464
+ }
465
+
466
+ // Claude carries the version in the marketplace entry. Codex keeps
467
+ // the authoritative version in .codex-plugin/plugin.json.
468
+ if (consumer === 'claude' && entry.version !== action.version) {
469
+ return createResult({
470
+ actionType,
471
+ status: ActionStatus.PREFLIGHT_FAILED,
472
+ error: `marketplace plugin entry version "${entry.version}" does not match action version "${action.version}"`,
473
+ });
474
+ }
475
+
476
+ // Verify plugin manifest name/version match marketplace entry
477
+ const pluginManifestResult = await validateManifestFile(manifestPath, ['name', 'version']);
478
+ if (!pluginManifestResult.valid) {
479
+ return createResult({
480
+ actionType,
481
+ status: ActionStatus.PREFLIGHT_FAILED,
482
+ error: `frozen snapshot ${manifestRelative} invalid: ${pluginManifestResult.error}`,
483
+ });
484
+ }
485
+ if (pluginManifestResult.manifest.name !== entry.name) {
486
+ return createResult({
487
+ actionType,
488
+ status: ActionStatus.PREFLIGHT_FAILED,
489
+ error: `plugin manifest name "${pluginManifestResult.manifest.name}" does not match marketplace entry name "${entry.name}"`,
490
+ });
491
+ }
492
+ if (pluginManifestResult.manifest.version !== action.version) {
493
+ return createResult({
494
+ actionType,
495
+ status: ActionStatus.PREFLIGHT_FAILED,
496
+ error: `plugin manifest version "${pluginManifestResult.manifest.version}" does not match action version "${action.version}"`,
497
+ });
498
+ }
499
+
500
+ // Verify entrySkill file exists in snapshot
501
+ const entrySkillFile = resolve(snapshotDirReal, 'skills', action.entrySkill, 'SKILL.md');
502
+ try {
503
+ await stat(entrySkillFile);
504
+ } catch {
505
+ return createResult({
506
+ actionType,
507
+ status: ActionStatus.PREFLIGHT_FAILED,
508
+ error: `entry skill not found in snapshot: skills/${action.entrySkill}/SKILL.md`,
509
+ });
510
+ }
511
+
512
+ // Verify manifestDigest matches actual snapshot content using frozen algorithm
513
+ try {
514
+ const { digest: actualDigest } = await computeFrozenSnapshot(snapshotDirReal);
515
+ if (actualDigest !== manifestDigest) {
516
+ return createResult({
517
+ actionType,
518
+ status: ActionStatus.PREFLIGHT_FAILED,
519
+ error: `manifestDigest mismatch: expected ${manifestDigest.slice(0, 16)}..., actual ${actualDigest.slice(0, 16)}...`,
520
+ });
521
+ }
522
+ } catch (digestErr) {
523
+ return createResult({
524
+ actionType,
525
+ status: ActionStatus.PREFLIGHT_FAILED,
526
+ error: `failed to compute snapshot digest: ${digestErr.message}`,
527
+ });
528
+ }
529
+
530
+ return createResult({
531
+ actionType,
532
+ status: ActionStatus.PREFLIGHT_PASSED,
533
+ });
534
+ }
535
+
536
+ return createResult({
537
+ actionType,
538
+ status: ActionStatus.PREFLIGHT_FAILED,
539
+ error: `Unsupported action type: ${actionType}`,
540
+ });
541
+ } catch (err) {
542
+ return createResult({
543
+ actionType,
544
+ status: ActionStatus.PREFLIGHT_FAILED,
545
+ error: err.message,
546
+ });
547
+ }
548
+ },
549
+
550
+ /**
551
+ * Execute: perform the validation/write action. For marketplace,
552
+ * "execute" means running structured validation.
553
+ * Some actions require authorization (e.g., updating remote metadata).
554
+ */
555
+ async execute(action, context) {
556
+ const { actionType } = action;
557
+
558
+ // Plugin validation is read-only; no authorization needed for validate
559
+ // Only actual remote writes require authorization
560
+ if (actionType === ActionType.PLUGIN_MANIFEST_VALIDATE) {
561
+ try {
562
+ const manifestPath = action.manifestPath;
563
+ const requiredFields = action.requiredFields ?? ['name', 'version', 'description'];
564
+
565
+ const result = await validateManifestFile(manifestPath, requiredFields);
566
+
567
+ if (!result.valid) {
568
+ return createResult({
569
+ actionType,
570
+ status: ActionStatus.EXECUTE_FAILED,
571
+ error: result.error,
572
+ observation: { valid: false, missing: result.missing },
573
+ });
574
+ }
575
+
576
+ // Additional structural validation via node --check if a JS entry is specified
577
+ if (action.entryPoint) {
578
+ try {
579
+ await exec(process.execPath, ['--check', action.entryPoint]);
580
+ } catch (checkErr) {
581
+ return createResult({
582
+ actionType,
583
+ status: ActionStatus.EXECUTE_FAILED,
584
+ error: `Entry point syntax check failed: ${checkErr.message}`,
585
+ });
586
+ }
587
+ }
588
+
589
+ return createResult({
590
+ actionType,
591
+ status: ActionStatus.EXECUTED,
592
+ observation: {
593
+ valid: true,
594
+ manifest: result.manifest,
595
+ manifestPath,
596
+ },
597
+ });
598
+ } catch (err) {
599
+ return createResult({
600
+ actionType,
601
+ status: ActionStatus.EXECUTE_FAILED,
602
+ error: err.message,
603
+ });
604
+ }
605
+ }
606
+
607
+ if (actionType === ActionType.PLUGIN_INSTALL_CHECK) {
608
+ // Install check may involve writing temp files in some cases
609
+ // For now it's read-only, so no authorization check needed
610
+ try {
611
+ const { pluginDir, requiredFiles } = action;
612
+ const check = await checkRequiredFiles(pluginDir, requiredFiles ?? []);
613
+
614
+ if (!check.allPresent) {
615
+ return createResult({
616
+ actionType,
617
+ status: ActionStatus.EXECUTE_FAILED,
618
+ error: `Missing required files: ${check.missing.join(', ')}`,
619
+ observation: { allPresent: false, missing: check.missing },
620
+ });
621
+ }
622
+
623
+ // Smoke test: try loading the entry point
624
+ if (action.entryPoint) {
625
+ try {
626
+ await exec(process.execPath, ['--check', resolve(pluginDir, action.entryPoint)]);
627
+ } catch (checkErr) {
628
+ return createResult({
629
+ actionType,
630
+ status: ActionStatus.EXECUTE_FAILED,
631
+ error: `Install smoke test failed: ${checkErr.message}`,
632
+ });
633
+ }
634
+ }
635
+
636
+ return createResult({
637
+ actionType,
638
+ status: ActionStatus.EXECUTED,
639
+ observation: {
640
+ allPresent: true,
641
+ pluginDir,
642
+ checkedFiles: requiredFiles ?? [],
643
+ },
644
+ });
645
+ } catch (err) {
646
+ return createResult({
647
+ actionType,
648
+ status: ActionStatus.EXECUTE_FAILED,
649
+ error: err.message,
650
+ });
651
+ }
652
+ }
653
+
654
+ // Marketplace install execute
655
+ if (
656
+ actionType === ActionType.CLAUDE_MARKETPLACE_INSTALL ||
657
+ actionType === ActionType.CODEX_MARKETPLACE_INSTALL
658
+ ) {
659
+ try {
660
+ assertIsolatedConsumerWritesAuthorized(context, actionType);
661
+
662
+ const validation = validateMarketplaceParams(action);
663
+ if (!validation.valid) {
664
+ return createResult({
665
+ actionType,
666
+ status: ActionStatus.EXECUTE_FAILED,
667
+ error: validation.error,
668
+ });
669
+ }
670
+
671
+ // Validate context
672
+ if (!context?.root) {
673
+ return createResult({
674
+ actionType,
675
+ status: ActionStatus.EXECUTE_FAILED,
676
+ error: 'context.root is required for marketplace install',
677
+ });
678
+ }
679
+ if (!context.runDir) {
680
+ return createResult({
681
+ actionType,
682
+ status: ActionStatus.EXECUTE_FAILED,
683
+ error: 'context.runDir is required for marketplace install',
684
+ });
685
+ }
686
+
687
+ const consumer = action.consumer;
688
+ const runDir = context.runDir;
689
+ const isolatedHome = resolve(runDir, 'consumers', `${consumer}-${action.plugin}`);
690
+
691
+ // Verify consumer directory is inside runDir
692
+ const runDirReal = await realpath(runDir).catch(() => runDir);
693
+ const isolatedHomePreReal = await realpath(isolatedHome).catch(() => isolatedHome);
694
+ const relToRun = relative(runDirReal, isolatedHomePreReal);
695
+ const sepE = process.platform === 'win32' ? '\\' : '/';
696
+ if (relToRun !== '' && (isAbsolute(relToRun) || relToRun === '..' || relToRun.startsWith(`..${sepE}`))) {
697
+ return createResult({
698
+ actionType,
699
+ status: ActionStatus.EXECUTE_FAILED,
700
+ error: `consumer directory escapes runDir: ${isolatedHome}`,
701
+ });
702
+ }
703
+
704
+ // Create isolated HOME and required subdirectories
705
+ await mkdir(isolatedHome, { recursive: true, mode: 0o700 });
706
+ if (consumer === 'claude') {
707
+ await mkdir(resolve(isolatedHome, '.claude'), { recursive: true, mode: 0o700 });
708
+ } else {
709
+ await mkdir(resolve(isolatedHome, '.codex'), { recursive: true, mode: 0o700 });
710
+ }
711
+
712
+ const cliCmd = consumer === 'claude' ? 'claude' : 'codex';
713
+ const baseEnv = { ...process.env, ...context.env };
714
+ const env = {
715
+ ...baseEnv,
716
+ ...(consumer === 'claude'
717
+ ? { HOME: isolatedHome, CLAUDE_CONFIG_DIR: resolve(isolatedHome, '.claude') }
718
+ : { HOME: isolatedHome, CODEX_HOME: isolatedHome }),
719
+ };
720
+ // Ensure real HOME/CODEX_HOME don't leak back (already overridden above)
721
+
722
+ // Step 1: Add marketplace
723
+ const ref = action.ref ?? `v${action.version}`;
724
+ let addOutput;
725
+ const marketplaceArgs = consumer === 'claude'
726
+ ? ['plugin', 'marketplace', 'add', `${action.repo}@${ref}`]
727
+ : ['plugin', 'marketplace', 'add', action.repo, '--ref', ref, '--json'];
728
+ try {
729
+ const addResult = await exec(cliCmd, marketplaceArgs, { env, cwd: context.root });
730
+ if (consumer === 'codex') {
731
+ try {
732
+ addOutput = JSON.parse(addResult.stdout);
733
+ if (!addOutput || typeof addOutput !== 'object') {
734
+ return createResult({
735
+ actionType,
736
+ status: ActionStatus.EXECUTE_FAILED,
737
+ error: 'marketplace add returned invalid JSON output',
738
+ });
739
+ }
740
+ if (addOutput.marketplaceName !== action.marketplace) {
741
+ return createResult({
742
+ actionType,
743
+ status: ActionStatus.EXECUTE_FAILED,
744
+ error: `marketplace add marketplaceName "${addOutput.marketplaceName}" does not match action marketplace "${action.marketplace}"`,
745
+ });
746
+ }
747
+ } catch {
748
+ return createResult({
749
+ actionType,
750
+ status: ActionStatus.EXECUTE_FAILED,
751
+ error: 'marketplace add returned malformed JSON',
752
+ });
753
+ }
754
+ }
755
+ } catch (addErr) {
756
+ return createResult({
757
+ actionType,
758
+ status: ActionStatus.EXECUTE_FAILED,
759
+ error: `marketplace add failed: ${addErr.message}`,
760
+ });
761
+ }
762
+
763
+ // Step 2: Install plugin
764
+ let installOutput;
765
+ const installArgs = consumer === 'claude'
766
+ ? ['plugin', 'install', `${action.plugin}@${action.marketplace}`]
767
+ : ['plugin', 'add', `${action.plugin}@${action.marketplace}`, '--json'];
768
+ try {
769
+ const installResult = await exec(cliCmd, installArgs, { env, cwd: context.root });
770
+ if (consumer === 'codex') {
771
+ try {
772
+ installOutput = JSON.parse(installResult.stdout);
773
+ if (!installOutput || typeof installOutput !== 'object') {
774
+ return createResult({
775
+ actionType,
776
+ status: ActionStatus.EXECUTE_FAILED,
777
+ error: 'plugin install returned invalid JSON output',
778
+ });
779
+ }
780
+ const expectedPluginId = `${action.plugin}@${action.marketplace}`;
781
+ const installFields = {
782
+ pluginId: installOutput.pluginId,
783
+ name: installOutput.name,
784
+ marketplaceName: installOutput.marketplaceName,
785
+ version: installOutput.version,
786
+ installedPath: installOutput.installedPath,
787
+ };
788
+ const expectedFields = {
789
+ pluginId: expectedPluginId,
790
+ name: action.plugin,
791
+ marketplaceName: action.marketplace,
792
+ version: action.version,
793
+ installedPath: undefined, // must exist and be non-empty
794
+ };
795
+ for (const [field, expected] of Object.entries(expectedFields)) {
796
+ if (field === 'installedPath') {
797
+ if (!installFields.installedPath) {
798
+ return createResult({
799
+ actionType,
800
+ status: ActionStatus.EXECUTE_FAILED,
801
+ error: `plugin install JSON missing installedPath`,
802
+ });
803
+ }
804
+ // installedPath must be inside isolated HOME
805
+ const installPathAbs = resolve(installFields.installedPath);
806
+ const installPathRel = relative(isolatedHome, installPathAbs);
807
+ if (isAbsolute(installPathRel) || installPathRel === '..' || installPathRel.startsWith(`..${sepE}`)) {
808
+ return createResult({
809
+ actionType,
810
+ status: ActionStatus.EXECUTE_FAILED,
811
+ error: `plugin install installedPath escapes isolated HOME: ${installFields.installedPath}`,
812
+ });
813
+ }
814
+ } else if (installFields[field] !== expected) {
815
+ return createResult({
816
+ actionType,
817
+ status: ActionStatus.EXECUTE_FAILED,
818
+ error: `plugin install JSON ${field} "${installFields[field]}" does not match expected "${expected}"`,
819
+ });
820
+ }
821
+ }
822
+ } catch {
823
+ return createResult({
824
+ actionType,
825
+ status: ActionStatus.EXECUTE_FAILED,
826
+ error: 'plugin install returned malformed JSON',
827
+ });
828
+ }
829
+ }
830
+ } catch (installErr) {
831
+ return createResult({
832
+ actionType,
833
+ status: ActionStatus.EXECUTE_FAILED,
834
+ error: `plugin install failed: ${installErr.message}`,
835
+ });
836
+ }
837
+
838
+ // Build and write structured evidence for observe cross-validation
839
+ const evidence = {
840
+ isolatedHome,
841
+ consumer,
842
+ plugin: action.plugin,
843
+ marketplace: action.marketplace,
844
+ repo: action.repo,
845
+ ref,
846
+ version: action.version,
847
+ addOutput,
848
+ installOutput,
849
+ executedAt: new Date().toISOString(),
850
+ };
851
+
852
+ // Write evidence file to runDir/evidence/ (outside isolatedHome/installPath digest scope)
853
+ const evidenceDir = resolve(runDir, 'evidence', `${consumer}-${action.plugin}`);
854
+ await mkdir(evidenceDir, { recursive: true, mode: 0o700 });
855
+ const evidencePath = resolve(evidenceDir, 'release-skill-install-evidence.json');
856
+ await writeEvidenceAtomic(evidencePath, evidence);
857
+
858
+ // Compute manifestDigest from installed content and build
859
+ // expected-compatible observation for executeCheckpoint's
860
+ // matchObservation check.
861
+ const installPath = installOutput?.installedPath;
862
+ let executeManifestDigest = null;
863
+ if (installPath) {
864
+ try {
865
+ const { digest } = await computeFrozenSnapshot(installPath, {
866
+ excludeRootEntries: consumer === 'codex' ? ['.git'] : [],
867
+ });
868
+ executeManifestDigest = digest;
869
+ } catch {
870
+ // Digest computation failure is caught at verify time
871
+ }
872
+ }
873
+
874
+ const executeObservation = {
875
+ ...evidence,
876
+ installed: true,
877
+ entrySkill: action.entrySkill,
878
+ ...(executeManifestDigest ? { manifestDigest: executeManifestDigest } : {}),
879
+ };
880
+
881
+ return createResult({
882
+ actionType,
883
+ status: ActionStatus.EXECUTED,
884
+ observation: executeObservation,
885
+ });
886
+ } catch (err) {
887
+ return createResult({
888
+ actionType,
889
+ status: ActionStatus.EXECUTE_FAILED,
890
+ error: err.message,
891
+ });
892
+ }
893
+ }
894
+
895
+ return createResult({
896
+ actionType,
897
+ status: ActionStatus.EXECUTE_FAILED,
898
+ error: `Unsupported action type: ${actionType}`,
899
+ });
900
+ },
901
+
902
+ /**
903
+ * Observe: read the current state of the plugin manifest and content.
904
+ * Never infers success from exit code alone.
905
+ *
906
+ * For Claude: uses id === "plugin@marketplace" match in list array,
907
+ * reads installPath from CLI output, verifies install dir is inside
908
+ * isolated HOME, computes real manifestDigest from installed content.
909
+ *
910
+ * For Codex: uses pluginId === "plugin@marketplace" match in installed array,
911
+ * reads installedPath from add/install output or list, verifies install dir
912
+ * is inside isolated HOME, computes real manifestDigest.
913
+ */
914
+ async observe(action, context) {
915
+ const { actionType } = action;
916
+
917
+ try {
918
+ if (actionType === ActionType.PLUGIN_MANIFEST_VALIDATE) {
919
+ const manifestPath = action.manifestPath;
920
+ try {
921
+ const content = await readFile(manifestPath, 'utf8');
922
+ const manifest = JSON.parse(content);
923
+ return createResult({
924
+ actionType,
925
+ status: ActionStatus.OBSERVED,
926
+ observation: {
927
+ exists: true,
928
+ name: manifest.name,
929
+ version: manifest.version,
930
+ description: manifest.description,
931
+ },
932
+ });
933
+ } catch {
934
+ return createResult({
935
+ actionType,
936
+ status: ActionStatus.OBSERVED,
937
+ observation: { exists: false },
938
+ });
939
+ }
940
+ }
941
+
942
+ if (actionType === ActionType.PLUGIN_INSTALL_CHECK) {
943
+ const { pluginDir, requiredFiles } = action;
944
+ const check = await checkRequiredFiles(pluginDir, requiredFiles ?? []);
945
+
946
+ return createResult({
947
+ actionType,
948
+ status: ActionStatus.OBSERVED,
949
+ observation: {
950
+ allPresent: check.allPresent,
951
+ missing: check.missing,
952
+ pluginDir,
953
+ },
954
+ });
955
+ }
956
+
957
+ // Marketplace install observe
958
+ if (
959
+ actionType === ActionType.CLAUDE_MARKETPLACE_INSTALL ||
960
+ actionType === ActionType.CODEX_MARKETPLACE_INSTALL
961
+ ) {
962
+ const consumer = action.consumer;
963
+ const runDir = context.runDir;
964
+ if (!runDir) {
965
+ return createResult({
966
+ actionType,
967
+ status: ActionStatus.OBSERVED,
968
+ observation: { installed: false, error: 'context.runDir is required' },
969
+ });
970
+ }
971
+ const isolatedHome = resolve(runDir, 'consumers', `${consumer}-${action.plugin}`);
972
+ const cliCmd = consumer === 'claude' ? 'claude' : 'codex';
973
+ const baseEnv = { ...process.env, ...(context.env ?? {}) };
974
+ const env = {
975
+ ...baseEnv,
976
+ ...(consumer === 'claude'
977
+ ? { HOME: isolatedHome, CLAUDE_CONFIG_DIR: resolve(isolatedHome, '.claude') }
978
+ : { HOME: isolatedHome, CODEX_HOME: isolatedHome }),
979
+ };
980
+
981
+ // Read execute evidence — mandatory for observe validation
982
+ let evidence = null;
983
+ try {
984
+ const evidenceRaw = await readFile(resolve(runDir, 'evidence', `${consumer}-${action.plugin}`, 'release-skill-install-evidence.json'), 'utf8');
985
+ evidence = JSON.parse(evidenceRaw);
986
+ } catch {
987
+ return createResult({
988
+ actionType,
989
+ status: ActionStatus.OBSERVED,
990
+ observation: {
991
+ installed: false,
992
+ error: 'execute evidence file is missing or unreadable',
993
+ },
994
+ });
995
+ }
996
+
997
+ if (
998
+ evidence.consumer !== consumer ||
999
+ evidence.plugin !== action.plugin ||
1000
+ evidence.marketplace !== action.marketplace ||
1001
+ evidence.version !== action.version ||
1002
+ evidence.repo !== action.repo ||
1003
+ evidence.ref !== action.ref ||
1004
+ evidence.isolatedHome !== isolatedHome
1005
+ ) {
1006
+ return createResult({
1007
+ actionType,
1008
+ status: ActionStatus.OBSERVED,
1009
+ observation: {
1010
+ installed: false,
1011
+ error: 'execute evidence identity does not match the frozen action',
1012
+ },
1013
+ });
1014
+ }
1015
+
1016
+ // Run list command to verify installation
1017
+ const listArgs = consumer === 'claude'
1018
+ ? ['plugin', 'list', '--json']
1019
+ : ['plugin', 'list', '--json'];
1020
+
1021
+ let listOutput;
1022
+ try {
1023
+ const result = await exec(cliCmd, listArgs, { env, cwd: context.root });
1024
+ listOutput = JSON.parse(result.stdout);
1025
+ } catch (listErr) {
1026
+ return createResult({
1027
+ actionType,
1028
+ status: ActionStatus.OBSERVED,
1029
+ observation: {
1030
+ installed: false,
1031
+ error: `list command failed: ${listErr.message}`,
1032
+ },
1033
+ });
1034
+ }
1035
+
1036
+ const pluginId = `${action.plugin}@${action.marketplace}`;
1037
+ let found = null;
1038
+ let installPath = null;
1039
+
1040
+ if (consumer === 'claude') {
1041
+ // Claude: list returns an array; find by id === "plugin@marketplace"
1042
+ if (!Array.isArray(listOutput)) {
1043
+ return createResult({
1044
+ actionType,
1045
+ status: ActionStatus.OBSERVED,
1046
+ observation: {
1047
+ installed: false,
1048
+ error: 'Claude plugin list did not return an array',
1049
+ },
1050
+ });
1051
+ }
1052
+ found = listOutput.find((p) => p.id === pluginId);
1053
+ if (!found) {
1054
+ return createResult({
1055
+ actionType,
1056
+ status: ActionStatus.OBSERVED,
1057
+ observation: {
1058
+ installed: false,
1059
+ error: `plugin "${pluginId}" not found in Claude plugin list`,
1060
+ },
1061
+ });
1062
+ }
1063
+ if (!found.installPath) {
1064
+ return createResult({
1065
+ actionType,
1066
+ status: ActionStatus.OBSERVED,
1067
+ observation: {
1068
+ installed: false,
1069
+ error: `plugin "${pluginId}" found but missing installPath`,
1070
+ },
1071
+ });
1072
+ }
1073
+ installPath = found.installPath;
1074
+ } else {
1075
+ // Codex: installPath comes from validated evidence, not from list
1076
+ installPath = evidence.installOutput?.installedPath;
1077
+ if (!installPath) {
1078
+ return createResult({
1079
+ actionType,
1080
+ status: ActionStatus.OBSERVED,
1081
+ observation: {
1082
+ installed: false,
1083
+ error: 'evidence install JSON missing installedPath',
1084
+ },
1085
+ });
1086
+ }
1087
+
1088
+ // Cross-validate with list (list does NOT provide installedPath)
1089
+ const installed = listOutput?.installed;
1090
+ if (!Array.isArray(installed)) {
1091
+ return createResult({
1092
+ actionType,
1093
+ status: ActionStatus.OBSERVED,
1094
+ observation: {
1095
+ installed: false,
1096
+ error: 'Codex plugin list did not return {installed: [...]}',
1097
+ },
1098
+ });
1099
+ }
1100
+ found = installed.find((p) => p.pluginId === pluginId);
1101
+ if (!found) {
1102
+ return createResult({
1103
+ actionType,
1104
+ status: ActionStatus.OBSERVED,
1105
+ observation: {
1106
+ installed: false,
1107
+ error: `plugin "${pluginId}" not found in Codex installed list`,
1108
+ },
1109
+ });
1110
+ }
1111
+ // Cross-validate: list fields must match evidence/action
1112
+ if (found.name !== action.plugin) {
1113
+ return createResult({
1114
+ actionType,
1115
+ status: ActionStatus.OBSERVED,
1116
+ observation: { installed: false, error: `list name "${found.name}" does not match action plugin "${action.plugin}"` },
1117
+ });
1118
+ }
1119
+ if (found.marketplaceName !== action.marketplace) {
1120
+ return createResult({
1121
+ actionType,
1122
+ status: ActionStatus.OBSERVED,
1123
+ observation: { installed: false, error: `list marketplaceName "${found.marketplaceName}" does not match action marketplace "${action.marketplace}"` },
1124
+ });
1125
+ }
1126
+ if (found.version !== action.version) {
1127
+ return createResult({
1128
+ actionType,
1129
+ status: ActionStatus.OBSERVED,
1130
+ observation: { installed: false, error: `list version "${found.version}" does not match action version "${action.version}"` },
1131
+ });
1132
+ }
1133
+ }
1134
+
1135
+ // Verify installPath is inside or at isolated HOME (path escape protection)
1136
+ const isolatedHomeReal = await realpath(isolatedHome).catch(() => isolatedHome);
1137
+ const installPathReal = await realpath(installPath).catch(() => installPath);
1138
+ const relToHome = relative(isolatedHomeReal, installPathReal);
1139
+ const sep = process.platform === 'win32' ? '\\' : '/';
1140
+ if (
1141
+ relToHome !== '' &&
1142
+ (isAbsolute(relToHome) || relToHome === '..' || relToHome.startsWith(`..${sep}`))
1143
+ ) {
1144
+ return createResult({
1145
+ actionType,
1146
+ status: ActionStatus.OBSERVED,
1147
+ observation: {
1148
+ installed: false,
1149
+ error: `install path escapes isolated HOME: ${installPath}`,
1150
+ },
1151
+ });
1152
+ }
1153
+
1154
+ // Verify entry skill exists as a regular file in install dir
1155
+ const entrySkillPath = resolve(installPath, 'skills', action.entrySkill, 'SKILL.md');
1156
+ let entrySkillFound = false;
1157
+ try {
1158
+ const skillStat = await lstat(entrySkillPath);
1159
+ if (skillStat.isFile() && !skillStat.isSymbolicLink()) {
1160
+ entrySkillFound = true;
1161
+ }
1162
+ } catch {
1163
+ // entry skill not found
1164
+ }
1165
+
1166
+ if (!entrySkillFound) {
1167
+ return createResult({
1168
+ actionType,
1169
+ status: ActionStatus.OBSERVED,
1170
+ observation: {
1171
+ installed: true,
1172
+ installPath,
1173
+ entrySkillFound: false,
1174
+ error: `entry skill not found: skills/${action.entrySkill}/SKILL.md`,
1175
+ },
1176
+ });
1177
+ }
1178
+
1179
+ // Compute real manifestDigest from installed content using frozen algorithm
1180
+ let manifestDigest;
1181
+ try {
1182
+ // Codex retains a root .git checkout as consumer-owned transport
1183
+ // metadata. It is not part of the published plugin payload.
1184
+ const { digest } = await computeFrozenSnapshot(installPath, {
1185
+ excludeRootEntries: consumer === 'codex' ? ['.git'] : [],
1186
+ });
1187
+ manifestDigest = digest;
1188
+ } catch (digestErr) {
1189
+ return createResult({
1190
+ actionType,
1191
+ status: ActionStatus.OBSERVED,
1192
+ observation: {
1193
+ installed: true,
1194
+ installPath,
1195
+ entrySkillFound: true,
1196
+ error: `failed to compute manifestDigest: ${digestErr.message}`,
1197
+ },
1198
+ });
1199
+ }
1200
+
1201
+ // Build observation with CLI-proven fields only (no action backfill)
1202
+ const observation = {
1203
+ installed: true,
1204
+ installPath,
1205
+ entrySkillFound: true,
1206
+ entrySkill: action.entrySkill,
1207
+ manifestDigest,
1208
+ consumer,
1209
+ };
1210
+
1211
+ // Fields from CLI evidence only
1212
+ if (consumer === 'claude') {
1213
+ // Claude list may not have name; extract plugin/marketplace from id
1214
+ const idParts = found.id.split('@');
1215
+ observation.plugin = idParts[0];
1216
+ observation.marketplace = idParts.slice(1).join('@');
1217
+ if (found.version) observation.version = found.version;
1218
+ } else {
1219
+ if (found.name) observation.plugin = found.name;
1220
+ if (found.marketplaceName) observation.marketplace = found.marketplaceName;
1221
+ if (found.version) observation.version = found.version;
1222
+ }
1223
+
1224
+ // Cross-validate version: evidence vs CLI
1225
+ if (evidence.version && observation.version && evidence.version !== observation.version) {
1226
+ return createResult({
1227
+ actionType,
1228
+ status: ActionStatus.OBSERVED,
1229
+ observation: {
1230
+ installed: true,
1231
+ installPath,
1232
+ entrySkillFound: true,
1233
+ manifestDigest,
1234
+ error: `version mismatch: CLI reports ${observation.version}, evidence shows ${evidence.version}`,
1235
+ },
1236
+ });
1237
+ }
1238
+
1239
+ // Verify installed manifest name/version matches CLI/evidence
1240
+ try {
1241
+ const installedManifestPath = resolve(installPath, consumer === 'claude' ? '.claude-plugin/plugin.json' : '.codex-plugin/plugin.json');
1242
+ const installedManifestContent = await readFile(installedManifestPath, 'utf8');
1243
+ const installedManifest = JSON.parse(installedManifestContent);
1244
+ const expectedName = observation.plugin;
1245
+ if (expectedName && installedManifest.name !== expectedName) {
1246
+ return createResult({
1247
+ actionType,
1248
+ status: ActionStatus.OBSERVED,
1249
+ observation: {
1250
+ installed: true,
1251
+ installPath,
1252
+ entrySkillFound: true,
1253
+ manifestDigest,
1254
+ error: `installed manifest name "${installedManifest.name}" does not match CLI plugin "${expectedName}"`,
1255
+ },
1256
+ });
1257
+ }
1258
+ if (observation.version && installedManifest.version !== observation.version) {
1259
+ return createResult({
1260
+ actionType,
1261
+ status: ActionStatus.OBSERVED,
1262
+ observation: {
1263
+ installed: true,
1264
+ installPath,
1265
+ entrySkillFound: true,
1266
+ manifestDigest,
1267
+ error: `installed manifest version "${installedManifest.version}" does not match CLI version "${observation.version}"`,
1268
+ },
1269
+ });
1270
+ }
1271
+ } catch (manifestErr) {
1272
+ return createResult({
1273
+ actionType,
1274
+ status: ActionStatus.OBSERVED,
1275
+ observation: {
1276
+ installed: true,
1277
+ installPath,
1278
+ entrySkillFound: true,
1279
+ manifestDigest,
1280
+ error: `installed plugin manifest is missing or invalid: ${manifestErr.message}`,
1281
+ },
1282
+ });
1283
+ }
1284
+
1285
+ // Cross-validate repo/ref: evidence requested values must match current action
1286
+ if (evidence.repo && evidence.repo !== action.repo) {
1287
+ return createResult({
1288
+ actionType,
1289
+ status: ActionStatus.OBSERVED,
1290
+ observation: {
1291
+ installed: true,
1292
+ installPath,
1293
+ entrySkillFound: true,
1294
+ manifestDigest,
1295
+ error: `evidence repo "${evidence.repo}" does not match action repo "${action.repo}"`,
1296
+ },
1297
+ });
1298
+ }
1299
+ if (evidence.ref && evidence.ref !== action.ref) {
1300
+ return createResult({
1301
+ actionType,
1302
+ status: ActionStatus.OBSERVED,
1303
+ observation: {
1304
+ installed: true,
1305
+ installPath,
1306
+ entrySkillFound: true,
1307
+ manifestDigest,
1308
+ error: `evidence ref "${evidence.ref}" does not match action ref "${action.ref}"`,
1309
+ },
1310
+ });
1311
+ }
1312
+
1313
+ // Output repo/ref only after cross-validation
1314
+ if (evidence.repo) observation.repo = evidence.repo;
1315
+ if (evidence.ref) observation.ref = evidence.ref;
1316
+
1317
+ return createResult({
1318
+ actionType,
1319
+ status: ActionStatus.OBSERVED,
1320
+ observation,
1321
+ });
1322
+ }
1323
+
1324
+ return createResult({
1325
+ actionType,
1326
+ status: ActionStatus.OBSERVED,
1327
+ observation: {},
1328
+ });
1329
+ } catch (err) {
1330
+ return createResult({
1331
+ actionType,
1332
+ status: ActionStatus.OBSERVED,
1333
+ error: err.message,
1334
+ observation: {},
1335
+ });
1336
+ }
1337
+ },
1338
+
1339
+ /**
1340
+ * Verify: compare observed state against the frozen plan's expected state.
1341
+ */
1342
+ async verify(action, context) {
1343
+ const observed = await this.observe(action, context);
1344
+
1345
+ if (observed.error) {
1346
+ return createResult({
1347
+ actionType: action.actionType,
1348
+ status: ActionStatus.VERIFY_FAILED,
1349
+ observation: observed.observation,
1350
+ error: observed.error,
1351
+ });
1352
+ }
1353
+
1354
+ const expected = action.expected ?? {};
1355
+ const { matches, mismatches } = matchObservation(expected, observed.observation);
1356
+
1357
+ return createResult({
1358
+ actionType: action.actionType,
1359
+ status: matches ? ActionStatus.VERIFIED : ActionStatus.VERIFY_FAILED,
1360
+ observation: observed.observation,
1361
+ error: matches ? null : `Observation mismatch: ${mismatches.join('; ')}`,
1362
+ });
1363
+ },
1364
+ });
1365
+ }