release-skill 0.2.3 → 0.2.4
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +27 -0
- package/INSTALL.md +95 -139
- package/INSTALL.zh-CN.md +70 -121
- package/README.md +264 -913
- package/README.zh-CN.md +222 -535
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +19054 -17573
- package/adapters/claude/schemas/release-plan.schema.json +137 -0
- package/adapters/claude/schemas/release-project.schema.json +93 -0
- package/adapters/claude/schemas/release-run.schema.json +70 -2
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +19054 -17573
- package/adapters/codex/schemas/release-plan.schema.json +137 -0
- package/adapters/codex/schemas/release-project.schema.json +93 -0
- package/adapters/codex/schemas/release-run.schema.json +70 -2
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +19054 -17573
- package/adapters/kimi/schemas/release-plan.schema.json +137 -0
- package/adapters/kimi/schemas/release-project.schema.json +93 -0
- package/adapters/kimi/schemas/release-run.schema.json +70 -2
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +19054 -17573
- package/adapters/workbuddy/schemas/release-plan.schema.json +137 -0
- package/adapters/workbuddy/schemas/release-project.schema.json +93 -0
- package/adapters/workbuddy/schemas/release-run.schema.json +70 -2
- package/bin/release-skill.bundle.mjs +19054 -17573
- package/package.json +1 -1
- package/schemas/release-plan.schema.json +137 -0
- package/schemas/release-project.schema.json +93 -0
- package/schemas/release-run.schema.json +70 -2
- package/src/adapters/plugin-marketplace.mjs +1190 -435
- package/src/commands/prepare.mjs +380 -40
- package/src/commands/publish.mjs +107 -75
- package/src/commands/reconcile.mjs +92 -327
- package/src/commands/setup.mjs +148 -20
- package/src/commands/verify.mjs +304 -20
- package/src/core/baseline.mjs +8 -1
- package/src/core/checkpoints.mjs +50 -7
- package/src/core/config.mjs +15 -0
- package/src/core/errors.mjs +2 -0
- package/src/core/installation-contract.mjs +341 -0
- package/src/core/plan.mjs +129 -6
- package/src/platforms/codebuddy.mjs +193 -280
- package/src/platforms/codex.mjs +369 -0
- package/src/platforms/kimi.mjs +164 -119
- package/src/platforms/registry.mjs +24 -6
package/src/commands/setup.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import addFormats from 'ajv-formats';
|
|
|
23
23
|
|
|
24
24
|
import { canonicalJson, sha256Hex } from '../core/digest.mjs';
|
|
25
25
|
import { acquireProjectLock } from '../artifacts/project-lock.mjs';
|
|
26
|
+
import { getPlatform, PLATFORMS } from '../platforms/registry.mjs';
|
|
26
27
|
import {
|
|
27
28
|
CONFIG_EXISTS,
|
|
28
29
|
CONFIG_INVALID,
|
|
@@ -38,6 +39,57 @@ const SKIP_DIRS = new Set([
|
|
|
38
39
|
'node_modules', 'dist', 'coverage', 'build', 'out', 'tmp', 'temp',
|
|
39
40
|
'runs', 'test', 'tests', 'test-fixtures', 'fixtures', 'examples',
|
|
40
41
|
]);
|
|
42
|
+
// Registry-driven discovery: derive marketplace and plugin manifest paths
|
|
43
|
+
// from the platform registry. This replaces hardcoded path checks so that
|
|
44
|
+
// new platforms (e.g. Codex's .agents/plugins/marketplace.json) are
|
|
45
|
+
// automatically discovered without editing setup.mjs.
|
|
46
|
+
const DISCOVERY_MANIFEST_SUFFIXES = new Set(
|
|
47
|
+
PLATFORMS.flatMap((p) => {
|
|
48
|
+
const paths = [p.manifestPaths.plugin];
|
|
49
|
+
if (p.manifestPaths.marketplace) paths.push(p.manifestPaths.marketplace);
|
|
50
|
+
// Kimi has pluginCandidates (kimi.plugin.json, .kimi-plugin/plugin.json)
|
|
51
|
+
if (p.manifestPaths.pluginCandidates) paths.push(...p.manifestPaths.pluginCandidates);
|
|
52
|
+
return paths;
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Build a map from manifest path suffix → host platform id(s).
|
|
57
|
+
// For marketplace and plugin files, this allows deterministic host detection
|
|
58
|
+
// without hardcoded path.includes() checks.
|
|
59
|
+
// Multi-value: Claude and CodeBuddy share .claude-plugin/marketplace.json;
|
|
60
|
+
// a single-value Map would overwrite one platform's identity with the other.
|
|
61
|
+
const MANIFEST_SUFFIX_HOST_MAP = new Map();
|
|
62
|
+
for (const platform of PLATFORMS) {
|
|
63
|
+
const mp = platform.manifestPaths.marketplace;
|
|
64
|
+
if (mp) {
|
|
65
|
+
const existing = MANIFEST_SUFFIX_HOST_MAP.get(mp);
|
|
66
|
+
if (existing) {
|
|
67
|
+
existing.push(platform.id);
|
|
68
|
+
} else {
|
|
69
|
+
MANIFEST_SUFFIX_HOST_MAP.set(mp, [platform.id]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const pp = platform.manifestPaths.plugin;
|
|
73
|
+
if (pp) {
|
|
74
|
+
const existing = MANIFEST_SUFFIX_HOST_MAP.get(pp);
|
|
75
|
+
if (existing) {
|
|
76
|
+
existing.push(platform.id);
|
|
77
|
+
} else {
|
|
78
|
+
MANIFEST_SUFFIX_HOST_MAP.set(pp, [platform.id]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (platform.manifestPaths.pluginCandidates) {
|
|
82
|
+
for (const candidate of platform.manifestPaths.pluginCandidates) {
|
|
83
|
+
const existing = MANIFEST_SUFFIX_HOST_MAP.get(candidate);
|
|
84
|
+
if (existing) {
|
|
85
|
+
existing.push(platform.id);
|
|
86
|
+
} else {
|
|
87
|
+
MANIFEST_SUFFIX_HOST_MAP.set(candidate, [platform.id]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
41
93
|
const MAX_JSON_BYTES = 1024 * 1024;
|
|
42
94
|
const schema = JSON.parse((await readTrustedPackageResource(
|
|
43
95
|
'schemas/release-project.schema.json',
|
|
@@ -86,12 +138,7 @@ async function walkDiscoveryFiles(root, maxDepth = 8) {
|
|
|
86
138
|
/^README(?:\.|$)/i.test(child.name) ||
|
|
87
139
|
/^LICENSE(?:\.|$)/i.test(child.name) ||
|
|
88
140
|
/^CHANGELOG(?:\.|$)/i.test(child.name) ||
|
|
89
|
-
absolute.endsWith(
|
|
90
|
-
absolute.endsWith('/.codex-plugin/plugin.json') ||
|
|
91
|
-
absolute.endsWith('/.kimi-plugin/plugin.json') ||
|
|
92
|
-
absolute.endsWith('/.codebuddy-plugin/plugin.json') ||
|
|
93
|
-
absolute.endsWith('/.claude-plugin/marketplace.json') ||
|
|
94
|
-
absolute.endsWith('/.codex-plugin/marketplace.json'))
|
|
141
|
+
[...DISCOVERY_MANIFEST_SUFFIXES].some((suffix) => absolute.endsWith(`/${suffix}`)))
|
|
95
142
|
) {
|
|
96
143
|
found.push(absolute);
|
|
97
144
|
}
|
|
@@ -458,16 +505,25 @@ async function discoverFacts(root) {
|
|
|
458
505
|
const manifests = [];
|
|
459
506
|
for (const path of [...pluginFiles, ...marketplaceFiles].sort()) {
|
|
460
507
|
const value = await readJsonBounded(path, 'discovered plugin manifest');
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
508
|
+
// Registry-driven host detection: find all platforms whose manifest path
|
|
509
|
+
// suffix matches this file. Falls back to 'codex' for unknown paths
|
|
510
|
+
// (preserving legacy behavior for edge cases).
|
|
511
|
+
// Multi-value: Claude and CodeBuddy share .claude-plugin/marketplace.json,
|
|
512
|
+
// so a single file may be claimed by multiple hosts.
|
|
513
|
+
let detectedHosts = ['codex'];
|
|
514
|
+
for (const [suffix, hostIds] of MANIFEST_SUFFIX_HOST_MAP) {
|
|
515
|
+
if (path.endsWith(`/${suffix}`)) {
|
|
516
|
+
detectedHosts = hostIds;
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const relPath = safeRelative(root, path);
|
|
521
|
+
const kind = path.endsWith('/marketplace.json') ? 'marketplace' : 'plugin';
|
|
522
|
+
const name = typeof value.name === 'string' ? value.name : null;
|
|
523
|
+
const version = typeof value.version === 'string' ? value.version : null;
|
|
524
|
+
for (const host of detectedHosts) {
|
|
525
|
+
manifests.push({ path: relPath, host, kind, name, version });
|
|
526
|
+
}
|
|
471
527
|
}
|
|
472
528
|
|
|
473
529
|
const legacyReleaseConfigs = [];
|
|
@@ -657,9 +713,20 @@ function buildCandidates(facts) {
|
|
|
657
713
|
const gates = [];
|
|
658
714
|
const ids = new Set();
|
|
659
715
|
const knownFiles = new Set(facts.fileDigests.map((file) => file.path));
|
|
716
|
+
// Extract manifest root from path using registry-driven suffix matching.
|
|
717
|
+
// This handles all platform paths including Codex's .agents/plugins/marketplace.json,
|
|
718
|
+
// not just the hardcoded .{platform}-plugin/ pattern.
|
|
660
719
|
const manifestRoots = facts.manifests.map((manifest) => {
|
|
661
|
-
|
|
662
|
-
|
|
720
|
+
let root = '.';
|
|
721
|
+
for (const suffix of MANIFEST_SUFFIX_HOST_MAP.keys()) {
|
|
722
|
+
const fullSuffix = `/${suffix}`;
|
|
723
|
+
const idx = manifest.path.lastIndexOf(fullSuffix);
|
|
724
|
+
if (idx >= 0) {
|
|
725
|
+
root = manifest.path.slice(0, idx) || '.';
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return { ...manifest, root };
|
|
663
730
|
});
|
|
664
731
|
const manifestOwners = new Map();
|
|
665
732
|
const legacyUnits = facts.legacyReleaseConfigs.flatMap((config) => config.releaseUnits);
|
|
@@ -968,6 +1035,17 @@ function buildRecommendedProposal(facts, candidates) {
|
|
|
968
1035
|
};
|
|
969
1036
|
}
|
|
970
1037
|
|
|
1038
|
+
// Discover marketplace index candidates for this unit's plugin hosts.
|
|
1039
|
+
//
|
|
1040
|
+
// 单一权威来源:平台注册表的 manifestPaths.marketplace 是默认索引路径;
|
|
1041
|
+
// publicFileMappingCandidates 是该 unit 唯一可发现的公开文件 to 路径集。
|
|
1042
|
+
// 按"等于默认索引路径或以 /<默认索引路径> 精确结尾"筛选候选。
|
|
1043
|
+
// 1 个候选:根布局省略显式路径,嵌套布局写 marketplaceIndexPath;
|
|
1044
|
+
// 0 个候选:产生 MARKETPLACE_ASSET_MISSING;
|
|
1045
|
+
// 多个候选:产生 MARKETPLACE_ASSET_AMBIGUOUS。
|
|
1046
|
+
// Kimi 默认路径为 null,不要求候选;CodeBuddy 复用 Claude 市场索引。
|
|
1047
|
+
const unitPrefix = unit.source === '.' ? '' : `${unit.source}/`;
|
|
1048
|
+
const marketplaceConflicts = [];
|
|
971
1049
|
const distributions = unit.distributionCandidates.map((type) => {
|
|
972
1050
|
if (type === 'npm') {
|
|
973
1051
|
return {
|
|
@@ -978,19 +1056,69 @@ function buildRecommendedProposal(facts, candidates) {
|
|
|
978
1056
|
};
|
|
979
1057
|
}
|
|
980
1058
|
const entrySkill = unit.entrySkillCandidates?.[0];
|
|
981
|
-
|
|
1059
|
+
const host = type.replace(/-plugin$/, '');
|
|
1060
|
+
const platform = getPlatform(host);
|
|
1061
|
+
const defaultMktPath = platform.manifestPaths.marketplace;
|
|
1062
|
+
const dist = {
|
|
982
1063
|
type,
|
|
983
1064
|
plugin: unit.id,
|
|
984
1065
|
marketplace: unit.id,
|
|
985
1066
|
entrySkill,
|
|
1067
|
+
marketplaceSourceType: 'bundled-family',
|
|
986
1068
|
};
|
|
1069
|
+
// Kimi 的 manifestPaths.marketplace 为 null,不要求候选
|
|
1070
|
+
if (defaultMktPath !== null) {
|
|
1071
|
+
const mappingCandidates = unit.publicFileMappingCandidates ?? [];
|
|
1072
|
+
const suffix = `/${defaultMktPath}`;
|
|
1073
|
+
const candidates = mappingCandidates.filter((m) => (
|
|
1074
|
+
m.to === defaultMktPath || m.to.endsWith(suffix)
|
|
1075
|
+
));
|
|
1076
|
+
if (candidates.length === 0) {
|
|
1077
|
+
marketplaceConflicts.push({
|
|
1078
|
+
code: 'MARKETPLACE_ASSET_MISSING',
|
|
1079
|
+
unit: unit.id,
|
|
1080
|
+
platform: host,
|
|
1081
|
+
expectedSuffix: defaultMktPath,
|
|
1082
|
+
});
|
|
1083
|
+
return null;
|
|
1084
|
+
}
|
|
1085
|
+
if (candidates.length > 1) {
|
|
1086
|
+
marketplaceConflicts.push({
|
|
1087
|
+
code: 'MARKETPLACE_ASSET_AMBIGUOUS',
|
|
1088
|
+
unit: unit.id,
|
|
1089
|
+
platform: host,
|
|
1090
|
+
candidates: candidates.map((m) => m.to).sort(),
|
|
1091
|
+
});
|
|
1092
|
+
return null;
|
|
1093
|
+
}
|
|
1094
|
+
// 1 个候选
|
|
1095
|
+
const candidateTo = candidates[0].to;
|
|
1096
|
+
if (candidateTo === defaultMktPath) {
|
|
1097
|
+
// 根布局:marketplace index 在平台默认路径,省略显式 marketplaceIndexPath
|
|
1098
|
+
} else {
|
|
1099
|
+
// 嵌套布局:candidateTo 以 /<defaultMktPath> 精确结尾。
|
|
1100
|
+
// 市场文件仍在同一发布快照内,属于 bundled-family 来源;
|
|
1101
|
+
// marketplaceIndexPath 标记索引在快照内的相对位置。
|
|
1102
|
+
// 不得伪造 marketplaceRepo(那是 standalone-index 独有字段)。
|
|
1103
|
+
dist.marketplaceIndexPath = candidateTo;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return dist;
|
|
987
1107
|
}).filter(Boolean);
|
|
988
1108
|
|
|
1109
|
+
// Marketplace asset conflicts: missing or ambiguous marketplace index
|
|
1110
|
+
if (marketplaceConflicts.length > 0) {
|
|
1111
|
+
return {
|
|
1112
|
+
answers: null,
|
|
1113
|
+
conflicts: marketplaceConflicts,
|
|
1114
|
+
assumptions,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
|
|
989
1118
|
// If any distribution failed to construct, bail
|
|
990
1119
|
if (distributions.length !== unit.distributionCandidates.length) return null;
|
|
991
1120
|
|
|
992
1121
|
const tagTemplate = unit.tagTemplateCandidates[0] ?? 'v{version}';
|
|
993
|
-
const unitPrefix = unit.source === '.' ? '' : `${unit.source}/`;
|
|
994
1122
|
const versionSource = unit.packagePath?.startsWith(unitPrefix)
|
|
995
1123
|
? unit.packagePath.slice(unitPrefix.length)
|
|
996
1124
|
: unit.packagePath;
|
package/src/commands/verify.mjs
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
* @module commands/verify
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { readFile, writeFile, mkdtemp, rm, mkdir, lstat, realpath } from 'node:fs/promises';
|
|
16
|
-
import {
|
|
15
|
+
import { readFile, writeFile, mkdtemp, rm, mkdir, lstat, realpath, readdir } from 'node:fs/promises';
|
|
16
|
+
import { realpathSync } from 'node:fs';
|
|
17
|
+
import { dirname, join, relative, isAbsolute, resolve, basename } from 'node:path';
|
|
17
18
|
import { tmpdir } from 'node:os';
|
|
18
19
|
import { execFile as execFileCb } from 'node:child_process';
|
|
19
20
|
import { promisify } from 'node:util';
|
|
@@ -50,11 +51,29 @@ import {
|
|
|
50
51
|
resolveNpmRegistryAuthToken,
|
|
51
52
|
} from '../adapters/npm.mjs';
|
|
52
53
|
import { runConsumerVerificationGates } from '../core/verification-gates.mjs';
|
|
54
|
+
import { isRemoteWriteAction, isMarketplaceAction } from '../core/checkpoints.mjs';
|
|
55
|
+
import {
|
|
56
|
+
shouldSkipVerification,
|
|
57
|
+
INSTALLATION_CONTRACT_ALGORITHM_VERSION,
|
|
58
|
+
} from '../core/installation-contract.mjs';
|
|
53
59
|
|
|
54
60
|
// ---------------------------------------------------------------------------
|
|
55
61
|
// Constants
|
|
56
62
|
// ---------------------------------------------------------------------------
|
|
57
63
|
|
|
64
|
+
/**
|
|
65
|
+
* 验证已解决结果类型。
|
|
66
|
+
*
|
|
67
|
+
* - PASSED_AUTOMATIC: 自动验证通过(adapter.verify 返回 VERIFIED)
|
|
68
|
+
* - PASSED_MANUAL: 人工验证通过(用户手动确认)
|
|
69
|
+
* - NOT_REQUIRED_UNCHANGED: 无需验证(远端状态未变化,跳过验证)
|
|
70
|
+
*/
|
|
71
|
+
export const VERIFICATION_RESOLVED_TYPES = Object.freeze({
|
|
72
|
+
PASSED_AUTOMATIC: 'PASSED_AUTOMATIC',
|
|
73
|
+
PASSED_MANUAL: 'PASSED_MANUAL',
|
|
74
|
+
NOT_REQUIRED_UNCHANGED: 'NOT_REQUIRED_UNCHANGED',
|
|
75
|
+
});
|
|
76
|
+
|
|
58
77
|
/**
|
|
59
78
|
* Map plan action type to adapter ActionType.
|
|
60
79
|
* Must match publish.mjs and reconcile.mjs.
|
|
@@ -80,6 +99,16 @@ function defaultClock() {
|
|
|
80
99
|
return new Date().toISOString();
|
|
81
100
|
}
|
|
82
101
|
|
|
102
|
+
/**
|
|
103
|
+
* 验证摘要格式是否为合法的 64 位十六进制字符串。
|
|
104
|
+
*
|
|
105
|
+
* @param {string} digest - 摘要
|
|
106
|
+
* @returns {boolean} 是否合法
|
|
107
|
+
*/
|
|
108
|
+
function isValidDigest(digest) {
|
|
109
|
+
return typeof digest === 'string' && /^[a-f0-9]{64}$/.test(digest);
|
|
110
|
+
}
|
|
111
|
+
|
|
83
112
|
// ---------------------------------------------------------------------------
|
|
84
113
|
// Smoke test
|
|
85
114
|
// ---------------------------------------------------------------------------
|
|
@@ -519,6 +548,7 @@ const defaultNpmExecutor = {
|
|
|
519
548
|
* @param {string} [options.root] - Project root for source access.
|
|
520
549
|
* @param {string} [options.runDir] - Evidence directory.
|
|
521
550
|
* @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
|
|
551
|
+
* @param {Object} [options.previousVerifyRun] - 上一次验证成功的 verify run 记录,用于安装契约摘要免验。
|
|
522
552
|
*
|
|
523
553
|
* @returns {Promise<{ planPath: string, status: string, adapterChecks: Object[], smokeTest: Object }>}
|
|
524
554
|
*
|
|
@@ -536,6 +566,7 @@ export async function verifyRelease(options) {
|
|
|
536
566
|
npmExecutor,
|
|
537
567
|
verificationGatesAuthorized,
|
|
538
568
|
gateEnv,
|
|
569
|
+
previousVerifyRun,
|
|
539
570
|
} = options ?? {};
|
|
540
571
|
|
|
541
572
|
const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
|
|
@@ -697,9 +728,13 @@ export async function verifyRelease(options) {
|
|
|
697
728
|
// Validate checkpoint mapping
|
|
698
729
|
validateRunCheckpointMapping(sourceRun, plan.externalActions ?? []);
|
|
699
730
|
|
|
700
|
-
// All checkpoints must be succeeded or skipped (no failed/pending)
|
|
731
|
+
// All checkpoints must be succeeded or skipped (no failed/pending),
|
|
732
|
+
// except marketplace install checkpoints which are re-verified by verify
|
|
733
|
+
// itself via consumer verification (human attestation or automatic).
|
|
734
|
+
// Deferred marketplace checkpoints from publish are also allowed through.
|
|
701
735
|
const incompleteCheckpoints = sourceRun.checkpoints.filter(
|
|
702
|
-
(cp) => cp.status !== 'succeeded' && cp.status !== 'skipped'
|
|
736
|
+
(cp) => cp.status !== 'succeeded' && cp.status !== 'skipped'
|
|
737
|
+
&& !((cp.status === 'failed' || cp.status === 'deferred') && isMarketplaceAction(cp.actionType)),
|
|
703
738
|
);
|
|
704
739
|
if (incompleteCheckpoints.length > 0) {
|
|
705
740
|
throw new ReleaseError(
|
|
@@ -731,13 +766,130 @@ export async function verifyRelease(options) {
|
|
|
731
766
|
|
|
732
767
|
const adapterChecks = [];
|
|
733
768
|
const consumerGateResults = [];
|
|
769
|
+
const consumerVerificationReceipts = [];
|
|
734
770
|
const actions = plan.externalActions ?? [];
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
771
|
+
|
|
772
|
+
// --- 自动发现可信消费端验证收据 ---
|
|
773
|
+
// 收据选择逻辑(per-action 从所有候选中选最新匹配):
|
|
774
|
+
// - 只读取同一权威 .release-skill/runs 的真实直接子目录
|
|
775
|
+
// - runs 根或候选目录不得是符号链接,不得物理越界
|
|
776
|
+
// - 候选必须经 loadRun(..., { requireDigest: true })、状态 VERIFIED、合法 finishedAt
|
|
777
|
+
// - 对当前 action 收集所有可信匹配收据,按 finishedAt 最新选择
|
|
778
|
+
// - 收据必须是 consumerVerificationReceipts,严格匹配 actionId/unitId/platform
|
|
779
|
+
// - 收据 planDigest 等于候选 run 自身 planDigest
|
|
780
|
+
// - 显式注入的 previousVerifyRun 也必须经过同等语义校验
|
|
781
|
+
|
|
782
|
+
/** @type {Array<Object>} 所有可信的验证 run 候选 */
|
|
783
|
+
const trustedVerifyRuns = [];
|
|
784
|
+
|
|
785
|
+
// 计算权威 runs 目录路径(基于 plan 的物理位置)
|
|
786
|
+
const planDir = dirname(planPath);
|
|
787
|
+
const releaseDir = basename(planDir) === 'plans' ? dirname(planDir) : planDir;
|
|
788
|
+
const runsDir = resolve(releaseDir, 'runs');
|
|
789
|
+
let runsDirReal = null;
|
|
790
|
+
let authorityDirReal = null;
|
|
791
|
+
|
|
792
|
+
// 校验并纳入显式注入的 previousVerifyRun
|
|
793
|
+
// 必须经过完整语义校验:status=VERIFIED、合法 runDigest、合法 finishedAt、
|
|
794
|
+
// 合法 planDigest、收据身份绑定。不满足则不注入(不参与复用),但不阻断。
|
|
795
|
+
if (previousVerifyRun) {
|
|
796
|
+
if (
|
|
797
|
+
previousVerifyRun.status === 'VERIFIED'
|
|
798
|
+
&& isValidDigest(previousVerifyRun.planDigest)
|
|
799
|
+
&& previousVerifyRun.finishedAt
|
|
800
|
+
&& typeof previousVerifyRun.finishedAt === 'string'
|
|
801
|
+
&& !isNaN(Date.parse(previousVerifyRun.finishedAt))
|
|
802
|
+
) {
|
|
803
|
+
const computedRunDigest = computeRunDigest(previousVerifyRun);
|
|
804
|
+
if (
|
|
805
|
+
typeof previousVerifyRun.runDigest === 'string'
|
|
806
|
+
&& previousVerifyRun.runDigest.length > 0
|
|
807
|
+
&& previousVerifyRun.runDigest === computedRunDigest
|
|
808
|
+
) {
|
|
809
|
+
trustedVerifyRuns.push(previousVerifyRun);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// 自动发现:从同一 .release-skill/runs 权威目录中发现
|
|
815
|
+
{
|
|
816
|
+
try {
|
|
817
|
+
const runsDirStat = await lstat(runsDir);
|
|
818
|
+
if (runsDirStat.isSymbolicLink()) {
|
|
819
|
+
// runs 根是符号链接:权威目录身份错误,失败关闭
|
|
820
|
+
throw new ReleaseError(
|
|
821
|
+
GATE_FAILED,
|
|
822
|
+
'runs directory is a symbolic link; authority identity compromised',
|
|
823
|
+
{ runsDir },
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
if (!runsDirStat.isDirectory()) {
|
|
827
|
+
throw new ReleaseError(
|
|
828
|
+
GATE_FAILED,
|
|
829
|
+
'runs path is not a directory',
|
|
830
|
+
{ runsDir },
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
// 以 plan 的物理权威目录为基准,验证 runs 是其中真实 runs 子目录
|
|
834
|
+
runsDirReal = realpathSync(runsDir);
|
|
835
|
+
authorityDirReal = realpathSync(releaseDir);
|
|
836
|
+
if (!runsDirReal.startsWith(authorityDirReal + '/') && runsDirReal !== authorityDirReal) {
|
|
837
|
+
throw new ReleaseError(
|
|
838
|
+
GATE_FAILED,
|
|
839
|
+
'runs directory is not a real child of the plan authority directory',
|
|
840
|
+
{ runsDir, runsDirReal, authorityDirReal },
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
} catch (err) {
|
|
844
|
+
if (err instanceof ReleaseError) throw err;
|
|
845
|
+
// runs 目录不存在等非致命情况:跳过自动发现
|
|
846
|
+
runsDirReal = null;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (runsDirReal) {
|
|
850
|
+
const entries = await readdir(runsDirReal, { withFileTypes: true });
|
|
851
|
+
for (const entry of entries) {
|
|
852
|
+
if (!entry.name.startsWith('verify-')) continue;
|
|
853
|
+
// 非目录或符号链接:失败关闭
|
|
854
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
855
|
+
throw new ReleaseError(
|
|
856
|
+
GATE_FAILED,
|
|
857
|
+
'verify-* candidate is a symbolic link or not a directory; authority identity compromised',
|
|
858
|
+
{ entry: entry.name, runsDir: runsDirReal },
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
const candidateDir = resolve(runsDirReal, entry.name);
|
|
862
|
+
const candidateStat = await lstat(candidateDir).catch(() => null);
|
|
863
|
+
if (!candidateStat || candidateStat.isSymbolicLink()) {
|
|
864
|
+
throw new ReleaseError(
|
|
865
|
+
GATE_FAILED,
|
|
866
|
+
'verify-* candidate is a symbolic link; authority identity compromised',
|
|
867
|
+
{ candidateDir, runsDir: runsDirReal },
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
// realpath 包含性校验:候选必须在权威 runs 目录内
|
|
871
|
+
const candidateReal = realpathSync(candidateDir);
|
|
872
|
+
if (!candidateReal.startsWith(runsDirReal + '/') && candidateReal !== runsDirReal) {
|
|
873
|
+
throw new ReleaseError(
|
|
874
|
+
GATE_FAILED,
|
|
875
|
+
'verify-* candidate real path is not contained in authority runs directory',
|
|
876
|
+
{ candidateDir, candidateReal, runsDir: runsDirReal },
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
const candidatePath = resolve(candidateDir, 'release-run.json');
|
|
880
|
+
try {
|
|
881
|
+
const candidate = await loadRun(candidatePath, { requireDigest: true });
|
|
882
|
+
if (candidate.status !== 'VERIFIED') continue;
|
|
883
|
+
if (!candidate.planDigest) continue;
|
|
884
|
+
if (!candidate.finishedAt || typeof candidate.finishedAt !== 'string') continue;
|
|
885
|
+
trustedVerifyRuns.push(candidate);
|
|
886
|
+
} catch {
|
|
887
|
+
// 真实直接目录里的坏/缺 release-run.json 可忽略
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
741
893
|
|
|
742
894
|
for (const action of actions) {
|
|
743
895
|
const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
|
|
@@ -765,7 +917,7 @@ export async function verifyRelease(options) {
|
|
|
765
917
|
);
|
|
766
918
|
}
|
|
767
919
|
|
|
768
|
-
if (
|
|
920
|
+
if (isMarketplaceAction(action.type)) {
|
|
769
921
|
// --- Marketplace: fresh consumer verification in verify's own runDir ---
|
|
770
922
|
// Context: isolatedConsumerWritesAuthorized allows writing to verify's
|
|
771
923
|
// runDir/consumers/ directory; externalWritesAuthorized stays false.
|
|
@@ -783,22 +935,105 @@ export async function verifyRelease(options) {
|
|
|
783
935
|
...action.parameters,
|
|
784
936
|
};
|
|
785
937
|
|
|
786
|
-
//
|
|
938
|
+
// --- 安装契约摘要免验检查 ---
|
|
939
|
+
// 从计划中读取冻结的安装契约摘要。
|
|
940
|
+
// 新计划在 prepare 阶段计算并冻结 installationContractDigest;
|
|
941
|
+
// 旧计划(无此字段)跳过免验检查,强制重新验证。
|
|
942
|
+
const unit = (plan.units ?? []).find((u) => u.id === action.unitId);
|
|
943
|
+
const typeToDist = {
|
|
944
|
+
'claude-marketplace-install': 'claude-plugin',
|
|
945
|
+
'codex-marketplace-install': 'codex-plugin',
|
|
946
|
+
'kimi-marketplace-install': 'kimi-plugin',
|
|
947
|
+
'codebuddy-marketplace-install': 'codebuddy-plugin',
|
|
948
|
+
};
|
|
949
|
+
const dist = unit?.distributions?.find((d) => d.type === typeToDist[action.type]);
|
|
950
|
+
|
|
951
|
+
// Step 3a: Preflight(始终先执行 adapter preflight,完成静态身份、版本、
|
|
952
|
+
// tag/ref/sha、来源和 payload 校验;只有 preflight 通过后才允许免验)
|
|
953
|
+
// 必须明确要求 PREFLIGHT_PASSED,其他状态全部失败关闭。
|
|
787
954
|
const preflightResult = await adapter.preflight(actionInput, marketplaceContext);
|
|
788
|
-
if (preflightResult.status
|
|
955
|
+
if (preflightResult.status !== 'PREFLIGHT_PASSED') {
|
|
789
956
|
adapterChecks.push({
|
|
790
957
|
actionId: action.id,
|
|
791
958
|
actionType: action.type,
|
|
792
959
|
status: 'FAILED',
|
|
793
|
-
error: `preflight
|
|
960
|
+
error: `preflight did not pass: status=${preflightResult.status}, error=${preflightResult.error}`,
|
|
794
961
|
});
|
|
795
962
|
throw new ReleaseError(
|
|
796
963
|
POST_PUBLISH_VERIFY_FAILED,
|
|
797
|
-
`marketplace preflight
|
|
964
|
+
`marketplace preflight did not pass for action "${action.id}": status=${preflightResult.status}, error=${preflightResult.error}`,
|
|
798
965
|
{ actionId: action.id },
|
|
799
966
|
);
|
|
800
967
|
}
|
|
801
968
|
|
|
969
|
+
// Preflight 通过后,检查是否可以免验
|
|
970
|
+
if (dist?.installationContractDigest) {
|
|
971
|
+
const currentDigest = dist.installationContractDigest;
|
|
972
|
+
const currentPlatform = action.parameters?.consumer ?? action.type.replace('-marketplace-install', '');
|
|
973
|
+
|
|
974
|
+
// 紧邻可信公开基线:只检查最近一个 VERIFIED run 的收据。
|
|
975
|
+
// 不能从任意更老历史中捞出相同摘要来免验。
|
|
976
|
+
// 若最近 run 无该 action/platform 的收据,则 REQUIRE_VERIFICATION。
|
|
977
|
+
let previousActionCheck = null;
|
|
978
|
+
if (trustedVerifyRuns.length > 0) {
|
|
979
|
+
// 按 finishedAt 降序排序,取最近一个
|
|
980
|
+
const sorted = [...trustedVerifyRuns].sort((a, b) => {
|
|
981
|
+
const ta = Date.parse(a.finishedAt) || 0;
|
|
982
|
+
const tb = Date.parse(b.finishedAt) || 0;
|
|
983
|
+
return tb - ta;
|
|
984
|
+
});
|
|
985
|
+
const latest = sorted[0];
|
|
986
|
+
const matchingReceipt = (latest.consumerVerificationReceipts ?? []).find(
|
|
987
|
+
(r) => r.actionId === action.id
|
|
988
|
+
&& r.unitId === action.unitId
|
|
989
|
+
&& r.platform === currentPlatform
|
|
990
|
+
&& r.planDigest === latest.planDigest,
|
|
991
|
+
);
|
|
992
|
+
if (matchingReceipt) {
|
|
993
|
+
previousActionCheck = { ...matchingReceipt, _runFinishedAtTime: Date.parse(latest.finishedAt) };
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
const previousDigest = previousActionCheck?.installationContractDigest ?? null;
|
|
997
|
+
|
|
998
|
+
const skipDecision = shouldSkipVerification({
|
|
999
|
+
currentDigest,
|
|
1000
|
+
previousDigest,
|
|
1001
|
+
previousReceipt: previousActionCheck,
|
|
1002
|
+
algorithmVersion: INSTALLATION_CONTRACT_ALGORITHM_VERSION,
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
if (skipDecision === 'NOT_REQUIRED_UNCHANGED') {
|
|
1006
|
+
adapterChecks.push({
|
|
1007
|
+
actionId: action.id,
|
|
1008
|
+
actionType: action.type,
|
|
1009
|
+
status: VERIFICATION_RESOLVED_TYPES.NOT_REQUIRED_UNCHANGED,
|
|
1010
|
+
installationContractDigest: currentDigest,
|
|
1011
|
+
reason: '安装契约摘要未变化,跳过验证',
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
consumerVerificationReceipts.push({
|
|
1015
|
+
actionId: action.id,
|
|
1016
|
+
unitId: action.unitId,
|
|
1017
|
+
platform: action.parameters?.consumer ?? action.type.replace('-marketplace-install', ''),
|
|
1018
|
+
result: VERIFICATION_RESOLVED_TYPES.NOT_REQUIRED_UNCHANGED,
|
|
1019
|
+
installationContractDigest: currentDigest,
|
|
1020
|
+
algorithmVersion: INSTALLATION_CONTRACT_ALGORITHM_VERSION,
|
|
1021
|
+
planDigest: plan.digest,
|
|
1022
|
+
verifiedAt: clockFn(),
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
await evidence.append({
|
|
1026
|
+
phase: 'verify-marketplace',
|
|
1027
|
+
actionId: action.id,
|
|
1028
|
+
actionType: action.type,
|
|
1029
|
+
status: VERIFICATION_RESOLVED_TYPES.NOT_REQUIRED_UNCHANGED,
|
|
1030
|
+
installationContractDigest: currentDigest,
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
802
1037
|
// Step 3b: Execute (install to isolated consumer directory)
|
|
803
1038
|
const executeResult = await adapter.execute(actionInput, marketplaceContext);
|
|
804
1039
|
if (executeResult.status !== 'EXECUTED') {
|
|
@@ -821,15 +1056,39 @@ export async function verifyRelease(options) {
|
|
|
821
1056
|
marketplaceContext,
|
|
822
1057
|
);
|
|
823
1058
|
|
|
1059
|
+
// 正确分类:人工确认 -> PASSED_MANUAL,其他自动通路 -> PASSED_AUTOMATIC
|
|
1060
|
+
const isHumanConfirmed = verifyResult.observation?.humanConfirmed === true;
|
|
1061
|
+
const resolvedStatus = verifyResult.status === 'VERIFIED'
|
|
1062
|
+
? (isHumanConfirmed
|
|
1063
|
+
? VERIFICATION_RESOLVED_TYPES.PASSED_MANUAL
|
|
1064
|
+
: VERIFICATION_RESOLVED_TYPES.PASSED_AUTOMATIC)
|
|
1065
|
+
: 'FAILED';
|
|
1066
|
+
|
|
824
1067
|
const check = {
|
|
825
1068
|
actionId: action.id,
|
|
826
1069
|
actionType: action.type,
|
|
827
|
-
status:
|
|
1070
|
+
status: resolvedStatus,
|
|
828
1071
|
observation: verifyResult.observation,
|
|
829
1072
|
error: verifyResult.error,
|
|
1073
|
+
...(dist?.installationContractDigest ? { installationContractDigest: dist.installationContractDigest } : {}),
|
|
830
1074
|
};
|
|
831
1075
|
adapterChecks.push(check);
|
|
832
1076
|
|
|
1077
|
+
// 持久化消费端验证收据
|
|
1078
|
+
// 旧计划无摘要时可不生成"安装契约复用收据",但不得写非法空字符串
|
|
1079
|
+
if (resolvedStatus !== 'FAILED' && dist?.installationContractDigest && /^[a-f0-9]{64}$/.test(dist.installationContractDigest)) {
|
|
1080
|
+
consumerVerificationReceipts.push({
|
|
1081
|
+
actionId: action.id,
|
|
1082
|
+
unitId: action.unitId,
|
|
1083
|
+
platform: action.parameters?.consumer ?? action.type.replace('-marketplace-install', ''),
|
|
1084
|
+
result: resolvedStatus,
|
|
1085
|
+
installationContractDigest: dist.installationContractDigest,
|
|
1086
|
+
algorithmVersion: INSTALLATION_CONTRACT_ALGORITHM_VERSION,
|
|
1087
|
+
planDigest: plan.digest,
|
|
1088
|
+
verifiedAt: clockFn(),
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
|
|
833
1092
|
await evidence.append({
|
|
834
1093
|
phase: 'verify-marketplace',
|
|
835
1094
|
actionId: action.id,
|
|
@@ -841,7 +1100,13 @@ export async function verifyRelease(options) {
|
|
|
841
1100
|
throw new ReleaseError(
|
|
842
1101
|
POST_PUBLISH_VERIFY_FAILED,
|
|
843
1102
|
`marketplace verification failed for action "${action.id}": ${verifyResult.error}`,
|
|
844
|
-
{
|
|
1103
|
+
{
|
|
1104
|
+
actionId: action.id,
|
|
1105
|
+
actionType: action.type,
|
|
1106
|
+
verificationResult: check.status,
|
|
1107
|
+
observation: verifyResult.observation,
|
|
1108
|
+
expected: action.expected,
|
|
1109
|
+
},
|
|
845
1110
|
);
|
|
846
1111
|
}
|
|
847
1112
|
|
|
@@ -901,7 +1166,7 @@ export async function verifyRelease(options) {
|
|
|
901
1166
|
const check = {
|
|
902
1167
|
actionId: action.id,
|
|
903
1168
|
actionType: action.type,
|
|
904
|
-
status: verifyResult.status === 'VERIFIED' ?
|
|
1169
|
+
status: verifyResult.status === 'VERIFIED' ? VERIFICATION_RESOLVED_TYPES.PASSED_AUTOMATIC : 'FAILED',
|
|
905
1170
|
observation: verifyResult.observation,
|
|
906
1171
|
error: verifyResult.error,
|
|
907
1172
|
};
|
|
@@ -919,7 +1184,13 @@ export async function verifyRelease(options) {
|
|
|
919
1184
|
throw new ReleaseError(
|
|
920
1185
|
POST_PUBLISH_VERIFY_FAILED,
|
|
921
1186
|
`adapter verification failed for action "${action.id}": ${verifyResult.error}`,
|
|
922
|
-
{
|
|
1187
|
+
{
|
|
1188
|
+
actionId: action.id,
|
|
1189
|
+
actionType: action.type,
|
|
1190
|
+
verificationResult: check.status,
|
|
1191
|
+
observation: verifyResult.observation,
|
|
1192
|
+
expected: action.expected,
|
|
1193
|
+
},
|
|
923
1194
|
);
|
|
924
1195
|
}
|
|
925
1196
|
}
|
|
@@ -994,13 +1265,26 @@ export async function verifyRelease(options) {
|
|
|
994
1265
|
status: VERIFIED,
|
|
995
1266
|
checkpoints: actions.map((a) => {
|
|
996
1267
|
const check = adapterChecks.find((c) => c.actionId === a.id);
|
|
1268
|
+
let status;
|
|
1269
|
+
if (check?.status === 'SKIPPED') {
|
|
1270
|
+
status = 'skipped';
|
|
1271
|
+
} else if (check?.status === VERIFICATION_RESOLVED_TYPES.PASSED_AUTOMATIC) {
|
|
1272
|
+
status = 'succeeded';
|
|
1273
|
+
} else if (check?.status === VERIFICATION_RESOLVED_TYPES.PASSED_MANUAL) {
|
|
1274
|
+
status = 'succeeded';
|
|
1275
|
+
} else if (check?.status === VERIFICATION_RESOLVED_TYPES.NOT_REQUIRED_UNCHANGED) {
|
|
1276
|
+
status = 'skipped';
|
|
1277
|
+
} else {
|
|
1278
|
+
status = 'succeeded';
|
|
1279
|
+
}
|
|
997
1280
|
return {
|
|
998
1281
|
actionId: a.id,
|
|
999
1282
|
actionType: a.type,
|
|
1000
|
-
status
|
|
1283
|
+
status,
|
|
1001
1284
|
};
|
|
1002
1285
|
}),
|
|
1003
1286
|
gateResults: consumerGateResults,
|
|
1287
|
+
consumerVerificationReceipts,
|
|
1004
1288
|
startedAt: clockFn(),
|
|
1005
1289
|
finishedAt: clockFn(),
|
|
1006
1290
|
};
|