release-skill 0.1.1 → 0.1.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 +2 -2
- package/.codex-plugin/plugin.json +3 -3
- package/CHANGELOG.md +89 -0
- package/INSTALL.md +216 -5
- package/INSTALL.zh-CN.md +358 -0
- package/README.md +411 -67
- package/README.zh-CN.md +377 -59
- 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 +79284 -0
- package/adapters/claude/bin/release-skill.mjs +34 -0
- package/adapters/claude/native/safe-write/binding.gyp +40 -0
- package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/claude/native/safe-write/prebuilds.json +24 -0
- package/adapters/claude/native/safe-write/src/safe_write.cc +2032 -0
- package/adapters/claude/schemas/.render-manifest.json +37 -0
- package/adapters/claude/schemas/approval-record.schema.json +115 -0
- package/adapters/claude/schemas/artifact-lock.schema.json +111 -0
- package/adapters/claude/schemas/artifact-plan.schema.json +52 -0
- package/adapters/claude/schemas/artifact-policy.schema.json +76 -0
- package/adapters/claude/schemas/evidence-event.schema.json +89 -0
- package/adapters/claude/schemas/release-plan.schema.json +860 -0
- package/adapters/claude/schemas/release-project.schema.json +736 -0
- package/adapters/claude/schemas/release-run.schema.json +342 -0
- package/adapters/claude/skills/release-assess/SKILL.md +5 -6
- package/adapters/claude/skills/release-help/SKILL.md +14 -18
- package/adapters/claude/skills/release-prepare/SKILL.md +16 -6
- package/adapters/claude/skills/release-publish/SKILL.md +7 -7
- package/adapters/claude/skills/release-reconcile/SKILL.md +6 -6
- package/adapters/claude/skills/release-setup/SKILL.md +95 -0
- package/adapters/claude/skills/release-verify/SKILL.md +7 -7
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +79284 -0
- package/adapters/codex/bin/release-skill.mjs +34 -0
- package/adapters/codex/native/safe-write/binding.gyp +40 -0
- package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/codex/native/safe-write/prebuilds.json +24 -0
- package/adapters/codex/native/safe-write/src/safe_write.cc +2032 -0
- package/adapters/codex/schemas/.render-manifest.json +37 -0
- package/adapters/codex/schemas/approval-record.schema.json +115 -0
- package/adapters/codex/schemas/artifact-lock.schema.json +111 -0
- package/adapters/codex/schemas/artifact-plan.schema.json +52 -0
- package/adapters/codex/schemas/artifact-policy.schema.json +76 -0
- package/adapters/codex/schemas/evidence-event.schema.json +89 -0
- package/adapters/codex/schemas/release-plan.schema.json +860 -0
- package/adapters/codex/schemas/release-project.schema.json +736 -0
- package/adapters/codex/schemas/release-run.schema.json +342 -0
- package/adapters/codex/skills/release-assess/SKILL.md +12 -6
- package/adapters/codex/skills/release-help/SKILL.md +21 -18
- package/adapters/codex/skills/release-prepare/SKILL.md +23 -6
- package/adapters/codex/skills/release-publish/SKILL.md +14 -7
- package/adapters/codex/skills/release-reconcile/SKILL.md +13 -6
- package/adapters/codex/skills/release-setup/SKILL.md +102 -0
- package/adapters/codex/skills/release-verify/SKILL.md +14 -7
- package/bin/release-skill-cli.mjs +807 -0
- package/bin/release-skill.bundle.mjs +79284 -0
- package/bin/release-skill.mjs +23 -732
- package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/native/safe-write/prebuilds.json +22 -2
- package/native/safe-write/src/safe_write.cc +11 -2
- package/package.json +8 -2
- package/references/02-project-config.md +55 -4
- package/references/05-evidence-and-errors.md +6 -2
- package/schemas/release-plan.schema.json +556 -65
- package/schemas/release-project.schema.json +406 -29
- package/schemas/release-run.schema.json +165 -18
- package/scripts/build-bundle.mjs +133 -0
- package/skills/release-assess/SKILL.md +5 -6
- package/skills/release-help/SKILL.md +14 -18
- package/skills/release-prepare/SKILL.md +16 -6
- package/skills/release-publish/SKILL.md +7 -7
- package/skills/release-reconcile/SKILL.md +6 -6
- package/skills/release-setup/SKILL.md +95 -0
- package/skills/release-verify/SKILL.md +7 -7
- package/skills-src/release-assess/SKILL.md +5 -6
- package/skills-src/release-help/SKILL.md +14 -18
- package/skills-src/release-prepare/SKILL.md +16 -6
- package/skills-src/release-publish/SKILL.md +7 -7
- package/skills-src/release-reconcile/SKILL.md +6 -6
- package/skills-src/release-setup/SKILL.md +95 -0
- package/skills-src/release-verify/SKILL.md +7 -7
- package/src/adapters/contract.mjs +3 -0
- package/src/adapters/git-github.mjs +84 -2
- package/src/adapters/npm.mjs +5 -13
- package/src/adapters/plugin-marketplace.mjs +132 -52
- package/src/adapters/push-snapshot.mjs +84 -17
- package/src/artifacts/policy.mjs +4 -7
- package/src/artifacts/safe-fs-backend-internal.mjs +69 -21
- package/src/commands/prepare.mjs +244 -20
- package/src/commands/publish.mjs +46 -0
- package/src/commands/reconcile.mjs +152 -0
- package/src/commands/setup.mjs +1525 -0
- package/src/commands/verify.mjs +122 -26
- package/src/core/approval.mjs +4 -6
- package/src/core/config.mjs +42 -8
- package/src/core/errors.mjs +4 -0
- package/src/core/pkg-root.mjs +22 -0
- package/src/core/plan.mjs +132 -4
- package/src/core/previous-public-baseline.mjs +21 -1
- package/src/core/run.mjs +4 -4
- package/src/core/trusted-resource.mjs +96 -0
- package/src/core/verification-gates.mjs +451 -0
- package/src/docs/version-gate.mjs +164 -0
- package/src/producers/build-adapters.mjs +512 -55
- package/src/snapshot/frozen.mjs +221 -7
- package/src/snapshot/public-map.mjs +7 -4
- package/src/snapshot/scan.mjs +2 -1
|
@@ -2,19 +2,180 @@
|
|
|
2
2
|
* Pure producer: build adapters from skills and plugin templates.
|
|
3
3
|
*
|
|
4
4
|
* Reads skill metadata from skills-src/ and plugin.json templates,
|
|
5
|
-
* generates adapter directories for each platform (claude, codex).
|
|
5
|
+
* generates self-contained adapter directories for each platform (claude, codex).
|
|
6
|
+
*
|
|
7
|
+
* Each adapter root contains:
|
|
8
|
+
* - Plugin manifest (.claude-plugin/ or .codex-plugin/)
|
|
9
|
+
* - Skills with host-specific root resolution
|
|
10
|
+
* - bin/release-skill.mjs (wrapper) + bin/release-skill.bundle.mjs (self-contained bundle)
|
|
11
|
+
* - schemas/ (JSON Schema files)
|
|
12
|
+
* - native/safe-write/ (prebuilds.json + platform binaries)
|
|
6
13
|
*
|
|
7
14
|
* Deterministic: sorted directory enumeration, no timestamps, no randomness.
|
|
8
15
|
*
|
|
9
16
|
* @module producers/build-adapters
|
|
10
17
|
*/
|
|
11
18
|
|
|
12
|
-
import { readdir,
|
|
13
|
-
import {
|
|
19
|
+
import { readdir, mkdir, lstat, realpath, open, rm } from 'node:fs/promises';
|
|
20
|
+
import { constants as fsConstants } from 'node:fs';
|
|
21
|
+
import { join, dirname, relative, isAbsolute, sep, resolve, posix as pathPosix } from 'node:path';
|
|
14
22
|
import { createHash } from 'node:crypto';
|
|
15
23
|
|
|
16
24
|
import { canonicalJson, sha256Hex } from '../core/digest.mjs';
|
|
17
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Assert that a resolved real path is contained within the allowed root.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} resolvedPath - Absolute resolved (realpath) path.
|
|
30
|
+
* @param {string} root - Absolute package root.
|
|
31
|
+
* @param {string} label - Human-readable label for error messages.
|
|
32
|
+
*/
|
|
33
|
+
function assertWithinRoot(resolvedPath, root, label) {
|
|
34
|
+
const rel = relative(root, resolvedPath);
|
|
35
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`SECURITY: ${label} escapes package root: resolved=${resolvedPath} root=${root}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertRegularSingleLink(fileStat, label) {
|
|
43
|
+
if (fileStat.isSymbolicLink()) throw new Error(`SECURITY: ${label} is a symlink`);
|
|
44
|
+
if (!fileStat.isFile()) throw new Error(`SECURITY: ${label} is not a regular file`);
|
|
45
|
+
if (fileStat.nlink !== 1) throw new Error(`SECURITY: ${label} has an unexpected hard-link count`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function assertTrustedDirectory(dirPath, physicalRoot, label) {
|
|
49
|
+
let dirStat;
|
|
50
|
+
try {
|
|
51
|
+
dirStat = await lstat(dirPath);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error?.code === 'ENOENT') throw new Error(`SECURITY: ${label} is missing`);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
if (dirStat.isSymbolicLink()) throw new Error(`SECURITY: ${label} is a symlink`);
|
|
57
|
+
if (!dirStat.isDirectory()) throw new Error(`SECURITY: ${label} is not a directory`);
|
|
58
|
+
const physicalPath = await realpath(dirPath);
|
|
59
|
+
assertWithinRoot(physicalPath, physicalRoot, label);
|
|
60
|
+
return physicalPath;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readTrustedFile(filePath, physicalRoot, label, encoding = null) {
|
|
64
|
+
let lexicalStat;
|
|
65
|
+
try {
|
|
66
|
+
lexicalStat = await lstat(filePath);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error?.code === 'ENOENT') throw new Error(`SECURITY: ${label} is missing`);
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
assertRegularSingleLink(lexicalStat, label);
|
|
72
|
+
const physicalPath = await realpath(filePath);
|
|
73
|
+
assertWithinRoot(physicalPath, physicalRoot, label);
|
|
74
|
+
|
|
75
|
+
const noFollow = fsConstants.O_NOFOLLOW ?? 0;
|
|
76
|
+
const handle = await open(physicalPath, fsConstants.O_RDONLY | noFollow);
|
|
77
|
+
try {
|
|
78
|
+
const openedStat = await handle.stat();
|
|
79
|
+
assertRegularSingleLink(openedStat, label);
|
|
80
|
+
if (openedStat.dev !== lexicalStat.dev || openedStat.ino !== lexicalStat.ino) {
|
|
81
|
+
throw new Error(`SECURITY: ${label} changed during trusted open`);
|
|
82
|
+
}
|
|
83
|
+
const content = await handle.readFile(encoding ? { encoding } : undefined);
|
|
84
|
+
const finalStat = await handle.stat();
|
|
85
|
+
if (finalStat.dev !== openedStat.dev
|
|
86
|
+
|| finalStat.ino !== openedStat.ino
|
|
87
|
+
|| finalStat.size !== openedStat.size
|
|
88
|
+
|| finalStat.mtimeMs !== openedStat.mtimeMs) {
|
|
89
|
+
throw new Error(`SECURITY: ${label} changed during read`);
|
|
90
|
+
}
|
|
91
|
+
return content;
|
|
92
|
+
} finally {
|
|
93
|
+
await handle.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function assertSafeNewOutputRoot(requestedPath, label) {
|
|
98
|
+
if (typeof requestedPath !== 'string' || requestedPath.trim() === '') {
|
|
99
|
+
throw new Error(`SECURITY: ${label} must be a non-empty path`);
|
|
100
|
+
}
|
|
101
|
+
const absolutePath = resolve(requestedPath);
|
|
102
|
+
try {
|
|
103
|
+
const targetStat = await lstat(absolutePath);
|
|
104
|
+
if (targetStat.isSymbolicLink()) {
|
|
105
|
+
throw new Error(`SECURITY: ${label} is a symlink: ${absolutePath}`);
|
|
106
|
+
}
|
|
107
|
+
throw new Error(`SECURITY: ${label} already exists: ${absolutePath}`);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let cursor = dirname(absolutePath);
|
|
113
|
+
while (true) {
|
|
114
|
+
const ancestorStat = await lstat(cursor);
|
|
115
|
+
if (ancestorStat.isSymbolicLink()) {
|
|
116
|
+
const physicalAncestor = await realpath(cursor);
|
|
117
|
+
const allowedSystemAlias = (cursor === '/var' && physicalAncestor === '/private/var')
|
|
118
|
+
|| (cursor === '/tmp' && physicalAncestor === '/private/tmp');
|
|
119
|
+
if (!allowedSystemAlias) {
|
|
120
|
+
throw new Error(`SECURITY: ${label} ancestor is a symlink: ${cursor}`);
|
|
121
|
+
}
|
|
122
|
+
} else if (!ancestorStat.isDirectory()) {
|
|
123
|
+
throw new Error(`SECURITY: ${label} ancestor is not a directory: ${cursor}`);
|
|
124
|
+
}
|
|
125
|
+
const parent = dirname(cursor);
|
|
126
|
+
if (parent === cursor) break;
|
|
127
|
+
cursor = parent;
|
|
128
|
+
}
|
|
129
|
+
return absolutePath;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function assertDisjointOutputRoots(entries) {
|
|
133
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
134
|
+
for (let otherIndex = index + 1; otherIndex < entries.length; otherIndex += 1) {
|
|
135
|
+
const [leftName, leftPath] = entries[index];
|
|
136
|
+
const [rightName, rightPath] = entries[otherIndex];
|
|
137
|
+
const leftToRight = relative(leftPath, rightPath);
|
|
138
|
+
const rightToLeft = relative(rightPath, leftPath);
|
|
139
|
+
const rightInsideLeft = leftToRight === ''
|
|
140
|
+
|| (!leftToRight.startsWith(`..${sep}`) && leftToRight !== '..' && !isAbsolute(leftToRight));
|
|
141
|
+
const leftInsideRight = rightToLeft === ''
|
|
142
|
+
|| (!rightToLeft.startsWith(`..${sep}`) && rightToLeft !== '..' && !isAbsolute(rightToLeft));
|
|
143
|
+
if (rightInsideLeft || leftInsideRight) {
|
|
144
|
+
throw new Error(`SECURITY: output roots overlap: ${leftName}=${leftPath} ${rightName}=${rightPath}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function assertOwnedOutputRoot(rootPath, identity, label) {
|
|
151
|
+
const current = await lstat(rootPath);
|
|
152
|
+
if (current.isSymbolicLink() || !current.isDirectory()
|
|
153
|
+
|| current.dev !== identity.dev || current.ino !== identity.ino) {
|
|
154
|
+
throw new Error(`SECURITY: ${label} changed during output generation`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function ensureTrustedOutputDirectory(rootPath, rootIdentity, relativeDir, label) {
|
|
159
|
+
let cursor = rootPath;
|
|
160
|
+
for (const segment of relativeDir.split(sep).filter(Boolean)) {
|
|
161
|
+
await assertOwnedOutputRoot(rootPath, rootIdentity, label);
|
|
162
|
+
cursor = join(cursor, segment);
|
|
163
|
+
try {
|
|
164
|
+
await mkdir(cursor, { mode: 0o700 });
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
167
|
+
}
|
|
168
|
+
const current = await lstat(cursor);
|
|
169
|
+
if (current.isSymbolicLink() || !current.isDirectory()) {
|
|
170
|
+
throw new Error(`SECURITY: ${label} output directory is untrusted: ${cursor}`);
|
|
171
|
+
}
|
|
172
|
+
const physicalCursor = await realpath(cursor);
|
|
173
|
+
const physicalRoot = await realpath(rootPath);
|
|
174
|
+
assertWithinRoot(physicalCursor, physicalRoot, `${label} output directory`);
|
|
175
|
+
}
|
|
176
|
+
return cursor;
|
|
177
|
+
}
|
|
178
|
+
|
|
18
179
|
/**
|
|
19
180
|
* Compute an implementation digest from the source bytes of this module.
|
|
20
181
|
*
|
|
@@ -29,7 +190,7 @@ export function computeBuildAdaptersDigest(sourceBytes) {
|
|
|
29
190
|
return `sha256:${h.digest('hex')}`;
|
|
30
191
|
}
|
|
31
192
|
|
|
32
|
-
// Platform definitions
|
|
193
|
+
// Platform definitions
|
|
33
194
|
const PLATFORMS = [
|
|
34
195
|
{
|
|
35
196
|
name: 'claude',
|
|
@@ -46,27 +207,44 @@ const PLATFORMS = [
|
|
|
46
207
|
},
|
|
47
208
|
];
|
|
48
209
|
|
|
210
|
+
const REQUIRED_SCHEMA_FILES = Object.freeze([
|
|
211
|
+
'approval-record.schema.json',
|
|
212
|
+
'artifact-policy.schema.json',
|
|
213
|
+
'release-plan.schema.json',
|
|
214
|
+
'release-project.schema.json',
|
|
215
|
+
'release-run.schema.json',
|
|
216
|
+
]);
|
|
217
|
+
|
|
49
218
|
/**
|
|
50
219
|
* Collect skill metadata from skills-src/.
|
|
51
220
|
*
|
|
52
221
|
* @param {string} srcDir - Absolute path to skills-src/.
|
|
53
222
|
* @returns {Promise<Array<{name:string,description:string,content:string}>>}
|
|
54
223
|
*/
|
|
55
|
-
async function collectSkills(srcDir) {
|
|
56
|
-
const
|
|
57
|
-
const
|
|
224
|
+
async function collectSkills(srcDir, physicalRoot) {
|
|
225
|
+
const physicalSrcDir = await assertTrustedDirectory(srcDir, physicalRoot, 'skills-src/');
|
|
226
|
+
const entries = await readdir(physicalSrcDir, { withFileTypes: true });
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (entry.isSymbolicLink()) {
|
|
229
|
+
throw new Error(`SECURITY: skills-src/${entry.name} is a symlink`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const dirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
58
233
|
dirs.sort();
|
|
59
234
|
|
|
60
235
|
const skills = [];
|
|
61
236
|
for (const dirName of dirs) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
237
|
+
const skillDirPath = join(physicalSrcDir, dirName);
|
|
238
|
+
const skillMdPath = join(skillDirPath, 'SKILL.md');
|
|
239
|
+
|
|
240
|
+
// Reject symlinks at any level: skill directory itself must resolve within root.
|
|
241
|
+
await assertTrustedDirectory(skillDirPath, physicalRoot, `skill directory ${dirName}`);
|
|
242
|
+
const content = await readTrustedFile(
|
|
243
|
+
skillMdPath,
|
|
244
|
+
physicalRoot,
|
|
245
|
+
`skills-src/${dirName}/SKILL.md`,
|
|
246
|
+
'utf-8',
|
|
247
|
+
);
|
|
70
248
|
|
|
71
249
|
// Extract description: first paragraph of the first ## section.
|
|
72
250
|
const lines = content.split('\n');
|
|
@@ -94,24 +272,105 @@ async function collectSkills(srcDir) {
|
|
|
94
272
|
return skills;
|
|
95
273
|
}
|
|
96
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Preamble prepended to every Codex SKILL.md.
|
|
277
|
+
*
|
|
278
|
+
* Codex agents load SKILL.md as data, not as a shell script.
|
|
279
|
+
* BASH_SOURCE / $0 do NOT point to SKILL.md — they point to the
|
|
280
|
+
* invoking shell or are unset entirely (verified in zsh -c / bash -c).
|
|
281
|
+
* The agent MUST resolve the entry point from the SKILL.md file path
|
|
282
|
+
* that the host already provided/loaded.
|
|
283
|
+
*/
|
|
284
|
+
const CODEX_PREAMBLE = `\
|
|
285
|
+
> **Codex 安装入口解析协议**:在调用 CLI 前,Agent 必须从宿主当前已加载技能的元数据中取得本 \`SKILL.md\` 的实际绝对路径,并将该字面量记为 \`SKILL_FILE\`。\n\
|
|
286
|
+
> \`SKILL_FILE\` 不是环境变量;禁止从工作目录、可执行搜索路径、源码仓库或 shell 调用上下文猜测。若宿主未提供该绝对路径,立即停止并报告安装定位失败。\n\
|
|
287
|
+
> 对 \`SKILL_FILE\` 执行 \`realpath\`,取其目录向上两级得到 \`PLUGIN_ROOT\`;校验真实技能路径匹配 \`PLUGIN_ROOT/skills/*/SKILL.md\`。\n\
|
|
288
|
+
> 令 \`RELEASE_SKILL_ENTRY=PLUGIN_ROOT/bin/release-skill.mjs\`,对入口执行 \`realpath\` containment、\`lstat\` 非符号链接且为普通文件校验。\n\
|
|
289
|
+
> 每一次 shell 工具调用都必须在同一个调用中用上述已验证绝对值设置 \`RELEASE_SKILL_ENTRY\`,然后执行 \`node "$RELEASE_SKILL_ENTRY" ...\`;不得依赖前一次 shell 的变量。\n\
|
|
290
|
+
>\n\
|
|
291
|
+
`;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Render SKILL.md content for a specific platform.
|
|
295
|
+
*
|
|
296
|
+
* - Claude: verbatim copy (uses ${CLAUDE_PLUGIN_ROOT})
|
|
297
|
+
* - Codex: replaces the Claude-only path with $RELEASE_SKILL_ENTRY,
|
|
298
|
+
* and prepends the path resolution protocol preamble.
|
|
299
|
+
*
|
|
300
|
+
* @param {string} content - Canonical SKILL.md content.
|
|
301
|
+
* @param {string} platformName - 'claude' or 'codex'.
|
|
302
|
+
* @returns {string}
|
|
303
|
+
*/
|
|
304
|
+
function renderSkillForPlatform(content, platformName) {
|
|
305
|
+
if (platformName === 'claude') return content;
|
|
306
|
+
|
|
307
|
+
if (platformName === 'codex') {
|
|
308
|
+
let rendered = content.replaceAll(
|
|
309
|
+
'${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs',
|
|
310
|
+
'$RELEASE_SKILL_ENTRY',
|
|
311
|
+
);
|
|
312
|
+
// A remaining bare host-root reference has no safe Codex equivalent.
|
|
313
|
+
if (rendered.includes('${CLAUDE_PLUGIN_ROOT}')) {
|
|
314
|
+
throw new Error('Codex rendering found an unsupported bare CLAUDE_PLUGIN_ROOT reference');
|
|
315
|
+
}
|
|
316
|
+
// Preserve YAML frontmatter as the first bytes so the host can discover
|
|
317
|
+
// the skill. Insert the host-specific protocol immediately after it.
|
|
318
|
+
const frontmatterEnd = rendered.indexOf('\n---\n', 4);
|
|
319
|
+
if (!rendered.startsWith('---\n') || frontmatterEnd < 0) {
|
|
320
|
+
throw new Error('Codex rendering requires a valid leading YAML frontmatter block');
|
|
321
|
+
}
|
|
322
|
+
const insertionPoint = frontmatterEnd + '\n---\n'.length;
|
|
323
|
+
return `${rendered.slice(0, insertionPoint)}\n${CODEX_PREAMBLE}${rendered.slice(insertionPoint)}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return content;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Collect binary file paths from a source directory recursively.
|
|
331
|
+
* Returns array of relative paths.
|
|
332
|
+
*/
|
|
333
|
+
async function collectBinaryFiles(baseDir, physicalRoot, relPrefix = '') {
|
|
334
|
+
const results = [];
|
|
335
|
+
const physicalDir = await assertTrustedDirectory(
|
|
336
|
+
baseDir,
|
|
337
|
+
physicalRoot,
|
|
338
|
+
relPrefix || relative(physicalRoot, baseDir),
|
|
339
|
+
);
|
|
340
|
+
const entries = await readdir(physicalDir, { withFileTypes: true });
|
|
341
|
+
for (const entry of entries) {
|
|
342
|
+
const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
343
|
+
if (entry.isSymbolicLink()) {
|
|
344
|
+
throw new Error(`SECURITY: symlink in resource tree: ${relPath}`);
|
|
345
|
+
}
|
|
346
|
+
if (entry.isDirectory()) {
|
|
347
|
+
results.push(...await collectBinaryFiles(join(physicalDir, entry.name), physicalRoot, relPath));
|
|
348
|
+
} else if (entry.isFile()) {
|
|
349
|
+
results.push({ relPath, sourcePath: join(physicalDir, entry.name) });
|
|
350
|
+
} else {
|
|
351
|
+
throw new Error(`SECURITY: resource is not a regular file: ${relPath}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return results;
|
|
355
|
+
}
|
|
356
|
+
|
|
97
357
|
/**
|
|
98
358
|
* Generate the full file tree for an adapter platform.
|
|
99
359
|
*
|
|
100
360
|
* @param {object} platform - Platform definition.
|
|
101
361
|
* @param {Array} skills - Skill metadata array.
|
|
102
362
|
* @param {object} templateJson - Parsed plugin.json template.
|
|
363
|
+
* @param {string} root - Package root (for reading binary assets).
|
|
103
364
|
* @param {object|null} marketplaceTemplateJson - Parsed marketplace template.
|
|
104
|
-
* @returns {Promise<Array<{relPath:string,content:
|
|
365
|
+
* @returns {Promise<Array<{relPath:string,content:Buffer,isBinary:boolean}>>}
|
|
105
366
|
*/
|
|
106
|
-
async function generateAdapterFiles(platform, skills, templateJson, marketplaceTemplateJson = null) {
|
|
367
|
+
async function generateAdapterFiles(platform, skills, templateJson, root, marketplaceTemplateJson = null) {
|
|
107
368
|
const files = [];
|
|
108
369
|
|
|
109
370
|
// Transform plugin.json: rewrite source paths
|
|
110
371
|
const adapted = JSON.parse(JSON.stringify(templateJson));
|
|
111
372
|
|
|
112
373
|
// Preserve platform-supported directory auto-discovery in generated adapters.
|
|
113
|
-
// Codex validation requires the canonical "skills" directory, and Claude
|
|
114
|
-
// also accepts the same plugin-root-relative directory contract.
|
|
115
374
|
if (typeof adapted.skills === 'string') {
|
|
116
375
|
adapted.skills = './skills/';
|
|
117
376
|
} else if (Array.isArray(adapted.skills)) {
|
|
@@ -123,12 +382,13 @@ async function generateAdapterFiles(platform, skills, templateJson, marketplaceT
|
|
|
123
382
|
const pluginJsonContent = JSON.stringify(adapted, null, 2) + '\n';
|
|
124
383
|
files.push({
|
|
125
384
|
relPath: join(platform.pluginDirName, 'plugin.json'),
|
|
126
|
-
content: pluginJsonContent,
|
|
385
|
+
content: Buffer.from(pluginJsonContent, 'utf-8'),
|
|
386
|
+
isBinary: false,
|
|
127
387
|
});
|
|
128
388
|
|
|
129
389
|
// Generate marketplace.json for platforms that need it
|
|
130
390
|
if (platform.hasMarketplace) {
|
|
131
|
-
const marketplace = JSON.parse(JSON.stringify(marketplaceTemplateJson
|
|
391
|
+
const marketplace = JSON.parse(JSON.stringify(marketplaceTemplateJson));
|
|
132
392
|
marketplace.name = adapted.name;
|
|
133
393
|
marketplace.description = adapted.description;
|
|
134
394
|
marketplace.owner = marketplace.owner ?? adapted.author;
|
|
@@ -142,15 +402,106 @@ async function generateAdapterFiles(platform, skills, templateJson, marketplaceT
|
|
|
142
402
|
const marketplaceContent = JSON.stringify(marketplace, null, 2) + '\n';
|
|
143
403
|
files.push({
|
|
144
404
|
relPath: join(platform.pluginDirName, 'marketplace.json'),
|
|
145
|
-
content: marketplaceContent,
|
|
405
|
+
content: Buffer.from(marketplaceContent, 'utf-8'),
|
|
406
|
+
isBinary: false,
|
|
146
407
|
});
|
|
147
408
|
}
|
|
148
409
|
|
|
149
|
-
// Copy SKILL.md files
|
|
410
|
+
// Copy SKILL.md files with host-specific rendering
|
|
150
411
|
for (const skill of skills) {
|
|
412
|
+
const rendered = renderSkillForPlatform(skill.content, platform.name);
|
|
151
413
|
files.push({
|
|
152
414
|
relPath: join('skills', skill.name, 'SKILL.md'),
|
|
153
|
-
content:
|
|
415
|
+
content: Buffer.from(rendered, 'utf-8'),
|
|
416
|
+
isBinary: false,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Copy bin/ entry point and bundle (text files)
|
|
421
|
+
const binFiles = ['release-skill.mjs', 'release-skill.bundle.mjs'];
|
|
422
|
+
for (const name of binFiles) {
|
|
423
|
+
const srcPath = join(root, 'bin', name);
|
|
424
|
+
const content = await readTrustedFile(srcPath, root, `bin/${name}`);
|
|
425
|
+
files.push({
|
|
426
|
+
relPath: join('bin', name),
|
|
427
|
+
content,
|
|
428
|
+
isBinary: false,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Copy schemas/ directory
|
|
433
|
+
const schemaFiles = await collectBinaryFiles(join(root, 'schemas'), root);
|
|
434
|
+
const schemaPaths = new Set(schemaFiles.map(({ relPath }) => relPath));
|
|
435
|
+
for (const requiredSchema of REQUIRED_SCHEMA_FILES) {
|
|
436
|
+
if (!schemaPaths.has(requiredSchema)) {
|
|
437
|
+
throw new Error(`SECURITY: required schema is missing: schemas/${requiredSchema}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
for (const { relPath: rel, sourcePath } of schemaFiles) {
|
|
441
|
+
const content = await readTrustedFile(sourcePath, root, `schemas/${rel}`);
|
|
442
|
+
files.push({
|
|
443
|
+
relPath: join('schemas', rel),
|
|
444
|
+
content,
|
|
445
|
+
isBinary: false,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Copy native/safe-write/ directory (prebuilds.json, prebuilds/, src/).
|
|
450
|
+
// Exclude build/ — it contains platform-specific build artifacts that are
|
|
451
|
+
// regenerated by node-gyp and should not be in the published adapter.
|
|
452
|
+
const nativeBase = join(root, 'native', 'safe-write');
|
|
453
|
+
const nativeFiles = await collectBinaryFiles(nativeBase, root);
|
|
454
|
+
if (!nativeFiles.some(({ relPath }) => relPath === 'prebuilds.json')) {
|
|
455
|
+
throw new Error('SECURITY: required native resource is missing: native/safe-write/prebuilds.json');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Semantic closure: validate prebuilds.json declarations against collected files.
|
|
459
|
+
const prebuildsEntry = nativeFiles.find(({ relPath }) => relPath === 'prebuilds.json');
|
|
460
|
+
const prebuildsRaw = await readTrustedFile(prebuildsEntry.sourcePath, root, 'native/safe-write/prebuilds.json', 'utf-8');
|
|
461
|
+
const prebuildsJson = JSON.parse(prebuildsRaw);
|
|
462
|
+
if (!prebuildsJson.prebuilds || typeof prebuildsJson.prebuilds !== 'object'
|
|
463
|
+
|| Array.isArray(prebuildsJson.prebuilds)
|
|
464
|
+
|| Object.keys(prebuildsJson.prebuilds).length === 0) {
|
|
465
|
+
throw new Error('SECURITY: prebuilds.json missing "prebuilds" object');
|
|
466
|
+
}
|
|
467
|
+
for (const [platformId, declaration] of Object.entries(prebuildsJson.prebuilds)) {
|
|
468
|
+
if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)
|
|
469
|
+
|| !declaration.path || typeof declaration.path !== 'string') {
|
|
470
|
+
throw new Error(`SECURITY: prebuilds.json platform "${platformId}" missing "path"`);
|
|
471
|
+
}
|
|
472
|
+
const portablePath = declaration.path.replace(/\\/g, '/');
|
|
473
|
+
const normalised = pathPosix.normalize(portablePath);
|
|
474
|
+
if (portablePath !== normalised
|
|
475
|
+
|| pathPosix.isAbsolute(normalised)
|
|
476
|
+
|| normalised === '.'
|
|
477
|
+
|| normalised.split('/').some((segment) => segment === '..' || segment === '')) {
|
|
478
|
+
throw new Error(`SECURITY: prebuilds.json path escapes collection tree: ${declaration.path}`);
|
|
479
|
+
}
|
|
480
|
+
if (!/^[a-f0-9]{64}$/.test(declaration.sha256 ?? '')) {
|
|
481
|
+
throw new Error(`SECURITY: prebuilds.json platform "${platformId}" has invalid sha256`);
|
|
482
|
+
}
|
|
483
|
+
if (normalised === 'build' || normalised.startsWith('build/')) {
|
|
484
|
+
throw new Error(`SECURITY: prebuilds.json declares an output-excluded build resource: ${declaration.path}`);
|
|
485
|
+
}
|
|
486
|
+
const matching = nativeFiles.find(({ relPath }) => relPath === normalised);
|
|
487
|
+
if (!matching) {
|
|
488
|
+
throw new Error(`SECURITY: prebuilds.json declares missing file: ${declaration.path}`);
|
|
489
|
+
}
|
|
490
|
+
const declaredContent = await readTrustedFile(matching.sourcePath, root, `native/safe-write/${normalised}`);
|
|
491
|
+
assertRegularSingleLink(await lstat(matching.sourcePath), `native/safe-write/${normalised}`);
|
|
492
|
+
const actualHash = sha256Hex(declaredContent);
|
|
493
|
+
if (declaration.sha256 !== actualHash) {
|
|
494
|
+
throw new Error(`SECURITY: prebuilds.json SHA-256 mismatch for ${declaration.path}: declared=${declaration.sha256} actual=${actualHash}`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
for (const { relPath: rel, sourcePath } of nativeFiles) {
|
|
498
|
+
// Skip build/ directory — platform-specific build artifacts.
|
|
499
|
+
if (rel.startsWith('build/') || rel.startsWith('build\\')) continue;
|
|
500
|
+
const content = await readTrustedFile(sourcePath, root, `native/safe-write/${rel}`);
|
|
501
|
+
files.push({
|
|
502
|
+
relPath: join('native', 'safe-write', rel),
|
|
503
|
+
content,
|
|
504
|
+
isBinary: true,
|
|
154
505
|
});
|
|
155
506
|
}
|
|
156
507
|
|
|
@@ -164,51 +515,157 @@ async function generateAdapterFiles(platform, skills, templateJson, marketplaceT
|
|
|
164
515
|
*
|
|
165
516
|
* @param {object} options
|
|
166
517
|
* @param {string} [options.inputs] - Root directory path. Defaults to package root.
|
|
167
|
-
* @param {string} options.output -
|
|
518
|
+
* @param {string} [options.output] - Single-platform output directory path.
|
|
519
|
+
* @param {Record<string,string>} [options.outputRoots] - Per-platform output roots.
|
|
168
520
|
* @param {string} [options.platformFilter] - Only generate for this platform name.
|
|
169
521
|
*/
|
|
170
|
-
export async function produceBuildAdapters({ inputs, output, platformFilter } = {}) {
|
|
522
|
+
export async function produceBuildAdapters({ inputs, output, outputRoots, platformFilter } = {}) {
|
|
171
523
|
const defaultRoot = new URL('../..', import.meta.url).pathname;
|
|
172
|
-
const
|
|
524
|
+
const requestedRoot = inputs ?? defaultRoot;
|
|
525
|
+
|
|
526
|
+
// ── Phase 1: Validate input root is not a symlink ──
|
|
527
|
+
let rootStat;
|
|
528
|
+
try {
|
|
529
|
+
rootStat = await lstat(requestedRoot);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
if (error?.code === 'ENOENT') throw new Error(`SECURITY: input root is missing: ${requestedRoot}`);
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
if (rootStat.isSymbolicLink()) {
|
|
535
|
+
throw new Error(`SECURITY: input root is a symlink: ${requestedRoot}`);
|
|
536
|
+
}
|
|
537
|
+
if (!rootStat.isDirectory()) throw new Error('SECURITY: input root is not a directory');
|
|
538
|
+
const root = await realpath(requestedRoot);
|
|
173
539
|
const srcDir = join(root, 'skills-src');
|
|
174
540
|
|
|
175
|
-
|
|
541
|
+
if (platformFilter && !PLATFORMS.some(({ name }) => name === platformFilter)) {
|
|
542
|
+
throw new Error(`Unsupported adapter platform: ${platformFilter}`);
|
|
543
|
+
}
|
|
544
|
+
const selectedPlatforms = PLATFORMS.filter(({ name }) => !platformFilter || name === platformFilter);
|
|
545
|
+
if (!outputRoots && selectedPlatforms.length !== 1) {
|
|
546
|
+
throw new Error('SECURITY: multi-platform generation requires outputRoots');
|
|
547
|
+
}
|
|
176
548
|
|
|
177
|
-
|
|
549
|
+
// ── Phase 2: Collect skills (validates symlinks inside) ──
|
|
550
|
+
const skills = await collectSkills(srcDir, root);
|
|
551
|
+
if (skills.length === 0) {
|
|
552
|
+
throw new Error('At least one skill is required; found 0 in skills-src/');
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ── Phase 3: Validate all platform inputs (no writes yet) ──
|
|
556
|
+
for (const platform of selectedPlatforms) {
|
|
557
|
+
await readTrustedFile(
|
|
558
|
+
join(root, platform.pluginDirName, platform.templateFileName),
|
|
559
|
+
root,
|
|
560
|
+
`${platform.pluginDirName}/${platform.templateFileName}`,
|
|
561
|
+
'utf-8',
|
|
562
|
+
);
|
|
563
|
+
if (platform.hasMarketplace) {
|
|
564
|
+
await readTrustedFile(
|
|
565
|
+
join(root, platform.pluginDirName, platform.marketplaceFileName),
|
|
566
|
+
root,
|
|
567
|
+
`${platform.pluginDirName}/${platform.marketplaceFileName}`,
|
|
568
|
+
'utf-8',
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
178
572
|
|
|
179
|
-
|
|
180
|
-
|
|
573
|
+
// ── Phase 4: Validate every output root before any target is created ──
|
|
574
|
+
const outputRootEntries = [];
|
|
575
|
+
for (const platform of selectedPlatforms) {
|
|
576
|
+
const requestedOutput = outputRoots?.[platform.name] ?? output;
|
|
577
|
+
const safeRoot = await assertSafeNewOutputRoot(requestedOutput, `${platform.name} output root`);
|
|
578
|
+
outputRootEntries.push([platform.name, safeRoot]);
|
|
579
|
+
}
|
|
580
|
+
assertDisjointOutputRoots(outputRootEntries);
|
|
581
|
+
const safeOutputRoots = new Map(outputRootEntries);
|
|
582
|
+
|
|
583
|
+
// ── Phase 5: Generate files (all inputs already validated) ──
|
|
584
|
+
const allPlatformFiles = [];
|
|
585
|
+
for (const platform of selectedPlatforms) {
|
|
181
586
|
const templatePath = join(root, platform.pluginDirName, platform.templateFileName);
|
|
182
|
-
const templateRaw = await
|
|
587
|
+
const templateRaw = await readTrustedFile(
|
|
588
|
+
templatePath,
|
|
589
|
+
root,
|
|
590
|
+
`${platform.pluginDirName}/${platform.templateFileName}`,
|
|
591
|
+
'utf-8',
|
|
592
|
+
);
|
|
183
593
|
const templateJson = JSON.parse(templateRaw);
|
|
184
594
|
let marketplaceTemplateJson = null;
|
|
185
595
|
if (platform.hasMarketplace) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
596
|
+
marketplaceTemplateJson = JSON.parse(
|
|
597
|
+
await readTrustedFile(
|
|
598
|
+
join(root, platform.pluginDirName, platform.marketplaceFileName),
|
|
599
|
+
root,
|
|
600
|
+
`${platform.pluginDirName}/${platform.marketplaceFileName}`,
|
|
601
|
+
'utf-8',
|
|
602
|
+
),
|
|
603
|
+
);
|
|
193
604
|
}
|
|
194
605
|
|
|
195
|
-
const files = await generateAdapterFiles(platform, skills, templateJson, marketplaceTemplateJson);
|
|
606
|
+
const files = await generateAdapterFiles(platform, skills, templateJson, root, marketplaceTemplateJson);
|
|
607
|
+
allPlatformFiles.push({ platform, files });
|
|
608
|
+
}
|
|
196
609
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
610
|
+
// ── Phase 6: Create all roots, then write with exclusive/no-follow opens ──
|
|
611
|
+
const outputs = [];
|
|
612
|
+
const createdRoots = [];
|
|
613
|
+
try {
|
|
614
|
+
for (const [platformName, rootPath] of outputRootEntries) {
|
|
615
|
+
await mkdir(rootPath, { mode: 0o700 });
|
|
616
|
+
const identity = await lstat(rootPath);
|
|
617
|
+
if (identity.isSymbolicLink() || !identity.isDirectory()) {
|
|
618
|
+
throw new Error(`SECURITY: ${platformName} output root was not created safely`);
|
|
619
|
+
}
|
|
620
|
+
createdRoots.push({ platformName, rootPath, identity });
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
for (const { platform, files } of allPlatformFiles) {
|
|
624
|
+
const rootPath = safeOutputRoots.get(platform.name);
|
|
625
|
+
const rootRecord = createdRoots.find((entry) => entry.platformName === platform.name);
|
|
626
|
+
for (const file of files) {
|
|
627
|
+
const parentDir = await ensureTrustedOutputDirectory(
|
|
628
|
+
rootPath,
|
|
629
|
+
rootRecord.identity,
|
|
630
|
+
dirname(file.relPath),
|
|
631
|
+
`${platform.name} output root`,
|
|
632
|
+
);
|
|
633
|
+
await assertOwnedOutputRoot(rootPath, rootRecord.identity, `${platform.name} output root`);
|
|
634
|
+
const dstPath = join(parentDir, file.relPath.split(sep).at(-1));
|
|
635
|
+
const createFlags = fsConstants.O_CREAT
|
|
636
|
+
| fsConstants.O_WRONLY
|
|
637
|
+
| fsConstants.O_EXCL
|
|
638
|
+
| (fsConstants.O_NOFOLLOW ?? 0);
|
|
639
|
+
const handle = await open(dstPath, createFlags, 0o600);
|
|
640
|
+
try {
|
|
641
|
+
await handle.writeFile(file.content);
|
|
642
|
+
const writtenStat = await handle.stat();
|
|
643
|
+
assertRegularSingleLink(writtenStat, `${platform.name}/${file.relPath}`);
|
|
644
|
+
} finally {
|
|
645
|
+
await handle.close();
|
|
646
|
+
}
|
|
202
647
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
648
|
+
outputs.push(Object.freeze({
|
|
649
|
+
platform: platform.name,
|
|
650
|
+
path: file.relPath,
|
|
651
|
+
type: 'blob',
|
|
652
|
+
mode: '100644',
|
|
653
|
+
content: file.content,
|
|
654
|
+
sha256: sha256Hex(file.content),
|
|
655
|
+
size: file.content.length,
|
|
656
|
+
}));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
} catch (error) {
|
|
660
|
+
for (const { rootPath, identity } of createdRoots.reverse()) {
|
|
661
|
+
try {
|
|
662
|
+
await assertOwnedOutputRoot(rootPath, identity, 'output root cleanup');
|
|
663
|
+
await rm(rootPath, { recursive: true, force: false });
|
|
664
|
+
} catch {
|
|
665
|
+
// Fail closed: never follow or remove a root whose identity changed.
|
|
666
|
+
}
|
|
211
667
|
}
|
|
668
|
+
throw error;
|
|
212
669
|
}
|
|
213
670
|
|
|
214
671
|
return Object.freeze({
|
|
@@ -224,8 +681,8 @@ export async function produceBuildAdapters({ inputs, output, platformFilter } =
|
|
|
224
681
|
* @returns {string}
|
|
225
682
|
*/
|
|
226
683
|
export function digestEntryOutputs(entries) {
|
|
227
|
-
const canonical = entries.map(({ path, type, mode, sha256, size }) => ({
|
|
228
|
-
path, type, mode, size, sha256,
|
|
684
|
+
const canonical = entries.map(({ platform, path, type, mode, sha256, size }) => ({
|
|
685
|
+
...(platform ? { platform } : {}), path, type, mode, size, sha256,
|
|
229
686
|
}));
|
|
230
687
|
return `sha256:${sha256Hex(canonicalJson(canonical))}`;
|
|
231
688
|
}
|