@openclaw/crabline 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +99 -12
  2. package/dist/src/bin/crabline.js +3 -1
  3. package/dist/src/bin/crabline.js.map +1 -1
  4. package/dist/src/cli/program.d.ts +5 -1
  5. package/dist/src/cli/program.js +264 -50
  6. package/dist/src/cli/program.js.map +1 -1
  7. package/dist/src/config/load.js +39 -3
  8. package/dist/src/config/load.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +20 -0
  10. package/dist/src/config/schema.js +163 -35
  11. package/dist/src/config/schema.js.map +1 -1
  12. package/dist/src/core/errors.d.ts +1 -0
  13. package/dist/src/core/errors.js +6 -3
  14. package/dist/src/core/errors.js.map +1 -1
  15. package/dist/src/core/http-path.d.ts +1 -0
  16. package/dist/src/core/http-path.js +14 -0
  17. package/dist/src/core/http-path.js.map +1 -0
  18. package/dist/src/core/matcher.d.ts +3 -1
  19. package/dist/src/core/matcher.js +58 -2
  20. package/dist/src/core/matcher.js.map +1 -1
  21. package/dist/src/core/nonces.d.ts +2 -0
  22. package/dist/src/core/nonces.js +11 -1
  23. package/dist/src/core/nonces.js.map +1 -1
  24. package/dist/src/core/reporters.d.ts +5 -0
  25. package/dist/src/core/reporters.js +26 -2
  26. package/dist/src/core/reporters.js.map +1 -1
  27. package/dist/src/core/run.d.ts +3 -1
  28. package/dist/src/core/run.js +178 -66
  29. package/dist/src/core/run.js.map +1 -1
  30. package/dist/src/matrix-ids.d.ts +5 -0
  31. package/dist/src/matrix-ids.js +83 -0
  32. package/dist/src/matrix-ids.js.map +1 -0
  33. package/dist/src/openclaw/artifact-generation.d.ts +12 -2
  34. package/dist/src/openclaw/artifact-generation.js +436 -15
  35. package/dist/src/openclaw/artifact-generation.js.map +1 -1
  36. package/dist/src/openclaw/bridges/matrix.js +21 -4
  37. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  38. package/dist/src/openclaw/bridges/mattermost.js +13 -8
  39. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  40. package/dist/src/openclaw/bridges/probe-response.d.ts +1 -0
  41. package/dist/src/openclaw/bridges/probe-response.js +5 -0
  42. package/dist/src/openclaw/bridges/probe-response.js.map +1 -0
  43. package/dist/src/openclaw/bridges/signal.js +15 -6
  44. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  45. package/dist/src/openclaw/bridges/slack.js +116 -23
  46. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  47. package/dist/src/openclaw/bridges/telegram.js +45 -19
  48. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  49. package/dist/src/openclaw/bridges/whatsapp.js +32 -16
  50. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  51. package/dist/src/openclaw/bridges/zalo.js +8 -3
  52. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  53. package/dist/src/openclaw/private-file.d.ts +40 -3
  54. package/dist/src/openclaw/private-file.js +2070 -95
  55. package/dist/src/openclaw/private-file.js.map +1 -1
  56. package/dist/src/openclaw/shared.d.ts +1 -0
  57. package/dist/src/openclaw/shared.js +48 -13
  58. package/dist/src/openclaw/shared.js.map +1 -1
  59. package/dist/src/openclaw/smoke-lock.d.ts +9 -0
  60. package/dist/src/openclaw/smoke-lock.js +696 -187
  61. package/dist/src/openclaw/smoke-lock.js.map +1 -1
  62. package/dist/src/openclaw.js +352 -29
  63. package/dist/src/openclaw.js.map +1 -1
  64. package/dist/src/platform/process-owned-lock.d.ts +11 -0
  65. package/dist/src/platform/process-owned-lock.js +1698 -0
  66. package/dist/src/platform/process-owned-lock.js.map +1 -0
  67. package/dist/src/platform/recorder-directory.d.ts +1 -0
  68. package/dist/src/platform/recorder-directory.js +14 -0
  69. package/dist/src/platform/recorder-directory.js.map +1 -0
  70. package/dist/src/platform/windows-acl.d.ts +18 -0
  71. package/dist/src/platform/windows-acl.js +1367 -0
  72. package/dist/src/platform/windows-acl.js.map +1 -0
  73. package/dist/src/platform/windows-lock-root.d.ts +13 -0
  74. package/dist/src/platform/windows-lock-root.js +98 -0
  75. package/dist/src/platform/windows-lock-root.js.map +1 -0
  76. package/dist/src/providers/builtin/discord.d.ts +4 -0
  77. package/dist/src/providers/builtin/discord.js +52 -9
  78. package/dist/src/providers/builtin/discord.js.map +1 -1
  79. package/dist/src/providers/builtin/external-webhook-auth.d.ts +1 -0
  80. package/dist/src/providers/builtin/external-webhook-auth.js +38 -1
  81. package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -1
  82. package/dist/src/providers/builtin/feishu.d.ts +1 -0
  83. package/dist/src/providers/builtin/feishu.js +96 -26
  84. package/dist/src/providers/builtin/feishu.js.map +1 -1
  85. package/dist/src/providers/builtin/googlechat.d.ts +1 -0
  86. package/dist/src/providers/builtin/googlechat.js +95 -26
  87. package/dist/src/providers/builtin/googlechat.js.map +1 -1
  88. package/dist/src/providers/builtin/imessage.js +60 -17
  89. package/dist/src/providers/builtin/imessage.js.map +1 -1
  90. package/dist/src/providers/builtin/loopback.js +88 -35
  91. package/dist/src/providers/builtin/loopback.js.map +1 -1
  92. package/dist/src/providers/builtin/matrix.d.ts +2 -2
  93. package/dist/src/providers/builtin/matrix.js +78 -21
  94. package/dist/src/providers/builtin/matrix.js.map +1 -1
  95. package/dist/src/providers/builtin/mattermost.d.ts +11 -7
  96. package/dist/src/providers/builtin/mattermost.js +138 -24
  97. package/dist/src/providers/builtin/mattermost.js.map +1 -1
  98. package/dist/src/providers/builtin/msteams.d.ts +1 -0
  99. package/dist/src/providers/builtin/msteams.js +118 -38
  100. package/dist/src/providers/builtin/msteams.js.map +1 -1
  101. package/dist/src/providers/builtin/native-local-mock.js +4 -4
  102. package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
  103. package/dist/src/providers/builtin/script.js +1057 -146
  104. package/dist/src/providers/builtin/script.js.map +1 -1
  105. package/dist/src/providers/builtin/slack.d.ts +8 -1
  106. package/dist/src/providers/builtin/slack.js +327 -24
  107. package/dist/src/providers/builtin/slack.js.map +1 -1
  108. package/dist/src/providers/builtin/telegram.d.ts +1 -1
  109. package/dist/src/providers/builtin/telegram.js +55 -14
  110. package/dist/src/providers/builtin/telegram.js.map +1 -1
  111. package/dist/src/providers/builtin/whatsapp.d.ts +4 -3
  112. package/dist/src/providers/builtin/whatsapp.js +157 -29
  113. package/dist/src/providers/builtin/whatsapp.js.map +1 -1
  114. package/dist/src/providers/builtin/zalo.d.ts +5 -2
  115. package/dist/src/providers/builtin/zalo.js +31 -7
  116. package/dist/src/providers/builtin/zalo.js.map +1 -1
  117. package/dist/src/providers/catalog.d.ts +1 -1
  118. package/dist/src/providers/catalog.js +1 -1
  119. package/dist/src/providers/catalog.js.map +1 -1
  120. package/dist/src/providers/local-mock.d.ts +4 -0
  121. package/dist/src/providers/local-mock.js +162 -35
  122. package/dist/src/providers/local-mock.js.map +1 -1
  123. package/dist/src/providers/native-ids.d.ts +2 -0
  124. package/dist/src/providers/native-ids.js +3 -0
  125. package/dist/src/providers/native-ids.js.map +1 -1
  126. package/dist/src/providers/recorder.d.ts +14 -3
  127. package/dist/src/providers/recorder.js +532 -42
  128. package/dist/src/providers/recorder.js.map +1 -1
  129. package/dist/src/providers/registry.d.ts +1 -0
  130. package/dist/src/providers/registry.js +65 -20
  131. package/dist/src/providers/registry.js.map +1 -1
  132. package/dist/src/providers/signed-jwt.d.ts +6 -1
  133. package/dist/src/providers/signed-jwt.js +241 -50
  134. package/dist/src/providers/signed-jwt.js.map +1 -1
  135. package/dist/src/providers/slack-ids.d.ts +1 -0
  136. package/dist/src/providers/slack-ids.js +5 -0
  137. package/dist/src/providers/slack-ids.js.map +1 -1
  138. package/dist/src/providers/target-normalizers.d.ts +9 -1
  139. package/dist/src/providers/target-normalizers.js +177 -58
  140. package/dist/src/providers/target-normalizers.js.map +1 -1
  141. package/dist/src/providers/webhook-server.d.ts +3 -0
  142. package/dist/src/providers/webhook-server.js +268 -47
  143. package/dist/src/providers/webhook-server.js.map +1 -1
  144. package/dist/src/servers/http.d.ts +14 -2
  145. package/dist/src/servers/http.js +229 -58
  146. package/dist/src/servers/http.js.map +1 -1
  147. package/dist/src/servers/matrix.d.ts +3 -0
  148. package/dist/src/servers/matrix.js +268 -110
  149. package/dist/src/servers/matrix.js.map +1 -1
  150. package/dist/src/servers/mattermost.d.ts +5 -0
  151. package/dist/src/servers/mattermost.js +389 -91
  152. package/dist/src/servers/mattermost.js.map +1 -1
  153. package/dist/src/servers/recorder.d.ts +11 -0
  154. package/dist/src/servers/recorder.js +1241 -22
  155. package/dist/src/servers/recorder.js.map +1 -1
  156. package/dist/src/servers/signal.d.ts +1 -0
  157. package/dist/src/servers/signal.js +279 -44
  158. package/dist/src/servers/signal.js.map +1 -1
  159. package/dist/src/servers/slack.d.ts +15 -0
  160. package/dist/src/servers/slack.js +259 -51
  161. package/dist/src/servers/slack.js.map +1 -1
  162. package/dist/src/servers/telegram-identity.d.ts +6 -0
  163. package/dist/src/servers/telegram-identity.js +27 -0
  164. package/dist/src/servers/telegram-identity.js.map +1 -0
  165. package/dist/src/servers/telegram.d.ts +17 -6
  166. package/dist/src/servers/telegram.js +1193 -118
  167. package/dist/src/servers/telegram.js.map +1 -1
  168. package/dist/src/servers/webhook-target.d.ts +12 -4
  169. package/dist/src/servers/webhook-target.js +226 -28
  170. package/dist/src/servers/webhook-target.js.map +1 -1
  171. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +60 -1
  172. package/dist/src/servers/whatsapp-baileys-websocket.js +782 -112
  173. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  174. package/dist/src/servers/whatsapp-jid.d.ts +1 -0
  175. package/dist/src/servers/whatsapp-jid.js +9 -5
  176. package/dist/src/servers/whatsapp-jid.js.map +1 -1
  177. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +1 -0
  178. package/dist/src/servers/whatsapp-wire/binary-node.js +37 -13
  179. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  180. package/dist/src/servers/whatsapp-wire/crypto.d.ts +3 -0
  181. package/dist/src/servers/whatsapp-wire/crypto.js +23 -8
  182. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  183. package/dist/src/servers/whatsapp-wire/handshake.d.ts +9 -3
  184. package/dist/src/servers/whatsapp-wire/handshake.js +173 -12
  185. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  186. package/dist/src/servers/whatsapp-wire/signal.js +3 -3
  187. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  188. package/dist/src/servers/whatsapp.d.ts +7 -0
  189. package/dist/src/servers/whatsapp.js +149 -24
  190. package/dist/src/servers/whatsapp.js.map +1 -1
  191. package/dist/src/servers/zalo.d.ts +1 -0
  192. package/dist/src/servers/zalo.js +193 -82
  193. package/dist/src/servers/zalo.js.map +1 -1
  194. package/docs/channel-setup.md +185 -43
  195. package/fixtures/examples/crabline.example.yaml +3 -2
  196. package/package.json +18 -10
  197. package/fixtures/examples/openclaw-bridge.yaml +0 -125
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { spawnSync } from "node:child_process";
3
- import { readFileSync } from "node:fs";
3
+ import fsSync, { readFileSync } from "node:fs";
4
4
  import fs, {} from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { performance } from "node:perf_hooks";
