release-skill 0.1.10 → 0.2.0

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 (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.kimi-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +19 -0
  6. package/INSTALL.md +4 -4
  7. package/INSTALL.zh-CN.md +4 -4
  8. package/README.md +17 -32
  9. package/README.zh-CN.md +17 -25
  10. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  11. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  12. package/adapters/claude/bin/release-skill.bundle.mjs +2721 -1843
  13. package/adapters/claude/schemas/.render-manifest.json +8 -8
  14. package/adapters/claude/schemas/approval-record.schema.json +1 -1
  15. package/adapters/claude/schemas/release-plan.schema.json +6 -2
  16. package/adapters/claude/schemas/release-project.schema.json +14 -0
  17. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  18. package/adapters/codex/bin/release-skill.bundle.mjs +2721 -1843
  19. package/adapters/codex/schemas/.render-manifest.json +8 -8
  20. package/adapters/codex/schemas/approval-record.schema.json +1 -1
  21. package/adapters/codex/schemas/release-plan.schema.json +6 -2
  22. package/adapters/codex/schemas/release-project.schema.json +14 -0
  23. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  24. package/adapters/kimi/bin/release-skill.bundle.mjs +2721 -1843
  25. package/adapters/kimi/schemas/.render-manifest.json +8 -8
  26. package/adapters/kimi/schemas/approval-record.schema.json +1 -1
  27. package/adapters/kimi/schemas/release-plan.schema.json +6 -2
  28. package/adapters/kimi/schemas/release-project.schema.json +14 -0
  29. package/bin/release-skill-cli.mjs +3 -0
  30. package/bin/release-skill.bundle.mjs +2721 -1843
  31. package/package.json +8 -2
  32. package/references/.render-manifest.json +8 -8
  33. package/references/01-state-machine.md +5 -5
  34. package/references/02-project-config.md +1 -1
  35. package/references/05-evidence-and-errors.md +1 -1
  36. package/references/06-adapter-contract.md +41 -1
  37. package/schemas/.render-manifest.json +8 -8
  38. package/schemas/approval-record.schema.json +1 -1
  39. package/schemas/release-plan.schema.json +6 -2
  40. package/schemas/release-project.schema.json +14 -0
  41. package/scripts/sync-public-files.mjs +462 -0
  42. package/src/adapters/contract.mjs +60 -0
  43. package/src/adapters/plugin-marketplace.mjs +289 -736
  44. package/src/commands/prepare.mjs +195 -182
  45. package/src/commands/publish.mjs +438 -122
  46. package/src/commands/reconcile.mjs +369 -191
  47. package/src/commands/verify.mjs +13 -2
  48. package/src/core/approval.mjs +72 -45
  49. package/src/core/baseline.mjs +5 -0
  50. package/src/core/checkpoints.mjs +143 -0
  51. package/src/core/evidence.mjs +30 -3
  52. package/src/core/hook-cache.mjs +254 -0
  53. package/src/core/hooks.mjs +37 -1
  54. package/src/core/observe-retry.mjs +223 -0
  55. package/src/core/plan.mjs +162 -253
  56. package/src/platforms/kimi.mjs +514 -0
  57. package/src/platforms/registry.mjs +393 -0
  58. package/src/producers/build-adapters.mjs +14 -22
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { execFile as execFileCb } from 'node:child_process';
16
16
  import { promisify } from 'node:util';
17
- import { readFile, stat, mkdir, writeFile, rename, readdir, rm, realpath, lstat } from 'node:fs/promises';
17
+ import { readFile, stat, mkdir, readdir, realpath, lstat } from 'node:fs/promises';
18
18
  import { join, resolve, relative, isAbsolute, basename } from 'node:path';
19
19
 
20
20
  import {
@@ -24,12 +24,23 @@ import {
24
24
  assertWritesAuthorized,
25
25
  assertIsolatedConsumerWritesAuthorized,
26
26
  matchObservation,
27
+ resolveTimeoutMs,
28
+ SAFE_ID_RE,
29
+ writeEvidenceAtomic,
27
30
  } from './contract.mjs';
28
31
 
29
32
  import { createHash } from 'node:crypto';
30
33
  import { computeFrozenSnapshot, resolveFrozenPath } from '../snapshot/frozen.mjs';
31
- import { computePlanDigest } from '../core/plan.mjs';
32
- import { canonicalJson } from '../core/digest.mjs';
34
+ import { PLATFORMS, getPlatform } from '../platforms/registry.mjs';
35
+ import {
36
+ KIMI_REQUIREMENT_FILE,
37
+ KIMI_ATTESTATION_FILE,
38
+ KIMI_MANAGED_SUBPATH,
39
+ resolveBoundPlanDigest,
40
+ kimiAuthorityDir,
41
+ validateKimiAttestation,
42
+ readKimiManifest,
43
+ } from '../platforms/kimi.mjs';
33
44
 
34
45
  const execFile = promisify(execFileCb);
35
46
 
@@ -48,22 +59,26 @@ function transportPayload(entries) {
48
59
  }));
49
60
  }
50
61
 
62
+ /**
63
+ * Payload verification contract marker written into marketplace install
64
+ * action parameters by prepare. Plans declaring this contract verify the
65
+ * installed payload by declared-manifest containment (every authority file
66
+ * present and byte-identical; host-added files recorded, not failed).
67
+ * Actions without the marker keep the legacy full-tree equality semantics.
68
+ */
69
+ const PAYLOAD_CONTRACT_DECLARED_MANIFEST = 'declared-manifest-v1';
70
+ /** Audit cap: at most this many extra installed paths are recorded. */
71
+ const EXTRA_INSTALLED_PATHS_CAP = 200;
72
+ /** Diagnostic cap: at most this many conflict paths are listed per error. */
73
+ const PAYLOAD_CONFLICT_REPORT_CAP = 10;
74
+
51
75
  // Consumer-owned transport metadata written into the plugin install root
52
- // that is not part of the published payload. Codex checks out the
53
- // repository (root `.git` metadata) and materializes migrated command
54
- // skills under `.codex-plugin/migrated-command-skills/` (the CLI converts
55
- // plugin commands/ into skill format at install time); Claude marks in-use
56
- // plugin checkouts with an empty root `.in_use` marker. Single-segment
57
- // exclusions apply to root entries only; the multi-segment exclusion names
58
- // the exact CLI-generated subtree. All other payload paths keep the
59
- // fail-closed file checks — the exemption must never widen to
60
- // ".codex-plugin/*" or arbitrary extra files.
61
- function consumerTransportExclusions(consumer) {
62
- if (consumer === 'claude') return ['.in_use'];
63
- if (consumer === 'codex') return ['.git', '.codex-plugin/migrated-command-skills'];
64
- if (consumer === 'kimi') return ['.git'];
65
- return [];
66
- }
76
+ // that is not part of the published payload (e.g. codex's root `.git`
77
+ // checkout and `.codex-plugin/migrated-command-skills/`, claude's `.in_use`
78
+ // marker) lives in each platform's `knownHostArtifacts` registry data. Only
79
+ // the legacy payload path (frozen plans without a `payloadContract` marker)
80
+ // applies that list; declared-manifest-v1 verification never excludes
81
+ // anything host-added files are recorded as `extraInstalledPaths` instead.
67
82
 
68
83
  /**
69
84
  * Extract the marketplace plugin entry's declared source as a validated,
@@ -76,11 +91,16 @@ function consumerTransportExclusions(consumer) {
76
91
  * slashes. Throws with the preflight's exact error messages.
77
92
  */
