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.
- package/.agents/plugins/marketplace.json +23 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +26 -0
- package/CHANGELOG.md +68 -0
- package/CODE_OF_CONDUCT.md +76 -0
- package/CONTRIBUTING.md +49 -0
- package/INSTALL.md +182 -0
- package/LICENSE +21 -0
- package/NOTICE +25 -0
- package/README.md +501 -0
- package/README.zh-CN.md +463 -0
- package/SECURITY.md +48 -0
- package/adapters/claude/.claude-plugin/marketplace.json +16 -0
- package/adapters/claude/.claude-plugin/plugin.json +10 -0
- package/adapters/claude/skills/release-assess/SKILL.md +52 -0
- package/adapters/claude/skills/release-help/SKILL.md +60 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
- package/adapters/claude/skills/release-publish/SKILL.md +55 -0
- package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/claude/skills/release-verify/SKILL.md +70 -0
- package/adapters/codex/.codex-plugin/plugin.json +26 -0
- package/adapters/codex/skills/release-assess/SKILL.md +52 -0
- package/adapters/codex/skills/release-help/SKILL.md +60 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
- package/adapters/codex/skills/release-publish/SKILL.md +55 -0
- package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
- package/adapters/codex/skills/release-verify/SKILL.md +70 -0
- package/bin/release-skill.mjs +743 -0
- package/native/safe-write/binding.gyp +40 -0
- package/native/safe-write/prebuilds.json +4 -0
- package/native/safe-write/src/safe_write.cc +2023 -0
- package/package.json +75 -0
- package/references/.render-manifest.json +33 -0
- package/references/00-target-state.md +124 -0
- package/references/01-state-machine.md +155 -0
- package/references/02-project-config.md +217 -0
- package/references/03-readme-quality.md +136 -0
- package/references/04-supply-chain.md +147 -0
- package/references/05-evidence-and-errors.md +164 -0
- package/references/06-adapter-contract.md +178 -0
- package/schemas/.render-manifest.json +37 -0
- package/schemas/approval-record.schema.json +115 -0
- package/schemas/artifact-lock.schema.json +111 -0
- package/schemas/artifact-plan.schema.json +52 -0
- package/schemas/artifact-policy.schema.json +76 -0
- package/schemas/evidence-event.schema.json +89 -0
- package/schemas/release-plan.schema.json +369 -0
- package/schemas/release-project.schema.json +359 -0
- package/schemas/release-run.schema.json +195 -0
- package/skills/release-assess/SKILL.md +52 -0
- package/skills/release-help/SKILL.md +60 -0
- package/skills/release-prepare/SKILL.md +71 -0
- package/skills/release-publish/SKILL.md +55 -0
- package/skills/release-reconcile/SKILL.md +73 -0
- package/skills/release-verify/SKILL.md +70 -0
- package/skills-src/release-assess/SKILL.md +52 -0
- package/skills-src/release-help/SKILL.md +60 -0
- package/skills-src/release-prepare/SKILL.md +71 -0
- package/skills-src/release-publish/SKILL.md +55 -0
- package/skills-src/release-reconcile/SKILL.md +73 -0
- package/skills-src/release-verify/SKILL.md +70 -0
- package/src/adapters/contract.mjs +214 -0
- package/src/adapters/git-github.mjs +214 -0
- package/src/adapters/npm.mjs +947 -0
- package/src/adapters/plugin-marketplace.mjs +1365 -0
- package/src/adapters/push-snapshot.mjs +216 -0
- package/src/artifacts/adoption.mjs +743 -0
- package/src/artifacts/artifact-plan.mjs +162 -0
- package/src/artifacts/entry.mjs +240 -0
- package/src/artifacts/git-authority.mjs +637 -0
- package/src/artifacts/graph.mjs +189 -0
- package/src/artifacts/inspect.mjs +520 -0
- package/src/artifacts/inventory.mjs +192 -0
- package/src/artifacts/merge/binary.mjs +77 -0
- package/src/artifacts/merge/entry-merge.mjs +228 -0
- package/src/artifacts/merge/json.mjs +641 -0
- package/src/artifacts/merge/markdown.mjs +246 -0
- package/src/artifacts/merge/regions.mjs +156 -0
- package/src/artifacts/merge/text.mjs +432 -0
- package/src/artifacts/merge/tree.mjs +202 -0
- package/src/artifacts/merge/yaml.mjs +669 -0
- package/src/artifacts/path-key.mjs +94 -0
- package/src/artifacts/policy.mjs +319 -0
- package/src/artifacts/producer-registry.mjs +439 -0
- package/src/artifacts/project-lock.mjs +732 -0
- package/src/artifacts/resolution.mjs +658 -0
- package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
- package/src/artifacts/safe-fs.mjs +72 -0
- package/src/artifacts/state.mjs +495 -0
- package/src/artifacts/transaction-journal.mjs +983 -0
- package/src/artifacts/transaction.mjs +1361 -0
- package/src/commands/approve.mjs +280 -0
- package/src/commands/artifacts.mjs +627 -0
- package/src/commands/assess.mjs +838 -0
- package/src/commands/prepare.mjs +1377 -0
- package/src/commands/publish.mjs +883 -0
- package/src/commands/reconcile.mjs +1255 -0
- package/src/commands/verify.mjs +915 -0
- package/src/core/approval.mjs +332 -0
- package/src/core/baseline.mjs +272 -0
- package/src/core/blackbox-hard-gates.mjs +142 -0
- package/src/core/config.mjs +448 -0
- package/src/core/digest.mjs +90 -0
- package/src/core/errors.mjs +113 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/hooks.mjs +241 -0
- package/src/core/node-version.mjs +64 -0
- package/src/core/plan.mjs +735 -0
- package/src/core/previous-public-baseline.mjs +204 -0
- package/src/core/run.mjs +681 -0
- package/src/core/state-machine.mjs +76 -0
- package/src/core/version-consistency.mjs +111 -0
- package/src/producers/build-adapters.mjs +231 -0
- package/src/producers/render-public-assets.mjs +152 -0
- package/src/producers/sync-skills.mjs +96 -0
- package/src/readme/contract.mjs +297 -0
- package/src/readme/examples.mjs +288 -0
- package/src/readme/parity.mjs +122 -0
- package/src/snapshot/export.mjs +99 -0
- package/src/snapshot/frozen.mjs +401 -0
- package/src/snapshot/manifest.mjs +207 -0
- package/src/snapshot/public-map.mjs +1459 -0
- package/src/snapshot/public-path.mjs +110 -0
- 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
|
+
}
|