@@ -8,10 +8,12 @@ import { isCrablineServerChannel } from "../servers/index.js";
8
8
  import { resolveWindowsPowerShellPath, securePrivateDirectory } from "./private-file.js";
9
9
  import { OPENCLAW_CRABLINE_MANIFEST_PATH } from "./shared.js";
10
10
  const LOCK_OWNER_FILE = "owner.json";
11
- const LOCK_COMMIT_STAGE_FILE_PREFIX = "commit-stage";
12
11
  const LOCK_LEASE_FILE_PREFIX = "lease.";
12
+ const LOCK_COMMIT_STAGE_FILE_PREFIX = "commit-stage.";
13
+ const LOCK_STAGED_FILE_PREFIX = ".commit-file.";
13
14
  const LEGACY_RECOVERY_SUFFIX = ".recovering";
14
15
  const COMMIT_CLAIM_SUFFIX = ".commit";
16
+ const OWNED_CLAIM_SUFFIX = ".owned";
15
17
  const RELEASE_CLAIM_SUFFIX = ".release";
16
18
  const LOCK_LEASE_MS = 10 * 60 * 1000;
17
19
  const RELEASE_ATTEMPTS = 3;
@@ -26,58 +28,6 @@ export function processStartedAtMsFromTimeOrigin(timeOrigin) {
26
28
  }
27
29
  return Math.trunc(timeOrigin);
28
30
  }
29
- function parseCommitStagePath(contents, token) {
30
- let value;
31
- try {
32
- value = JSON.parse(contents);
33
- }
34
- catch (error) {
35
- throw new Error("OpenClaw Crabline smoke lock commit stage metadata is malformed.", {
36
- cause: error,
37
- });
38
- }
39
- const expectedPrefix = `.commit-file.${token}.`;
40
- if (value.token !== token ||
41
- typeof value.path !== "string" ||
42
- !path.isAbsolute(value.path) ||
43
- !path.basename(value.path).startsWith(expectedPrefix) ||
44
- !path.basename(value.path).endsWith(".tmp")) {
45
- throw new Error("OpenClaw Crabline smoke lock commit stage metadata is malformed.");
46
- }
47
- return value.path;
48
- }
49
- async function readCommitStagePath(lockDirectory, token) {
50
- try {
51
- return parseCommitStagePath(await fs.readFile(path.join(lockDirectory, commitStageFileName(token)), "utf8"), token);
52
- }
53
- catch (error) {
54
- if (isMissingPathError(error)) {
55
- return null;
56
- }
57
- throw error;
58
- }
59
- }
60
- function commitStageFileName(token) {
61
- return `${LOCK_COMMIT_STAGE_FILE_PREFIX}.${token}.json`;
62
- }
63
- async function writeCommitStagePath(lockDirectory, stagedFilePath, token) {
64
- const temporaryPath = path.join(lockDirectory, `.${commitStageFileName(token)}.${randomUUID()}.tmp`);
65
- try {
66
- await fs.writeFile(temporaryPath, `${JSON.stringify({ path: stagedFilePath, token })}\n`, {
67
- encoding: "utf8",
68
- flag: "wx",
69
- mode: 0o600,
70
- });
71
- await fs.chmod(temporaryPath, 0o600);
72
- await fs.rename(temporaryPath, path.join(lockDirectory, commitStageFileName(token)));
73
- }
74
- finally {
75
- await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
76
- }
77
- }
78
- const removeLockDirectory = async (lockDirectory) => {
79
- await fs.rm(lockDirectory, { force: true, recursive: true });
80
- };
81
31
  const sleep = async (delayMs) => {
82
32
  await new Promise((resolve) => setTimeout(resolve, delayMs));
83
33
  };
@@ -153,9 +103,98 @@ async function readDirectoryIdentity(directoryPath) {
153
103
  throw error;
154
104
  }
155
105
  }
106
+ async function readFileIdentity(filePath) {
107
+ try {
108
+ const stats = await fs.lstat(filePath, { bigint: true });
109
+ if (!stats.isFile() || stats.nlink !== 1n || stats.ino <= 0n) {
110
+ return null;
111
+ }
112
+ return {
113
+ device: stats.dev,
114
+ inode: stats.ino,
115
+ };
116
+ }
117
+ catch (error) {
118
+ if (isMissingPathError(error)) {
119
+ return null;
120
+ }
121
+ throw error;
122
+ }
123
+ }
156
124
  function hasSameDirectoryIdentity(left, right) {
157
125
  return (left !== null && right !== null && left.device === right.device && left.inode === right.inode);
158
126
  }