78
93
  function extractDeclaredPluginSource(consumer, entry) {
79
- const rawSource = consumer === 'claude'
94
+ const platform = getPlatform(consumer);
95
+ // Source form is registry data: claude declares a plain string, codex an
96
+ // {source:"local",path} object; any other form has no raw source.
97
+ const rawSource = platform.marketplaceSourceForm === 'string'
80
98
  ? entry.source
81
- : entry.source?.source === 'local' ? entry.source?.path : null;
99
+ : platform.marketplaceSourceForm === 'local-path-object'
100
+ ? (entry.source?.source === 'local' ? entry.source?.path : null)
101
+ : null;
82
102
  if (typeof rawSource !== 'string' || rawSource.length === 0) {
83
- throw new Error(`marketplace plugin entry source must be a non-empty relative path${consumer === 'codex' ? ' (object with source:"local")' : ''}, got ${JSON.stringify(entry.source)}`);
103
+ throw new Error(`marketplace plugin entry source must be a non-empty relative path${platform.marketplaceSourceForm === 'local-path-object' ? ' (object with source:"local")' : ''}, got ${JSON.stringify(entry.source)}`);
84
104
  }
85
105
  if (
86
106
  rawSource.startsWith('/') ||
@@ -112,10 +132,10 @@ function extractDeclaredPluginSource(consumer, entry) {
112
132
  * tampering with it fails the snapshot digest revalidation first.
113
133
  */
114
134
  async function resolveInstalledPayloadSubpath(snapshotDir, sourceEntries, action, consumer) {
115
- if (consumer === 'kimi') return '.';
116
- const marketplaceRelative = consumer === 'claude'
117
- ? '.claude-plugin/marketplace.json'
118
- : '.agents/plugins/marketplace.json';
135
+ // Platforms without a marketplace manifest (kimi) install the whole
136
+ // snapshot as the payload.
137
+ const marketplaceRelative = getPlatform(consumer).manifestPaths.marketplace;
138
+ if (marketplaceRelative === null) return '.';
119
139
  // Anchor the manifest read to the digest-verified entry walk: the target
120
140
  // must be one of the regular files that already passed the fail-closed
121
141
  // read checks (O_NOFOLLOW, single link, before/after stat stability).
@@ -174,8 +194,72 @@ async function verifyInstalledMarketplacePayload(action, context, installPath, c
174
194
  if (authorityEntries.length === 0) {
175
195
  throw new Error('frozen snapshot contains no payload under the declared marketplace source');
176
196
  }
197
+ // Contract selection is bound by the frozen plan: prepare writes
198
+ // `payloadContract` into marketplace install action parameters; frozen
199
+ // plans without the marker keep the legacy full-tree equality semantics
200
+ // byte-for-byte (including the consumer transport exclusion list).
201
+ const payloadContract = action.payloadContract;
202
+ if (payloadContract !== undefined && payloadContract !== PAYLOAD_CONTRACT_DECLARED_MANIFEST) {
203
+ throw new Error(`unsupported marketplace payload contract: ${JSON.stringify(payloadContract)}`);
204
+ }
205
+ if (payloadContract === PAYLOAD_CONTRACT_DECLARED_MANIFEST) {
206
+ // declared-manifest-v1: every authority entry must exist in the installed
207
+ // payload and agree in type/size/bytes/non-write mode bits. Host-added
208
+ // files are NOT failures — they are recorded (relative paths, capped) as
209
+ // audit evidence so host evolution can never break a release, while any
210
+ // missing or altered declared file still fails closed.
211
+ const installedSnapshot = await computeFrozenSnapshot(installPath);
212
+ const authorityPayload = transportPayload(authorityEntries);
213
+ const installedByPath = new Map(
214
+ transportPayload(installedSnapshot.entries).map((entry) => [entry.path, entry]),
215
+ );
216
+ const conflicts = [];
217
+ for (const authorityEntry of authorityPayload) {
218
+ const installedEntry = installedByPath.get(authorityEntry.path);
219
+ if (!installedEntry) {
220
+ conflicts.push(`missing: ${authorityEntry.path}`);
221
+ continue;
222
+ }
223
+ if (installedEntry.type !== authorityEntry.type) {
224
+ conflicts.push(`type mismatch: ${authorityEntry.path}`);
225
+ } else if (
226
+ installedEntry.size !== authorityEntry.size ||
227
+ installedEntry.contentDigest !== authorityEntry.contentDigest
228
+ ) {
229
+ conflicts.push(`content mismatch: ${authorityEntry.path}`);
230
+ } else if (installedEntry.mode !== authorityEntry.mode) {
231
+ conflicts.push(`mode mismatch: ${authorityEntry.path}`);
232
+ }
233
+ }
234
+ if (conflicts.length > 0) {
235
+ const listed = conflicts.slice(0, PAYLOAD_CONFLICT_REPORT_CAP).join('; ');
236
+ const overflow = conflicts.length > PAYLOAD_CONFLICT_REPORT_CAP
237
+ ? `; and ${conflicts.length - PAYLOAD_CONFLICT_REPORT_CAP} more conflicting path(s)`
238
+ : '';
239
+ throw new Error(
240
+ `installed marketplace payload differs in path, bytes, size, or non-write mode bits (${PAYLOAD_CONTRACT_DECLARED_MANIFEST}): ${listed}${overflow}`,
241
+ );
242
+ }
243
+ const authorityPaths = new Set(authorityPayload.map((entry) => entry.path));
244
+ const extraPaths = installedSnapshot.entries
245
+ .map((entry) => entry.path)
246
+ .filter((path) => !authorityPaths.has(path));
247
+ const extraInstalledPaths = extraPaths.slice(0, EXTRA_INSTALLED_PATHS_CAP);
248
+ // This is not an expected-value backfill: the sealed authority digest was
249
+ // revalidated above and every declared file was independently compared.
250
+ return {
251
+ manifestDigest: action.manifestDigest,
252
+ extraInstalledPaths,
253
+ ...(extraPaths.length > EXTRA_INSTALLED_PATHS_CAP
254
+ ? { extraInstalledPathsTotal: extraPaths.length }
255
+ : {}),
256
+ };
257
+ }
258
+ // Legacy contract (frozen plans without payloadContract): full-tree
259
+ // equality against the consumer-exclusion-filtered install tree. Behavior
260
+ // is frozen byte-for-byte; do not change.
177
261
  const installedSnapshot = await computeFrozenSnapshot(installPath, {
178
- excludeRootEntries: consumerTransportExclusions(consumer),
262
+ excludeRootEntries: getPlatform(consumer).knownHostArtifacts,
179
263
  });
180
264
  if (
181
265
  JSON.stringify(transportPayload(authorityEntries))
@@ -183,222 +267,29 @@ async function verifyInstalledMarketplacePayload(action, context, installPath, c
183
267
  ) {
184
268
  throw new Error('installed marketplace payload differs in path, bytes, size, or non-write mode bits');
185
269
  }
186
- // This is not an expected-value backfill: the sealed authority digest was
187
- // revalidated above and the installed payload was independently compared.
188
- return action.manifestDigest;
189
- }
190
-
191
- async function writeEvidenceAtomic(filePath, value) {
192
- const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
193
- try {
194
- await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
195
- await rename(tempPath, filePath);
196
- } catch (err) {
197
- await rm(tempPath, { force: true }).catch(() => {});
198
- throw err;
199
- }
200
- }
201
-
202
- // ---------------------------------------------------------------------------
203
- // Kimi Code protocol-gap modeling (BLOCKER-1 / MAJOR-1 / MAJOR-4 / MINOR-1).
204
- //
205
- // Kimi Code has NO scriptable plugin install/list CLI: plugin management is
206
- // interactive-only (`/plugins install <path-or-url>` in the TUI). There is no
207
- // `kimi plugins ...` subcommand and no `--json` output protocol. Therefore the
208
- // kimi-marketplace-install action is modeled as a protocol capability gap:
209
- //
210
- // - execute NEVER execs a kimi CLI. It emits an actionable, version-pinned
211
- // manual-install requirement bound to the frozen plan digest + identity.
212
- // - observe consumes a structured human attestation (written after the
213
- // operator runs the interactive install) plus read-only verification of
214
- // the installed managed copy. Missing/expired/mismatched/escaping proof
215
- // fails closed, so a kimi unit can never reach VERIFIED without it.
216
- // ---------------------------------------------------------------------------
217
-
218
- /** Structured manual-install requirement written by kimi execute. */
219
- const KIMI_REQUIREMENT_FILE = 'release-skill-kimi-manual-install.json';
220
- /** Structured human attestation consumed by kimi observe. */
221
- const KIMI_ATTESTATION_FILE = 'release-skill-kimi-attestation.json';
222
- /** Kimi Code managed install layout: $KIMI_CODE_HOME/plugins/managed/<id>/. */
223
- const KIMI_MANAGED_SUBPATH = join('plugins', 'managed');
224
- /** Maximum attestation validity window (mirrors the 24h approval expiry). */
225
- const KIMI_MAX_ATTESTATION_VALIDITY_MS = 24 * 60 * 60 * 1000;
226
-
227
- /** 64-char lowercase hex plan/payload digest pattern. */
228
- const HEX_DIGEST_RE = /^[a-f0-9]{64}$/;
229
-
230
- /**
231
- * Normalize a plan back to its frozen form for digest comparison.
232
- *
233
- * Only lifecycle status fields are reset: the top-level `status` returns to
234
- * "PREPARED" and every `externalActions[].status` returns to "PENDING". Every
235
- * other field is preserved verbatim. publish/reconcile/verify mutate exactly
236
- * these status fields in memory as the saga progresses, so normalizing them
237
- * recovers the frozen digest while leaving all security-relevant fields
238
- * (baseline, units, action parameters/expected, production config, …) intact.
239
- *
240
- * @param {object} plan
241
- * @returns {object} the lifecycle-normalized plan
242
- */
243
- function normalizePlanForDigest(plan) {
244
- const normalized = { ...plan, status: 'PREPARED' };
245
- if (Array.isArray(plan.externalActions)) {
246
- normalized.externalActions = plan.externalActions.map((action) => (
247
- action && typeof action === 'object' && !Array.isArray(action)
248
- ? { ...action, status: 'PENDING' }
249
- : action
250
- ));
251
- }
252
- return normalized;
253
- }
254
-
255
- /**
256
- * Resolve and verify the genuine frozen plan digest from the adapter context.
257
- *
258
- * The kimi manual-install requirement and attestation bind to the REAL frozen
259
- * plan digest (`context.plan.digest`) — never to `action.manifestDigest`, which
260
- * is only the snapshot payload digest.
261
- *
262
- * Integrity model: the carried `context.plan.digest` is recomputed from the
263
- * lifecycle-normalized plan and must match EXACTLY. Status transitions
264
- * (top-level status, per-action checkpoint status) are normalized away, but any
265
- * other field tamper changes the recomputed digest and fails closed. This
266
- * proves the attestation is bound to the genuine frozen plan, not to a spoofed
267
- * or mutated stand-in.
268
- *
269
- * @param {object} context - adapter context (must carry the frozen `plan`).
270
- * @returns {string} the verified frozen plan digest.
271
- * @throws {Error} when the plan is absent or the digest does not match.
272
- */
273
- function resolveBoundPlanDigest(context) {
274
- const plan = context?.plan;
275
- if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
276
- throw new Error('context.plan is required to bind the kimi plan digest');
277
- }
278
- const carried = plan.digest;
279
- if (typeof carried !== 'string' || !HEX_DIGEST_RE.test(carried)) {
280
- throw new Error('context.plan.digest must be a 64-char lowercase hex frozen plan digest');
281
- }
282
- const normalized = normalizePlanForDigest(plan);
283
- if (computePlanDigest(normalized) !== carried) {
284
- throw new Error('context.plan.digest does not match the normalized frozen plan (a non-lifecycle field was tampered)');
285
- }
286
- return carried;
287
- }
288
-
289
- /**
290
- * Authoritative, cross-run attestation directory for a kimi install.
291
- *
292
- * Lives at a stable root-fixed location keyed by the verified frozen plan
293
- * digest and plugin id:
294
- * <root>/.release-skill/kimi-attestations/<planDigest>/<plugin>/
295
- *
296
- * This survives the publish -> manual install -> reconcile -> verify chain,
297
- * where each command otherwise uses a fresh runDir (an attestation written to a
298
- * publish runDir would be invisible to reconcile/verify). Both the requirement
299
- * and the human attestation live here. The segments are pre-validated (planDigest
300
- * is 64-hex, plugin matches SAFE_ID_RE) and the resolved path is contained
301
- * within the authority base, so no path escape is possible.
302
- *
303
- * @param {object} context - adapter context (needs `root`).
304
- * @param {string} planDigest - verified frozen plan digest (64-hex).
305
- * @param {string} plugin - plugin id (SAFE_ID_RE).
306
- * @returns {string} absolute authority directory.
307
- */
308
- function kimiAuthorityDir(context, planDigest, plugin) {
309
- if (!context?.root) {
310
- throw new Error('context.root is required for the kimi attestation authority');
311
- }
312
- if (!HEX_DIGEST_RE.test(planDigest)) {
313
- throw new Error('kimi attestation authority requires a 64-hex plan digest');
314
- }
315
- if (!SAFE_ID_RE.test(plugin)) {
316
- throw new Error(`kimi attestation authority requires a safe plugin id: "${plugin}"`);
317
- }
318
- const base = resolve(context.root, '.release-skill', 'kimi-attestations');
319
- const dir = resolve(base, planDigest, plugin);
320
- const rel = relative(base, dir);
321
- const sep = process.platform === 'win32' ? '\\' : '/';
322
- if (
323
- rel === '' || rel === '..' || isAbsolute(rel) || rel.startsWith(`..${sep}`)
324
- || rel.split(sep).some((segment) => segment === '..' || segment === '')
325
- ) {
326
- throw new Error('kimi attestation authority path escapes its base');
327
- }
328
- return dir;
270
+ return { manifestDigest: action.manifestDigest };
329
271
  }
330
272
 
331
273
  /**
332
- * Build the official, version-pinned install URL for a frozen Git ref.
333
- *
334
- * Prefers the GitHub release-tag URL (`/releases/tag/<ref>`), which pins the
335
- * exact published ref; `/tree/<ref>` is the documented equivalent. A bare
336
- * repository URL is NOT acceptable because it installs the latest release (or
337
- * default branch), which need not equal the frozen version.
338
- *
339
- * @param {string} repo - owner/repo
340
- * @param {string} ref - frozen Git ref (tag)
341
- * @returns {string}
274
+ * Build the audit fields recorded when a declared-manifest-v1 verification
275
+ * observes host-added files in the install tree. Empty for legacy bindings
276
+ * (and for failed bindings), so legacy observations stay byte-identical.
342
277
  */
343
- function buildKimiInstallUrl(repo, ref) {
344
- return `https://github.com/${repo}/releases/tag/${ref}`;
278
+ function extraInstalledPathsAudit(binding) {
279
+ if (!binding || !Array.isArray(binding.extraInstalledPaths)) return {};
280
+ return {
281
+ extraInstalledPaths: binding.extraInstalledPaths,
282
+ ...(binding.extraInstalledPathsTotal !== undefined
283
+ ? { extraInstalledPathsTotal: binding.extraInstalledPathsTotal }
284
+ : {}),
285
+ };
345
286
  }
346
287
 
347
- /**
348
- * Human-facing, actionable manual-install closed-loop instructions for Kimi Code.
349
- *
350
- * @param {{installUrl:string, plugin:string, version:string, ref:string, isolatedHome:string, attestationDir:string}} p
351
- * @returns {string[]}
352
- */
353
- function buildKimiManualInstructions({ installUrl, plugin, version, ref, isolatedHome, attestationDir }) {
354
- return [
355
- `Kimi Code has no scriptable plugin-install CLI; installation is a manual, interactive step.`,
356
- `1) publish fails closed at this kimi checkpoint and leaves the run PARTIAL (the automated Git branch/tag, npm, and GitHub Release writes still complete first).`,
357
- `2) Launch Kimi Code with the ISOLATED home from this requirement so the managed copy lands inside it: set HOME="${isolatedHome}" and KIMI_CODE_HOME="${isolatedHome}". The plugin installs to "${isolatedHome}/plugins/managed/${plugin}/".`,
358
- `3) In that isolated Kimi Code session run: /plugins install ${installUrl} (pinned to frozen ref "${ref}", version ${version}; never install the bare repository URL). Confirm the trust prompt for plugin "${plugin}", then run /plugins reload (or /new).`,
359
- `4) Write the attestation JSON to: ${attestationDir}/${KIMI_ATTESTATION_FILE}. planDigest MUST be the frozen plan digest; payloadDigest MUST be the frozen snapshot payload digest; installPath MUST be the isolated managed directory above. attestedAt must not be in the future and expiresAt must be within 24 hours of attestedAt.`,
360
- ` Required fields: consumer="kimi", plugin, version, entrySkill, repo, ref, installPath, planDigest, payloadDigest, attestedBy, attestedAt, expiresAt.`,
361
- `5) Re-run release-skill reconcile (promotes PARTIAL -> PUBLISHED) and then verify (-> VERIFIED). Both read the attestation from this same plan-digest-keyed authority directory, so a fresh run directory does not lose the proof.`,
362
- `An install into the ordinary ~/.kimi-code is NOT acceptable proof: the attested installPath must resolve inside this requirement's isolated KIMI_CODE_HOME managed root, otherwise verification fails closed.`,
363
- ];
364
- }
365
-
366
- /**
367
- * Read the authoritative Kimi plugin manifest from a verified plugin root.
368
- *
369
- * `kimi.plugin.json` at the root takes priority over `.kimi-plugin/plugin.json`
370
- * when both exist (official precedence). Returns the parsed manifest and the
371
- * root-relative manifest path. Throws when no valid manifest is present.
372
- *
373
- * @param {string} pluginRootReal - realpath of the verified plugin root.
374
- * @returns {Promise<{manifest:object, manifestRelative:string}>}
375
- */
376
- async function readKimiManifest(pluginRootReal) {
377
- const candidates = [
378
- 'kimi.plugin.json',
379
- join('.kimi-plugin', 'plugin.json'),
380
- ];
381
- for (const manifestRelative of candidates) {
382
- const manifestPath = resolve(pluginRootReal, manifestRelative);
383
- let content;
384
- try {
385
- content = await readFile(manifestPath, 'utf8');
386
- } catch {
387
- continue;
388
- }
389
- let manifest;
390
- try {
391
- manifest = JSON.parse(content);
392
- } catch {
393
- throw new Error(`kimi plugin manifest ${manifestRelative} is not valid JSON`);
394
- }
395
- if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
396
- throw new Error(`kimi plugin manifest ${manifestRelative} is not an object`);
397
- }
398
- return { manifest, manifestRelative };
399
- }
400
- throw new Error('no kimi plugin manifest found (expected kimi.plugin.json or .kimi-plugin/plugin.json)');
401
- }
288
+ // The kimi protocol closure (manual-install requirement, human attestation
289
+ // validation, manifest reading, shared constants) lives in
290
+ // ../platforms/kimi.mjs and is referenced from the platform registry's kimi
291
+ // strategy table. Shared adapter primitives (SAFE_ID_RE, resolveTimeoutMs,
292
+ // writeEvidenceAtomic) live in ./contract.mjs.
402
293
 
403
294
  /**
404
295
  * Validate that a manifest `skills` value is a safe plugin-root-relative path.
@@ -504,281 +395,6 @@ async function resolveKimiEntrySkillFile(pluginRootReal, manifest, entrySkill) {
504
395
  return entryReal;
505
396
  }
506
397
 
507
- /**
508
- * Validate a structured kimi manual-install attestation against the frozen
509
- * action and the verified frozen plan digest.
510
- *
511
- * Bindings (fail closed on any mismatch):
512
- * - `planDigest` binds to the REAL frozen plan digest (`boundPlanDigest`, from
513
- * `context.plan.digest`) — NOT to `action.manifestDigest`.
514
- * - `payloadDigest` binds separately to `action.manifestDigest` (the sealed
515
- * snapshot payload digest).
516
- * - plugin identity, version, entry skill, repo, and frozen ref must match.
517
- * - Time bounds: `attestedAt` must not be in the future, the validity window
518
- * (`expiresAt - attestedAt`) must not exceed 24h, and the attestation must
519
- * not be expired relative to `isoNow`.
520
- *
521
- * @param {object} attestation - parsed attestation JSON.
522
- * @param {object} action - the expanded kimi action (top-level fields).
523
- * @param {string} isoNow - current ISO timestamp.
524
- * @param {string} boundPlanDigest - verified frozen plan digest.
525
- * @returns {{valid:boolean, error:string|null}}
526
- */
527
- function validateKimiAttestation(attestation, action, isoNow, boundPlanDigest) {
528
- if (!attestation || typeof attestation !== 'object' || Array.isArray(attestation)) {
529
- return { valid: false, error: 'kimi attestation is not an object' };
530
- }
531
- const requiredStrings = ['plugin', 'version', 'entrySkill', 'repo', 'ref', 'installPath', 'payloadDigest', 'planDigest', 'attestedBy', 'attestedAt', 'expiresAt'];
532
- for (const field of requiredStrings) {
533
- if (typeof attestation[field] !== 'string' || attestation[field].length === 0) {
534
- return { valid: false, error: `kimi attestation missing required field "${field}"` };
535
- }
536
- }
537
- if (attestation.consumer !== 'kimi') {
538
- return { valid: false, error: `kimi attestation consumer "${attestation.consumer}" must be "kimi"` };
539
- }
540
- if (!HEX_DIGEST_RE.test(attestation.planDigest)) {
541
- return { valid: false, error: 'kimi attestation planDigest must be a 64-char lowercase hex digest' };
542
- }
543
- if (attestation.planDigest !== boundPlanDigest) {
544
- return { valid: false, error: 'kimi attestation planDigest does not match the frozen plan digest' };
545
- }
546
- if (attestation.plugin !== action.plugin) {
547
- return { valid: false, error: `kimi attestation plugin "${attestation.plugin}" does not match action plugin "${action.plugin}"` };
548
- }
549
- if (attestation.version !== action.version) {
550
- return { valid: false, error: `kimi attestation version "${attestation.version}" does not match action version "${action.version}"` };
551
- }
552
- if (attestation.entrySkill !== action.entrySkill) {
553
- return { valid: false, error: `kimi attestation entrySkill "${attestation.entrySkill}" does not match action entrySkill "${action.entrySkill}"` };
554
- }
555
- if (attestation.repo !== action.repo) {
556
- return { valid: false, error: `kimi attestation repo "${attestation.repo}" does not match action repo "${action.repo}"` };
557
- }
558
- const expectedRef = action.ref ?? `v${action.version}`;
559
- if (attestation.ref !== expectedRef) {
560
- return { valid: false, error: `kimi attestation ref "${attestation.ref}" does not match frozen ref "${expectedRef}"` };
561
- }
562
- if (attestation.payloadDigest !== action.manifestDigest) {
563
- return { valid: false, error: 'kimi attestation payloadDigest does not match the frozen payload digest' };
564
- }
565
- const attestedMs = Date.parse(attestation.attestedAt);
566
- const expiresMs = Date.parse(attestation.expiresAt);
567
- const nowMs = Date.parse(isoNow);
568
- if (!Number.isFinite(attestedMs) || !Number.isFinite(expiresMs) || !Number.isFinite(nowMs)) {
569
- return { valid: false, error: 'kimi attestation attestedAt/expiresAt must be valid ISO timestamps' };
570
- }
571
- if (attestedMs > nowMs) {
572
- return { valid: false, error: 'kimi attestation attestedAt is in the future' };
573
- }
574
- if (expiresMs <= attestedMs) {
575
- return { valid: false, error: 'kimi attestation expiresAt must be after attestedAt' };
576
- }
577
- if (expiresMs - attestedMs > KIMI_MAX_ATTESTATION_VALIDITY_MS) {
578
- return { valid: false, error: 'kimi attestation validity must not exceed 24 hours' };
579
- }
580
- if (nowMs > expiresMs) {
581
- return { valid: false, error: 'kimi attestation has expired' };
582
- }
583
- return { valid: true, error: null };
584
- }
585
-
586
- /**
587
- * Kimi Code protocol capability gap (BLOCKER-1): there is NO scriptable
588
- * `kimi plugins install/list` CLI and no `--json` protocol. execute NEVER execs
589
- * a kimi command. Instead it emits an actionable, version-pinned manual-install
590
- * requirement bound to the real frozen plan digest + identity, and leaves
591
- * success to observe, which consumes only a trusted human attestation plus
592
- * read-only verification. Without that proof the checkpoint fails closed and can
593
- * never reach VERIFIED.
594
- *
595
- * Isolation model (B/C): the kimi home is a STABLE, plan-digest-keyed directory
596
- * under the attestation authority (`<authorityDir>/kimi-home`), not the per-run
597
- * runDir consumer dir. The operator launches Kimi Code with that KIMI_CODE_HOME
598
- * so the managed copy lands at `<kimiHome>/plugins/managed/<plugin>/`, a
599
- * location that is identical across publish/reconcile/verify run dirs. execute
600
- * creates ONLY the managed parent (`plugins/managed`), never `managed/<plugin>`
601
- * (the operator's interactive install creates that). The requirement write is
602
- * idempotent: an identical existing requirement is left untouched, a divergent
603
- * one fails closed.
604
- *
605
- * @param {object} action - expanded kimi action (validated params already).
606
- * @param {object} context - adapter context (root, runDir, plan).
607
- * @returns {Promise<import('./contract.mjs').AdapterResult>}
608
- */
609
- async function executeKimiManualRequirement(action, context) {
610
- const actionType = ActionType.KIMI_MARKETPLACE_INSTALL;
611
-
612
- // (A) Bind to the REAL frozen plan digest via strict normalized recompute.
613
- let planDigest;
614
- try {
615
- planDigest = resolveBoundPlanDigest(context);
616
- } catch (planErr) {
617
- return createResult({
618
- actionType,
619
- status: ActionStatus.EXECUTE_FAILED,
620
- error: `cannot bind kimi requirement to the frozen plan: ${planErr.message}`,
621
- });
622
- }
623
-
624
- // Validate the frozen timeout. Kimi execs no CLI, but the frozen-timeout
625
- // fail-closed invariant still holds for every marketplace action.
626
- try {
627
- resolveTimeoutMs(action);
628
- } catch (timeoutErr) {
629
- return createResult({
630
- actionType,
631
- status: ActionStatus.EXECUTE_FAILED,
632
- error: timeoutErr.message,
633
- });
634
- }
635
-
636
- const ref = action.ref ?? `v${action.version}`;
637
- const installUrl = buildKimiInstallUrl(action.repo, ref);
638
-
639
- // (B) Stable, plan-digest-keyed authority dir — the ONLY kimi home, shared
640
- // across publish/reconcile/verify run dirs.
641
- let attestationDir;
642
- try {
643
- attestationDir = kimiAuthorityDir(context, planDigest, action.plugin);
644
- } catch (dirErr) {
645
- return createResult({
646
- actionType,
647
- status: ActionStatus.EXECUTE_FAILED,
648
- error: dirErr.message,
649
- });
650
- }
651
- const kimiHome = resolve(attestationDir, 'kimi-home');
652
- const managedParent = resolve(kimiHome, KIMI_MANAGED_SUBPATH); // plugins/managed
653
- // plugins/managed/<plugin> — created by the operator's interactive install.
654
- const managedInstallRoot = resolve(managedParent, action.plugin);
655
-
656
- const instructions = buildKimiManualInstructions({
657
- installUrl,
658
- plugin: action.plugin,
659
- version: action.version,
660
- ref,
661
- isolatedHome: kimiHome,
662
- attestationDir,
663
- });
664
-
665
- const requirement = {
666
- kind: 'kimi-manual-install-requirement',
667
- consumer: 'kimi',
668
- plugin: action.plugin,
669
- version: action.version,
670
- entrySkill: action.entrySkill,
671
- repo: action.repo,
672
- ref,
673
- installUrl,
674
- // (A) planDigest binds to the real frozen plan digest;
675
- // expectedPayloadDigest binds separately to the snapshot payload digest.
676
- planDigest,
677
- expectedPayloadDigest: action.manifestDigest,
678
- isolatedHome: kimiHome,
679
- kimiCodeHome: kimiHome,
680
- managedInstallRoot,
681
- attestationDir,
682
- attestationFile: KIMI_ATTESTATION_FILE,
683
- attestationTemplate: {
684
- consumer: 'kimi',
685
- plugin: action.plugin,
686
- version: action.version,
687
- entrySkill: action.entrySkill,
688
- repo: action.repo,
689
- ref,
690
- installPath: managedInstallRoot,
691
- planDigest,
692
- payloadDigest: action.manifestDigest,
693
- attestedBy: '<person responsible for the manual install>',
694
- attestedAt: '<ISO 8601 now; must not be in the future>',
695
- expiresAt: '<ISO 8601; within 24h of attestedAt>',
696
- },
697
- instructions,
698
- };
699
-
700
- // Create ONLY the managed parent (plugins/managed); never pre-create
701
- // managed/<plugin> — the operator's interactive install creates that.
702
- try {
703
- await mkdir(managedParent, { recursive: true, mode: 0o700 });
704
- } catch (mkdirErr) {
705
- return createResult({
706
- actionType,
707
- status: ActionStatus.EXECUTE_FAILED,
708
- error: `cannot create kimi managed parent directory: ${mkdirErr.message}`,
709
- });
710
- }
711
-
712
- // Idempotent requirement write: an identical existing requirement is left
713
- // untouched; a divergent existing requirement fails closed (never silently
714
- // overwritten). `createdAt` is volatile and excluded from the comparison.
715
- const requirementPath = resolve(attestationDir, KIMI_REQUIREMENT_FILE);
716
- let existing = null;
717
- let requirementMissing = false;
718
- try {
719
- const existingRaw = await readFile(requirementPath, 'utf8');
720
- try {
721
- existing = JSON.parse(existingRaw);
722
- } catch (parseErr) {
723
- return createResult({
724
- actionType,
725
- status: ActionStatus.EXECUTE_FAILED,
726
- error: `existing kimi manual-install requirement is invalid JSON; refusing to overwrite: ${parseErr.message}`,
727
- });
728
- }
729
- } catch (readErr) {
730
- if (readErr?.code === 'ENOENT') {
731
- requirementMissing = true;
732
- } else {
733
- return createResult({
734
- actionType,
735
- status: ActionStatus.EXECUTE_FAILED,
736
- error: `existing kimi manual-install requirement cannot be read; refusing to overwrite: ${readErr.message}`,
737
- });
738
- }
739
- }
740
- if (!requirementMissing) {
741
- if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
742
- return createResult({
743
- actionType,
744
- status: ActionStatus.EXECUTE_FAILED,
745
- error: 'existing kimi manual-install requirement is not an object; refusing to overwrite',
746
- });
747
- }
748
- const { createdAt: _existingCreatedAt, ...existingBody } = existing;
749
- if (canonicalJson(existingBody) !== canonicalJson(requirement)) {
750
- return createResult({
751
- actionType,
752
- status: ActionStatus.EXECUTE_FAILED,
753
- error: 'existing kimi manual-install requirement conflicts with the current frozen action; refusing to overwrite',
754
- });
755
- }
756
- } else {
757
- await writeEvidenceAtomic(requirementPath, { ...requirement, createdAt: new Date().toISOString() });
758
- }
759
-
760
- return createResult({
761
- actionType,
762
- status: ActionStatus.EXECUTED,
763
- observation: {
764
- installed: false,
765
- manualInstallRequired: true,
766
- consumer: 'kimi',
767
- plugin: action.plugin,
768
- version: action.version,
769
- entrySkill: action.entrySkill,
770
- repo: action.repo,
771
- ref,
772
- installUrl,
773
- planDigest,
774
- attestationDir,
775
- kimiCodeHome: kimiHome,
776
- managedInstallRoot,
777
- instructions,
778
- },
779
- });
780
- }
781
-
782
398
  const SUPPORTED_TYPES = [
783
399
  ActionType.PLUGIN_MANIFEST_VALIDATE,
784
400
  ActionType.PLUGIN_INSTALL_CHECK,
@@ -787,12 +403,12 @@ const SUPPORTED_TYPES = [
787
403
  ActionType.KIMI_MARKETPLACE_INSTALL,
788
404
  ];
789
405
 
790
- /** Safe identifier pattern: lowercase alphanumeric, hyphens, dots, underscores. */
791
- const SAFE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
792
-
793
406
  /** Safe repo pattern: owner/repo with alphanumeric, hyphens, dots, underscores. */
794
407
  const SAFE_REPO_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\/[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
795
408
 
409
+ /** Valid consumer ids, derived from the platform registry (single source). */
410
+ const CONSUMER_IDS = new Set(PLATFORMS.map((p) => p.id));
411
+
796
412
  /**
797
413
  * Strict semver pattern: supports prerelease and build metadata.
798
414
  * Matches: 1.0.0, 1.0.0-beta.1, 1.0.0-rc.1+build.123
@@ -862,7 +478,7 @@ function validateMarketplaceParams(params) {
862
478
  return { valid: false, error: 'parameters must be an object' };
863
479
  }
864
480
  const { consumer, plugin, marketplace, repo, version, entrySkill } = params;
865
- if (!['claude', 'codex', 'kimi'].includes(consumer)) {
481
+ if (!CONSUMER_IDS.has(consumer)) {
866
482
  return { valid: false, error: `invalid consumer: "${consumer}"` };
867
483
  }
868
484
  if (!plugin || !SAFE_ID_RE.test(plugin)) {
@@ -873,7 +489,7 @@ function validateMarketplaceParams(params) {
873
489
  // marketplace but NO non-interactive install API, so `marketplace` carries no
874
490
  // executable meaning for kimi and is optional (validated only if present); it
875
491
  // must not become a required identity condition for kimi execution/observe.
876
- if (consumer === 'kimi') {
492
+ if (!getPlatform(consumer).automatable) {
877
493
  if (marketplace !== undefined && marketplace !== null && !SAFE_ID_RE.test(marketplace)) {
878
494
  return { valid: false, error: `unsafe marketplace identifier: "${marketplace}"` };
879
495
  }
@@ -893,42 +509,6 @@ function validateMarketplaceParams(params) {
893
509
  }
894
510
 
895
511
 
896
- /**
897
- * Resolve and validate the frozen timeoutMs from the expanded adapter action.
898
- *
899
- * The publish/reconcile/verify call path expands plan actions as
900
- * `{ actionType, ...action.parameters }`, so `parameters.timeoutMs` in the
901
- * plan becomes `action.timeoutMs` at the adapter level. This function reads
902
- * from the top-level action, not from a nested `parameters` sub-object.
903
- *
904
- * Rules:
905
- * - Missing field (undefined): returns 300000 default (legacy compatibility).
906
- * - Present but null/invalid (null, string, NaN, Infinity, non-integer,
907
- * out of range): fail-closed, throws.
908
- * - Valid integer in [30000, 900000]: returns the value as-is.
909
- *
910
- * @param {object} action - The expanded adapter action (top-level).
911
- * @returns {number} Validated timeout in milliseconds.
912
- * @throws {Error} If the value is present but invalid.
913
- */
914
- function resolveTimeoutMs(action) {
915
- const raw = action?.timeoutMs;
916
- if (raw === undefined) {
917
- return 300000;
918
- }
919
- if (raw === null || typeof raw !== 'number' || !Number.isFinite(raw) || !Number.isInteger(raw)) {
920
- throw new Error(
921
- `action.timeoutMs must be a finite integer, got: ${JSON.stringify(raw)}`,
922
- );
923
- }
924
- if (raw < 30000 || raw > 900000) {
925
- throw new Error(
926
- `action.timeoutMs must be between 30000 and 900000, got: ${raw}`,
927
- );
928
- }
929
- return raw;
930
- }
931
-
932
512
  /**
933
513
  * Run a CLI command using execFile (never shell: true).
934
514
  */
@@ -1157,6 +737,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1157
737
 
1158
738
  // 6. Verify frozen snapshot exists and contains required marketplace files
1159
739
  const consumer = action.consumer;
740
+ const platform = getPlatform(consumer);
1160
741
  let snapshotDirReal;
1161
742
  // Authoritative kimi manifest (from the frozen snapshot), used to
1162
743
  // resolve the entry skill via the manifest-declared skills root.
@@ -1171,14 +752,15 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1171
752
  });
1172
753
  }
1173
754
 
1174
- // Kimi has no non-interactive marketplace/install API: the whole repo
1175
- // is installed as one plugin. The authoritative manifest is read from
1176
- // the verified snapshot root with official precedence
755
+ // A non-automatable platform (kimi) has no non-interactive
756
+ // marketplace/install API: the whole repo is installed as one
757
+ // plugin. The authoritative manifest is read from the verified
758
+ // snapshot root via the platform strategy's official precedence
1177
759
  // (kimi.plugin.json over .kimi-plugin/plugin.json).
1178
- if (consumer === 'kimi') {
760
+ if (!platform.automatable) {
1179
761
  let kimiManifestResult;
1180
762
  try {
1181
- kimiManifestResult = await readKimiManifest(snapshotDirReal);
763
+ kimiManifestResult = await platform.strategy.readManifest(snapshotDirReal);
1182
764
  } catch (manifestErr) {
1183
765
  return createResult({
1184
766
  actionType,
@@ -1204,13 +786,11 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1204
786
  kimiSnapshotManifest = kimiManifest;
1205
787
  }
1206
788
 
1207
- if (consumer !== 'kimi') {
789
+ if (platform.automatable) {
1208
790
  // Verify marketplace files exist.
1209
791
  // marketplace.json is at the snapshot root; plugin manifest is
1210
792
  // resolved relative to the entry's declared source path.
1211
- const marketplaceRelative = consumer === 'claude'
1212
- ? '.claude-plugin/marketplace.json'
1213
- : '.agents/plugins/marketplace.json';
793
+ const marketplaceRelative = platform.manifestPaths.marketplace;
1214
794
 
1215
795
  const marketplacePath = resolve(snapshotDirReal, marketplaceRelative);
1216
796
 
@@ -1294,9 +874,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1294
874
  // snapshot/.claude-plugin/plugin.json
1295
875
  // For subdirectory layouts (source: "./adapters/claude"), this resolves to
1296
876
  // snapshot/adapters/claude/.claude-plugin/plugin.json
1297
- const manifestRelative = consumer === 'claude'
1298
- ? join(sourcePath, '.claude-plugin', 'plugin.json')
1299
- : join(sourcePath, '.codex-plugin', 'plugin.json');
877
+ const manifestRelative = join(sourcePath, platform.manifestPaths.plugin);
1300
878
  const manifestPath = resolve(snapshotDirReal, manifestRelative);
1301
879
 
1302
880
  const manifestResult = await validateManifestFile(manifestPath, ['name', 'version']);
@@ -1308,9 +886,12 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1308
886
  });
1309
887
  }
1310
888
 
1311
- // Claude carries the version in the marketplace entry. Codex keeps
1312
- // the authoritative version in .codex-plugin/plugin.json.
1313
- if (consumer === 'claude' && entry.version !== action.version) {
889
+ // Whether the marketplace entry itself carries the authoritative
890
+ // version is a platform protocol split (registry data): claude
891
+ // binds entry.version to the action version, codex keeps the
892
+ // authoritative version in .codex-plugin/plugin.json (the entry
893
+ // version is never bound), kimi has no marketplace.
894
+ if (platform.marketplaceEntryCarriesVersion && entry.version !== action.version) {
1314
895
  return createResult({
1315
896
  actionType,
1316
897
  status: ActionStatus.PREFLIGHT_FAILED,
@@ -1344,12 +925,13 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1344
925
  }
1345
926
 
1346
927
  // Verify the entry skill exists in the snapshot.
1347
- // Claude/Codex manifests always declare ./skills/, so the fixed
1348
- // skills/<entrySkill>/SKILL.md layout is authoritative for them.
1349
- // Kimi resolves the entry skill via the manifest-declared skills root
1350
- // (MAJOR-4): the root is validated + realpath-contained, and omitted
1351
- // `skills` means the official single-skill root SKILL.md.
1352
- if (consumer === 'kimi') {
928
+ // Automatable platforms' manifests always declare ./skills/, so the
929
+ // fixed skills/<entrySkill>/SKILL.md layout is authoritative for
930
+ // them. A non-automatable platform (kimi) resolves the entry skill
931
+ // via the manifest-declared skills root (MAJOR-4): the root is
932
+ // validated + realpath-contained, and omitted `skills` means the
933
+ // official single-skill root SKILL.md.
934
+ if (!platform.automatable) {
1353
935
  try {
1354
936
  await resolveKimiEntrySkillFile(snapshotDirReal, kimiSnapshotManifest, action.entrySkill);
1355
937
  } catch (entryErr) {
@@ -1548,13 +1130,15 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1548
1130
  });
1549
1131
  }
1550
1132
 
1551
- // Kimi has NO scriptable install CLI. It is handled entirely by the
1552
- // manual-requirement helper, which uses a stable plan-digest-keyed
1553
- // home and deliberately SKIPS the per-run isolated consumer dir and
1554
- // its runDir containment check (that model only fits claude/codex,
1555
- // which exec a real CLI into a per-run HOME).
1556
- if (action.consumer === 'kimi') {
1557
- return executeKimiManualRequirement(action, context);
1133
+ // A non-automatable platform (kimi) has NO scriptable install CLI.
1134
+ // It is handled entirely by its manual-requirement strategy, which
1135
+ // uses a stable plan-digest-keyed home and deliberately SKIPS the
1136
+ // per-run isolated consumer dir and its runDir containment check
1137
+ // (that model only fits automatable platforms, which exec a real
1138
+ // CLI into a per-run HOME).
1139
+ const platform = getPlatform(action.consumer);
1140
+ if (!platform.automatable) {
1141
+ return platform.strategy.buildManualRequirement(action, context);
1558
1142
  }
1559
1143
 
1560
1144
  const consumer = action.consumer;
@@ -1574,21 +1158,18 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1574
1158
  });
1575
1159
  }
1576
1160
 
1577
- // Create isolated HOME and required subdirectories
1161
+ // Create isolated HOME and the consumer state subdirectories the
1162
+ // registry declares for this platform.
1578
1163
  await mkdir(isolatedHome, { recursive: true, mode: 0o700 });
1579
- if (consumer === 'claude') {
1580
- await mkdir(resolve(isolatedHome, '.claude'), { recursive: true, mode: 0o700 });
1581
- } else {
1582
- await mkdir(resolve(isolatedHome, '.codex'), { recursive: true, mode: 0o700 });
1164
+ for (const subdir of platform.isolationSubdirs) {
1165
+ await mkdir(resolve(isolatedHome, subdir), { recursive: true, mode: 0o700 });
1583
1166
  }
1584
1167
 
1585
- const cliCmd = consumer === 'claude' ? 'claude' : 'codex';
1168
+ const cliCmd = platform.cli.binary;
1586
1169
  const baseEnv = { ...process.env, ...context.env };
1587
1170
  const env = {
1588
1171
  ...baseEnv,
1589
- ...(consumer === 'claude'
1590
- ? { HOME: isolatedHome, CLAUDE_CONFIG_DIR: resolve(isolatedHome, '.claude') }
1591
- : { HOME: isolatedHome, CODEX_HOME: isolatedHome }),
1172
+ ...platform.isolationEnv(isolatedHome),
1592
1173
  };
1593
1174
  // Ensure real HOME/CODEX_HOME don't leak back (already overridden
1594
1175
  // above).
@@ -1610,16 +1191,14 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1610
1191
  });
1611
1192
  }
1612
1193
 
1613
- // Step 1: Add marketplace (claude/codex only; kimi returned above)
1194
+ // Step 1: Add marketplace (automatable platforms only; the
1195
+ // non-automatable manual-requirement path returned above)
1614
1196
  const ref = action.ref ?? `v${action.version}`;
1615
1197
  let addOutput = null;
1616
- if (consumer !== 'kimi') {
1617
- const marketplaceArgs = consumer === 'claude'
1618
- ? ['plugin', 'marketplace', 'add', `${action.repo}@${ref}`]
1619
- : ['plugin', 'marketplace', 'add', action.repo, '--ref', ref, '--json'];
1198
+ const marketplaceArgs = platform.cli.marketplaceAdd(action.repo, ref);
1620
1199
  try {
1621
1200
  const addResult = await exec(cliCmd, marketplaceArgs, { env, cwd: context.root, timeout: frozenTimeoutMs });
1622
- if (consumer === 'codex') {
1201
+ if (platform.jsonProtocol.marketplaceAddOutput === 'json') {
1623
1202
  try {
1624
1203
  addOutput = JSON.parse(addResult.stdout);
1625
1204
  if (!addOutput || typeof addOutput !== 'object') {
@@ -1651,17 +1230,14 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1651
1230
  error: `marketplace add failed: ${addErr.message}`,
1652
1231
  });
1653
1232
  }
1654
- }
1655
1233
 
1656
- // Step 2: Install plugin (claude/codex only; kimi has no install CLI
1657
- // and returned the manual-install requirement above).
1234
+ // Step 2: Install plugin (the non-automatable manual-requirement
1235
+ // path returned above; it has no install CLI).
1658
1236
  let installOutput;
1659
- const installArgs = consumer === 'claude'
1660
- ? ['plugin', 'install', `${action.plugin}@${action.marketplace}`]
1661
- : ['plugin', 'add', `${action.plugin}@${action.marketplace}`, '--json'];
1237
+ const installArgs = platform.cli.install(action.plugin, action.marketplace);
1662
1238
  try {
1663
1239
  const installResult = await exec(cliCmd, installArgs, { env, cwd: context.root, timeout: frozenTimeoutMs });
1664
- if (consumer === 'codex') {
1240
+ if (platform.jsonProtocol.pluginInstallOutput === 'json') {
1665
1241
  try {
1666
1242
  installOutput = JSON.parse(installResult.stdout);
1667
1243
  if (!installOutput || typeof installOutput !== 'object') {
@@ -1729,6 +1305,29 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1729
1305
  });
1730
1306
  }
1731
1307
 
1308
+ // Bind the installed payload to the sealed authority before writing
1309
+ // evidence so the evidence file can carry the declared-manifest
1310
+ // audit fields (host-added paths). Best-effort: binding failure is
1311
+ // caught at verify time. Claude's install CLI reports no
1312
+ // installedPath, so claude binds only at observe (via `plugin
1313
+ // list`); codex binds here from the validated install JSON.
1314
+ const installPath = installOutput?.installedPath;
1315
+ let executeManifestDigest = null;
1316
+ let executeBinding = null;
1317
+ if (installPath) {
1318
+ try {
1319
+ executeBinding = await verifyInstalledMarketplacePayload(
1320
+ action,
1321
+ context,
1322
+ installPath,
1323
+ consumer,
1324
+ );
1325
+ executeManifestDigest = executeBinding.manifestDigest;
1326
+ } catch {
1327
+ // Digest computation failure is caught at verify time
1328
+ }
1329
+ }
1330
+
1732
1331
  // Build and write structured evidence for observe cross-validation
1733
1332
  const evidence = {
1734
1333
  isolatedHome,
@@ -1741,6 +1340,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1741
1340
  addOutput,
1742
1341
  installOutput,
1743
1342
  executedAt: new Date().toISOString(),
1343
+ ...extraInstalledPathsAudit(executeBinding),
1744
1344
  };
1745
1345
 
1746
1346
  // Write evidence file to runDir/evidence/ (outside isolatedHome/installPath digest scope)
@@ -1749,24 +1349,8 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1749
1349
  const evidencePath = resolve(evidenceDir, 'release-skill-install-evidence.json');
1750
1350
  await writeEvidenceAtomic(evidencePath, evidence);
1751
1351
 
1752
- // Compute manifestDigest from installed content and build
1753
- // expected-compatible observation for executeCheckpoint's
1352
+ // Build expected-compatible observation for executeCheckpoint's
1754
1353
  // matchObservation check.
1755
- const installPath = installOutput?.installedPath;
1756
- let executeManifestDigest = null;
1757
- if (installPath) {
1758
- try {
1759
- executeManifestDigest = await verifyInstalledMarketplacePayload(
1760
- action,
1761
- context,
1762
- installPath,
1763
- consumer,
1764
- );
1765
- } catch {
1766
- // Digest computation failure is caught at verify time
1767
- }
1768
- }
1769
-
1770
1354
  const executeObservation = {
1771
1355
  ...evidence,
1772
1356
  installed: true,
@@ -1870,15 +1454,19 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1870
1454
  });
1871
1455
  }
1872
1456
  const isolatedHome = resolve(runDir, 'consumers', `${consumer}-${action.plugin}`);
1873
- const cliCmd = consumer === 'claude' ? 'claude' : consumer === 'codex' ? 'codex' : 'kimi';
1457
+ // Registry-driven platform data. observe historically applies no
1458
+ // consumer validation gate (execute/preflight validate it), so an
1459
+ // unregistered consumer keeps the legacy fall-through shape: a
1460
+ // kimi-shaped env, no attestation branch, and no CLI binary — it
1461
+ // still fails closed on the missing execute evidence below.
1462
+ const platform = PLATFORMS.find((p) => p.id === consumer) ?? null;
1463
+ const cliCmd = platform ? (platform.cli ? platform.cli.binary : null) : 'kimi';
1874
1464
  const baseEnv = { ...process.env, ...(context.env ?? {}) };
1875
1465
  const env = {
1876
1466
  ...baseEnv,
1877
- ...(consumer === 'claude'
1878
- ? { HOME: isolatedHome, CLAUDE_CONFIG_DIR: resolve(isolatedHome, '.claude') }
1879
- : consumer === 'codex'
1880
- ? { HOME: isolatedHome, CODEX_HOME: isolatedHome }
1881
- : { HOME: isolatedHome, KIMI_CODE_HOME: isolatedHome }),
1467
+ ...(platform
1468
+ ? platform.isolationEnv(isolatedHome)
1469
+ : { HOME: isolatedHome, KIMI_CODE_HOME: isolatedHome }),
1882
1470
  };
1883
1471
 
1884
1472
  // Resolve frozen timeoutMs from the expanded action (top-level).
@@ -1895,16 +1483,17 @@ export function createPluginMarketplaceAdapter(deps = {}) {
1895
1483
  });
1896
1484
  }
1897
1485
 
1898
- // Kimi Code protocol capability gap (BLOCKER-1): there is NO
1899
- // `kimi plugins list --json` interface. observe never execs a kimi
1900
- // command. Instead it consumes a structured human attestation (written
1901
- // after the interactive install) bound to the frozen plan digest and
1902
- // expected identity, then performs read-only verification of the
1903
- // installed managed copy: payload digest vs the sealed authority,
1904
- // entry skill resolved via the manifest skills root (MAJOR-4), and
1905
- // manifest name/version. Missing/expired/mismatched/escaping proof
1906
- // fails closed so a kimi unit can never reach VERIFIED without it.
1907
- if (consumer === 'kimi') {
1486
+ // Non-automatable platform protocol capability gap (BLOCKER-1):
1487
+ // there is NO `kimi plugins list --json` interface. observe never
1488
+ // execs a kimi command. Instead it consumes a structured human
1489
+ // attestation (written after the interactive install) bound to the
1490
+ // frozen plan digest and expected identity, then performs read-only
1491
+ // verification of the installed managed copy: payload digest vs the
1492
+ // sealed authority, entry skill resolved via the manifest skills
1493
+ // root (MAJOR-4), and manifest name/version.
1494
+ // Missing/expired/mismatched/escaping proof fails closed so a kimi
1495
+ // unit can never reach VERIFIED without it.
1496
+ if (platform && !platform.automatable) {
1908
1497
  const expectedRef = action.ref ?? `v${action.version}`;
1909
1498
 
1910
1499
  // Bind to the REAL frozen plan digest (A). Fail closed if the
@@ -2085,8 +1674,10 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2085
1674
  // Read-only payload binding: the installed managed copy must match
2086
1675
  // the sealed frozen authority exactly (transport-normalized).
2087
1676
  let manifestDigest;
1677
+ let payloadBinding = null;
2088
1678
  try {
2089
- manifestDigest = await verifyInstalledMarketplacePayload(action, context, installPathReal, consumer);
1679
+ payloadBinding = await verifyInstalledMarketplacePayload(action, context, installPathReal, consumer);
1680
+ manifestDigest = payloadBinding.manifestDigest;
2090
1681
  } catch (digestErr) {
2091
1682
  return createResult({
2092
1683
  actionType,
@@ -2115,7 +1706,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2115
1706
  let installedManifest;
2116
1707
  let entrySkillFound = false;
2117
1708
  try {
2118
- const readManifest = await readKimiManifest(installPathReal);
1709
+ const readManifest = await platform.strategy.readManifest(installPathReal);
2119
1710
  installedManifest = readManifest.manifest;
2120
1711
  await resolveKimiEntrySkillFile(installPathReal, installedManifest, action.entrySkill);
2121
1712
  entrySkillFound = true;
@@ -2174,6 +1765,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2174
1765
  version: installedManifest.version,
2175
1766
  repo: action.repo,
2176
1767
  ref: expectedRef,
1768
+ ...extraInstalledPathsAudit(payloadBinding),
2177
1769
  };
2178
1770
  return createResult({
2179
1771
  actionType,
@@ -2217,8 +1809,9 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2217
1809
  });
2218
1810
  }
2219
1811
 
2220
- // Run list command to verify installation (claude/codex only; kimi
2221
- // has no list CLI and returned via the attestation path above).
1812
+ // Run list command to verify installation (automatable platforms
1813
+ // only; a non-automatable platform has no list CLI and returned via
1814
+ // the attestation path above).
2222
1815
  const listArgs = ['plugin', 'list', '--json'];
2223
1816
 
2224
1817
  let listOutput;
@@ -2240,98 +1833,57 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2240
1833
  let found = null;
2241
1834
  let installPath = null;
2242
1835
 
2243
- if (consumer === 'claude') {
2244
- // Claude: list returns an array; find by id === "plugin@marketplace"
2245
- if (!Array.isArray(listOutput)) {
2246
- return createResult({
2247
- actionType,
2248
- status: ActionStatus.OBSERVED,
2249
- observation: {
2250
- installed: false,
2251
- error: 'Claude plugin list did not return an array',
2252
- },
2253
- });
2254
- }
2255
- found = listOutput.find((p) => p.id === pluginId);
2256
- if (!found) {
2257
- return createResult({
2258
- actionType,
2259
- status: ActionStatus.OBSERVED,
2260
- observation: {
2261
- installed: false,
2262
- error: `plugin "${pluginId}" not found in Claude plugin list`,
2263
- },
2264
- });
2265
- }
2266
- if (!found.installPath) {
2267
- return createResult({
2268
- actionType,
2269
- status: ActionStatus.OBSERVED,
2270
- observation: {
2271
- installed: false,
2272
- error: `plugin "${pluginId}" found but missing installPath`,
2273
- },
2274
- });
2275
- }
2276
- installPath = found.installPath;
2277
- } else if (consumer === 'codex') {
2278
- // Codex: installPath comes from validated evidence, not from list
2279
- installPath = evidence.installOutput?.installedPath;
2280
- if (!installPath) {
1836
+ // Protocol differences live in the platform strategy functions.
1837
+ // Where the install path comes from install evidence (codex) that
1838
+ // check runs BEFORE the list shape check (legacy ordering); where it
1839
+ // comes from the parsed list entry (claude), extractInstallPath is
1840
+ // only reached after parseListOutput returned ok — which has already
1841
+ // fail-closed on a missing installPath, so the lenient
1842
+ // claudeExtractInstallPath boundary can never observe an incomplete
1843
+ // listParsed (slice-1 review leftover).
1844
+ if (platform && platform.jsonProtocol.installPathSource === 'install-output') {
1845
+ const extracted = platform.strategy.extractInstallPath({ execEvidence: evidence });
1846
+ if (!extracted.ok) {
2281
1847
  return createResult({
2282
1848
  actionType,
2283
1849
  status: ActionStatus.OBSERVED,
2284
- observation: {
2285
- installed: false,
2286
- error: 'evidence install JSON missing installedPath',
2287
- },
1850
+ observation: { installed: false, error: extracted.error },
2288
1851
  });
2289
1852
  }
2290
-
2291
- // Cross-validate with list (list does NOT provide installedPath)
2292
- const installed = listOutput?.installed;
2293
- if (!Array.isArray(installed)) {
2294
- return createResult({
2295
- actionType,
2296
- status: ActionStatus.OBSERVED,
2297
- observation: {
2298
- installed: false,
2299
- error: 'Codex plugin list did not return {installed: [...]}',
2300
- },
2301
- });
2302
- }
2303
- found = installed.find((p) => p.pluginId === pluginId);
2304
- if (!found) {
2305
- return createResult({
2306
- actionType,
2307
- status: ActionStatus.OBSERVED,
2308
- observation: {
2309
- installed: false,
2310
- error: `plugin "${pluginId}" not found in Codex installed list`,
2311
- },
2312
- });
2313
- }
2314
- // Cross-validate: list fields must match evidence/action
2315
- if (found.name !== action.plugin) {
1853
+ installPath = extracted.installPath;
1854
+ }
1855
+ if (platform && platform.strategy.parseListOutput) {
1856
+ const listParsed = platform.strategy.parseListOutput(listOutput, pluginId);
1857
+ if (!listParsed.ok) {
2316
1858
  return createResult({
2317
1859
  actionType,
2318
1860
  status: ActionStatus.OBSERVED,
2319
- observation: { installed: false, error: `list name "${found.name}" does not match action plugin "${action.plugin}"` },
1861
+ observation: { installed: false, error: listParsed.error },
2320
1862
  });
2321
1863
  }
2322
- if (found.marketplaceName !== action.marketplace) {
2323
- return createResult({
2324
- actionType,
2325
- status: ActionStatus.OBSERVED,
2326
- observation: { installed: false, error: `list marketplaceName "${found.marketplaceName}" does not match action marketplace "${action.marketplace}"` },
2327
- });
1864
+ found = listParsed.found;
1865
+ if (platform.jsonProtocol.installPathSource === 'list') {
1866
+ const extracted = platform.strategy.extractInstallPath({ listParsed });
1867
+ if (!extracted.ok) {
1868
+ return createResult({
1869
+ actionType,
1870
+ status: ActionStatus.OBSERVED,
1871
+ observation: { installed: false, error: extracted.error },
1872
+ });
1873
+ }
1874
+ installPath = extracted.installPath;
2328
1875
  }
2329
- if (found.version !== action.version) {
2330
- return createResult({
2331
- actionType,
2332
- status: ActionStatus.OBSERVED,
2333
- observation: { installed: false, error: `list version "${found.version}" does not match action version "${action.version}"` },
2334
- });
1876
+ // Cross-validate list identity fields against the frozen action
1877
+ // where the platform protocol requires it (codex).
1878
+ if (platform.strategy.crossValidateListEntry) {
1879
+ const crossCheck = platform.strategy.crossValidateListEntry(found, action);
1880
+ if (!crossCheck.ok) {
1881
+ return createResult({
1882
+ actionType,
1883
+ status: ActionStatus.OBSERVED,
1884
+ observation: { installed: false, error: crossCheck.error },
1885
+ });
1886
+ }
2335
1887
  }
2336
1888
  }
2337
1889
 
@@ -2383,20 +1935,26 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2383
1935
  // normalizing only transport-restored write permission bits.
2384
1936
  let manifestDigest;
2385
1937
  let manifestError = null;
1938
+ let payloadBinding = null;
2386
1939
  try {
2387
- manifestDigest = await verifyInstalledMarketplacePayload(
1940
+ payloadBinding = await verifyInstalledMarketplacePayload(
2388
1941
  action,
2389
1942
  context,
2390
1943
  installPath,
2391
1944
  consumer,
2392
1945
  );
1946
+ manifestDigest = payloadBinding.manifestDigest;
2393
1947
  } catch (digestErr) {
2394
1948
  // Preserve independently observed fields for diagnostics. This
2395
1949
  // raw digest is not accepted as plan authority because the error
2396
- // is returned and verify therefore fails closed.
1950
+ // is returned and verify therefore fails closed. The legacy
1951
+ // contract filters consumer-owned transport metadata; the
1952
+ // declared-manifest contract never excludes anything.
2397
1953
  try {
2398
1954
  const installedSnapshot = await computeFrozenSnapshot(installPath, {
2399
- excludeRootEntries: consumerTransportExclusions(consumer),
1955
+ excludeRootEntries: action.payloadContract === undefined
1956
+ ? getPlatform(consumer).knownHostArtifacts
1957
+ : [],
2400
1958
  });
2401
1959
  manifestDigest = installedSnapshot.digest;
2402
1960
  } catch {
@@ -2413,20 +1971,15 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2413
1971
  entrySkill: action.entrySkill,
2414
1972
  manifestDigest,
2415
1973
  consumer,
1974
+ ...extraInstalledPathsAudit(payloadBinding),
2416
1975
  };
2417
1976
 
2418
- // Fields from CLI evidence only (claude/codex; kimi observe returns
2419
- // via the attestation path above and never reaches this point).
2420
- if (consumer === 'claude') {
2421
- // Claude list may not have name; extract plugin/marketplace from id
2422
- const idParts = found.id.split('@');
2423
- observation.plugin = idParts[0];
2424
- observation.marketplace = idParts.slice(1).join('@');
2425
- if (found.version) observation.version = found.version;
2426
- } else if (consumer === 'codex') {
2427
- if (found.name) observation.plugin = found.name;
2428
- if (found.marketplaceName) observation.marketplace = found.marketplaceName;
2429
- if (found.version) observation.version = found.version;
1977
+ // Fields from CLI evidence only, extracted by the platform strategy
1978
+ // (a non-automatable platform observe returns via the attestation
1979
+ // path above and never reaches this point). Key insertion order is
1980
+ // strategy-owned and mirrors the legacy backfill.
1981
+ if (platform && platform.strategy.extractListIdentity) {
1982
+ Object.assign(observation, platform.strategy.extractListIdentity(found));
2430
1983
  }
2431
1984
 
2432
1985
  // Cross-validate version: evidence vs CLI
@@ -2446,7 +1999,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2446
1999
 
2447
2000
  // Verify installed manifest name/version matches CLI/evidence
2448
2001
  try {
2449
- const installedManifestPath = resolve(installPath, consumer === 'claude' ? '.claude-plugin/plugin.json' : '.codex-plugin/plugin.json');
2002
+ const installedManifestPath = resolve(installPath, platform.manifestPaths.plugin);
2450
2003
  const installedManifestContent = await readFile(installedManifestPath, 'utf8');
2451
2004
  const installedManifest = JSON.parse(installedManifestContent);
2452
2005
  const expectedName = observation.plugin;