@openclaw/crabline 0.1.11 → 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 +7 -2
  34. package/dist/src/openclaw/artifact-generation.js +399 -27
  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 +38 -3
  54. package/dist/src/openclaw/private-file.js +2008 -87
  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 +626 -172
  61. package/dist/src/openclaw/smoke-lock.js.map +1 -1
  62. package/dist/src/openclaw.js +225 -42
  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 +521 -41
  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 +1072 -69
  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 +1169 -107
  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,11 +506,14 @@ function needsLiveOwnerConfirmation(record, runtime) {
389
506
  !hasProcessIdentityMismatch(record.owner, runtime) &&
390
507
  !isLockOwnerActive(record, runtime));
391
508
  }
509
+ function heartbeatIntervalMs(leaseMs) {
510
+ return Math.max(1, Math.floor(leaseMs / 3));
511
+ }
392
512
  async function confirmObservedOwner(lockDirectory, observed, runtime) {
393
513
  if (!needsLiveOwnerConfirmation(observed, runtime)) {
394
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
518
  if (revalidated.kind !== "record") {
399
519
  return "unchanged";
@@ -409,7 +529,33 @@ async function confirmObservedOwner(lockDirectory, observed, runtime) {
409
529
  function activeRunError(params) {
410
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 });
411
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
+ }
412
557
  async function removeOwnedLock(params) {
558
+ await params.beforeClaim?.();
413
559
  const observed = await readLockRecord(params.ownedDirectory);
414
560
  if (observed.kind === "missing" || observed.record.owner.token !== params.token) {
415
561
  return false;
@@ -418,7 +564,15 @@ async function removeOwnedLock(params) {
418
564
  if (observedIdentity === null) {
419
565
  return false;
420
566
  }
421
- 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?.();
422
576
  const releaseClaim = `${params.lockDirectory}${RELEASE_CLAIM_SUFFIX}.${params.token}.${randomUUID()}`;
423
577
  try {
424
578
  await fs.rename(params.ownedDirectory, releaseClaim);
@@ -449,22 +603,62 @@ async function removeOwnedLock(params) {
449
603
  }
450
604
  return false;
451
605
  }
452
- const stagedFilePath = await readCommitStagePath(releaseClaim, params.token);
453
- if (stagedFilePath) {
454
- const revokedStagePath = path.join(path.dirname(stagedFilePath), `.revoked-commit-file.${params.token}.${randomUUID()}.tmp`);
455
- try {
456
- await fs.rename(stagedFilePath, revokedStagePath);
457
- await fs.rm(revokedStagePath, { force: true });
458
- }
459
- catch (error) {
460
- if (!isMissingPathError(error)) {
461
- throw error;
462
- }
463
- }
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
+ });
464
616
  }
465
- await (params.removeDirectory ?? removeLockDirectory)(releaseClaim);
466
617
  return true;
467
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
+ }
468
662
  async function renewOwnedLock(lockDirectory, token, renewedAtMs) {
469
663
  const temporaryLeasePath = path.join(lockDirectory, `.${leaseFileName(token)}.${randomUUID()}.tmp`);
470
664
  try {
@@ -509,6 +703,7 @@ async function listLockClaims(lockDirectory) {
509
703
  const prefixes = [
510
704
  { kind: "recovering", prefix: claimPrefix(lockDirectory, LEGACY_RECOVERY_SUFFIX) },
511
705
  { kind: "commit", prefix: claimPrefix(lockDirectory, COMMIT_CLAIM_SUFFIX) },
706
+ { kind: "owned", prefix: claimPrefix(lockDirectory, OWNED_CLAIM_SUFFIX) },
512
707
  { kind: "release", prefix: claimPrefix(lockDirectory, RELEASE_CLAIM_SUFFIX) },
513
708
  ];
514
709
  const entries = await fs.readdir(directory, { withFileTypes: true });
@@ -683,7 +878,8 @@ async function resolveLockContention(params) {
683
878
  });
684
879
  }
685
880
  async function createLockCandidate(params) {
686
- 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`;
687
883
  await fs.mkdir(candidateDirectory, { mode: 0o700 });
688
884
  try {
689
885
  const securedDirectory = await securePrivateDirectory(candidateDirectory, {
@@ -704,14 +900,16 @@ async function createLockCandidate(params) {
704
900
  await fs.utimes(ownerPath, createdAt, createdAt);
705
901
  await params.afterOwnerWrite?.(candidateDirectory);
706
902
  await securedDirectory.assertIdentityAt();
707
- const leasePath = path.join(candidateDirectory, leaseFileName(params.owner.token));
708
- await fs.writeFile(leasePath, `${JSON.stringify({ renewedAtMs: params.owner.createdAtMs, token: params.owner.token })}\n`, {
709
- encoding: "utf8",
710
- flag: "wx",
711
- mode: 0o600,
712
- });
713
- await fs.chmod(leasePath, 0o600);
714
- 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
+ }
715
913
  await securedDirectory.assertIdentityAt();
716
914
  return { candidateDirectory, securedDirectory };
717
915
  }
@@ -721,18 +919,39 @@ async function createLockCandidate(params) {
721
919
  }
722
920
  }
723
921
  export async function acquireOpenClawCrablineSmokeRunLock(params, dependencies = {}) {
724
- 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
+ }
725
937
  const lockDirectory = path.join(outputDir, `.${OPENCLAW_CRABLINE_MANIFEST_PATH}.lock`);
726
938
  const token = randomUUID();
727
939
  const currentPid = dependencies.pid ?? process.pid;
728
- 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));
729
944
  const runtime = {
730
945
  currentPid,
731
946
  currentProcessIdentity: dependencies.processIdentity === undefined
732
947
  ? getRuntimeProcessIdentity(currentPid)
733
948
  : dependencies.processIdentity,
949
+ currentProcessIdentityV2: dependencies.processIdentityV2 === undefined
950
+ ? getRuntimeProcessIdentityV2(currentPid)
951
+ : dependencies.processIdentityV2,
734
952
  currentProcessStartedAtMs: dependencies.processStartedAtMs ?? CURRENT_PROCESS_STARTED_AT_MS,
735
953
  getProcessIdentity: getRuntimeProcessIdentity,
954
+ getProcessIdentityV2: getRuntimeProcessIdentityV2,
736
955
  isProcessAlive: dependencies.isProcessAlive ?? isProcessAlive,
737
956
  leaseMs: dependencies.leaseMs ?? LOCK_LEASE_MS,
738
957
  now: dependencies.now ?? Date.now,
@@ -743,6 +962,9 @@ export async function acquireOpenClawCrablineSmokeRunLock(params, dependencies =
743
962
  (runtime.currentProcessIdentity !== null &&
744
963
  (runtime.currentProcessIdentity.length === 0 ||
745
964
  runtime.currentProcessIdentity.length > 256)) ||
965
+ (runtime.currentProcessIdentityV2 !== null &&
966
+ (runtime.currentProcessIdentityV2.length === 0 ||
967
+ runtime.currentProcessIdentityV2.length > 256)) ||
746
968
  !isPositiveSafeInteger(runtime.currentProcessStartedAtMs) ||
747
969
  !isPositiveSafeInteger(runtime.leaseMs) ||
748
970
  !isPositiveSafeInteger(createdAtMs)) {
@@ -754,78 +976,196 @@ export async function acquireOpenClawCrablineSmokeRunLock(params, dependencies =
754
976
  leaseVersion: 1,
755
977
  pid: runtime.currentPid,
756
978
  ...(runtime.currentProcessIdentity ? { processIdentity: runtime.currentProcessIdentity } : {}),
979
+ ...(runtime.currentProcessIdentityV2
980
+ ? { processIdentityV2: runtime.currentProcessIdentityV2 }
981
+ : {}),
757
982
  processStartedAtMs: runtime.currentProcessStartedAtMs,
758
983
  token,
759
984
  };
760
- await fs.mkdir(outputDir, { recursive: true });
985
+ const compatibilityOwner = {
986
+ ...owner,
987
+ createdAtMs: 1,
988
+ };
761
989
  for (;;) {
762
990
  const lockClaims = await listLockClaims(lockDirectory);
763
991
  if (lockClaims.length > 0 || (await pathExists(`${lockDirectory}${LEGACY_RECOVERY_SUFFIX}`))) {
764
992
  await resolveLockClaims({
765
993
  beforeRecoveryDeleteClaim: dependencies.beforeRecoveryDeleteClaim ?? (async () => undefined),
766
994
  lockDirectory,
767
- outputDir,
995
+ outputDir: requestedOutputDir,
768
996
  requestedChannel: params.channel,
769
997
  runtime,
770
998
  });
771
999
  continue;
772
1000
  }
1001
+ const ownerDirectory = `${lockDirectory}${OWNED_CLAIM_SUFFIX}.${token}`;
773
1002
  const { candidateDirectory, securedDirectory } = await createLockCandidate({
774
1003
  ...(dependencies.afterLockOwnerWrite
775
1004
  ? { afterOwnerWrite: dependencies.afterLockOwnerWrite }
776
1005
  : {}),
1006
+ includeLease: false,
777
1007
  lockDirectory,
778
- owner,
1008
+ owner: compatibilityOwner,
779
1009
  ...(dependencies.platform ? { platform: dependencies.platform } : {}),
780
1010
  ...(dependencies.secureWindowsDirectory
781
1011
  ? { secureWindowsDirectory: dependencies.secureWindowsDirectory }
782
1012
  : {}),
783
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;
784
1053
  try {
785
1054
  await fs.rename(candidateDirectory, lockDirectory);
786
1055
  }
787
1056
  catch (error) {
1057
+ await markerHandle.close().catch(() => undefined);
788
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);
789
1062
  await resolveLockContention({
790
1063
  beforeRecoveryDeleteClaim: dependencies.beforeRecoveryDeleteClaim ?? (async () => undefined),
791
1064
  cause: error,
792
1065
  lockDirectory,
793
- outputDir,
1066
+ outputDir: requestedOutputDir,
794
1067
  requestedChannel: params.channel,
795
1068
  runtime,
796
1069
  beforeRecoveryClaim: dependencies.beforeRecoveryClaim ?? (async () => undefined),
797
1070
  });
798
1071
  continue;
799
1072
  }
800
- await dependencies.afterLockCandidateInstall?.(lockDirectory);
801
- await securedDirectory.assertIdentityAt(lockDirectory);
802
- 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 ||
803
1125
  (await pathExists(`${lockDirectory}${LEGACY_RECOVERY_SUFFIX}`))) {
804
- await removeOwnedLock({
1126
+ const removed = await removeOwnedLock({
805
1127
  lockDirectory,
806
- ownedDirectory: lockDirectory,
1128
+ ownedDirectory: ownerDirectory,
807
1129
  token,
808
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
+ }
809
1141
  await resolveLockClaims({
810
1142
  beforeRecoveryDeleteClaim: dependencies.beforeRecoveryDeleteClaim ?? (async () => undefined),
811
1143
  lockDirectory,
812
- outputDir,
1144
+ outputDir: requestedOutputDir,
813
1145
  requestedChannel: params.channel,
814
1146
  runtime,
815
1147
  });
816
1148
  continue;
817
1149
  }
818
- const installed = await readLockRecord(lockDirectory);
819
- if (installed.kind !== "record" || installed.record.owner.token !== token) {
820
- continue;
821
- }
822
- let ownedDirectory = lockDirectory;
1150
+ let ownedDirectory = ownerDirectory;
823
1151
  let clockRegressed = false;
824
1152
  let lastObservedNowMs = createdAtMs;
825
1153
  let lastRenewedAtMs = createdAtMs;
826
1154
  let pendingRenewal = Promise.resolve();
827
1155
  let commitFenceConsumed = false;
1156
+ let markerClosed = false;
1157
+ let markerRetired = false;
828
1158
  let heartbeat;
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;
1167
+ }
1168
+ };
829
1169
  const renew = async () => {
830
1170
  const renewal = pendingRenewal
831
1171
  .catch(() => undefined)
@@ -845,122 +1185,235 @@ export async function acquireOpenClawCrablineSmokeRunLock(params, dependencies =
845
1185
  if (!(await renewOwnedLock(ownedDirectory, token, renewedAtMs))) {
846
1186
  throw new Error("OpenClaw Crabline smoke lock ownership was lost.");
847
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
+ }
848
1198
  lastRenewedAtMs = renewedAtMs;
849
1199
  });
850
1200
  pendingRenewal = renewal;
851
1201
  await renewal;
852
1202
  };
853
1203
  try {
854
- heartbeat = (dependencies.startHeartbeat ?? startHeartbeat)(renew, Math.max(1, Math.floor(runtime.leaseMs / 3)));
1204
+ heartbeat = (dependencies.startHeartbeat ?? startHeartbeat)(renew, heartbeatIntervalMs(runtime.leaseMs));
855
1205
  }
856
1206
  catch (error) {
857
- await removeOwnedLock({
858
- lockDirectory,
859
- ownedDirectory: lockDirectory,
860
- token,
861
- });
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
+ }
862
1220
  throw error;
863
1221
  }
864
1222
  let heartbeatStopped = false;
865
1223
  let released = false;
866
1224
  return {
867
1225
  async assertOwned() {
868
- if (released) {
1226
+ if (released || heartbeatStopped) {
869
1227
  throw new Error("OpenClaw Crabline smoke lock has already been released.");
870
1228
  }
871
1229
  heartbeat.assertHealthy();
872
1230
  await renew();
873
1231
  heartbeat.assertHealthy();
1232
+ await assertCompatibilityMarkerOwned({
1233
+ lockDirectory,
1234
+ securedDirectory,
1235
+ token,
1236
+ });
874
1237
  },
875
1238
  async commitFileAtomically({ contents, destinationPath, stageDirectory, stageFile }) {
876
1239
  if (released) {
877
1240
  throw new Error("OpenClaw Crabline smoke lock has already been released.");
878
1241
  }
879
- if (commitFenceConsumed || heartbeatStopped || ownedDirectory !== lockDirectory) {
1242
+ if (commitFenceConsumed || heartbeatStopped || ownedDirectory !== ownerDirectory) {
880
1243
  throw new Error("OpenClaw Crabline smoke lock has already committed its fence.");
881
1244
  }
882
1245
  heartbeat.assertHealthy();
883
1246
  await renew();
884
1247
  heartbeat.assertHealthy();
885
- const stagedFileName = `.commit-file.${token}.${randomUUID()}.tmp`;
886
- const stagedFilePath = path.join(stageDirectory ?? lockDirectory, stagedFileName);
887
- let committed = false;
888
- try {
889
- await stageFile(stagedFilePath, contents);
890
- if (stageDirectory) {
891
- await writeCommitStagePath(lockDirectory, stagedFilePath, token);
892
- }
893
- heartbeat.assertHealthy();
894
- await renew();
895
- heartbeat.assertHealthy();
896
- const observedIdentity = await readDirectoryIdentity(lockDirectory);
897
- const observed = await readLockRecord(lockDirectory);
898
- if (observedIdentity === null ||
899
- observed.kind === "missing" ||
900
- observed.record.owner.token !== token) {
901
- throw new Error("OpenClaw Crabline smoke lock ownership was lost.");
902
- }
903
- await dependencies.beforeCommitClaim?.();
904
- 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) {
905
1266
  try {
906
- await fs.rename(lockDirectory, commitClaim);
1267
+ stageDirectoryIdentity = await readDirectoryIdentity(resolvedStageDirectory);
907
1268
  }
908
1269
  catch (error) {
909
- if (isMissingPathError(error)) {
910
- throw new Error("OpenClaw Crabline smoke lock ownership was lost.", {
911
- cause: error,
912
- });
913
- }
914
- throw error;
1270
+ throw new Error("OpenClaw Crabline smoke lock commit stage directory is invalid.", {
1271
+ cause: error,
1272
+ });
915
1273
  }
916
- ownedDirectory = commitClaim;
917
- commitFenceConsumed = true;
918
- const claimed = await readLockRecord(commitClaim);
919
- const claimedIdentity = await readDirectoryIdentity(commitClaim);
920
- if (claimed.kind === "missing" ||
921
- claimed.record.owner.token !== token ||
922
- !hasSameDirectoryIdentity(observedIdentity, claimedIdentity)) {
923
- if (!(await pathExists(lockDirectory))) {
924
- try {
925
- await fs.rename(commitClaim, lockDirectory);
926
- ownedDirectory = lockDirectory;
927
- }
928
- catch (error) {
929
- if (!isMissingPathError(error)) {
930
- throw error;
931
- }
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;
932
1337
  }
933
1338
  }
934
- throw new Error("OpenClaw Crabline smoke lock commit fence was lost.");
935
1339
  }
936
- await dependencies.beforeCommitFileRename?.();
937
- heartbeat.assertHealthy();
938
- await renew();
939
- heartbeat.assertHealthy();
940
- await heartbeat.settle();
941
- heartbeat.assertHealthy();
942
- await dependencies.beforeCommitRename?.();
943
- await fs.rename(stageDirectory ? stagedFilePath : path.join(commitClaim, stagedFileName), destinationPath);
944
- committed = true;
945
- }
946
- finally {
947
- if (!committed && stageDirectory) {
948
- 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;
949
1363
  }
950
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);
951
1390
  },
952
1391
  async release() {
953
1392
  if (released) {
954
1393
  return;
955
1394
  }
956
1395
  if (!heartbeatStopped) {
957
- await heartbeat.stop();
958
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);
959
1406
  }
960
1407
  await removeOwnedLock({
961
1408
  ...(dependencies.beforeReleaseClaim
962
1409
  ? { beforeClaim: dependencies.beforeReleaseClaim }
963
1410
  : {}),
1411
+ ...(dependencies.beforeReleaseRename
1412
+ ? { beforeRename: dependencies.beforeReleaseRename }
1413
+ : {}),
1414
+ ...(dependencies.beforeReleaseRemove
1415
+ ? { beforeRemove: dependencies.beforeReleaseRemove }
1416
+ : {}),
964
1417
  lockDirectory,
965
1418
  onOwnedDirectoryChange: (directoryPath) => {
966
1419
  ownedDirectory = directoryPath;
@@ -982,6 +1435,7 @@ export async function acquireOpenClawCrablineSmokeRunLock(params, dependencies =
982
1435
  token,
983
1436
  });
984
1437
  }
1438
+ await retireMarker();
985
1439
  released = true;
986
1440
  },
987
1441
  };