127
+ function isOwnedLockArtifactName(name, token) {
128
+ const leaseName = leaseFileName(token);
129
+ const commitStageName = `${LOCK_COMMIT_STAGE_FILE_PREFIX}${token}.json`;
130
+ return (name === LOCK_OWNER_FILE ||
131
+ name === leaseName ||
132
+ (name.startsWith(`.${leaseName}.`) && name.endsWith(".tmp")) ||
133
+ name === commitStageName ||
134
+ (name.startsWith(`.${LOCK_COMMIT_STAGE_FILE_PREFIX}${token}.`) && name.endsWith(".tmp")) ||
135
+ (name.startsWith(`${LOCK_STAGED_FILE_PREFIX}${token}.`) && name.endsWith(".tmp")));
136
+ }
137
+ async function removeVerifiedLockDirectory(params) {
138
+ if (!hasSameDirectoryIdentity(params.expectedIdentity, await readDirectoryIdentity(params.directoryPath))) {
139
+ throw new Error("OpenClaw Crabline smoke lock cleanup target changed.");
140
+ }
141
+ await params.beforeRemove?.();
142
+ const quarantinePath = `${params.directoryPath}.cleanup.${randomUUID()}`;
143
+ fsSync.renameSync(params.directoryPath, quarantinePath);
144
+ let quarantinedIdentity = null;
145
+ const stats = fsSync.lstatSync(quarantinePath, { bigint: true });
146
+ if (stats.isDirectory() && !stats.isSymbolicLink() && stats.ino > 0n) {
147
+ quarantinedIdentity = { device: stats.dev, inode: stats.ino };
148
+ }
149
+ if (!hasSameDirectoryIdentity(params.expectedIdentity, quarantinedIdentity)) {
150
+ throw new Error("OpenClaw Crabline smoke lock cleanup target changed.");
151
+ }
152
+ const entries = fsSync.readdirSync(quarantinePath);
153
+ const artifacts = [];
154
+ for (const entry of entries) {
155
+ if (!isOwnedLockArtifactName(entry, params.token)) {
156
+ throw new Error("OpenClaw Crabline smoke lock cleanup found an unexpected artifact.");
157
+ }
158
+ const artifactPath = path.join(quarantinePath, entry);
159
+ const artifactStats = fsSync.lstatSync(artifactPath, { bigint: true });
160
+ if (!artifactStats.isFile() ||
161
+ artifactStats.isSymbolicLink() ||
162
+ artifactStats.nlink !== 1n ||
163
+ artifactStats.ino <= 0n) {
164
+ throw new Error("OpenClaw Crabline smoke lock cleanup found an unsafe artifact.");
165
+ }
166
+ artifacts.push({
167
+ identity: { device: artifactStats.dev, inode: artifactStats.ino },
168
+ path: artifactPath,
169
+ });
170
+ }
171
+ artifacts.sort((left, right) => Number(path.basename(left.path) === LOCK_OWNER_FILE) -
172
+ Number(path.basename(right.path) === LOCK_OWNER_FILE));
173
+ for (const artifact of artifacts) {
174
+ const directoryStats = fsSync.lstatSync(quarantinePath, { bigint: true });
175
+ const artifactStats = fsSync.lstatSync(artifact.path, { bigint: true });
176
+ if (!directoryStats.isDirectory() ||
177
+ directoryStats.isSymbolicLink() ||
178
+ directoryStats.dev !== params.expectedIdentity.device ||
179
+ directoryStats.ino !== params.expectedIdentity.inode ||
180
+ !artifactStats.isFile() ||
181
+ artifactStats.isSymbolicLink() ||
182
+ artifactStats.nlink !== 1n ||
183
+ artifactStats.dev !== artifact.identity.device ||
184
+ artifactStats.ino !== artifact.identity.inode) {
185
+ throw new Error("OpenClaw Crabline smoke lock cleanup artifact changed.");
186
+ }
187
+ fsSync.unlinkSync(artifact.path);
188
+ }
189
+ const finalStats = fsSync.lstatSync(quarantinePath, { bigint: true });
190
+ if (!finalStats.isDirectory() ||
191
+ finalStats.isSymbolicLink() ||
192
+ finalStats.dev !== params.expectedIdentity.device ||
193
+ finalStats.ino !== params.expectedIdentity.inode) {
194
+ throw new Error("OpenClaw Crabline smoke lock cleanup target changed.");
195
+ }
196
+ fsSync.rmdirSync(quarantinePath);
197
+ }
159
198
  function isPositiveSafeInteger(value) {
160
199
  return Number.isSafeInteger(value) && Number(value) > 0;
161
200
  }
@@ -164,15 +203,19 @@ function isValidProcessId(value) {
164
203
  }
165
204
  const LOCK_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
166
205
  function parseLockOwner(contents) {
167
- let owner;
206
+ let parsed;
168
207
  try {
169
- owner = JSON.parse(contents);
208
+ parsed = JSON.parse(contents);
170
209
  }
171
210
  catch (error) {
172
211
  throw new Error("OpenClaw Crabline smoke lock owner metadata is malformed.", {
173
212
  cause: error,
174
213
  });
175
214
  }
215
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
216
+ throw new Error("OpenClaw Crabline smoke lock owner metadata is malformed.");
217
+ }
218
+ const owner = parsed;
176
219
  if (typeof owner.channel !== "string" ||
177
220
  !isCrablineServerChannel(owner.channel) ||
178
221
  !isValidProcessId(owner.pid) ||
@@ -194,7 +237,11 @@ function parseLockOwner(contents) {
194
237
  (owner.processIdentity !== undefined &&
195
238
  (typeof owner.processIdentity !== "string" ||
196
239
  owner.processIdentity.length === 0 ||
197
- owner.processIdentity.length > 256))) {
240
+ owner.processIdentity.length > 256)) ||
241
+ (owner.processIdentityV2 !== undefined &&
242
+ (typeof owner.processIdentityV2 !== "string" ||
243
+ owner.processIdentityV2.length === 0 ||
244
+ owner.processIdentityV2.length > 256))) {
198
245
  throw new Error("OpenClaw Crabline smoke lock owner metadata is malformed.");
199
246
  }
200
247
  if (owner.leaseVersion === undefined) {
@@ -203,6 +250,7 @@ function parseLockOwner(contents) {
203
250
  createdAtMs: owner.createdAtMs,
204
251
  pid: owner.pid,
205
252
  ...(owner.processIdentity ? { processIdentity: owner.processIdentity } : {}),
253
+ ...(owner.processIdentityV2 ? { processIdentityV2: owner.processIdentityV2 } : {}),
206
254
  processStartedAtMs: owner.processStartedAtMs,
207
255
  token: owner.token,
208
256
  };
@@ -216,6 +264,7 @@ function parseLockOwner(contents) {
216
264
  leaseVersion: owner.leaseVersion,
217
265
  pid: owner.pid,
218
266
  ...(owner.processIdentity ? { processIdentity: owner.processIdentity } : {}),
267
+ ...(owner.processIdentityV2 ? { processIdentityV2: owner.processIdentityV2 } : {}),
219
268
  processStartedAtMs: owner.processStartedAtMs,
220
269
  token: owner.token,
221
270
  };
@@ -303,6 +352,17 @@ export function processIdentityFromLinuxStat(value, bootId) {
303
352
  }
304
353
  export function processIdentityFromDarwin(processStartedAt, bootTime) {
305
354
  const bootMatch = /\bsec = (\d+), usec = (\d+)\b/u.exec(bootTime);
355
+ const launchMatch = /^Launch Time:\s*(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})\.(\d{3,6}) ([+-])(\d{2})(\d{2})$/mu.exec(processStartedAt);
356
+ if (bootMatch && launchMatch) {
357
+ const [, year, month, day, hour, minute, second, fraction, sign, offsetHour, offsetMinute] = launchMatch;
358
+ const offsetMs = (Number(offsetHour) * 60 + Number(offsetMinute)) * 60_000 * (sign === "+" ? 1 : -1);
359
+ const fractionMicros = Number(fraction.padEnd(6, "0"));
360
+ const utcMilliseconds = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second), Math.floor(fractionMicros / 1_000)) - offsetMs;
361
+ if (Number.isSafeInteger(utcMilliseconds) && utcMilliseconds > 0) {
362
+ const startedAtMicros = BigInt(utcMilliseconds) * 1000n + BigInt(fractionMicros % 1_000);
363
+ return `darwin:${bootMatch[1]}.${bootMatch[2]}:us:${startedAtMicros}`;
364
+ }
365
+ }
306
366
  const normalizedStartedAt = processStartedAt.trim().replace(/\s+/gu, " ");
307
367
  if (!bootMatch || normalizedStartedAt.length === 0 || normalizedStartedAt.length > 64) {
308
368
  return null;
@@ -325,11 +385,15 @@ const getProcessIdentity = (pid) => {
325
385
  const options = {
326
386
  encoding: "utf8",
327
387
  env: darwinProcessIdentityEnvironment(process.env),
388
+ maxBuffer: 512 * 1024,
328
389
  timeout: 1_000,
329
390
  };
330
- const startedAt = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], options);
331
- const bootTime = spawnSync("sysctl", ["-n", "kern.boottime"], options);
332
- return startedAt.status === 0 && bootTime.status === 0
391
+ const bootTime = spawnSync("/usr/sbin/sysctl", ["-n", "kern.boottime"], options);
392
+ if (bootTime.status !== 0) {
393
+ return null;
394
+ }
395
+ const startedAt = spawnSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)], options);
396
+ return startedAt.status === 0
333
397
  ? processIdentityFromDarwin(startedAt.stdout, bootTime.stdout)
334
398
  : null;
335
399
  }
@@ -352,32 +416,85 @@ const getProcessIdentity = (pid) => {
352
416
  const ticks = result.status === 0 ? result.stdout.trim() : "";
353
417
  return /^\d+$/u.test(ticks) ? `windows:${ticks}` : null;
354
418
  };
419
+ const getProcessIdentityV2 = (pid) => {
420
+ if (process.platform !== "darwin") {
421
+ return null;
422
+ }
423
+ const options = {
424
+ encoding: "utf8",
425
+ env: darwinProcessIdentityEnvironment(process.env),
426
+ maxBuffer: 512 * 1024,
427
+ timeout: 3_000,
428
+ };
429
+ const bootTime = spawnSync("/usr/sbin/sysctl", ["-n", "kern.boottime"], options);
430
+ const processDetails = spawnSync("/usr/bin/vmmap", ["-summary", String(pid)], options);
431
+ if (bootTime.status !== 0 || processDetails.status !== 0) {
432
+ return null;
433
+ }
434
+ const identity = processIdentityFromDarwin(processDetails.stdout, bootTime.stdout);
435
+ return identity?.includes(":us:") ? identity : null;
436
+ };
437
+ let cachedCurrentProcessIdentity = null;
438
+ let cachedCurrentProcessIdentityV2 = null;
439
+ function getCachedCurrentProcessIdentity() {
440
+ return (cachedCurrentProcessIdentity ??= getProcessIdentity(process.pid));
441
+ }
442
+ function getCachedCurrentProcessIdentityV2() {
443
+ return (cachedCurrentProcessIdentityV2 ??= getProcessIdentityV2(process.pid));
444
+ }
355
445
  function hasExactProcessIdentity(owner) {
356
446
  return hasProcessIdentity(owner) && typeof owner.processIdentity === "string";
357
447
  }
