kimi-plugin-cc 2.4.5 → 2.5.2
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/CHANGELOG.md +22 -0
- package/README.md +103 -29
- package/dist/commands/setup.js +78 -51
- package/dist/dsh/companion.js +177 -0
- package/dist/dsh/index.js +169 -0
- package/dist/hooks/install-paths.js +2 -2
- package/dist/hooks/managed-block.js +86 -20
- package/dist/version.js +1 -1
- package/docs/migration.md +4 -2
- package/docs/models.md +5 -1
- package/docs/registry-releases.md +84 -20
- package/docs/safety.md +25 -10
- package/docs/upstream-2.4.1-certification.md +6 -0
- package/package.json +75 -8
- package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/kimi-codex/dist/commands/setup.js +78 -51
- package/plugins/kimi-codex/dist/dsh/companion.js +177 -0
- package/plugins/kimi-codex/dist/dsh/index.js +169 -0
- package/plugins/kimi-codex/dist/hooks/install-paths.js +2 -2
- package/plugins/kimi-codex/dist/hooks/managed-block.js +86 -20
- package/plugins/kimi-codex/dist/version.js +1 -1
- package/plugins/kimi-dsh/LICENSE +201 -0
- package/plugins/kimi-dsh/README.md +83 -0
- package/plugins/kimi-dsh/cordis.patch.yml +45 -0
- package/plugins/kimi-dsh/dist/background-spawn.js +216 -0
- package/plugins/kimi-dsh/dist/cli-cancellation.js +43 -0
- package/plugins/kimi-dsh/dist/cli-client.js +876 -0
- package/plugins/kimi-dsh/dist/commands/ask.js +336 -0
- package/plugins/kimi-dsh/dist/commands/cancel.js +216 -0
- package/plugins/kimi-dsh/dist/commands/cli-helpers.js +201 -0
- package/plugins/kimi-dsh/dist/commands/models.js +140 -0
- package/plugins/kimi-dsh/dist/commands/not-implemented.js +4 -0
- package/plugins/kimi-dsh/dist/commands/pursue.js +307 -0
- package/plugins/kimi-dsh/dist/commands/recover-swarm.js +135 -0
- package/plugins/kimi-dsh/dist/commands/registry.js +74 -0
- package/plugins/kimi-dsh/dist/commands/repair-sessions.js +160 -0
- package/plugins/kimi-dsh/dist/commands/replay.js +124 -0
- package/plugins/kimi-dsh/dist/commands/rescue.js +347 -0
- package/plugins/kimi-dsh/dist/commands/result.js +66 -0
- package/plugins/kimi-dsh/dist/commands/review-gate.js +390 -0
- package/plugins/kimi-dsh/dist/commands/review.js +220 -0
- package/plugins/kimi-dsh/dist/commands/setup.js +1074 -0
- package/plugins/kimi-dsh/dist/commands/status.js +27 -0
- package/plugins/kimi-dsh/dist/commands/swarm.js +754 -0
- package/plugins/kimi-dsh/dist/commands/worker.js +25 -0
- package/plugins/kimi-dsh/dist/companion.js +97 -0
- package/plugins/kimi-dsh/dist/config.js +22 -0
- package/plugins/kimi-dsh/dist/dsh/companion.js +177 -0
- package/plugins/kimi-dsh/dist/dsh/index.js +169 -0
- package/plugins/kimi-dsh/dist/errors.js +40 -0
- package/plugins/kimi-dsh/dist/git.js +224 -0
- package/plugins/kimi-dsh/dist/help.js +43 -0
- package/plugins/kimi-dsh/dist/hook-escape.js +71 -0
- package/plugins/kimi-dsh/dist/hooks/approval-hook.js +101 -0
- package/plugins/kimi-dsh/dist/hooks/approval-policy.js +211 -0
- package/plugins/kimi-dsh/dist/hooks/config-safety.js +653 -0
- package/plugins/kimi-dsh/dist/hooks/install-paths.js +428 -0
- package/plugins/kimi-dsh/dist/hooks/install.js +210 -0
- package/plugins/kimi-dsh/dist/hooks/managed-block.js +685 -0
- package/plugins/kimi-dsh/dist/hooks/review-gate-stop.js +19 -0
- package/plugins/kimi-dsh/dist/job-id.js +12 -0
- package/plugins/kimi-dsh/dist/job-store.js +659 -0
- package/plugins/kimi-dsh/dist/jobs.js +217 -0
- package/plugins/kimi-dsh/dist/kimi-command.js +70 -0
- package/plugins/kimi-dsh/dist/kimi-engine.js +632 -0
- package/plugins/kimi-dsh/dist/kimi-errors.js +260 -0
- package/plugins/kimi-dsh/dist/kimi-home.js +9 -0
- package/plugins/kimi-dsh/dist/kimi-timeouts.js +52 -0
- package/plugins/kimi-dsh/dist/kimi-version-probe.js +216 -0
- package/plugins/kimi-dsh/dist/logging.js +22 -0
- package/plugins/kimi-dsh/dist/mcp/binding.js +58 -0
- package/plugins/kimi-dsh/dist/mcp/job-control.js +345 -0
- package/plugins/kimi-dsh/dist/mcp/launcher.js +257 -0
- package/plugins/kimi-dsh/dist/mcp/operations.js +301 -0
- package/plugins/kimi-dsh/dist/mcp/protocol.js +275 -0
- package/plugins/kimi-dsh/dist/mcp/server.js +8 -0
- package/plugins/kimi-dsh/dist/mcp/stdio.js +78 -0
- package/plugins/kimi-dsh/dist/mcp/supervised-child.js +15 -0
- package/plugins/kimi-dsh/dist/mcp/tool-result.js +7 -0
- package/plugins/kimi-dsh/dist/mcp/workspace.js +34 -0
- package/plugins/kimi-dsh/dist/model-command.js +32 -0
- package/plugins/kimi-dsh/dist/native-v2-preflight.js +417 -0
- package/plugins/kimi-dsh/dist/package.json +1 -0
- package/plugins/kimi-dsh/dist/parsing.js +620 -0
- package/plugins/kimi-dsh/dist/paths.js +27 -0
- package/plugins/kimi-dsh/dist/plugin-data.js +98 -0
- package/plugins/kimi-dsh/dist/process-tree.js +479 -0
- package/plugins/kimi-dsh/dist/prompt-size.js +18 -0
- package/plugins/kimi-dsh/dist/render.js +177 -0
- package/plugins/kimi-dsh/dist/rescue-approval.js +865 -0
- package/plugins/kimi-dsh/dist/schemas/review-gate-output.js +43 -0
- package/plugins/kimi-dsh/dist/session-recovery.js +200 -0
- package/plugins/kimi-dsh/dist/session-title.js +293 -0
- package/plugins/kimi-dsh/dist/stream-json.js +346 -0
- package/plugins/kimi-dsh/dist/swarm-activation.js +834 -0
- package/plugins/kimi-dsh/dist/swarm-recovery.js +134 -0
- package/plugins/kimi-dsh/dist/types.js +1 -0
- package/plugins/kimi-dsh/dist/vendor/shell-quote/LICENSE +24 -0
- package/plugins/kimi-dsh/dist/vendor/shell-quote/index.js +1 -0
- package/plugins/kimi-dsh/dist/vendor/shell-quote/parse.js +229 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/LICENSE +24 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/date.js +156 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/error.js +67 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/extract.js +293 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/parse.js +164 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/primitive.js +151 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/struct.js +156 -0
- package/plugins/kimi-dsh/dist/vendor/smol-toml/util.js +57 -0
- package/plugins/kimi-dsh/dist/version.js +7 -0
- package/plugins/kimi-dsh/package.json +78 -0
- package/plugins/kimi-dsh/scripts/companion.sh +37 -0
- package/plugins/kimi-dsh/scripts/review-gate-hook.sh +32 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { link, lstat, mkdir, open, rename, unlink } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { RuntimeError } from "../errors.js";
|
|
6
|
+
import { probeKimiVersion } from "../kimi-version-probe.js";
|
|
7
|
+
import { extractValue } from "../vendor/smol-toml/extract.js";
|
|
8
|
+
import { parse as parseToml } from "../vendor/smol-toml/parse.js";
|
|
9
|
+
import { parseKey } from "../vendor/smol-toml/struct.js";
|
|
10
|
+
import { skipVoid } from "../vendor/smol-toml/util.js";
|
|
11
|
+
const LOCK_SUFFIX = ".kimi-plugin-cc.lock";
|
|
12
|
+
const DEFAULT_LOCK_WAIT_MS = 10_000;
|
|
13
|
+
const DEFAULT_LOCK_STALE_MS = 2_000;
|
|
14
|
+
const DEFAULT_LOCK_RETRY_MS = 25;
|
|
15
|
+
export const KIMI_CONFIG_LOCK_METADATA_MAX_BYTES = 4_096;
|
|
16
|
+
const HOOK_FIELDS = new Set(["event", "matcher", "command", "timeout"]);
|
|
17
|
+
// These events exist throughout the supported 0.x range. The remaining events
|
|
18
|
+
// are additive and require a local version probe before they can be blessed.
|
|
19
|
+
const BASE_HOOK_EVENTS = new Set([
|
|
20
|
+
"PreToolUse",
|
|
21
|
+
"PostToolUse",
|
|
22
|
+
"PostToolUseFailure",
|
|
23
|
+
"UserPromptSubmit",
|
|
24
|
+
"Stop",
|
|
25
|
+
"StopFailure",
|
|
26
|
+
"SessionStart",
|
|
27
|
+
"SessionEnd",
|
|
28
|
+
"SubagentStart",
|
|
29
|
+
"SubagentStop",
|
|
30
|
+
"PreCompact",
|
|
31
|
+
"PostCompact",
|
|
32
|
+
"Notification",
|
|
33
|
+
]);
|
|
34
|
+
const VERSION_SENSITIVE_HOOK_EVENTS = new Map([
|
|
35
|
+
["PermissionRequest", 8],
|
|
36
|
+
["PermissionResult", 8],
|
|
37
|
+
["Interrupt", 14],
|
|
38
|
+
]);
|
|
39
|
+
export function kimiConfigLockPath(configPath) {
|
|
40
|
+
return `${configPath}${LOCK_SUFFIX}`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Serialize setup read-modify-write operations with a private sibling lock.
|
|
44
|
+
* A crashed owner is recoverable after `staleMs`; a live owner is never
|
|
45
|
+
* stolen, even when old, and wait time is bounded.
|
|
46
|
+
*/
|
|
47
|
+
export async function withKimiConfigLock(configPath, operation, options = {}) {
|
|
48
|
+
const lockPath = kimiConfigLockPath(configPath);
|
|
49
|
+
const waitMs = options.waitMs ?? DEFAULT_LOCK_WAIT_MS;
|
|
50
|
+
const staleMs = options.staleMs ?? DEFAULT_LOCK_STALE_MS;
|
|
51
|
+
const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
|
|
52
|
+
const owner = {
|
|
53
|
+
pid: process.pid,
|
|
54
|
+
token: randomBytes(16).toString("hex"),
|
|
55
|
+
createdAt: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
await mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
|
|
58
|
+
const deadline = Date.now() + waitMs;
|
|
59
|
+
let lastOwner = null;
|
|
60
|
+
let acquired = null;
|
|
61
|
+
for (;;) {
|
|
62
|
+
try {
|
|
63
|
+
acquired = await createPrivateLockFile(lockPath, owner);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code !== "EEXIST") {
|
|
68
|
+
if (error instanceof RuntimeError)
|
|
69
|
+
throw error;
|
|
70
|
+
throw new RuntimeError("SETUP_CONFIG_LOCK_FAILED", `Failed to acquire setup lock ${lockPath}: ${error.message}`, "setup.config-lock", error instanceof Error
|
|
71
|
+
? { cause: error, details: { configPath, lockPath } }
|
|
72
|
+
: { details: { configPath, lockPath } });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const snapshot = await readLockSnapshot(lockPath);
|
|
76
|
+
lastOwner = snapshot?.owner ?? null;
|
|
77
|
+
if (snapshot !== null && isStaleLock(snapshot, staleMs)) {
|
|
78
|
+
await options.testHooks?.beforeStaleRecovery?.(lockPath, {
|
|
79
|
+
identity: snapshot.identity,
|
|
80
|
+
ownerToken: snapshot.owner?.token ?? null,
|
|
81
|
+
});
|
|
82
|
+
const recovered = await tryRecoverStaleLock(lockPath, snapshot, staleMs);
|
|
83
|
+
if (recovered)
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (Date.now() >= deadline) {
|
|
87
|
+
const ownerDetail = lastOwner === null
|
|
88
|
+
? "owner metadata is unavailable"
|
|
89
|
+
: `owner pid ${lastOwner.pid}, created ${lastOwner.createdAt}`;
|
|
90
|
+
throw new RuntimeError("SETUP_CONFIG_LOCK_TIMEOUT", `Timed out after ${waitMs}ms waiting for setup lock ${lockPath} (${ownerDetail}). Another setup may still be running; retry after it finishes or remove the lock only if that process is gone.`, "setup.config-lock", { details: { configPath, lockPath, waitMs, owner: lastOwner } });
|
|
91
|
+
}
|
|
92
|
+
await sleep(Math.min(retryMs, Math.max(1, deadline - Date.now())));
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
await options.testHooks?.afterPublish?.(lockPath);
|
|
96
|
+
await assertOwnedLock(lockPath, acquired, owner.token);
|
|
97
|
+
return await operation();
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
await releaseOwnedLock(lockPath, acquired, owner.token);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Publish complete owner metadata atomically. `open(lockPath, "wx")` is not
|
|
105
|
+
* sufficient: it exposes a zero-byte inode while metadata is being written,
|
|
106
|
+
* which a stale-lock observer can misclassify and remove. A private candidate
|
|
107
|
+
* is written and synced first; hard-link creation is the atomic create-if-absent
|
|
108
|
+
* operation that makes those complete bytes visible at the public lock path.
|
|
109
|
+
*/
|
|
110
|
+
async function createPrivateLockFile(lockPath, owner) {
|
|
111
|
+
const candidatePath = `${lockPath}.candidate.${owner.pid}.${owner.token}`;
|
|
112
|
+
const handle = await open(candidatePath, fsConstants.O_WRONLY |
|
|
113
|
+
fsConstants.O_CREAT |
|
|
114
|
+
fsConstants.O_EXCL |
|
|
115
|
+
(fsConstants.O_NOFOLLOW ?? 0), 0o600);
|
|
116
|
+
let candidateIdentity = "";
|
|
117
|
+
try {
|
|
118
|
+
if (process.platform !== "win32")
|
|
119
|
+
await handle.chmod(0o600);
|
|
120
|
+
await handle.writeFile(`${JSON.stringify(owner)}\n`, "utf8");
|
|
121
|
+
await handle.sync();
|
|
122
|
+
const stats = await handle.stat({ bigint: true });
|
|
123
|
+
candidateIdentity = lockIdentity(stats.dev, stats.ino);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await handle.close().catch(() => undefined);
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
await link(candidatePath, lockPath);
|
|
133
|
+
const published = await readLockSnapshot(lockPath);
|
|
134
|
+
if (published === null ||
|
|
135
|
+
published.identity !== candidateIdentity ||
|
|
136
|
+
published.owner?.token !== owner.token) {
|
|
137
|
+
throw lockOwnershipLost(lockPath, owner.token, published);
|
|
138
|
+
}
|
|
139
|
+
return published;
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
await unlink(candidatePath).catch(() => undefined);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function readLockSnapshot(lockPath) {
|
|
146
|
+
let before;
|
|
147
|
+
try {
|
|
148
|
+
before = await lstat(lockPath, { bigint: true });
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (error.code === "ENOENT")
|
|
152
|
+
return null;
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
if (!before.isFile())
|
|
156
|
+
throw unsafeLockPath(lockPath, describeFileType(before));
|
|
157
|
+
let handle;
|
|
158
|
+
try {
|
|
159
|
+
handle = await open(lockPath, fsConstants.O_RDONLY |
|
|
160
|
+
(fsConstants.O_NOFOLLOW ?? 0) |
|
|
161
|
+
(fsConstants.O_NONBLOCK ?? 0));
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error.code === "ENOENT")
|
|
165
|
+
return null;
|
|
166
|
+
if (error.code === "ELOOP") {
|
|
167
|
+
throw unsafeLockPath(lockPath, "symbolic link");
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const stats = await handle.stat({ bigint: true });
|
|
173
|
+
if (!stats.isFile())
|
|
174
|
+
throw unsafeLockPath(lockPath, describeFileType(stats));
|
|
175
|
+
// Always cap the actual read, even if size changes after fstat(). O_NONBLOCK
|
|
176
|
+
// above prevents a raced FIFO/device replacement from hanging setup.
|
|
177
|
+
const buffer = Buffer.alloc(KIMI_CONFIG_LOCK_METADATA_MAX_BYTES + 1);
|
|
178
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
179
|
+
const metadataTruncated = stats.size > BigInt(KIMI_CONFIG_LOCK_METADATA_MAX_BYTES) ||
|
|
180
|
+
bytesRead > KIMI_CONFIG_LOCK_METADATA_MAX_BYTES;
|
|
181
|
+
const raw = metadataTruncated
|
|
182
|
+
? ""
|
|
183
|
+
: buffer.subarray(0, bytesRead).toString("utf8");
|
|
184
|
+
return {
|
|
185
|
+
identity: lockIdentity(stats.dev, stats.ino),
|
|
186
|
+
owner: parseLockOwner(raw),
|
|
187
|
+
ageMs: Math.max(0, Date.now() - Number(stats.mtimeMs)),
|
|
188
|
+
metadataTruncated,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
await handle.close();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function lockIdentity(device, inode) {
|
|
196
|
+
return `${device.toString()}:${inode.toString()}`;
|
|
197
|
+
}
|
|
198
|
+
function describeFileType(stats) {
|
|
199
|
+
if (stats.isSymbolicLink())
|
|
200
|
+
return "symbolic link";
|
|
201
|
+
if (stats.isDirectory())
|
|
202
|
+
return "directory";
|
|
203
|
+
if (stats.isFIFO())
|
|
204
|
+
return "FIFO";
|
|
205
|
+
if (stats.isCharacterDevice())
|
|
206
|
+
return "character device";
|
|
207
|
+
if (stats.isBlockDevice())
|
|
208
|
+
return "block device";
|
|
209
|
+
if (stats.isSocket())
|
|
210
|
+
return "socket";
|
|
211
|
+
return "non-regular file";
|
|
212
|
+
}
|
|
213
|
+
function unsafeLockPath(lockPath, type) {
|
|
214
|
+
return new RuntimeError("SETUP_CONFIG_LOCK_UNSAFE", `Refusing setup lock ${lockPath}: the path is a ${type}, not a regular private lock file. Remove it manually after verifying its origin; setup will not follow or read it.`, "setup.config-lock", { details: { lockPath, type } });
|
|
215
|
+
}
|
|
216
|
+
function parseLockOwner(raw) {
|
|
217
|
+
try {
|
|
218
|
+
const value = JSON.parse(raw);
|
|
219
|
+
if (!Number.isInteger(value.pid) ||
|
|
220
|
+
(value.pid ?? 0) <= 0 ||
|
|
221
|
+
typeof value.token !== "string" ||
|
|
222
|
+
!/^[a-f0-9]{32}$/.test(value.token) ||
|
|
223
|
+
typeof value.createdAt !== "string") {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
return { pid: value.pid, token: value.token, createdAt: value.createdAt };
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function isStaleLock(snapshot, staleMs) {
|
|
233
|
+
if (snapshot.ageMs < staleMs)
|
|
234
|
+
return false;
|
|
235
|
+
return snapshot.owner === null || !isProcessAlive(snapshot.owner.pid);
|
|
236
|
+
}
|
|
237
|
+
function isProcessAlive(pid) {
|
|
238
|
+
try {
|
|
239
|
+
process.kill(pid, 0);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
return error.code !== "ESRCH";
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async function tryRecoverStaleLock(lockPath, observed, staleMs) {
|
|
247
|
+
const identityHash = createHash("sha256").update(observed.identity).digest("hex").slice(0, 16);
|
|
248
|
+
const recoveryPath = `${lockPath}.recover.${identityHash}`;
|
|
249
|
+
const recoveryOwner = {
|
|
250
|
+
pid: process.pid,
|
|
251
|
+
token: randomBytes(16).toString("hex"),
|
|
252
|
+
createdAt: new Date().toISOString(),
|
|
253
|
+
};
|
|
254
|
+
let recoverySnapshot;
|
|
255
|
+
try {
|
|
256
|
+
recoverySnapshot = await createPrivateLockFile(recoveryPath, recoveryOwner);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
if (error.code !== "EEXIST")
|
|
260
|
+
throw error;
|
|
261
|
+
const existingRecovery = await readLockSnapshot(recoveryPath);
|
|
262
|
+
if (existingRecovery !== null && isStaleLock(existingRecovery, staleMs)) {
|
|
263
|
+
// One bounded, non-recursive claim. A crashed recovery lease is renamed
|
|
264
|
+
// to a unique quarantine path, verified there, and removed only when its
|
|
265
|
+
// inode and owner token still match the snapshot we observed.
|
|
266
|
+
await removeStaleRecoveryLease(recoveryPath, existingRecovery);
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
let recovered = false;
|
|
271
|
+
try {
|
|
272
|
+
await assertOwnedLock(recoveryPath, recoverySnapshot, recoveryOwner.token);
|
|
273
|
+
const current = await readLockSnapshot(lockPath);
|
|
274
|
+
if (current !== null &&
|
|
275
|
+
current.identity === observed.identity &&
|
|
276
|
+
current.owner?.token === observed.owner?.token &&
|
|
277
|
+
isStaleLock(current, staleMs)) {
|
|
278
|
+
// The per-identity recovery lock above ensures only one observer of this
|
|
279
|
+
// inode can claim it. Rename is the atomic removal from the public path;
|
|
280
|
+
// verification happens on the claimed inode before its unique name is
|
|
281
|
+
// unlinked, eliminating the old check(path) -> unlink(path) ABA window.
|
|
282
|
+
const quarantinePath = `${lockPath}.stale.${identityHash}.${recoveryOwner.token}`;
|
|
283
|
+
try {
|
|
284
|
+
await rename(lockPath, quarantinePath);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (error.code === "ENOENT")
|
|
288
|
+
return false;
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
const claimed = await readLockSnapshot(quarantinePath);
|
|
292
|
+
if (!sameObservedLock(claimed, observed)) {
|
|
293
|
+
throw new RuntimeError("SETUP_CONFIG_LOCK_RECOVERY_RACE", `Setup lock ${lockPath} changed identity while stale recovery claimed it; the claimed file was preserved at ${quarantinePath} instead of being deleted.`, "setup.config-lock", { details: { lockPath, quarantinePath, observed: observed.identity, claimed: claimed?.identity } });
|
|
294
|
+
}
|
|
295
|
+
await unlink(quarantinePath);
|
|
296
|
+
recovered = true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
await releaseOwnedLock(recoveryPath, recoverySnapshot, recoveryOwner.token);
|
|
301
|
+
}
|
|
302
|
+
return recovered;
|
|
303
|
+
}
|
|
304
|
+
async function removeStaleRecoveryLease(recoveryPath, observed) {
|
|
305
|
+
const quarantinePath = `${recoveryPath}.stale.${process.pid}.${randomBytes(16).toString("hex")}`;
|
|
306
|
+
try {
|
|
307
|
+
await rename(recoveryPath, quarantinePath);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (error.code === "ENOENT")
|
|
311
|
+
return;
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
const claimed = await readLockSnapshot(quarantinePath);
|
|
315
|
+
if (!sameObservedLock(claimed, observed)) {
|
|
316
|
+
throw new RuntimeError("SETUP_CONFIG_LOCK_RECOVERY_RACE", `Recovery lease ${recoveryPath} changed identity while being claimed; the claimed file was preserved at ${quarantinePath} instead of being deleted.`, "setup.config-lock", {
|
|
317
|
+
details: {
|
|
318
|
+
recoveryPath,
|
|
319
|
+
quarantinePath,
|
|
320
|
+
observed: observed.identity,
|
|
321
|
+
claimed: claimed?.identity,
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
await unlink(quarantinePath);
|
|
326
|
+
}
|
|
327
|
+
function sameObservedLock(current, observed) {
|
|
328
|
+
return current !== null &&
|
|
329
|
+
current.identity === observed.identity &&
|
|
330
|
+
current.owner?.token === observed.owner?.token;
|
|
331
|
+
}
|
|
332
|
+
async function assertOwnedLock(lockPath, acquired, token) {
|
|
333
|
+
const current = await readLockSnapshot(lockPath);
|
|
334
|
+
if (current === null ||
|
|
335
|
+
current.identity !== acquired.identity ||
|
|
336
|
+
current.owner?.token !== token) {
|
|
337
|
+
throw lockOwnershipLost(lockPath, token, current);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function lockOwnershipLost(lockPath, expectedToken, current) {
|
|
341
|
+
return new RuntimeError("SETUP_CONFIG_LOCK_OWNERSHIP_LOST", `Setup lock ${lockPath} changed before the protected operation began; refusing to enter the config transaction.`, "setup.config-lock", {
|
|
342
|
+
details: {
|
|
343
|
+
lockPath,
|
|
344
|
+
expectedToken,
|
|
345
|
+
currentIdentity: current?.identity,
|
|
346
|
+
currentToken: current?.owner?.token,
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
async function releaseOwnedLock(lockPath, acquired, token) {
|
|
351
|
+
const current = await readLockSnapshot(lockPath);
|
|
352
|
+
if (current === null ||
|
|
353
|
+
current.identity !== acquired.identity ||
|
|
354
|
+
current.owner?.token !== token)
|
|
355
|
+
return;
|
|
356
|
+
const releasePath = `${lockPath}.release.${token}`;
|
|
357
|
+
try {
|
|
358
|
+
await rename(lockPath, releasePath);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
if (error.code === "ENOENT")
|
|
362
|
+
return;
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
const claimed = await readLockSnapshot(releasePath);
|
|
366
|
+
if (claimed === null ||
|
|
367
|
+
claimed.identity !== acquired.identity ||
|
|
368
|
+
claimed.owner?.token !== token) {
|
|
369
|
+
throw new RuntimeError("SETUP_CONFIG_LOCK_RELEASE_RACE", `Setup lock ${lockPath} changed identity during release; the claimed file was preserved at ${releasePath}.`, "setup.config-lock", { details: { lockPath, releasePath, expectedIdentity: acquired.identity, claimedIdentity: claimed?.identity } });
|
|
370
|
+
}
|
|
371
|
+
await unlink(releasePath);
|
|
372
|
+
}
|
|
373
|
+
function sleep(ms) {
|
|
374
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Convert a validated top-level inline `hooks = [...]` assignment to canonical
|
|
378
|
+
* `[[hooks]]` tables so setup can append its own array-table entry without
|
|
379
|
+
* redefining the TOML key. Bytes outside that one assignment are untouched;
|
|
380
|
+
* formatting and comments inside the assignment are intentionally normalized.
|
|
381
|
+
*/
|
|
382
|
+
export function normalizeTopLevelInlineHooks(contents, lineEnding) {
|
|
383
|
+
let config;
|
|
384
|
+
try {
|
|
385
|
+
config = parseToml(contents);
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return { contents, found: false, converted: false, entryCount: 0 };
|
|
389
|
+
}
|
|
390
|
+
const assignment = findTopLevelHooksAssignment(contents);
|
|
391
|
+
if (assignment === null) {
|
|
392
|
+
return { contents, found: false, converted: false, entryCount: 0 };
|
|
393
|
+
}
|
|
394
|
+
const hooks = config["hooks"];
|
|
395
|
+
if (!Array.isArray(hooks)) {
|
|
396
|
+
return { contents, found: true, converted: false, entryCount: 0 };
|
|
397
|
+
}
|
|
398
|
+
const rendered = [];
|
|
399
|
+
for (const entry of hooks) {
|
|
400
|
+
if (!isPlainRecord(entry)) {
|
|
401
|
+
return { contents, found: true, converted: false, entryCount: hooks.length };
|
|
402
|
+
}
|
|
403
|
+
const table = renderCanonicalHookTable(entry, lineEnding);
|
|
404
|
+
if (table === null) {
|
|
405
|
+
return { contents, found: true, converted: false, entryCount: hooks.length };
|
|
406
|
+
}
|
|
407
|
+
rendered.push(table);
|
|
408
|
+
}
|
|
409
|
+
const replacementBody = rendered.join(`${lineEnding}${lineEnding}`);
|
|
410
|
+
const replacement = replacementBody.length > 0 && assignment.hadLineEnding
|
|
411
|
+
? `${replacementBody}${lineEnding}`
|
|
412
|
+
: replacementBody;
|
|
413
|
+
return {
|
|
414
|
+
contents: `${contents.slice(0, assignment.start)}${replacement}${contents.slice(assignment.end)}`,
|
|
415
|
+
found: true,
|
|
416
|
+
converted: true,
|
|
417
|
+
entryCount: hooks.length,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
// Same defaults as smol-toml 1.9.1 parse(); this walker only locates assignments.
|
|
421
|
+
// tests/runtime/toml-walker-context.test.ts asserts these against the vendored parse().
|
|
422
|
+
export function assignmentParseContext(s) {
|
|
423
|
+
return { s, p: s.charCodeAt(0) === 0xfeff ? 1 : 0, d: 1_000, bi: false, ld: true, uk: 0 };
|
|
424
|
+
}
|
|
425
|
+
function findTopLevelHooksAssignment(contents) {
|
|
426
|
+
const ctx = assignmentParseContext(contents);
|
|
427
|
+
skipVoid(ctx);
|
|
428
|
+
while (ctx.p < contents.length) {
|
|
429
|
+
// A root-level direct assignment cannot resume after entering a table.
|
|
430
|
+
if (contents[ctx.p] === "[")
|
|
431
|
+
return null;
|
|
432
|
+
const declarationStart = ctx.p;
|
|
433
|
+
const key = parseKey(ctx);
|
|
434
|
+
skipVoid(ctx, true, true);
|
|
435
|
+
extractValue(ctx, undefined);
|
|
436
|
+
const valueEnd = ctx.p;
|
|
437
|
+
if (key.length === 1 && key[0] === "hooks") {
|
|
438
|
+
const lineStart = contents.lastIndexOf("\n", declarationStart - 1) + 1;
|
|
439
|
+
const start = /^[\t ]*$/.test(contents.slice(lineStart, declarationStart))
|
|
440
|
+
? lineStart
|
|
441
|
+
: declarationStart;
|
|
442
|
+
let end = valueEnd;
|
|
443
|
+
while (end < contents.length && contents[end] !== "\n" && contents[end] !== "\r")
|
|
444
|
+
end += 1;
|
|
445
|
+
let hadLineEnding = false;
|
|
446
|
+
if (contents[end] === "\r" && contents[end + 1] === "\n") {
|
|
447
|
+
end += 2;
|
|
448
|
+
hadLineEnding = true;
|
|
449
|
+
}
|
|
450
|
+
else if (contents[end] === "\r" || contents[end] === "\n") {
|
|
451
|
+
end += 1;
|
|
452
|
+
hadLineEnding = true;
|
|
453
|
+
}
|
|
454
|
+
return { start, end, hadLineEnding };
|
|
455
|
+
}
|
|
456
|
+
skipVoid(ctx);
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
function renderCanonicalHookTable(entry, lineEnding) {
|
|
461
|
+
const lines = ["[[hooks]]"];
|
|
462
|
+
for (const field of ["event", "matcher", "command", "timeout"]) {
|
|
463
|
+
if (!Object.hasOwn(entry, field))
|
|
464
|
+
continue;
|
|
465
|
+
const value = entry[field];
|
|
466
|
+
if (field === "timeout") {
|
|
467
|
+
if (typeof value !== "number" || !Number.isInteger(value))
|
|
468
|
+
return null;
|
|
469
|
+
lines.push(`${field} = ${value}`);
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
if (typeof value !== "string")
|
|
473
|
+
return null;
|
|
474
|
+
lines.push(`${field} = ${JSON.stringify(value)}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (Object.keys(entry).some((field) => !HOOK_FIELDS.has(field)))
|
|
478
|
+
return null;
|
|
479
|
+
return lines.join(lineEnding);
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Validate the complete configured hook array against kimi-code's strict
|
|
483
|
+
* HookDef schema. Upstream salvages a bad `hooks[n]` issue by dropping the
|
|
484
|
+
* entire `hooks` section, so one foreign invalid entry disables our otherwise
|
|
485
|
+
* canonical PreToolUse block too.
|
|
486
|
+
*/
|
|
487
|
+
export function validateKimiHookSet(contents, version) {
|
|
488
|
+
const inspection = inspectKimiHookSet(contents);
|
|
489
|
+
if (!inspection.validation.valid)
|
|
490
|
+
return inspection.validation;
|
|
491
|
+
return validateVersionSensitiveEvents(inspection.versionSensitiveEvents, version);
|
|
492
|
+
}
|
|
493
|
+
/** Probe `kimi --version` only when the config uses an additive hook event. */
|
|
494
|
+
export async function validateKimiHookSetForEnvironment(contents, env) {
|
|
495
|
+
const inspection = inspectKimiHookSet(contents);
|
|
496
|
+
if (!inspection.validation.valid || inspection.versionSensitiveEvents.length === 0) {
|
|
497
|
+
return inspection.validation;
|
|
498
|
+
}
|
|
499
|
+
const probe = await probeKimiVersion({
|
|
500
|
+
kimiBin: env.KIMI_PLUGIN_CC_KIMI_BIN || undefined,
|
|
501
|
+
env,
|
|
502
|
+
});
|
|
503
|
+
const first = inspection.versionSensitiveEvents[0];
|
|
504
|
+
if (probe.kind === "failed") {
|
|
505
|
+
return invalidHookSet(`cannot verify version-sensitive event ${JSON.stringify(first.event)} because kimi-code version detection failed (${probe.reason})`, first.entry, first.line);
|
|
506
|
+
}
|
|
507
|
+
return validateVersionSensitiveEvents(inspection.versionSensitiveEvents, probe);
|
|
508
|
+
}
|
|
509
|
+
function inspectKimiHookSet(contents) {
|
|
510
|
+
let config;
|
|
511
|
+
try {
|
|
512
|
+
config = parseToml(contents);
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
const line = tomlErrorLine(error);
|
|
516
|
+
return {
|
|
517
|
+
validation: invalidHookSet(`TOML parse failed: ${error instanceof Error ? error.message : String(error)}`, 0, line, "parse"),
|
|
518
|
+
versionSensitiveEvents: [],
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
const versionSensitiveEvents = [];
|
|
522
|
+
const hooks = config["hooks"];
|
|
523
|
+
if (hooks === undefined)
|
|
524
|
+
return { validation: { valid: true }, versionSensitiveEvents };
|
|
525
|
+
if (!Array.isArray(hooks)) {
|
|
526
|
+
return {
|
|
527
|
+
validation: invalidHookSet("top-level `hooks` must be an array of hook entries", 0, 1),
|
|
528
|
+
versionSensitiveEvents,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
const entryLines = findHookEntryLines(contents);
|
|
532
|
+
for (let offset = 0; offset < hooks.length; offset += 1) {
|
|
533
|
+
const entry = hooks[offset];
|
|
534
|
+
const index = offset + 1;
|
|
535
|
+
const line = entryLines[offset] ?? 1;
|
|
536
|
+
if (!isPlainRecord(entry)) {
|
|
537
|
+
return {
|
|
538
|
+
validation: invalidHookSet("hook entry must be a TOML table", index, line),
|
|
539
|
+
versionSensitiveEvents,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const result = validateHookEntry(entry, index, line, versionSensitiveEvents);
|
|
543
|
+
if (!result.valid)
|
|
544
|
+
return { validation: result, versionSensitiveEvents };
|
|
545
|
+
}
|
|
546
|
+
return { validation: { valid: true }, versionSensitiveEvents };
|
|
547
|
+
}
|
|
548
|
+
function validateHookEntry(entry, index, line, versionSensitiveEvents) {
|
|
549
|
+
for (const field of Object.keys(entry)) {
|
|
550
|
+
if (!HOOK_FIELDS.has(field)) {
|
|
551
|
+
return invalidHookSet(`unknown field ${JSON.stringify(field)} (allowed: event, matcher, command, timeout)`, index, line);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const event = entry["event"];
|
|
555
|
+
if (event === undefined)
|
|
556
|
+
return invalidHookSet("missing required field `event`", index, line);
|
|
557
|
+
if (typeof event !== "string")
|
|
558
|
+
return invalidHookSet("`event` must be a string", index, line);
|
|
559
|
+
const minimumMinor = VERSION_SENSITIVE_HOOK_EVENTS.get(event);
|
|
560
|
+
if (!BASE_HOOK_EVENTS.has(event) && minimumMinor === undefined) {
|
|
561
|
+
return invalidHookSet(`unsupported hook event ${JSON.stringify(event)}`, index, line);
|
|
562
|
+
}
|
|
563
|
+
if (minimumMinor !== undefined) {
|
|
564
|
+
versionSensitiveEvents.push({
|
|
565
|
+
event,
|
|
566
|
+
minimumMinor,
|
|
567
|
+
entry: index,
|
|
568
|
+
line,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
const command = entry["command"];
|
|
572
|
+
if (command === undefined)
|
|
573
|
+
return invalidHookSet("missing required field `command`", index, line);
|
|
574
|
+
if (typeof command !== "string" || command.length === 0) {
|
|
575
|
+
return invalidHookSet("`command` must be a non-empty string", index, line);
|
|
576
|
+
}
|
|
577
|
+
const matcher = entry["matcher"];
|
|
578
|
+
if (matcher !== undefined && typeof matcher !== "string") {
|
|
579
|
+
return invalidHookSet("`matcher` must be a string when present", index, line);
|
|
580
|
+
}
|
|
581
|
+
const timeout = entry["timeout"];
|
|
582
|
+
if (timeout !== undefined &&
|
|
583
|
+
(typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 1 || timeout > 600)) {
|
|
584
|
+
return invalidHookSet("`timeout` must be an integer from 1 through 600", index, line);
|
|
585
|
+
}
|
|
586
|
+
return { valid: true };
|
|
587
|
+
}
|
|
588
|
+
function validateVersionSensitiveEvents(events, version) {
|
|
589
|
+
if (events.length === 0)
|
|
590
|
+
return { valid: true };
|
|
591
|
+
const first = events[0];
|
|
592
|
+
if (version === undefined) {
|
|
593
|
+
return invalidHookSet(`version-sensitive event ${JSON.stringify(first.event)} requires a verified kimi-code version`, first.entry, first.line);
|
|
594
|
+
}
|
|
595
|
+
// Exact schemas reviewed at 2.0.0 (1b89e4b0), 2.0.1 (caf7d4e2),
|
|
596
|
+
// 2.0.2 (9d07f634), 2.1.0 (52437299), 2.1.1 (f67e6398), 2.2.0 (472192c3),
|
|
597
|
+
// 2.3.0 (b5e28fd6), 2.3.1 (684bae9e), 2.3.2 (b8dd3a13), 2.3.3 (ec495f0c),
|
|
598
|
+
// 2.4.0 (f26e4d91), and 2.4.1 (2c73f3f5): the additive events and HookDefSchema are unchanged.
|
|
599
|
+
// Require the full version string AND matching components; no inferred 2.x compatibility.
|
|
600
|
+
if (version.major === 2 && ((version.minor === 0 && ((version.version === "2.0.0" && version.patch === 0) ||
|
|
601
|
+
(version.version === "2.0.1" && version.patch === 1) ||
|
|
602
|
+
(version.version === "2.0.2" && version.patch === 2))) ||
|
|
603
|
+
(version.minor === 1 && ((version.version === "2.1.0" && version.patch === 0) ||
|
|
604
|
+
(version.version === "2.1.1" && version.patch === 1))) ||
|
|
605
|
+
(version.minor === 2 && ((version.version === "2.2.0" && version.patch === 0))) ||
|
|
606
|
+
(version.minor === 3 && ((version.version === "2.3.0" && version.patch === 0) ||
|
|
607
|
+
(version.version === "2.3.1" && version.patch === 1) ||
|
|
608
|
+
(version.version === "2.3.2" && version.patch === 2) ||
|
|
609
|
+
(version.version === "2.3.3" && version.patch === 3))) ||
|
|
610
|
+
(version.minor === 4 && ((version.version === "2.4.0" && version.patch === 0) ||
|
|
611
|
+
(version.version === "2.4.1" && version.patch === 1))))) {
|
|
612
|
+
return { valid: true };
|
|
613
|
+
}
|
|
614
|
+
if (version.major !== 0) {
|
|
615
|
+
return invalidHookSet(`version-sensitive event ${JSON.stringify(first.event)} has no verified schema contract for kimi-code ${version.major}.${version.minor}`, first.entry, first.line);
|
|
616
|
+
}
|
|
617
|
+
for (const event of events) {
|
|
618
|
+
if (version.minor < event.minimumMinor) {
|
|
619
|
+
return invalidHookSet(`event ${JSON.stringify(event.event)} requires kimi-code >= 0.${event.minimumMinor}, but the installed version is 0.${version.minor}`, event.entry, event.line);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return { valid: true };
|
|
623
|
+
}
|
|
624
|
+
function invalidHookSet(reason, entry, line, failureKind = "schema") {
|
|
625
|
+
const location = entry > 0 ? `hooks[${entry - 1}] at line ${line}` : `hooks configuration at line ${line}`;
|
|
626
|
+
const consequence = failureKind === "parse"
|
|
627
|
+
? "kimi-code cannot parse this config, so no configured PreToolUse hook will load."
|
|
628
|
+
: "kimi-code drops the entire hooks array when any entry fails its strict schema, so no configured PreToolUse hook will run.";
|
|
629
|
+
return {
|
|
630
|
+
valid: false,
|
|
631
|
+
reason: `${location} is invalid: ${reason}. ${consequence}`,
|
|
632
|
+
entry,
|
|
633
|
+
line,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function tomlErrorLine(error) {
|
|
637
|
+
if (typeof error !== "object" || error === null)
|
|
638
|
+
return 1;
|
|
639
|
+
const line = error.line;
|
|
640
|
+
return typeof line === "number" && Number.isInteger(line) && line > 0 ? line : 1;
|
|
641
|
+
}
|
|
642
|
+
function findHookEntryLines(contents) {
|
|
643
|
+
const lines = [];
|
|
644
|
+
const header = /^\s*\[\[\s*(?:hooks|"hooks"|'hooks')\s*\]\]\s*(?:#.*)?$/;
|
|
645
|
+
for (const [offset, line] of contents.split(/\r?\n/).entries()) {
|
|
646
|
+
if (header.test(line))
|
|
647
|
+
lines.push(offset + 1);
|
|
648
|
+
}
|
|
649
|
+
return lines;
|
|
650
|
+
}
|
|
651
|
+
function isPlainRecord(value) {
|
|
652
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
653
|
+
}
|