release-skill 0.4.1 → 0.5.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.
- 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 +41 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +27 -9
- package/README.zh-CN.md +23 -8
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/error-codes.json +154 -0
- package/adapters/claude/bin/kernel-protocol.json +60 -0
- package/adapters/claude/bin/registry.json +165 -0
- package/adapters/claude/bin/release-skill.bundle.mjs +3918 -1462
- package/adapters/claude/bin/rules.json +104 -0
- package/adapters/claude/schemas/release-plan.schema.json +5 -0
- package/adapters/claude/schemas/release-project.schema.json +5 -0
- package/adapters/claude/skills/release-assess/SKILL.md +2 -0
- package/adapters/claude/skills/release-help/SKILL.md +3 -1
- package/adapters/claude/skills/release-prepare/SKILL.md +2 -2
- package/adapters/claude/skills/release-verify/SKILL.md +2 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/error-codes.json +154 -0
- package/adapters/codex/bin/kernel-protocol.json +60 -0
- package/adapters/codex/bin/registry.json +165 -0
- package/adapters/codex/bin/release-skill.bundle.mjs +3918 -1462
- package/adapters/codex/bin/rules.json +104 -0
- package/adapters/codex/schemas/release-plan.schema.json +5 -0
- package/adapters/codex/schemas/release-project.schema.json +5 -0
- package/adapters/codex/skills/release-assess/SKILL.md +2 -0
- package/adapters/codex/skills/release-help/SKILL.md +3 -1
- package/adapters/codex/skills/release-prepare/SKILL.md +2 -2
- package/adapters/codex/skills/release-verify/SKILL.md +2 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/error-codes.json +154 -0
- package/adapters/kimi/bin/kernel-protocol.json +60 -0
- package/adapters/kimi/bin/registry.json +165 -0
- package/adapters/kimi/bin/release-skill.bundle.mjs +3918 -1462
- package/adapters/kimi/bin/rules.json +104 -0
- package/adapters/kimi/schemas/release-plan.schema.json +5 -0
- package/adapters/kimi/schemas/release-project.schema.json +5 -0
- package/adapters/kimi/skills/release-assess/SKILL.md +2 -0
- package/adapters/kimi/skills/release-help/SKILL.md +3 -1
- package/adapters/kimi/skills/release-prepare/SKILL.md +2 -2
- package/adapters/kimi/skills/release-verify/SKILL.md +2 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/error-codes.json +154 -0
- package/adapters/workbuddy/bin/kernel-protocol.json +60 -0
- package/adapters/workbuddy/bin/registry.json +165 -0
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +3918 -1462
- package/adapters/workbuddy/bin/rules.json +104 -0
- package/adapters/workbuddy/schemas/release-plan.schema.json +5 -0
- package/adapters/workbuddy/schemas/release-project.schema.json +5 -0
- package/adapters/workbuddy/skills/release-assess/SKILL.md +2 -0
- package/adapters/workbuddy/skills/release-help/SKILL.md +3 -1
- package/adapters/workbuddy/skills/release-prepare/SKILL.md +2 -2
- package/adapters/workbuddy/skills/release-verify/SKILL.md +2 -0
- package/bin/error-codes.json +154 -0
- package/bin/kernel-protocol.json +60 -0
- package/bin/registry.json +165 -0
- package/bin/release-skill.bundle.mjs +3918 -1462
- package/bin/rules.json +104 -0
- package/package.json +3 -1
- package/schemas/release-plan.schema.json +5 -0
- package/schemas/release-project.schema.json +5 -0
- package/scripts/build-bundle.mjs +159 -5
- package/scripts/sync-public-files.mjs +5 -0
- package/skills/release-assess/SKILL.md +2 -0
- package/skills/release-help/SKILL.md +3 -1
- package/skills/release-prepare/SKILL.md +2 -2
- package/skills/release-verify/SKILL.md +2 -0
- package/skills-src/release-assess/SKILL.md +2 -0
- package/skills-src/release-help/SKILL.md +3 -1
- package/skills-src/release-prepare/SKILL.md +2 -2
- package/skills-src/release-verify/SKILL.md +2 -0
- package/src/adapters/contract.mjs +10 -10
- package/src/adapters/plugin-marketplace.mjs +21 -5
- package/src/artifacts/entry.mjs +4 -1
- package/src/artifacts/producer-registry.mjs +9 -6
- package/src/artifacts/project-lock.mjs +324 -214
- package/src/commands/assess.mjs +60 -2
- package/src/commands/attest.mjs +11 -0
- package/src/commands/prepare.mjs +19 -13
- package/src/core/approval.mjs +5 -2
- package/src/core/digest.mjs +47 -12
- package/src/core/foundation-inflight.mjs +39 -0
- package/src/core/hook-cache.mjs +3 -0
- package/src/core/installation-contract.mjs +4 -2
- package/src/core/plan.mjs +34 -30
- package/src/core/run.mjs +22 -36
- package/src/core/skill-resource-closure.mjs +6 -4
- package/src/core/trusted-resource.mjs +64 -15
- package/src/platforms/codebuddy.mjs +92 -10
- package/src/platforms/registry.mjs +4 -3
- package/src/producers/build-adapters.mjs +27 -9
- package/src/producers/render-public-assets.mjs +12 -7
- package/src/producers/sync-skills.mjs +12 -7
- package/src/snapshot/public-map.mjs +10 -2
|
@@ -1,23 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared project lock for artifact commands.
|
|
2
|
+
* Shared project lock for artifact commands (G4: token-lock adoption).
|
|
3
3
|
*
|
|
4
4
|
* All mutating artifact commands (apply, accept, recover, resolve submit,
|
|
5
|
-
* prepare) share a single project lock domain. The lock is acquired via
|
|
6
|
-
*
|
|
5
|
+
* prepare) share a single project lock domain. The lock is acquired via the
|
|
6
|
+
* Foundation in-flight `token-lock` mechanism: `.release-skill/lock` is a
|
|
7
|
+
* SINGLE FILE (0600) holding the lock record
|
|
8
|
+
* `{schemaVersion:1, owner:<owner6-JSON string>, tokenDigest, acquiredAt}`.
|
|
9
|
+
* The owner record is published atomically together with the lock — there is
|
|
10
|
+
* no ownerless-lock window (G4 conflict-surface.md 第六节).
|
|
7
11
|
*
|
|
8
12
|
* Owner record contains: pid, host, bootId (or session id), nonce, command,
|
|
9
|
-
* startedAt.
|
|
10
|
-
* acquire call returns success.
|
|
13
|
+
* startedAt. It is embedded as the lock record `owner` string.
|
|
11
14
|
*
|
|
12
|
-
* TTL is informational only — aging a lock
|
|
13
|
-
*
|
|
14
|
-
* lock with `breakProjectLock` which requires matching the exact
|
|
15
|
-
* writes audit evidence.
|
|
15
|
+
* TTL is informational only — aging a lock never permits automatic deletion.
|
|
16
|
+
* Only the exact owner can release (token + identity), or an operator can
|
|
17
|
+
* break the lock with `breakProjectLock` which requires matching the exact
|
|
18
|
+
* owner and writes audit evidence before recovery.
|
|
19
|
+
*
|
|
20
|
+
* Old lock domain: `.release-skill/lock` as a DIRECTORY (with `.owner`).
|
|
21
|
+
* Detecting a directory at the lock path fails closed with
|
|
22
|
+
* LOCK_MIGRATION_REQUIRED — never auto-deleted, never auto-converted
|
|
23
|
+
* (same-path different-form makes old/new code mutually exclusive).
|
|
24
|
+
*
|
|
25
|
+
* Bridge: in-flight Foundation capabilities are imported through
|
|
26
|
+
* `../core/foundation-inflight.mjs` (relative-path import of the Foundation
|
|
27
|
+
* workspace; released form must be the bundle).
|
|
16
28
|
*
|
|
17
29
|
* @module artifacts/project-lock
|
|
18
30
|
*/
|
|
19
31
|
|
|
20
|
-
import { mkdir,
|
|
32
|
+
import { mkdir, writeFile, lstat, open } from 'node:fs/promises';
|
|
21
33
|
import { readFileSync } from 'node:fs';
|
|
22
34
|
import { join } from 'node:path';
|
|
23
35
|
import { hostname } from 'node:os';
|
|
@@ -27,15 +39,25 @@ import {
|
|
|
27
39
|
ReleaseError,
|
|
28
40
|
TRANSACTION_INCOMPLETE,
|
|
29
41
|
PATH_UNSAFE,
|
|
42
|
+
LOCK_MIGRATION_REQUIRED,
|
|
30
43
|
} from '../core/errors.mjs';
|
|
44
|
+
import {
|
|
45
|
+
acquireFilesystemLock,
|
|
46
|
+
inspectFilesystemLock,
|
|
47
|
+
releaseFilesystemLock,
|
|
48
|
+
recoverFilesystemLock,
|
|
49
|
+
HARNESS_ERROR_KINDS as INFLIGHT_KINDS,
|
|
50
|
+
} from '../core/foundation-inflight.mjs';
|
|
31
51
|
|
|
32
52
|
// ---------------------------------------------------------------------------
|
|
33
53
|
// Constants
|
|
34
54
|
// ---------------------------------------------------------------------------
|
|
35
55
|
|
|
36
|
-
|
|
37
|
-
const
|
|
56
|
+
/** Foundation token-lock relative path (single file — replaces old directory). */
|
|
57
|
+
const LOCK_REL_PATH = join('.release-skill', 'lock');
|
|
38
58
|
const AUDIT_DIR_NAME = 'lock-audit';
|
|
59
|
+
/** Foundation assertOwner upper bound on the owner string (token-lock.mjs). */
|
|
60
|
+
const MAX_OWNER_JSON_LENGTH = 200;
|
|
39
61
|
|
|
40
62
|
/** All owner fields that must match exactly. */
|
|
41
63
|
const OWNER_FIELDS = ['pid', 'host', 'bootId', 'nonce', 'command', 'startedAt'];
|
|
@@ -87,12 +109,8 @@ function getBootId() {
|
|
|
87
109
|
// Internal: path helpers
|
|
88
110
|
// ---------------------------------------------------------------------------
|
|
89
111
|
|
|
90
|
-
function
|
|
91
|
-
return join(root,
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function ownerPath(root) {
|
|
95
|
-
return join(lockDir(root), OWNER_FILE_NAME);
|
|
112
|
+
function lockPath(root) {
|
|
113
|
+
return join(root, LOCK_REL_PATH);
|
|
96
114
|
}
|
|
97
115
|
|
|
98
116
|
function auditDir(root) {
|
|
@@ -214,6 +232,10 @@ function sanitizeReason(reason) {
|
|
|
214
232
|
.replace(/(^|[\s("'=])(?:[A-Za-z]:[\\/]|\\\\)[^\s,;:'")\]]+/g, '$1<absolute-path>');
|
|
215
233
|
}
|
|
216
234
|
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Internal: durability observation (real fsync at wrapper boundaries)
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
217
239
|
async function emitDurability(observer, event) {
|
|
218
240
|
if (!observer) return;
|
|
219
241
|
try {
|
|
@@ -233,124 +255,140 @@ async function fsyncDirObserved(dirPath, observer) {
|
|
|
233
255
|
await emitDurability(observer, { operation: 'fsync-dir', path: dirPath });
|
|
234
256
|
}
|
|
235
257
|
|
|
236
|
-
// ---------------------------------------------------------------------------
|
|
237
|
-
// Internal: assert owner matches on disk
|
|
238
|
-
// ---------------------------------------------------------------------------
|
|
239
|
-
|
|
240
258
|
/**
|
|
241
|
-
*
|
|
242
|
-
* All six fields (pid, host, bootId, nonce, command, startedAt) must match
|
|
243
|
-
* exactly.
|
|
259
|
+
* Fsync a file by path — opens, syncs, closes.
|
|
244
260
|
*
|
|
245
|
-
* @param {
|
|
246
|
-
* @param {string} root - Repository root.
|
|
247
|
-
* @returns {Promise<void>}
|
|
248
|
-
* @throws {ReleaseError} TRANSACTION_INCOMPLETE if owner mismatch or missing.
|
|
261
|
+
* @param {string} filePath
|
|
249
262
|
*/
|
|
250
|
-
async function
|
|
251
|
-
|
|
263
|
+
async function fsyncFile(filePath) {
|
|
264
|
+
const fh = await open(filePath, 'r');
|
|
252
265
|
try {
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
throw new ReleaseError(
|
|
257
|
-
TRANSACTION_INCOMPLETE,
|
|
258
|
-
'project lock directory does not exist — ownership lost',
|
|
259
|
-
{ root },
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
|
-
throw err;
|
|
266
|
+
await fh.sync();
|
|
267
|
+
} finally {
|
|
268
|
+
await fh.close();
|
|
263
269
|
}
|
|
270
|
+
}
|
|
264
271
|
|
|
265
|
-
|
|
272
|
+
/**
|
|
273
|
+
* Fsync a directory by path.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} dirPath
|
|
276
|
+
*/
|
|
277
|
+
async function fsyncDir(dirPath) {
|
|
278
|
+
const fh = await open(dirPath, 'r');
|
|
266
279
|
try {
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
TRANSACTION_INCOMPLETE,
|
|
271
|
-
'project lock owner file is corrupt',
|
|
272
|
-
{ root },
|
|
273
|
-
);
|
|
280
|
+
await fh.sync();
|
|
281
|
+
} finally {
|
|
282
|
+
await fh.close();
|
|
274
283
|
}
|
|
284
|
+
}
|
|
275
285
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Internal: Foundation error mapping (G4 conflict-surface.md 第五节)
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
function mapKindToCode(kind) {
|
|
291
|
+
switch (kind) {
|
|
292
|
+
case INFLIGHT_KINDS.STORE_LOCKED:
|
|
293
|
+
case INFLIGHT_KINDS.LOCK_CORRUPT:
|
|
294
|
+
case INFLIGHT_KINDS.LOCK_RECOVERY_REFUSED:
|
|
295
|
+
case INFLIGHT_KINDS.EXCLUSIVE_PUBLISH_CONFLICT:
|
|
296
|
+
return TRANSACTION_INCOMPLETE;
|
|
297
|
+
case INFLIGHT_KINDS.UNSAFE_STATE_ENTRY:
|
|
298
|
+
return PATH_UNSAFE;
|
|
299
|
+
default:
|
|
300
|
+
// 未知 kind → fail-closed,默认拒绝
|
|
301
|
+
return PATH_UNSAFE;
|
|
284
302
|
}
|
|
285
303
|
}
|
|
286
304
|
|
|
287
305
|
/**
|
|
288
|
-
*
|
|
289
|
-
* After removal, fsync the parent .release-skill directory for durability.
|
|
306
|
+
* Map a Foundation mechanism error to a local ReleaseError.
|
|
290
307
|
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
* @
|
|
308
|
+
* Errors WITHOUT `details.kind` are genuine local I/O failures (ENOENT,
|
|
309
|
+
* ENOSPC, EACCES, ...) and are rethrown unchanged — they must surface with
|
|
310
|
+
* their native message and code.
|
|
311
|
+
*
|
|
312
|
+
* @param {Error} cause - The caught error.
|
|
313
|
+
* @param {string} fallbackMessage - Message when the cause has none.
|
|
314
|
+
* @returns {ReleaseError}
|
|
296
315
|
*/
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
316
|
+
function mapFoundationError(cause, fallbackMessage) {
|
|
317
|
+
const kind = cause?.details?.kind;
|
|
318
|
+
if (typeof kind === 'string') {
|
|
319
|
+
return new ReleaseError(mapKindToCode(kind), cause.message ?? fallbackMessage, {
|
|
320
|
+
...cause.details,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (cause instanceof TypeError) {
|
|
324
|
+
// Foundation assertOwner 上限(owner 非字符串/超长)——已由 wrapper 写前预检兜底
|
|
325
|
+
return new ReleaseError(PATH_UNSAFE, cause.message ?? fallbackMessage, {});
|
|
326
|
+
}
|
|
327
|
+
throw cause;
|
|
303
328
|
}
|
|
304
329
|
|
|
305
330
|
// ---------------------------------------------------------------------------
|
|
306
|
-
// Internal:
|
|
331
|
+
// Internal: lock path state & fail-closed checks
|
|
307
332
|
// ---------------------------------------------------------------------------
|
|
308
333
|
|
|
309
334
|
/**
|
|
310
|
-
*
|
|
335
|
+
* Classify the lock path state.
|
|
311
336
|
*
|
|
312
|
-
* @param {string}
|
|
337
|
+
* @param {string} root - Repository root.
|
|
338
|
+
* @returns {Promise<'absent'|'file'|'dir'|'symlink'>}
|
|
313
339
|
*/
|
|
314
|
-
async function
|
|
315
|
-
|
|
340
|
+
async function lockPathState(root) {
|
|
341
|
+
let st;
|
|
316
342
|
try {
|
|
317
|
-
await
|
|
318
|
-
}
|
|
319
|
-
|
|
343
|
+
st = await lstat(lockPath(root));
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (err.code === 'ENOENT') return 'absent';
|
|
346
|
+
throw err;
|
|
320
347
|
}
|
|
348
|
+
if (st.isSymbolicLink()) return 'symlink';
|
|
349
|
+
if (st.isDirectory()) return 'dir';
|
|
350
|
+
return 'file';
|
|
321
351
|
}
|
|
322
352
|
|
|
323
353
|
/**
|
|
324
|
-
*
|
|
354
|
+
* Fail closed when the lock path holds an unsafe or old-domain form.
|
|
325
355
|
*
|
|
326
|
-
*
|
|
356
|
+
* - symlink → PATH_UNSAFE (same as pre-migration assertion)
|
|
357
|
+
* - directory → LOCK_MIGRATION_REQUIRED (old lock domain; never auto-migrated)
|
|
358
|
+
* - absent/file → OK (Foundation acquires / inspects the single-file lock)
|
|
359
|
+
*
|
|
360
|
+
* @param {string} root - Repository root.
|
|
361
|
+
* @returns {Promise<void>}
|
|
327
362
|
*/
|
|
328
|
-
async function
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
363
|
+
async function assertLockPathSafe(root) {
|
|
364
|
+
const state = await lockPathState(root);
|
|
365
|
+
if (state === 'symlink') {
|
|
366
|
+
throw new ReleaseError(
|
|
367
|
+
PATH_UNSAFE,
|
|
368
|
+
'.release-skill/lock is a symlink — refusing to operate on symlinked path',
|
|
369
|
+
{ path: lockPath(root) },
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (state === 'dir') {
|
|
373
|
+
throw new ReleaseError(
|
|
374
|
+
LOCK_MIGRATION_REQUIRED,
|
|
375
|
+
'project lock uses the old directory format; migration required',
|
|
376
|
+
{ root, lockPath: lockPath(root), hint: 'confirm no command is in progress, then remove the .release-skill/lock directory' },
|
|
377
|
+
);
|
|
334
378
|
}
|
|
335
379
|
}
|
|
336
380
|
|
|
337
|
-
// ---------------------------------------------------------------------------
|
|
338
|
-
// Internal: symlink/non-directory fail-closed checks
|
|
339
|
-
// ---------------------------------------------------------------------------
|
|
340
|
-
|
|
341
381
|
/**
|
|
342
|
-
* Assert that
|
|
343
|
-
* Fails closed with PATH_UNSAFE on any violation.
|
|
382
|
+
* Assert that .release-skill exists as a real directory (not a symlink).
|
|
344
383
|
*
|
|
345
|
-
* @param {string}
|
|
346
|
-
* @param {string} label - Human label for error messages.
|
|
384
|
+
* @param {string} root - Repository root.
|
|
347
385
|
* @returns {Promise<void>}
|
|
348
|
-
* @throws {ReleaseError} PATH_UNSAFE if symlink or non-directory.
|
|
349
386
|
*/
|
|
350
|
-
async function
|
|
387
|
+
async function assertReleaseSkillDirSafe(root) {
|
|
388
|
+
const releaseSkillDir = join(root, '.release-skill');
|
|
351
389
|
let st;
|
|
352
390
|
try {
|
|
353
|
-
st = await lstat(
|
|
391
|
+
st = await lstat(releaseSkillDir);
|
|
354
392
|
} catch (err) {
|
|
355
393
|
if (err.code === 'ENOENT') return; // doesn't exist yet — OK
|
|
356
394
|
throw err;
|
|
@@ -358,42 +396,112 @@ async function assertNotSymlinkOrFile(dirPath, label) {
|
|
|
358
396
|
if (st.isSymbolicLink()) {
|
|
359
397
|
throw new ReleaseError(
|
|
360
398
|
PATH_UNSAFE,
|
|
361
|
-
|
|
362
|
-
{ path:
|
|
399
|
+
'.release-skill is a symlink — refusing to operate on symlinked path',
|
|
400
|
+
{ path: releaseSkillDir },
|
|
363
401
|
);
|
|
364
402
|
}
|
|
365
403
|
if (!st.isDirectory()) {
|
|
366
404
|
throw new ReleaseError(
|
|
367
405
|
PATH_UNSAFE,
|
|
368
|
-
|
|
369
|
-
{ path:
|
|
406
|
+
'.release-skill exists but is not a directory',
|
|
407
|
+
{ path: releaseSkillDir },
|
|
370
408
|
);
|
|
371
409
|
}
|
|
372
410
|
}
|
|
373
411
|
|
|
374
412
|
/**
|
|
375
|
-
* Assert that the
|
|
376
|
-
* Checks every level: .release-skill, .release-skill/lock, .release-skill/lock-audit.
|
|
413
|
+
* Assert that the audit directory is not a symlink.
|
|
377
414
|
*
|
|
378
415
|
* @param {string} root - Repository root.
|
|
379
416
|
* @returns {Promise<void>}
|
|
380
417
|
*/
|
|
381
|
-
async function
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
418
|
+
async function assertAuditPathNotSymlink(root) {
|
|
419
|
+
await assertReleaseSkillDirSafe(root);
|
|
420
|
+
const auditPath = auditDir(root);
|
|
421
|
+
let st;
|
|
422
|
+
try {
|
|
423
|
+
st = await lstat(auditPath);
|
|
424
|
+
} catch (err) {
|
|
425
|
+
if (err.code === 'ENOENT') return;
|
|
426
|
+
throw err;
|
|
427
|
+
}
|
|
428
|
+
if (st.isSymbolicLink()) {
|
|
429
|
+
throw new ReleaseError(
|
|
430
|
+
PATH_UNSAFE,
|
|
431
|
+
'.release-skill/lock-audit is a symlink — refusing to operate on symlinked path',
|
|
432
|
+
{ path: auditPath },
|
|
433
|
+
);
|
|
434
|
+
}
|
|
385
435
|
}
|
|
386
436
|
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// Internal: assert owner matches persisted lock record
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
387
441
|
/**
|
|
388
|
-
*
|
|
442
|
+
* Parse the persisted owner string from a lock record and compare all six
|
|
443
|
+
* fields exactly with the expected owner.
|
|
444
|
+
*
|
|
445
|
+
* @param {string} persistedOwnerJson - `record.owner` from the lock file.
|
|
446
|
+
* @param {object} expected - The expected owner record.
|
|
447
|
+
* @param {string} root - Repository root.
|
|
448
|
+
* @param {string} messagePrefix - Prefix for the mismatch message.
|
|
449
|
+
* @returns {void}
|
|
450
|
+
* @throws {ReleaseError} TRANSACTION_INCOMPLETE on mismatch or corrupt record.
|
|
451
|
+
*/
|
|
452
|
+
function assertOwnerMatches(persistedOwnerJson, expected, root, messagePrefix) {
|
|
453
|
+
let actual;
|
|
454
|
+
try {
|
|
455
|
+
actual = JSON.parse(persistedOwnerJson);
|
|
456
|
+
} catch {
|
|
457
|
+
throw new ReleaseError(
|
|
458
|
+
TRANSACTION_INCOMPLETE,
|
|
459
|
+
'project lock owner record is corrupt',
|
|
460
|
+
{ root },
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
for (const field of OWNER_FIELDS) {
|
|
464
|
+
if (actual[field] !== expected[field]) {
|
|
465
|
+
throw new ReleaseError(
|
|
466
|
+
TRANSACTION_INCOMPLETE,
|
|
467
|
+
`${messagePrefix} — field "${field}" differs`,
|
|
468
|
+
{ root, field, expected: field === 'nonce' ? expected[field]?.slice(0, 8) : undefined },
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Assert that the persisted lock record still belongs to `owner`.
|
|
389
476
|
*
|
|
477
|
+
* @param {object} owner - The expected owner record.
|
|
390
478
|
* @param {string} root - Repository root.
|
|
391
479
|
* @returns {Promise<void>}
|
|
480
|
+
* @throws {ReleaseError} TRANSACTION_INCOMPLETE if lock missing or owner mismatch.
|
|
392
481
|
*/
|
|
393
|
-
async function
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
482
|
+
async function assertOwned(owner, root) {
|
|
483
|
+
let inspected;
|
|
484
|
+
try {
|
|
485
|
+
inspected = await inspectFilesystemLock(root, LOCK_REL_PATH);
|
|
486
|
+
} catch (cause) {
|
|
487
|
+
if (cause?.code === 'ENOENT') {
|
|
488
|
+
// Lock domain (or .release-skill) does not exist at all — ownership lost.
|
|
489
|
+
throw new ReleaseError(
|
|
490
|
+
TRANSACTION_INCOMPLETE,
|
|
491
|
+
'project lock does not exist — ownership lost',
|
|
492
|
+
{ root },
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
throw mapFoundationError(cause, 'project lock inspection failed');
|
|
496
|
+
}
|
|
497
|
+
if (!inspected.locked) {
|
|
498
|
+
throw new ReleaseError(
|
|
499
|
+
TRANSACTION_INCOMPLETE,
|
|
500
|
+
'project lock does not exist — ownership lost',
|
|
501
|
+
{ root },
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
assertOwnerMatches(inspected.owner, owner, root, 'project lock owner does not match');
|
|
397
505
|
}
|
|
398
506
|
|
|
399
507
|
// ---------------------------------------------------------------------------
|
|
@@ -403,8 +511,10 @@ async function assertAuditPathNotSymlink(root) {
|
|
|
403
511
|
/**
|
|
404
512
|
* Acquire the project lock.
|
|
405
513
|
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
514
|
+
* Delegates to Foundation `acquireFilesystemLock` — one exclusive create of
|
|
515
|
+
* the lock record file (temp + fsync + exclusive link + verify + directory
|
|
516
|
+
* fsync). The owner is published atomically with the lock, so there is no
|
|
517
|
+
* ownerless-lock window.
|
|
408
518
|
*
|
|
409
519
|
* If the lock is already held, throws `TRANSACTION_INCOMPLETE` — there is
|
|
410
520
|
* no automatic stale lock breakage based on TTL.
|
|
@@ -448,20 +558,30 @@ export async function acquireProjectLock({
|
|
|
448
558
|
}
|
|
449
559
|
|
|
450
560
|
// Construct and validate the owner before touching the filesystem so a bad
|
|
451
|
-
// injected clock cannot
|
|
561
|
+
// injected clock cannot shape any persisted state.
|
|
452
562
|
const owner = buildOwner(command, clock);
|
|
563
|
+
const ownerJson = JSON.stringify(owner);
|
|
564
|
+
if (ownerJson.length > MAX_OWNER_JSON_LENGTH) {
|
|
565
|
+
// Foundation assertOwner 上限(200 字符);写前预检 fail-closed(G4 C.4)
|
|
566
|
+
throw new ReleaseError(
|
|
567
|
+
PATH_UNSAFE,
|
|
568
|
+
'lock owner record exceeds the 200-character limit; refusing to acquire',
|
|
569
|
+
{ length: ownerJson.length },
|
|
570
|
+
);
|
|
571
|
+
}
|
|
453
572
|
|
|
454
|
-
// Symlink/
|
|
573
|
+
// Symlink / old-domain fail-closed: check every path level before touching fs
|
|
455
574
|
const releaseSkillDir = join(root, '.release-skill');
|
|
456
|
-
await
|
|
575
|
+
await assertReleaseSkillDirSafe(root);
|
|
576
|
+
await assertLockPathSafe(root);
|
|
457
577
|
|
|
458
|
-
// Ensure parent directory exists (only
|
|
459
|
-
let parentExisted =
|
|
578
|
+
// Ensure parent directory exists (only fsync root when newly created)
|
|
579
|
+
let parentExisted = true;
|
|
460
580
|
try {
|
|
461
581
|
await lstat(releaseSkillDir);
|
|
462
|
-
parentExisted = true;
|
|
463
582
|
} catch (err) {
|
|
464
583
|
if (err.code !== 'ENOENT') throw err;
|
|
584
|
+
parentExisted = false;
|
|
465
585
|
}
|
|
466
586
|
if (!parentExisted) {
|
|
467
587
|
await mkdir(releaseSkillDir, { recursive: true, mode: 0o700 });
|
|
@@ -470,62 +590,54 @@ export async function acquireProjectLock({
|
|
|
470
590
|
await fsyncDirObserved(root, durabilityObserver);
|
|
471
591
|
}
|
|
472
592
|
|
|
473
|
-
|
|
593
|
+
if (faultInjector) await faultInjector('after-lock-create');
|
|
474
594
|
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
let lockCreated = false;
|
|
595
|
+
// Foundation single-file atomic lock acquisition. A failure before commit
|
|
596
|
+
// leaves nothing behind (staging is cleaned by publishFileExclusive).
|
|
597
|
+
let handle;
|
|
479
598
|
try {
|
|
480
|
-
await
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
if (err.code === 'EEXIST') {
|
|
599
|
+
handle = await acquireFilesystemLock(root, LOCK_REL_PATH, { owner: ownerJson });
|
|
600
|
+
} catch (cause) {
|
|
601
|
+
if (cause?.details?.kind === INFLIGHT_KINDS.STORE_LOCKED) {
|
|
484
602
|
// Lock is held — TTL never permits automatic breakage
|
|
485
603
|
throw new ReleaseError(
|
|
486
604
|
TRANSACTION_INCOMPLETE,
|
|
487
605
|
'project lock is already held; another command is in progress',
|
|
488
|
-
{ root,
|
|
606
|
+
{ root, lockPath: lockPath(root) },
|
|
489
607
|
);
|
|
490
608
|
}
|
|
491
|
-
throw
|
|
609
|
+
throw mapFoundationError(cause, 'project lock acquisition failed');
|
|
492
610
|
}
|
|
493
611
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
await writeFile(ownerFilePath, JSON.stringify(owner), { mode: 0o600, flag: 'wx' });
|
|
501
|
-
await emitDurability(durabilityObserver, { operation: 'write-file', path: ownerFilePath });
|
|
502
|
-
if (faultInjector) await faultInjector('after-owner-write');
|
|
503
|
-
|
|
504
|
-
// Fsync owner file then lock directory for durability
|
|
505
|
-
await fsyncFileObserved(ownerFilePath, durabilityObserver);
|
|
506
|
-
await fsyncDirObserved(dir, durabilityObserver);
|
|
507
|
-
} catch (writeErr) {
|
|
508
|
-
// Cleanup: remove the lock dir if owner write failed — prevents zombie lock
|
|
509
|
-
// without an owner file (which cannot be broken since break requires owner).
|
|
510
|
-
if (!lockCreated) throw writeErr;
|
|
612
|
+
// Foundation publishFileExclusive already fsynced .release-skill; record the
|
|
613
|
+
// completed boundary durability at the wrapper edge.
|
|
614
|
+
await emitDurability(durabilityObserver, { operation: 'fsync-dir', path: releaseSkillDir });
|
|
615
|
+
|
|
616
|
+
if (faultInjector) {
|
|
511
617
|
try {
|
|
512
|
-
await
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
{
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
618
|
+
await faultInjector('after-owner-write');
|
|
619
|
+
} catch (writeErr) {
|
|
620
|
+
// The atomic commit already happened; roll the lock back so an
|
|
621
|
+
// "acquire reported failure" never leaves a held lock behind.
|
|
622
|
+
try {
|
|
623
|
+
await releaseFilesystemLock(handle);
|
|
624
|
+
await emitDurability(durabilityObserver, { operation: 'remove-dir', path: lockPath(root) });
|
|
625
|
+
await emitDurability(durabilityObserver, { operation: 'fsync-dir', path: releaseSkillDir });
|
|
626
|
+
} catch (cleanupErr) {
|
|
627
|
+
const incomplete = new ReleaseError(
|
|
628
|
+
TRANSACTION_INCOMPLETE,
|
|
629
|
+
'project lock acquisition failed and cleanup could not be made durable',
|
|
630
|
+
{
|
|
631
|
+
acquireErrorCode: typeof writeErr?.code === 'string' ? writeErr.code : null,
|
|
632
|
+
cleanupErrorCode: typeof cleanupErr?.code === 'string' ? cleanupErr.code : null,
|
|
633
|
+
},
|
|
634
|
+
);
|
|
635
|
+
incomplete.cause = writeErr;
|
|
636
|
+
incomplete.cleanupCause = cleanupErr;
|
|
637
|
+
throw incomplete;
|
|
638
|
+
}
|
|
639
|
+
throw writeErr;
|
|
527
640
|
}
|
|
528
|
-
throw writeErr;
|
|
529
641
|
}
|
|
530
642
|
|
|
531
643
|
return Object.freeze({
|
|
@@ -543,14 +655,14 @@ export async function acquireProjectLock({
|
|
|
543
655
|
* @throws {ReleaseError} if ownership verification fails.
|
|
544
656
|
*/
|
|
545
657
|
async capture(fn) {
|
|
546
|
-
await
|
|
658
|
+
await assertOwned(owner, root);
|
|
547
659
|
let fnResult;
|
|
548
660
|
try {
|
|
549
661
|
fnResult = await fn();
|
|
550
662
|
} catch (fnErr) {
|
|
551
663
|
// fn threw — still perform post-owner check (fail-closed)
|
|
552
664
|
try {
|
|
553
|
-
await
|
|
665
|
+
await assertOwned(owner, root);
|
|
554
666
|
} catch {
|
|
555
667
|
// Both fn error AND owner lost — fail closed with TRANSACTION_INCOMPLETE,
|
|
556
668
|
// preserve the original business error as cause
|
|
@@ -566,7 +678,7 @@ export async function acquireProjectLock({
|
|
|
566
678
|
throw fnErr;
|
|
567
679
|
}
|
|
568
680
|
// fn succeeded — post-owner check
|
|
569
|
-
await
|
|
681
|
+
await assertOwned(owner, root);
|
|
570
682
|
return fnResult;
|
|
571
683
|
},
|
|
572
684
|
|
|
@@ -577,18 +689,25 @@ export async function acquireProjectLock({
|
|
|
577
689
|
* @throws {ReleaseError} TRANSACTION_INCOMPLETE if ownership lost.
|
|
578
690
|
*/
|
|
579
691
|
async assertOwner() {
|
|
580
|
-
return
|
|
692
|
+
return assertOwned(owner, root);
|
|
581
693
|
},
|
|
582
694
|
|
|
583
695
|
/**
|
|
584
|
-
* Release the lock. Only succeeds if the persisted
|
|
585
|
-
*
|
|
696
|
+
* Release the lock. Only succeeds if the persisted record still matches
|
|
697
|
+
* the token and identity captured at acquire. Foundation `unlinkSame`
|
|
698
|
+
* fsyncs `.release-skill` as part of removal.
|
|
586
699
|
*
|
|
587
700
|
* @returns {Promise<void>}
|
|
588
|
-
* @throws {ReleaseError} TRANSACTION_INCOMPLETE if owner mismatch.
|
|
701
|
+
* @throws {ReleaseError} TRANSACTION_INCOMPLETE if token/owner mismatch.
|
|
589
702
|
*/
|
|
590
703
|
async release() {
|
|
591
|
-
|
|
704
|
+
try {
|
|
705
|
+
await releaseFilesystemLock(handle);
|
|
706
|
+
} catch (cause) {
|
|
707
|
+
throw mapFoundationError(cause, 'project lock release failed — token or owner does not match');
|
|
708
|
+
}
|
|
709
|
+
await emitDurability(durabilityObserver, { operation: 'remove-dir', path: lockPath(root) });
|
|
710
|
+
await emitDurability(durabilityObserver, { operation: 'fsync-dir', path: releaseSkillDir });
|
|
592
711
|
},
|
|
593
712
|
});
|
|
594
713
|
}
|
|
@@ -596,9 +715,10 @@ export async function acquireProjectLock({
|
|
|
596
715
|
/**
|
|
597
716
|
* Break a project lock by force.
|
|
598
717
|
*
|
|
599
|
-
* Requires the exact owner record to match what is persisted
|
|
600
|
-
* Writes an audit record to `.release-skill/lock-audit/` before
|
|
601
|
-
* the lock
|
|
718
|
+
* Requires the exact owner record to match what is persisted in the lock
|
|
719
|
+
* record. Writes an audit record to `.release-skill/lock-audit/` before
|
|
720
|
+
* recovering (removing) the lock file via Foundation `recoverFilesystemLock`
|
|
721
|
+
* (explicit operator recovery — no age/PID/liveness inference).
|
|
602
722
|
*
|
|
603
723
|
* @param {object} options
|
|
604
724
|
* @param {string} options.root - Repository root (absolute).
|
|
@@ -627,45 +747,26 @@ export async function breakProjectLock({ root, expectedOwner, reason, clock, dur
|
|
|
627
747
|
const brokenAt = clock ? clock() : new Date().toISOString();
|
|
628
748
|
assertIsoTimestamp(brokenAt, 'clock result');
|
|
629
749
|
|
|
630
|
-
// Symlink/
|
|
631
|
-
await
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
let raw;
|
|
635
|
-
try {
|
|
636
|
-
raw = await readFile(ownerPath(root), 'utf8');
|
|
637
|
-
} catch (err) {
|
|
638
|
-
if (err.code === 'ENOENT') {
|
|
639
|
-
throw new ReleaseError(
|
|
640
|
-
TRANSACTION_INCOMPLETE,
|
|
641
|
-
'no project lock to break — lock directory does not exist',
|
|
642
|
-
{ root },
|
|
643
|
-
);
|
|
644
|
-
}
|
|
645
|
-
throw err;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
let actualOwner;
|
|
649
|
-
try {
|
|
650
|
-
actualOwner = JSON.parse(raw);
|
|
651
|
-
} catch {
|
|
750
|
+
// Symlink / old-domain / absent fail-closed check before reading the lock record
|
|
751
|
+
await assertReleaseSkillDirSafe(root);
|
|
752
|
+
await assertLockPathSafe(root);
|
|
753
|
+
if ((await lockPathState(root)) === 'absent') {
|
|
652
754
|
throw new ReleaseError(
|
|
653
755
|
TRANSACTION_INCOMPLETE,
|
|
654
|
-
'project lock
|
|
756
|
+
'no project lock to break — lock file does not exist',
|
|
655
757
|
{ root },
|
|
656
758
|
);
|
|
657
759
|
}
|
|
658
760
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
`break-lock rejected: expectedOwner does not match persisted owner (field: ${field})`,
|
|
665
|
-
{ root, field },
|
|
666
|
-
);
|
|
667
|
-
}
|
|
761
|
+
let inspected;
|
|
762
|
+
try {
|
|
763
|
+
inspected = await inspectFilesystemLock(root, LOCK_REL_PATH);
|
|
764
|
+
} catch (cause) {
|
|
765
|
+
throw mapFoundationError(cause, 'project lock inspection failed');
|
|
668
766
|
}
|
|
767
|
+
assertOwnerMatches(inspected.owner, expectedOwner, root, 'break-lock rejected: expectedOwner does not match persisted owner');
|
|
768
|
+
|
|
769
|
+
const actualOwner = JSON.parse(inspected.owner); // 已通过 assertOwnerMatches 校验
|
|
669
770
|
|
|
670
771
|
// Check audit path is not a symlink before writing
|
|
671
772
|
await assertAuditPathNotSymlink(root);
|
|
@@ -688,7 +789,7 @@ export async function breakProjectLock({ root, expectedOwner, reason, clock, dur
|
|
|
688
789
|
breakerHost: hostname(),
|
|
689
790
|
});
|
|
690
791
|
|
|
691
|
-
// Write audit evidence before
|
|
792
|
+
// Write audit evidence before recovering the lock
|
|
692
793
|
const auditDirectory = auditDir(root);
|
|
693
794
|
await mkdir(auditDirectory, { recursive: true, mode: 0o700 });
|
|
694
795
|
await emitDurability(durabilityObserver, { operation: 'create-dir', path: auditDirectory });
|
|
@@ -704,12 +805,21 @@ export async function breakProjectLock({ root, expectedOwner, reason, clock, dur
|
|
|
704
805
|
await fsyncFileObserved(auditFilePath, durabilityObserver);
|
|
705
806
|
await fsyncDirObserved(auditDirectory, durabilityObserver);
|
|
706
807
|
|
|
707
|
-
//
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
808
|
+
// Explicit operator recovery: digest must still match the inspected record
|
|
809
|
+
// (TOCTOU fail-closed — the lock may not have changed between inspect and recover).
|
|
810
|
+
try {
|
|
811
|
+
await recoverFilesystemLock(root, LOCK_REL_PATH, {
|
|
812
|
+
expectedTokenDigest: inspected.tokenDigest,
|
|
813
|
+
confirmAbandoned: true,
|
|
814
|
+
});
|
|
815
|
+
} catch (cause) {
|
|
816
|
+
throw mapFoundationError(cause, 'project lock break failed — lock changed after inspection');
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Foundation unlinkSame fsynced .release-skill as part of removal; record
|
|
820
|
+
// the completed boundary durability at the wrapper edge.
|
|
821
|
+
await emitDurability(durabilityObserver, { operation: 'remove-dir', path: lockPath(root) });
|
|
822
|
+
await emitDurability(durabilityObserver, { operation: 'fsync-dir', path: join(root, '.release-skill') });
|
|
713
823
|
|
|
714
824
|
return auditRecord;
|
|
715
825
|
}
|