358
- function hasProcessIdentityMismatch(owner, runtime) {
448
+ function hasCoarseDarwinProcessIdentity(owner) {
449
+ if (!hasExactProcessIdentity(owner)) {
450
+ return false;
451
+ }
452
+ return /^darwin:\d+\.\d+:(?!us:).+$/u.test(owner.processIdentity);
453
+ }
454
+ function compareProcessIdentity(owner, runtime) {
359
455
  if (hasProcessIdentity(owner) &&
360
456
  owner.pid === runtime.currentPid &&
361
457
  owner.processStartedAtMs !== runtime.currentProcessStartedAtMs) {
362
- return true;
458
+ return "mismatch";
363
459
  }
364
460
  if (!hasExactProcessIdentity(owner)) {
365
- return false;
461
+ return "unknown";
462
+ }
463
+ if (hasProcessIdentity(owner) && typeof owner.processIdentityV2 === "string") {
464
+ const actualProcessIdentityV2 = owner.pid === runtime.currentPid
465
+ ? runtime.currentProcessIdentityV2
466
+ : runtime.getProcessIdentityV2(owner.pid);
467
+ if (actualProcessIdentityV2 !== null) {
468
+ return owner.processIdentityV2 === actualProcessIdentityV2 ? "v2" : "mismatch";
469
+ }
366
470
  }
367
471
  const actualProcessIdentity = owner.pid === runtime.currentPid
368
472
  ? runtime.currentProcessIdentity
369
473
  : runtime.getProcessIdentity(owner.pid);
370
- return actualProcessIdentity !== null && owner.processIdentity !== actualProcessIdentity;
474
+ if (actualProcessIdentity === null) {
475
+ return "unknown";
476
+ }
477
+ return owner.processIdentity === actualProcessIdentity ? "legacy" : "mismatch";
478
+ }
479
+ function hasProcessIdentityMismatch(owner, runtime) {
480
+ return compareProcessIdentity(owner, runtime) === "mismatch";
371
481
  }
372
482
  function isLockOwnerActive(record, runtime) {
373
483
  const { owner } = record;
374
484
  if (!runtime.isProcessAlive(owner.pid)) {
375
485
  return false;
376
486
  }
377
- if (hasProcessIdentityMismatch(owner, runtime)) {
487
+ const identityMatch = compareProcessIdentity(owner, runtime);
488
+ if (identityMatch === "mismatch") {
378
489
  return false;
379
490
  }
380
491
  if (!isRenewableOwner(owner)) {
492
+ if (owner.pid !== runtime.currentPid &&
493
+ hasCoarseDarwinProcessIdentity(owner) &&
494
+ identityMatch !== "v2") {
495
+ const ageMs = runtime.now() - Math.max(owner.createdAtMs, record.renewedAtMs);
496
+ return ageMs >= -runtime.leaseMs && ageMs <= runtime.leaseMs;
497
+ }
381
498
  return true;
382
499
  }
383
500
  const ageMs = runtime.now() - Math.max(owner.createdAtMs, record.renewedAtMs);
@@ -389,21 +506,56 @@ function needsLiveOwnerConfirmation(record, runtime) {
389
506
  !hasProcessIdentityMismatch(record.owner, runtime) &&
390
507
  !isLockOwnerActive(record, runtime));
391
508
  }
392
- async function observedOwnerChangedDuringConfirmation(lockDirectory, observed, runtime) {
509
+ function heartbeatIntervalMs(leaseMs) {
510
+ return Math.max(1, Math.floor(leaseMs / 3));
511
+ }
512
+ async function confirmObservedOwner(lockDirectory, observed, runtime) {
393
513
  if (!needsLiveOwnerConfirmation(observed, runtime)) {
394
- return false;
514
+ return "unchanged";
395
515
  }
396
- await runtime.sleep(runtime.leaseMs);
516
+ await runtime.sleep(heartbeatIntervalMs(runtime.leaseMs));
397
517
  const revalidated = await readLockRecord(lockDirectory);
398
- return (revalidated.kind === "record" &&
399
- (revalidated.record.owner.token !== observed.owner.token ||
400
- revalidated.record.renewedAtMs !== observed.renewedAtMs ||
401
- isLockOwnerActive(revalidated.record, runtime)));
518
+ if (revalidated.kind !== "record") {
519
+ return "unchanged";
520
+ }
521
+ if (revalidated.record.owner.token !== observed.owner.token) {
522
+ return "changed";
523
+ }
524
+ return revalidated.record.renewedAtMs !== observed.renewedAtMs ||
525
+ isLockOwnerActive(revalidated.record, runtime)
526
+ ? "renewed"
527
+ : "unchanged";
402
528
  }
403
529
  function activeRunError(params) {
404
530
  return new Error(`OpenClaw Crabline smoke is already running for channel "${params.channel ?? "unknown"}" in "${params.outputDir}"; cannot start channel "${params.requestedChannel}".`, params.cause === undefined ? undefined : { cause: params.cause });
405
531
  }
532
+ function isPathConfinedTo(rootPath, candidatePath) {
533
+ const relative = path.relative(rootPath, candidatePath);
534
+ return (relative === "" ||
535
+ (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)));
536
+ }
537
+ async function resolveConfinedPath(rootPath, candidatePath, description, candidateKind) {
538
+ const resolvedPath = path.resolve(candidatePath);
539
+ if (resolvedPath !== candidatePath) {
540
+ throw new Error(`OpenClaw Crabline smoke lock ${description} escapes its output directory.`);
541
+ }
542
+ const parentPath = candidateKind === "directory" ? resolvedPath : path.dirname(resolvedPath);
543
+ let realParentPath;
544
+ try {
545
+ realParentPath = await fs.realpath(parentPath);
546
+ }
547
+ catch {
548
+ throw new Error(`OpenClaw Crabline smoke lock ${description} is invalid.`);
549
+ }
550
+ if (!isPathConfinedTo(rootPath, realParentPath)) {
551
+ throw new Error(`OpenClaw Crabline smoke lock ${description} escapes its output directory.`);
552
+ }
553
+ return candidateKind === "directory"
554
+ ? realParentPath
555
+ : path.join(realParentPath, path.basename(resolvedPath));
556
+ }
406
557
  async function removeOwnedLock(params) {
558
+ await params.beforeClaim?.();
407
559
  const observed = await readLockRecord(params.ownedDirectory);
408
560
  if (observed.kind === "missing" || observed.record.owner.token !== params.token) {
409
561
  return false;
@@ -412,7 +564,15 @@ async function removeOwnedLock(params) {
412
564
  if (observedIdentity === null) {
413
565
  return false;
414
566
  }
415
- await params.beforeClaim?.();
567
+ const revalidated = await readLockRecord(params.ownedDirectory);
568
+ const revalidatedIdentity = await readDirectoryIdentity(params.ownedDirectory);
569
+ if (revalidated.kind === "missing" ||
570
+ revalidated.record.owner.token !== params.token ||
571
+ !hasSameDirectoryIdentity(observedIdentity, revalidatedIdentity) ||
572
+ (params.runtime !== undefined && isLockOwnerActive(revalidated.record, params.runtime))) {
573
+ return false;
574
+ }
575
+ await params.beforeRename?.();
416
576
  const releaseClaim = `${params.lockDirectory}${RELEASE_CLAIM_SUFFIX}.${params.token}.${randomUUID()}`;
417
577
  try {
418
578
  await fs.rename(params.ownedDirectory, releaseClaim);
@@ -443,22 +603,62 @@ async function removeOwnedLock(params) {
443
603
  }
444
604
  return false;
445
605
  }
446
- const stagedFilePath = await readCommitStagePath(releaseClaim, params.token);
447
- if (stagedFilePath) {
448
- const revokedStagePath = path.join(path.dirname(stagedFilePath), `.revoked-commit-file.${params.token}.${randomUUID()}.tmp`);
449
- try {
450
- await fs.rename(stagedFilePath, revokedStagePath);
451
- await fs.rm(revokedStagePath, { force: true });
452
- }
453
- catch (error) {
454
- if (!isMissingPathError(error)) {
455
- throw error;
456
- }
457
- }
606
+ if (params.removeDirectory) {
607
+ await params.removeDirectory(releaseClaim);
608
+ }
609
+ else {
610
+ await removeVerifiedLockDirectory({
611
+ ...(params.beforeRemove ? { beforeRemove: params.beforeRemove } : {}),
612
+ directoryPath: releaseClaim,
613
+ expectedIdentity: observedIdentity,
614
+ token: params.token,
615
+ });
458
616
  }
459
- await (params.removeDirectory ?? removeLockDirectory)(releaseClaim);
460
617
  return true;
461
618
  }
619
+ async function renewCompatibilityMarker(params) {
620
+ await params.securedDirectory.assertIdentityAt(params.lockDirectory);
621
+ const observed = await readLockRecord(params.lockDirectory);
622
+ if (observed.kind === "missing" || observed.record.owner.token !== params.token) {
623
+ return false;
624
+ }
625
+ const renewedAt = new Date(params.renewedAtMs);
626
+ await params.handle.utimes(renewedAt, renewedAt);
627
+ await params.handle.sync();
628
+ await params.securedDirectory.assertIdentityAt(params.lockDirectory);
629
+ const revalidated = await readLockRecord(params.lockDirectory);
630
+ return revalidated.kind === "record" && revalidated.record.owner.token === params.token;
631
+ }
632
+ async function assertCompatibilityMarkerOwned(params) {
633
+ await params.securedDirectory.assertIdentityAt(params.lockDirectory);
634
+ const observed = await readLockRecord(params.lockDirectory);
635
+ if (observed.kind === "missing" || observed.record.owner.token !== params.token) {
636
+ throw new Error("OpenClaw Crabline smoke lock compatibility marker was lost.");
637
+ }
638
+ }
639
+ async function retireCompatibilityMarker(handle, owner) {
640
+ const retiredOwner = {
641
+ ...owner,
642
+ createdAtMs: 1,
643
+ pid: MAX_PROCESS_ID,
644
+ ...(owner.processIdentity ? { processIdentity: "retired" } : {}),
645
+ ...(owner.processIdentityV2 ? { processIdentityV2: "retired" } : {}),
646
+ processStartedAtMs: 1,
647
+ };
648
+ const originalBytes = Buffer.byteLength(`${JSON.stringify(owner)}\n`, "utf8");
649
+ const retiredJson = JSON.stringify(retiredOwner);
650
+ const retiredBytes = Buffer.byteLength(retiredJson, "utf8") + 1;
651
+ const targetBytes = Math.max(originalBytes, retiredBytes);
652
+ const paddingBytes = targetBytes - retiredBytes;
653
+ const retiredContents = `${retiredJson}${" ".repeat(paddingBytes)}\n`;
654
+ const written = await handle.write(retiredContents, 0, "utf8");
655
+ if (written.bytesWritten !== targetBytes) {
656
+ throw new Error("OpenClaw Crabline smoke lock compatibility marker retirement was incomplete.");
657
+ }
658
+ const retiredAt = new Date(1);
659
+ await handle.utimes(retiredAt, retiredAt);
660
+ await handle.sync();
661
+ }
462
662
  async function renewOwnedLock(lockDirectory, token, renewedAtMs) {
463
663
  const temporaryLeasePath = path.join(lockDirectory, `.${leaseFileName(token)}.${randomUUID()}.tmp`);
464
664
  try {
@@ -503,6 +703,7 @@ async function listLockClaims(lockDirectory) {
503
703
  const prefixes = [
504
704
  { kind: "recovering", prefix: claimPrefix(lockDirectory, LEGACY_RECOVERY_SUFFIX) },
505
705
  { kind: "commit", prefix: claimPrefix(lockDirectory, COMMIT_CLAIM_SUFFIX) },
706
+ { kind: "owned", prefix: claimPrefix(lockDirectory, OWNED_CLAIM_SUFFIX) },
506
707
  { kind: "release", prefix: claimPrefix(lockDirectory, RELEASE_CLAIM_SUFFIX) },
507
708
  ];
508
709
  const entries = await fs.readdir(directory, { withFileTypes: true });
@@ -581,7 +782,25 @@ async function resolveLockClaim(params) {
581
782
  requestedChannel: params.requestedChannel,
582
783
  });
583
784
  }
584
- if (await observedOwnerChangedDuringConfirmation(params.claim.directoryPath, observed.record, params.runtime)) {
785
+ const confirmation = await confirmObservedOwner(params.claim.directoryPath, observed.record, params.runtime);
786
+ if (confirmation === "renewed") {
787
+ if (params.claim.kind === "recovering") {
788
+ try {
789
+ await fs.rename(params.claim.directoryPath, params.lockDirectory);
790
+ }
791
+ catch (error) {
792
+ if (!(await pathExists(params.lockDirectory))) {
793
+ throw error;
794
+ }
795
+ }
796
+ }
797
+ throw activeRunError({
798
+ channel: observed.record.owner.channel,
799
+ outputDir: params.outputDir,
800
+ requestedChannel: params.requestedChannel,
801
+ });
802
+ }
803
+ if (confirmation === "changed") {
585
804
  await resolveLockClaim(params);
586
805
  return;
587
806
  }
@@ -624,10 +843,19 @@ async function resolveLockContention(params) {
624
843
  requestedChannel: params.requestedChannel,
625
844
  });
626
845
  }
627
- if (observed.kind === "record" &&
628
- (await observedOwnerChangedDuringConfirmation(params.lockDirectory, observed.record, params.runtime))) {
629
- await resolveLockContention(params);
630
- return;
846
+ if (observed.kind === "record") {
847
+ const confirmation = await confirmObservedOwner(params.lockDirectory, observed.record, params.runtime);
848
+ if (confirmation === "renewed") {
849
+ throw activeRunError({
850
+ channel: observed.record.owner.channel,
851
+ outputDir: params.outputDir,
852
+ requestedChannel: params.requestedChannel,
853
+ });
854
+ }
855
+ if (confirmation === "changed") {
856
+ await resolveLockContention(params);
857
+ return;
858
+ }
631
859
  }
632
860
  await params.beforeRecoveryClaim();
633
861
  const claimDirectory = `${params.lockDirectory}${LEGACY_RECOVERY_SUFFIX}.${randomUUID()}`;
@@ -650,7 +878,8 @@ async function resolveLockContention(params) {
650
878
  });
651
879
  }
652
880
  async function createLockCandidate(params) {
653
- const candidateDirectory = `${params.lockDirectory}.${params.owner.pid}.${params.owner.token}.tmp`;
881
+ const candidateDirectory = params.candidateDirectory ??
882
+ `${params.lockDirectory}.${params.owner.pid}.${params.owner.token}.tmp`;
654
883
  await fs.mkdir(candidateDirectory, { mode: 0o700 });
655
884
  try {
656
885
  const securedDirectory = await securePrivateDirectory(candidateDirectory, {
@@ -671,14 +900,16 @@ async function createLockCandidate(params) {
671
900
  await fs.utimes(ownerPath, createdAt, createdAt);
672
901
  await params.afterOwnerWrite?.(candidateDirectory);
673
902
  await securedDirectory.assertIdentityAt();
674
- const leasePath = path.join(candidateDirectory, leaseFileName(params.owner.token));
675
- await fs.writeFile(leasePath, `${JSON.stringify({ renewedAtMs: params.owner.createdAtMs, token: params.owner.token })}\n`, {
676
- encoding: "utf8",
677
- flag: "wx",
678
- mode: 0o600,
679
- });
680
- await fs.chmod(leasePath, 0o600);
681
- await fs.utimes(leasePath, createdAt, createdAt);
903
+ if (params.includeLease !== false) {
904
+ const leasePath = path.join(candidateDirectory, leaseFileName(params.owner.token));
905
+ await fs.writeFile(leasePath, `${JSON.stringify({ renewedAtMs: params.owner.createdAtMs, token: params.owner.token })}\n`, {
906
+ encoding: "utf8",
907
+ flag: "wx",
908
+ mode: 0o600,
909
+ });
910
+ await fs.chmod(leasePath, 0o600);
911
+ await fs.utimes(leasePath, createdAt, createdAt);
912
+ }
682
913
  await securedDirectory.assertIdentityAt();
683
914
  return { candidateDirectory, securedDirectory };
684
915
  }
@@ -688,18 +919,39 @@ async function createLockCandidate(params) {
688
919
  }
689
920
  }
690
921
  export async function acquireOpenClawCrablineSmokeRunLock(params, dependencies = {}) {
691
- const outputDir = path.resolve(params.outputDir);
922
+ const requestedOutputDir = path.resolve(params.outputDir);
923
+ await fs.mkdir(requestedOutputDir, { recursive: true });
924
+ const outputDir = await fs.realpath(requestedOutputDir);
925
+ const privateDirectoryOptions = {
926
+ ...(dependencies.platform ? { platform: dependencies.platform } : {}),
927
+ ...(dependencies.secureWindowsDirectory
928
+ ? { secureWindowsDirectory: dependencies.secureWindowsDirectory }
929
+ : {}),
930
+ };
931
+ const securedOutputDirectory = await securePrivateDirectory(outputDir, privateDirectoryOptions);
932
+ await securedOutputDirectory.assertIdentityAt(outputDir);
933
+ const outputDirectoryIdentity = await readDirectoryIdentity(outputDir);
934
+ if (outputDirectoryIdentity === null) {
935
+ throw new Error("OpenClaw Crabline smoke lock output directory is invalid.");
936
+ }
692
937
  const lockDirectory = path.join(outputDir, `.${OPENCLAW_CRABLINE_MANIFEST_PATH}.lock`);
693
938
  const token = randomUUID();
694
939
  const currentPid = dependencies.pid ?? process.pid;
695
- const getRuntimeProcessIdentity = dependencies.getProcessIdentity ?? getProcessIdentity;
940
+ const getRuntimeProcessIdentity = dependencies.getProcessIdentity ??
941
+ ((pid) => (pid === process.pid ? getCachedCurrentProcessIdentity() : getProcessIdentity(pid)));
942
+ const getRuntimeProcessIdentityV2 = dependencies.getProcessIdentityV2 ??
943
+ ((pid) => pid === process.pid ? getCachedCurrentProcessIdentityV2() : getProcessIdentityV2(pid));
696
944
  const runtime = {
697
945
  currentPid,
698
946
  currentProcessIdentity: dependencies.processIdentity === undefined
699
947
  ? getRuntimeProcessIdentity(currentPid)
700
948
  : dependencies.processIdentity,
949
+ currentProcessIdentityV2: dependencies.processIdentityV2 === undefined
950
+ ? getRuntimeProcessIdentityV2(currentPid)
951
+ : dependencies.processIdentityV2,
701
952
  currentProcessStartedAtMs: dependencies.processStartedAtMs ?? CURRENT_PROCESS_STARTED_AT_MS,
702
953
  getProcessIdentity: getRuntimeProcessIdentity,
954
+ getProcessIdentityV2: getRuntimeProcessIdentityV2,
703
955
  isProcessAlive: dependencies.isProcessAlive ?? isProcessAlive,
704
956
  leaseMs: dependencies.leaseMs ?? LOCK_LEASE_MS,
705
957
  now: dependencies.now ?? Date.now,
@@ -710,6 +962,9 @@ export async function acquireOpenClawCrablineSmokeRunLock(params, dependencies =
710
962
  (runtime.currentProcessIdentity !== null &&
711
963
  (runtime.currentProcessIdentity.length === 0 ||
712
964
  runtime.currentProcessIdentity.length > 256)) ||
965
+ (runtime.currentProcessIdentityV2 !== null &&
966
+ (runtime.currentProcessIdentityV2.length === 0 ||
967
+ runtime.currentProcessIdentityV2.length > 256)) ||
713
968
  !isPositiveSafeInteger(runtime.currentProcessStartedAtMs) ||
714
969
  !isPositiveSafeInteger(runtime.leaseMs) ||
715
970
  !isPositiveSafeInteger(createdAtMs)) {
@@ -721,191 +976,444 @@ export async function acquireOpenClawCrablineSmokeRunLock(params, dependencies =
721
976
  leaseVersion: 1,
722
977
  pid: runtime.currentPid,
723
978
  ...(runtime.currentProcessIdentity ? { processIdentity: runtime.currentProcessIdentity } : {}),
979
+ ...(runtime.currentProcessIdentityV2
980
+ ? { processIdentityV2: runtime.currentProcessIdentityV2 }
981
+ : {}),
724
982
  processStartedAtMs: runtime.currentProcessStartedAtMs,
725
983
  token,
726
984
  };
727
- await fs.mkdir(outputDir, { recursive: true });
985
+ const compatibilityOwner = {
986
+ ...owner,
987
+ createdAtMs: 1,
988
+ };
728
989
  for (;;) {
729
990
  const lockClaims = await listLockClaims(lockDirectory);
730
991
  if (lockClaims.length > 0 || (await pathExists(`${lockDirectory}${LEGACY_RECOVERY_SUFFIX}`))) {
731
992
  await resolveLockClaims({
732
993
  beforeRecoveryDeleteClaim: dependencies.beforeRecoveryDeleteClaim ?? (async () => undefined),
733
994
  lockDirectory,
734
- outputDir,
995
+ outputDir: requestedOutputDir,
735
996
  requestedChannel: params.channel,
736
997
  runtime,
737
998
  });
738
999
  continue;
739
1000
  }
1001
+ const ownerDirectory = `${lockDirectory}${OWNED_CLAIM_SUFFIX}.${token}`;
740
1002
  const { candidateDirectory, securedDirectory } = await createLockCandidate({
741
1003
  ...(dependencies.afterLockOwnerWrite
742
1004
  ? { afterOwnerWrite: dependencies.afterLockOwnerWrite }
743
1005
  : {}),
1006
+ includeLease: false,
744
1007
  lockDirectory,
745
- owner,
1008
+ owner: compatibilityOwner,
746
1009
  ...(dependencies.platform ? { platform: dependencies.platform } : {}),
747
1010
  ...(dependencies.secureWindowsDirectory
748
1011
  ? { secureWindowsDirectory: dependencies.secureWindowsDirectory }
749
1012
  : {}),
750
1013
  });
1014
+ let ownerCandidate;
1015
+ try {
1016
+ ownerCandidate = await createLockCandidate({
1017
+ candidateDirectory: `${lockDirectory}.${runtime.currentPid}.${token}.owned.tmp`,
1018
+ lockDirectory: ownerDirectory,
1019
+ owner,
1020
+ ...(dependencies.platform ? { platform: dependencies.platform } : {}),
1021
+ ...(dependencies.secureWindowsDirectory
1022
+ ? { secureWindowsDirectory: dependencies.secureWindowsDirectory }
1023
+ : {}),
1024
+ });
1025
+ }
1026
+ catch (error) {
1027
+ await fs.rm(candidateDirectory, { force: true, recursive: true }).catch(() => undefined);
1028
+ throw error;
1029
+ }
1030
+ let compatibilityMarkerHandle;
1031
+ try {
1032
+ compatibilityMarkerHandle = await fs.open(path.join(candidateDirectory, LOCK_OWNER_FILE), "r+");
1033
+ const compatibilityRecord = await readLockRecordFromHandle(compatibilityMarkerHandle);
1034
+ if (compatibilityRecord.owner.token !== token) {
1035
+ throw new Error("OpenClaw Crabline smoke lock compatibility marker was replaced.");
1036
+ }
1037
+ const activeAt = new Date(createdAtMs);
1038
+ await compatibilityMarkerHandle.utimes(activeAt, activeAt);
1039
+ await compatibilityMarkerHandle.sync();
1040
+ }
1041
+ catch (error) {
1042
+ await compatibilityMarkerHandle?.close().catch(() => undefined);
1043
+ await fs.rm(candidateDirectory, { force: true, recursive: true }).catch(() => undefined);
1044
+ await fs
1045
+ .rm(ownerCandidate.candidateDirectory, { force: true, recursive: true })
1046
+ .catch(() => undefined);
1047
+ throw error;
1048
+ }
1049
+ if (!compatibilityMarkerHandle) {
1050
+ throw new Error("OpenClaw Crabline smoke lock compatibility marker was not opened.");
1051
+ }
1052
+ const markerHandle = compatibilityMarkerHandle;
751
1053
  try {
752
1054
  await fs.rename(candidateDirectory, lockDirectory);
753
1055
  }
754
1056
  catch (error) {
1057
+ await markerHandle.close().catch(() => undefined);
755
1058
  await fs.rm(candidateDirectory, { force: true, recursive: true }).catch(() => undefined);
1059
+ await fs
1060
+ .rm(ownerCandidate.candidateDirectory, { force: true, recursive: true })
1061
+ .catch(() => undefined);
756
1062
  await resolveLockContention({
757
1063
  beforeRecoveryDeleteClaim: dependencies.beforeRecoveryDeleteClaim ?? (async () => undefined),
758
1064
  cause: error,
759
1065
  lockDirectory,
760
- outputDir,
1066
+ outputDir: requestedOutputDir,
761
1067
  requestedChannel: params.channel,
762
1068
  runtime,
763
1069
  beforeRecoveryClaim: dependencies.beforeRecoveryClaim ?? (async () => undefined),
764
1070
  });
765
1071
  continue;
766
1072
  }
767
- await dependencies.afterLockCandidateInstall?.(lockDirectory);
768
- await securedDirectory.assertIdentityAt(lockDirectory);
769
- if ((await listLockClaims(lockDirectory)).length > 0 ||
1073
+ let ownerInstalled = false;
1074
+ try {
1075
+ await dependencies.afterLockCandidateInstall?.(lockDirectory);
1076
+ await securedDirectory.assertIdentityAt(lockDirectory);
1077
+ const installed = await readLockRecord(lockDirectory);
1078
+ if (installed.kind !== "record" || installed.record.owner.token !== token) {
1079
+ await retireCompatibilityMarker(markerHandle, compatibilityOwner);
1080
+ await markerHandle.close();
1081
+ await fs.rm(ownerCandidate.candidateDirectory, { force: true, recursive: true });
1082
+ continue;
1083
+ }
1084
+ await fs.rename(ownerCandidate.candidateDirectory, ownerDirectory);
1085
+ ownerInstalled = true;
1086
+ await dependencies.afterLockOwnerClaimInstall?.(ownerDirectory);
1087
+ await ownerCandidate.securedDirectory.assertIdentityAt(ownerDirectory);
1088
+ }
1089
+ catch (error) {
1090
+ let cleanupError;
1091
+ try {
1092
+ if (ownerInstalled) {
1093
+ await removeOwnedLock({
1094
+ lockDirectory,
1095
+ ownedDirectory: ownerDirectory,
1096
+ token,
1097
+ });
1098
+ }
1099
+ else {
1100
+ await fs.rm(ownerCandidate.candidateDirectory, { force: true, recursive: true });
1101
+ }
1102
+ await retireCompatibilityMarker(markerHandle, compatibilityOwner);
1103
+ }
1104
+ catch (caughtCleanupError) {
1105
+ cleanupError = caughtCleanupError;
1106
+ }
1107
+ try {
1108
+ await markerHandle.close();
1109
+ }
1110
+ catch (closeError) {
1111
+ cleanupError =
1112
+ cleanupError === undefined
1113
+ ? closeError
1114
+ : new AggregateError([cleanupError, closeError], "OpenClaw Crabline smoke lock setup cleanup also failed.");
1115
+ }
1116
+ if (cleanupError !== undefined) {
1117
+ const aggregateError = new AggregateError([error, cleanupError], "OpenClaw Crabline smoke lock setup and cleanup failed.");
1118
+ aggregateError.cause = error;
1119
+ throw aggregateError;
1120
+ }
1121
+ throw error;
1122
+ }
1123
+ const competingClaims = (await listLockClaims(lockDirectory)).filter((claim) => claim.directoryPath !== ownerDirectory);
1124
+ if (competingClaims.length > 0 ||
770
1125
  (await pathExists(`${lockDirectory}${LEGACY_RECOVERY_SUFFIX}`))) {
771
- await removeOwnedLock({
1126
+ const removed = await removeOwnedLock({
772
1127
  lockDirectory,
773
- ownedDirectory: lockDirectory,
1128
+ ownedDirectory: ownerDirectory,
774
1129
  token,
775
1130
  });
1131
+ if (!removed) {
1132
+ await markerHandle.close();
1133
+ throw new Error("OpenClaw Crabline smoke lock ownership was lost.");
1134
+ }
1135
+ try {
1136
+ await retireCompatibilityMarker(markerHandle, compatibilityOwner);
1137
+ }
1138
+ finally {
1139
+ await markerHandle.close();
1140
+ }
776
1141
  await resolveLockClaims({
777
1142
  beforeRecoveryDeleteClaim: dependencies.beforeRecoveryDeleteClaim ?? (async () => undefined),
778
1143
  lockDirectory,
779
- outputDir,
1144
+ outputDir: requestedOutputDir,
780
1145
  requestedChannel: params.channel,
781
1146
  runtime,
782
1147
  });
783
1148
  continue;
784
1149
  }
785
- const installed = await readLockRecord(lockDirectory);
786
- if (installed.kind !== "record" || installed.record.owner.token !== token) {
787
- continue;
788
- }
789
- let ownedDirectory = lockDirectory;
1150
+ let ownedDirectory = ownerDirectory;
1151
+ let clockRegressed = false;
1152
+ let lastObservedNowMs = createdAtMs;
1153
+ let lastRenewedAtMs = createdAtMs;
1154
+ let pendingRenewal = Promise.resolve();
790
1155
  let commitFenceConsumed = false;
1156
+ let markerClosed = false;
1157
+ let markerRetired = false;
791
1158
  let heartbeat;
792
- const renew = async () => {
793
- const renewedAtMs = runtime.now();
794
- if (!(await renewOwnedLock(ownedDirectory, token, renewedAtMs))) {
795
- throw new Error("OpenClaw Crabline smoke lock ownership was lost.");
1159
+ const retireMarker = async () => {
1160
+ if (!markerRetired) {
1161
+ await retireCompatibilityMarker(markerHandle, compatibilityOwner);
1162
+ markerRetired = true;
1163
+ }
1164
+ if (!markerClosed) {
1165
+ await markerHandle.close();
1166
+ markerClosed = true;
796
1167
  }
797
1168
  };
1169
+ const renew = async () => {
1170
+ const renewal = pendingRenewal
1171
+ .catch(() => undefined)
1172
+ .then(async () => {
1173
+ const nowMs = runtime.now();
1174
+ if (nowMs < lastObservedNowMs) {
1175
+ clockRegressed = true;
1176
+ }
1177
+ else if (clockRegressed && nowMs >= lastRenewedAtMs) {
1178
+ clockRegressed = false;
1179
+ }
1180
+ lastObservedNowMs = nowMs;
1181
+ const renewedAtMs = clockRegressed ? lastRenewedAtMs + 1 : nowMs;
1182
+ if (!Number.isSafeInteger(renewedAtMs)) {
1183
+ throw new Error("OpenClaw Crabline smoke lock renewal timestamp overflowed.");
1184
+ }
1185
+ if (!(await renewOwnedLock(ownedDirectory, token, renewedAtMs))) {
1186
+ throw new Error("OpenClaw Crabline smoke lock ownership was lost.");
1187
+ }
1188
+ await dependencies.beforeCompatibilityMarkerRenew?.();
1189
+ if (!(await renewCompatibilityMarker({
1190
+ handle: markerHandle,
1191
+ lockDirectory,
1192
+ renewedAtMs,
1193
+ securedDirectory,
1194
+ token,
1195
+ }))) {
1196
+ throw new Error("OpenClaw Crabline smoke lock compatibility marker was lost.");
1197
+ }
1198
+ lastRenewedAtMs = renewedAtMs;
1199
+ });
1200
+ pendingRenewal = renewal;
1201
+ await renewal;
1202
+ };
798
1203
  try {
799
- heartbeat = (dependencies.startHeartbeat ?? startHeartbeat)(renew, Math.max(1, Math.floor(runtime.leaseMs / 3)));
1204
+ heartbeat = (dependencies.startHeartbeat ?? startHeartbeat)(renew, heartbeatIntervalMs(runtime.leaseMs));
800
1205
  }
801
1206
  catch (error) {
802
- await removeOwnedLock({
803
- lockDirectory,
804
- ownedDirectory: lockDirectory,
805
- token,
806
- });
1207
+ try {
1208
+ await removeOwnedLock({
1209
+ lockDirectory,
1210
+ ownedDirectory: ownerDirectory,
1211
+ token,
1212
+ });
1213
+ await retireMarker();
1214
+ }
1215
+ catch (cleanupError) {
1216
+ const aggregateError = new AggregateError([error, cleanupError], "OpenClaw Crabline smoke lock heartbeat startup and cleanup failed.");
1217
+ aggregateError.cause = error;
1218
+ throw aggregateError;
1219
+ }
807
1220
  throw error;
808
1221
  }
809
1222
  let heartbeatStopped = false;
810
1223
  let released = false;
811
1224
  return {
812
1225
  async assertOwned() {
813
- if (released) {
1226
+ if (released || heartbeatStopped) {
814
1227
  throw new Error("OpenClaw Crabline smoke lock has already been released.");
815
1228
  }
816
1229
  heartbeat.assertHealthy();
817
1230
  await renew();
818
1231
  heartbeat.assertHealthy();
1232
+ await assertCompatibilityMarkerOwned({
1233
+ lockDirectory,
1234
+ securedDirectory,
1235
+ token,
1236
+ });
819
1237
  },
820
1238
  async commitFileAtomically({ contents, destinationPath, stageDirectory, stageFile }) {
821
1239
  if (released) {
822
1240
  throw new Error("OpenClaw Crabline smoke lock has already been released.");
823
1241
  }
824
- if (commitFenceConsumed || heartbeatStopped || ownedDirectory !== lockDirectory) {
1242
+ if (commitFenceConsumed || heartbeatStopped || ownedDirectory !== ownerDirectory) {
825
1243
  throw new Error("OpenClaw Crabline smoke lock has already committed its fence.");
826
1244
  }
827
1245
  heartbeat.assertHealthy();
828
1246
  await renew();
829
1247
  heartbeat.assertHealthy();
830
- const stagedFileName = `.commit-file.${token}.${randomUUID()}.tmp`;
831
- const stagedFilePath = path.join(stageDirectory ?? lockDirectory, stagedFileName);
832
- let committed = false;
833
- try {
834
- await stageFile(stagedFilePath, contents);
835
- if (stageDirectory) {
836
- await writeCommitStagePath(lockDirectory, stagedFilePath, token);
837
- }
838
- heartbeat.assertHealthy();
839
- await renew();
840
- heartbeat.assertHealthy();
841
- const observedIdentity = await readDirectoryIdentity(lockDirectory);
842
- const observed = await readLockRecord(lockDirectory);
843
- if (observedIdentity === null ||
844
- observed.kind === "missing" ||
845
- observed.record.owner.token !== token) {
846
- throw new Error("OpenClaw Crabline smoke lock ownership was lost.");
847
- }
848
- await dependencies.beforeCommitClaim?.();
849
- const commitClaim = `${lockDirectory}${COMMIT_CLAIM_SUFFIX}.${token}.${randomUUID()}`;
1248
+ const resolvedDestinationPath = await resolveConfinedPath(outputDir, destinationPath, "commit destination", "file");
1249
+ const destinationDirectory = path.dirname(resolvedDestinationPath);
1250
+ const securedDestinationDirectory = destinationDirectory === outputDir
1251
+ ? securedOutputDirectory
1252
+ : await securePrivateDirectory(destinationDirectory, privateDirectoryOptions);
1253
+ await securedDestinationDirectory.assertIdentityAt(destinationDirectory);
1254
+ const destinationDirectoryIdentity = await readDirectoryIdentity(destinationDirectory);
1255
+ if (destinationDirectoryIdentity === null) {
1256
+ throw new Error("OpenClaw Crabline smoke lock commit destination is invalid.");
1257
+ }
1258
+ if (destinationDirectoryIdentity.device !== outputDirectoryIdentity.device) {
1259
+ throw new Error("OpenClaw Crabline smoke lock commit destination is on another filesystem.");
1260
+ }
1261
+ const resolvedStageDirectory = stageDirectory
1262
+ ? await resolveConfinedPath(outputDir, stageDirectory, "commit stage directory", "directory")
1263
+ : ownerDirectory;
1264
+ let stageDirectoryIdentity = null;
1265
+ if (stageDirectory) {
850
1266
  try {
851
- await fs.rename(lockDirectory, commitClaim);
1267
+ stageDirectoryIdentity = await readDirectoryIdentity(resolvedStageDirectory);
852
1268
  }
853
1269
  catch (error) {
854
- if (isMissingPathError(error)) {
855
- throw new Error("OpenClaw Crabline smoke lock ownership was lost.", {
856
- cause: error,
857
- });
858
- }
859
- throw error;
1270
+ throw new Error("OpenClaw Crabline smoke lock commit stage directory is invalid.", {
1271
+ cause: error,
1272
+ });
860
1273
  }
861
- ownedDirectory = commitClaim;
862
- commitFenceConsumed = true;
863
- const claimed = await readLockRecord(commitClaim);
864
- const claimedIdentity = await readDirectoryIdentity(commitClaim);
865
- if (claimed.kind === "missing" ||
866
- claimed.record.owner.token !== token ||
867
- !hasSameDirectoryIdentity(observedIdentity, claimedIdentity)) {
868
- if (!(await pathExists(lockDirectory))) {
869
- try {
870
- await fs.rename(commitClaim, lockDirectory);
871
- ownedDirectory = lockDirectory;
872
- }
873
- catch (error) {
874
- if (!isMissingPathError(error)) {
875
- throw error;
876
- }
1274
+ }
1275
+ if (stageDirectory && stageDirectoryIdentity === null) {
1276
+ throw new Error("OpenClaw Crabline smoke lock commit stage directory is invalid.");
1277
+ }
1278
+ if (stageDirectoryIdentity !== null &&
1279
+ stageDirectoryIdentity.device !== outputDirectoryIdentity.device) {
1280
+ throw new Error("OpenClaw Crabline smoke lock commit stage directory is on another filesystem.");
1281
+ }
1282
+ const stagedFileName = `.commit-file.${token}.${randomUUID()}.tmp`;
1283
+ const stagedFilePath = path.join(ownerDirectory, stagedFileName);
1284
+ await stageFile(stagedFilePath, contents);
1285
+ if (stageDirectory) {
1286
+ if (!hasSameDirectoryIdentity(stageDirectoryIdentity, await readDirectoryIdentity(resolvedStageDirectory))) {
1287
+ throw new Error("OpenClaw Crabline smoke lock commit stage directory identity changed.");
1288
+ }
1289
+ }
1290
+ const stagedFileIdentity = await readFileIdentity(stagedFilePath);
1291
+ if (stagedFileIdentity === null) {
1292
+ throw new Error("OpenClaw Crabline smoke lock commit stage file is invalid.");
1293
+ }
1294
+ heartbeat.assertHealthy();
1295
+ await renew();
1296
+ heartbeat.assertHealthy();
1297
+ await dependencies.beforeCommitClaim?.();
1298
+ await assertCompatibilityMarkerOwned({
1299
+ lockDirectory,
1300
+ securedDirectory,
1301
+ token,
1302
+ });
1303
+ const commitClaim = `${lockDirectory}${COMMIT_CLAIM_SUFFIX}.${token}.${randomUUID()}`;
1304
+ const observedIdentity = await readDirectoryIdentity(ownerDirectory);
1305
+ const observed = await readLockRecord(ownerDirectory);
1306
+ if (observedIdentity === null ||
1307
+ observed.kind === "missing" ||
1308
+ observed.record.owner.token !== token) {
1309
+ throw new Error("OpenClaw Crabline smoke lock ownership was lost.");
1310
+ }
1311
+ try {
1312
+ await fs.rename(ownerDirectory, commitClaim);
1313
+ }
1314
+ catch (error) {
1315
+ if (isMissingPathError(error)) {
1316
+ throw new Error("OpenClaw Crabline smoke lock ownership was lost.", {
1317
+ cause: error,
1318
+ });
1319
+ }
1320
+ throw error;
1321
+ }
1322
+ ownedDirectory = commitClaim;
1323
+ commitFenceConsumed = true;
1324
+ const claimed = await readLockRecord(commitClaim);
1325
+ const claimedIdentity = await readDirectoryIdentity(commitClaim);
1326
+ if (claimed.kind === "missing" ||
1327
+ claimed.record.owner.token !== token ||
1328
+ !hasSameDirectoryIdentity(observedIdentity, claimedIdentity)) {
1329
+ if (!(await pathExists(ownerDirectory))) {
1330
+ try {
1331
+ await fs.rename(commitClaim, ownerDirectory);
1332
+ ownedDirectory = ownerDirectory;
1333
+ }
1334
+ catch (error) {
1335
+ if (!isMissingPathError(error)) {
1336
+ throw error;
877
1337
  }
878
1338
  }
879
- throw new Error("OpenClaw Crabline smoke lock commit fence was lost.");
880
1339
  }
881
- await dependencies.beforeCommitFileRename?.();
882
- heartbeat.assertHealthy();
883
- await renew();
884
- heartbeat.assertHealthy();
885
- await heartbeat.settle();
886
- heartbeat.assertHealthy();
887
- await dependencies.beforeCommitRename?.();
888
- await fs.rename(stageDirectory ? stagedFilePath : path.join(commitClaim, stagedFileName), destinationPath);
889
- committed = true;
890
- }
891
- finally {
892
- if (!committed && stageDirectory) {
893
- await fs.rm(stagedFilePath, { force: true }).catch(() => undefined);
1340
+ throw new Error("OpenClaw Crabline smoke lock commit fence was lost.");
1341
+ }
1342
+ await dependencies.beforeCommitFileRename?.();
1343
+ heartbeat.assertHealthy();
1344
+ await renew();
1345
+ heartbeat.assertHealthy();
1346
+ await heartbeat.settle();
1347
+ heartbeat.assertHealthy();
1348
+ await dependencies.beforeCommitRename?.();
1349
+ await assertCompatibilityMarkerOwned({
1350
+ lockDirectory,
1351
+ securedDirectory,
1352
+ token,
1353
+ });
1354
+ const claimedStagedFilePath = path.join(commitClaim, stagedFileName);
1355
+ if (stageDirectory) {
1356
+ const currentDirectoryIdentity = await readDirectoryIdentity(resolvedStageDirectory);
1357
+ const currentFileIdentity = await readFileIdentity(claimedStagedFilePath);
1358
+ if (!hasSameDirectoryIdentity(stageDirectoryIdentity, currentDirectoryIdentity) ||
1359
+ !hasSameDirectoryIdentity(stagedFileIdentity, currentFileIdentity)) {
1360
+ const error = new Error("OpenClaw Crabline smoke lock commit stage identity changed.");
1361
+ Object.assign(error, { code: "ENOENT", path: claimedStagedFilePath });
1362
+ throw error;
894
1363
  }
895
1364
  }
1365
+ else if (!hasSameDirectoryIdentity(stagedFileIdentity, await readFileIdentity(claimedStagedFilePath))) {
1366
+ throw new Error("OpenClaw Crabline smoke lock commit stage identity changed.");
1367
+ }
1368
+ if (!hasSameDirectoryIdentity(outputDirectoryIdentity, await readDirectoryIdentity(outputDir))) {
1369
+ throw new Error("OpenClaw Crabline smoke lock output directory identity changed.");
1370
+ }
1371
+ await securedOutputDirectory.assertIdentityAt(outputDir);
1372
+ let currentDestinationDirectoryIdentity = null;
1373
+ try {
1374
+ currentDestinationDirectoryIdentity = await readDirectoryIdentity(destinationDirectory);
1375
+ }
1376
+ catch {
1377
+ // A replaced directory or symlink is an identity mismatch.
1378
+ }
1379
+ if (!hasSameDirectoryIdentity(destinationDirectoryIdentity, currentDestinationDirectoryIdentity)) {
1380
+ throw new Error("OpenClaw Crabline smoke lock commit destination directory identity changed.");
1381
+ }
1382
+ await securedDestinationDirectory.assertIdentityAt(destinationDirectory);
1383
+ const revalidatedDestinationPath = await resolveConfinedPath(outputDir, resolvedDestinationPath, "commit destination", "file");
1384
+ if (revalidatedDestinationPath !== resolvedDestinationPath) {
1385
+ throw new Error("OpenClaw Crabline smoke lock commit destination path identity changed.");
1386
+ }
1387
+ // The owner-only output tree and smoke lock serialize supported writers; confinement does
1388
+ // not treat another process running as the same OS user as a lower-privilege adversary.
1389
+ await fs.rename(claimedStagedFilePath, revalidatedDestinationPath);
896
1390
  },
897
1391
  async release() {
898
1392
  if (released) {
899
1393
  return;
900
1394
  }
901
1395
  if (!heartbeatStopped) {
902
- await heartbeat.stop();
903
1396
  heartbeatStopped = true;
1397
+ try {
1398
+ await heartbeat.stop();
1399
+ }
1400
+ finally {
1401
+ await pendingRenewal.catch(() => undefined);
1402
+ }
1403
+ }
1404
+ else {
1405
+ await pendingRenewal.catch(() => undefined);
904
1406
  }
905
1407
  await removeOwnedLock({
906
1408
  ...(dependencies.beforeReleaseClaim
907
1409
  ? { beforeClaim: dependencies.beforeReleaseClaim }
908
1410
  : {}),
1411
+ ...(dependencies.beforeReleaseRename
1412
+ ? { beforeRename: dependencies.beforeReleaseRename }
1413
+ : {}),
1414
+ ...(dependencies.beforeReleaseRemove
1415
+ ? { beforeRemove: dependencies.beforeReleaseRemove }
1416
+ : {}),
909
1417
  lockDirectory,
910
1418
  onOwnedDirectoryChange: (directoryPath) => {
911
1419
  ownedDirectory = directoryPath;
@@ -927,6 +1435,7 @@ export async function acquireOpenClawCrablineSmokeRunLock(params, dependencies =
927
1435
  token,
928
1436
  });
929
1437
  }
1438
+ await retireMarker();
930
1439
  released = true;
931
1440
  },
932
1441
  };