@alfe.ai/openclaw-sync 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin2.js CHANGED
@@ -1,10 +1,11 @@
1
- import { c as loadIgnorePatterns, l as shouldIgnore, n as isRecoveryArtifact, p as readManifest, t as createSyncEngine, u as shouldIgnoreDir } from "./sync-engine.js";
1
+ import { c as loadIgnorePatterns, l as shouldIgnore, n as isRecoveryArtifact, p as readManifest, t as createSyncEngine, u as shouldIgnoreDir, y as validatePrivateRelativePath } from "./sync-engine.js";
2
2
  import { createRequire } from "node:module";
3
- import { mkdir, rm, unlink, writeFile } from "node:fs/promises";
3
+ import { lstat, mkdir, readdir, rm, rmdir, unlink, writeFile } from "node:fs/promises";
4
4
  import { existsSync } from "node:fs";
5
- import { dirname, join, normalize, relative, sep } from "node:path";
5
+ import { dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
6
+ import { createLogger } from "@auriclabs/logger";
6
7
  import { watch } from "chokidar";
7
- import { DEFAULT_SOCKET_PATH, DEFAULT_WORKSPACE_PATH, configExists, resolveConfig } from "@alfe.ai/config";
8
+ import { DEFAULT_SOCKET_PATH, DEFAULT_WORKSPACE_PATH, configExists, deriveServiceWsUrl, resolveConfig } from "@alfe.ai/config";
8
9
  import { connectToDaemon, getActivationKey, guardedStart, resetActivation } from "@alfe.ai/openclaw-plugin-kit";
9
10
  import { AgentApiClient } from "@alfe.ai/agent-api-client";
10
11
  //#region src/watcher.ts
@@ -14,6 +15,8 @@ import { AgentApiClient } from "@alfe.ai/agent-api-client";
14
15
  * Uses chokidar to watch the workspace root, debounces per-file changes
15
16
  * by 2 seconds, and emits batches of changed paths.
16
17
  */
18
+ const WORKSPACE_IGNORE_FILE = ".alfesyncignore";
19
+ const log = createLogger("SyncWatcher");
17
20
  /**
18
21
  * Start watching a workspace for file changes.
19
22
  *
@@ -21,7 +24,8 @@ import { AgentApiClient } from "@alfe.ai/agent-api-client";
21
24
  */
22
25
  async function startWatcher(options) {
23
26
  const { workspacePath, runtime = "openclaw", debounceMs = 2e3, onChanges } = options;
24
- const ignorePatterns = await loadIgnorePatterns(workspacePath, runtime);
27
+ let ignorePatterns = await loadIgnorePatterns(workspacePath, runtime);
28
+ let stopped = false;
25
29
  const pending = /* @__PURE__ */ new Map();
26
30
  let batchPaths = /* @__PURE__ */ new Set();
27
31
  let flushTimer = null;
@@ -32,11 +36,12 @@ async function startWatcher(options) {
32
36
  if (batchPaths.size === 0) return;
33
37
  const paths = [...batchPaths];
34
38
  batchPaths = /* @__PURE__ */ new Set();
35
- onChanges(paths);
39
+ Promise.resolve(onChanges(paths)).catch((error) => {
40
+ log.error({ err: error }, "Sync watcher change handler failed");
41
+ });
36
42
  }, debounceMs);
37
43
  }
38
- function handleChange(absolutePath) {
39
- const relativePath = relative(workspacePath, absolutePath).replace(/\\/g, "/");
44
+ function queueChange(relativePath) {
40
45
  if (shouldIgnore(relativePath, ignorePatterns)) return;
41
46
  const existingTimer = pending.get(relativePath);
42
47
  if (existingTimer) clearTimeout(existingTimer);
@@ -60,16 +65,40 @@ async function startWatcher(options) {
60
65
  return shouldIgnoreDir(rel, ignorePatterns);
61
66
  }
62
67
  });
68
+ let ignoreReloadTail = Promise.resolve();
69
+ function handleChange(absolutePath) {
70
+ const relativePath = relative(workspacePath, absolutePath).replace(/\\/g, "/");
71
+ if (relativePath === WORKSPACE_IGNORE_FILE) {
72
+ ignoreReloadTail = ignoreReloadTail.then(async () => {
73
+ const nextRules = await loadIgnorePatterns(workspacePath, runtime);
74
+ if (stopped) return;
75
+ ignorePatterns = nextRules;
76
+ watcher.unwatch(workspacePath);
77
+ watcher.add(workspacePath);
78
+ queueChange(relativePath);
79
+ }).catch((error) => {
80
+ log.error({ err: error }, "Failed to reload sync ignore rules");
81
+ });
82
+ return;
83
+ }
84
+ queueChange(relativePath);
85
+ }
63
86
  watcher.on("add", handleChange);
64
87
  watcher.on("change", handleChange);
65
88
  watcher.on("unlink", handleChange);
89
+ watcher.on("error", (error) => {
90
+ log.error({ err: error }, "Sync watcher failed");
91
+ });
66
92
  return async () => {
93
+ stopped = true;
67
94
  for (const timer of pending.values()) clearTimeout(timer);
68
95
  pending.clear();
69
96
  if (flushTimer) {
70
97
  clearTimeout(flushTimer);
71
98
  flushTimer = null;
72
99
  }
100
+ batchPaths.clear();
101
+ await ignoreReloadTail;
73
102
  await watcher.close();
74
103
  };
75
104
  }
@@ -87,25 +116,56 @@ async function startWatcher(options) {
87
116
  * `AgentApiClient.sharedDownloadUrl`).
88
117
  */
89
118
  const MAX_SHARED_FILE_SIZE = 100 * 1024 * 1024;
119
+ const SHARED_LIST_PAGE_SIZE = 500;
120
+ const MAX_SHARED_LIST_PAGES = 100;
121
+ function validateScope(scope) {
122
+ const candidate = scope;
123
+ if (candidate === null || candidate.scopeType !== "org" && candidate.scopeType !== "team" && candidate.scopeType !== "project" || typeof candidate.scopeId !== "string" || candidate.scopeId.length === 0 || candidate.scopeId.length > 512 || candidate.scopeId === "." || candidate.scopeId === ".." || candidate.scopeId.includes("/") || candidate.scopeId.includes("\\") || candidate.scopeId.includes("\0") || typeof candidate.name !== "string" || candidate.name.length > 512) throw new Error("Invalid shared sync scope");
124
+ return candidate;
125
+ }
126
+ function validateSharedFilePath(filePath) {
127
+ if (filePath.length === 0 || filePath.length > 1024 || isAbsolute(filePath) || filePath.includes("\\") || filePath.includes("\0")) throw new Error("Invalid shared sync file path");
128
+ const segments = filePath.split("/");
129
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) throw new Error("Invalid shared sync file path");
130
+ return segments.join("/");
131
+ }
90
132
  /** Throw if `resolvedPath` would escape `baseDir`. */
91
133
  function assertContained(baseDir, resolvedPath) {
92
134
  const normalizedBase = normalize(baseDir) + sep;
93
135
  const normalizedPath = normalize(resolvedPath);
94
136
  if (!normalizedPath.startsWith(normalizedBase) && normalizedPath !== normalize(baseDir)) throw new Error(`Path traversal blocked: ${resolvedPath} escapes ${baseDir}`);
95
137
  }
138
+ async function assertNoSymlinkTraversal(baseDir, relativePath) {
139
+ const canonical = validateSharedFilePath(relativePath);
140
+ const resolvedPath = join(baseDir, canonical);
141
+ assertContained(baseDir, resolvedPath);
142
+ let current = normalize(baseDir);
143
+ for (const segment of canonical.split("/")) {
144
+ current = join(current, segment);
145
+ try {
146
+ if ((await lstat(current)).isSymbolicLink()) throw new Error("Shared sync path crosses a symbolic link");
147
+ } catch (error) {
148
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") break;
149
+ throw error;
150
+ }
151
+ }
152
+ return resolvedPath;
153
+ }
96
154
  function createSharedSyncEngine(config, log) {
97
155
  let activeScopes = [];
98
156
  const sharedDir = join(config.workspacePath, "shared");
99
157
  function scopeDir(scope) {
158
+ validateScope(scope);
100
159
  if (scope.scopeType === "org") return join(sharedDir, "org");
101
160
  return join(sharedDir, scope.scopeType === "team" ? "teams" : "projects", scope.scopeId);
102
161
  }
103
162
  async function downloadFile(scope, filePath, localPath) {
104
- assertContained(scopeDir(scope), localPath);
163
+ const canonicalPath = validateSharedFilePath(filePath);
164
+ if (await assertNoSymlinkTraversal(scopeDir(scope), canonicalPath) !== normalize(localPath)) throw new Error("Shared sync local path does not match its canonical path");
105
165
  const { downloadUrl } = await config.client.sharedDownloadUrl({
106
166
  scope: scope.scopeType,
107
167
  scopeId: scope.scopeId,
108
- filePath
168
+ filePath: canonicalPath
109
169
  });
110
170
  const response = await fetch(downloadUrl);
111
171
  if (!response.ok) throw new Error(`Download failed: HTTP ${String(response.status)}`);
@@ -116,66 +176,147 @@ function createSharedSyncEngine(config, log) {
116
176
  await mkdir(dirname(localPath), { recursive: true });
117
177
  await writeFile(localPath, buffer);
118
178
  }
119
- async function syncScope(scope) {
120
- const dir = scopeDir(scope);
121
- await mkdir(dir, { recursive: true });
122
- try {
123
- const { files } = await config.client.sharedListFiles({
179
+ async function listRemoteFiles(scope) {
180
+ const files = /* @__PURE__ */ new Map();
181
+ const seenCursors = /* @__PURE__ */ new Set();
182
+ let cursor;
183
+ for (let page = 0; page < MAX_SHARED_LIST_PAGES; page++) {
184
+ const response = await config.client.sharedListFiles({
124
185
  scope: scope.scopeType,
125
- scopeId: scope.scopeId
186
+ scopeId: scope.scopeId,
187
+ limit: SHARED_LIST_PAGE_SIZE,
188
+ ...cursor ? { cursor } : {}
126
189
  });
127
- for (const file of files) {
128
- const localPath = join(dir, file.filePath);
190
+ for (const file of response.files) {
191
+ const filePath = validateSharedFilePath(file.filePath);
192
+ files.set(filePath, {
193
+ ...file,
194
+ filePath
195
+ });
196
+ }
197
+ if (!response.nextCursor) {
198
+ const paths = new Set(files.keys());
199
+ for (const filePath of paths) {
200
+ const segments = filePath.split("/");
201
+ for (let index = 1; index < segments.length; index++) if (paths.has(segments.slice(0, index).join("/"))) throw new Error("Shared file listing contains a file/directory collision");
202
+ }
203
+ return [...files.values()];
204
+ }
205
+ if (seenCursors.has(response.nextCursor)) throw new Error("Shared file listing returned a repeated cursor");
206
+ seenCursors.add(response.nextCursor);
207
+ cursor = response.nextCursor;
208
+ }
209
+ throw new Error(`Shared file listing exceeded ${String(MAX_SHARED_LIST_PAGES)} pages`);
210
+ }
211
+ async function pruneLocalMirror(dir, remotePaths) {
212
+ const remoteDirectories = /* @__PURE__ */ new Set();
213
+ for (const filePath of remotePaths) {
214
+ const segments = filePath.split("/");
215
+ for (let index = 1; index < segments.length; index++) remoteDirectories.add(segments.slice(0, index).join("/"));
216
+ }
217
+ const walk = async (currentDir, prefix) => {
218
+ const entries = await readdir(currentDir, { withFileTypes: true });
219
+ for (const entry of entries) {
220
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
221
+ const fullPath = join(currentDir, entry.name);
222
+ let canonicalPath = null;
129
223
  try {
130
- await downloadFile(scope, file.filePath, localPath);
131
- log.debug(`Shared sync: downloaded ${scope.scopeType}/${scope.scopeId}/${file.filePath}`);
132
- } catch (err) {
133
- log.error(`Shared sync: failed to download ${file.filePath}: ${err instanceof Error ? err.message : String(err)}`);
224
+ canonicalPath = validateSharedFilePath(relativePath);
225
+ } catch {}
226
+ if (entry.isSymbolicLink()) {
227
+ await unlink(fullPath);
228
+ log.warn(`Shared sync: removed symbolic link ${relativePath}`);
229
+ continue;
230
+ }
231
+ if (entry.isDirectory()) {
232
+ if (!canonicalPath) {
233
+ await rm(fullPath, {
234
+ recursive: true,
235
+ force: true
236
+ });
237
+ continue;
238
+ }
239
+ await walk(fullPath, canonicalPath);
240
+ if (!remoteDirectories.has(canonicalPath)) try {
241
+ await rmdir(fullPath);
242
+ } catch (error) {
243
+ if (!(error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTEMPTY"))) throw error;
244
+ }
245
+ continue;
246
+ }
247
+ if (!canonicalPath || !remotePaths.has(canonicalPath)) {
248
+ await unlink(fullPath);
249
+ log.debug(`Shared sync: pruned stale local file ${relativePath}`);
134
250
  }
135
251
  }
252
+ };
253
+ await walk(dir, "");
254
+ }
255
+ async function syncScope(scope) {
256
+ const dir = scopeDir(scope);
257
+ await mkdir(dir, { recursive: true });
258
+ const files = await listRemoteFiles(scope);
259
+ await pruneLocalMirror(dir, new Set(files.map((file) => file.filePath)));
260
+ for (const file of files) try {
261
+ const filePath = validateSharedFilePath(file.filePath);
262
+ await downloadFile(scope, filePath, join(dir, filePath));
263
+ log.debug(`Shared sync: downloaded ${scope.scopeType}/${scope.scopeId}/${file.filePath}`);
136
264
  } catch (err) {
137
- log.error(`Shared sync: failed to list files for ${scope.scopeType}/${scope.scopeId}: ${err instanceof Error ? err.message : String(err)}`);
265
+ log.error(`Shared sync: failed to download ${file.filePath}: ${err instanceof Error ? err.message : String(err)}`);
138
266
  }
139
267
  }
140
268
  function parseScopedPath(filePath) {
141
- if (filePath.includes("..")) return null;
269
+ if (filePath.length > 2048 || filePath.includes("\\") || filePath.includes("\0")) return null;
142
270
  const orgMatch = /^shared\/org\/(.+)$/.exec(filePath);
143
271
  if (orgMatch) {
144
272
  const scope = activeScopes.find((s) => s.scopeType === "org");
145
- if (scope) return {
146
- scope,
147
- relativePath: orgMatch[1]
148
- };
273
+ if (scope) try {
274
+ return {
275
+ scope,
276
+ relativePath: validateSharedFilePath(orgMatch[1])
277
+ };
278
+ } catch {
279
+ return null;
280
+ }
149
281
  }
150
282
  const teamMatch = /^shared\/teams\/([^/]+)\/(.+)$/.exec(filePath);
151
283
  if (teamMatch) {
152
284
  const scope = activeScopes.find((s) => s.scopeType === "team" && s.scopeId === teamMatch[1]);
153
- if (scope) return {
154
- scope,
155
- relativePath: teamMatch[2]
156
- };
285
+ if (scope) try {
286
+ return {
287
+ scope,
288
+ relativePath: validateSharedFilePath(teamMatch[2])
289
+ };
290
+ } catch {
291
+ return null;
292
+ }
157
293
  }
158
294
  const projectMatch = /^shared\/projects\/([^/]+)\/(.+)$/.exec(filePath);
159
295
  if (projectMatch) {
160
296
  const scope = activeScopes.find((s) => s.scopeType === "project" && s.scopeId === projectMatch[1]);
161
- if (scope) return {
162
- scope,
163
- relativePath: projectMatch[2]
164
- };
297
+ if (scope) try {
298
+ return {
299
+ scope,
300
+ relativePath: validateSharedFilePath(projectMatch[2])
301
+ };
302
+ } catch {
303
+ return null;
304
+ }
165
305
  }
166
306
  return null;
167
307
  }
168
308
  return {
169
309
  async initialize(scopes) {
170
- activeScopes = [...scopes];
310
+ activeScopes = scopes.map(validateScope);
171
311
  log.info(`Shared sync: initializing with ${String(scopes.length)} scope(s)`);
172
312
  await mkdir(sharedDir, { recursive: true });
173
313
  for (const scope of scopes) await syncScope(scope);
174
314
  log.info("Shared sync: initialization complete");
175
315
  },
176
316
  async updateScopes(newScopes) {
317
+ const validatedScopes = newScopes.map(validateScope);
177
318
  const oldIds = new Set(activeScopes.map((s) => `${s.scopeType}:${s.scopeId}`));
178
- const newIds = new Set(newScopes.map((s) => `${s.scopeType}:${s.scopeId}`));
319
+ const newIds = new Set(validatedScopes.map((s) => `${s.scopeType}:${s.scopeId}`));
179
320
  for (const scope of activeScopes) {
180
321
  const key = `${scope.scopeType}:${scope.scopeId}`;
181
322
  if (!newIds.has(key)) {
@@ -191,14 +332,14 @@ function createSharedSyncEngine(config, log) {
191
332
  }
192
333
  }
193
334
  }
194
- for (const scope of newScopes) {
335
+ for (const scope of validatedScopes) {
195
336
  const key = `${scope.scopeType}:${scope.scopeId}`;
196
337
  if (!oldIds.has(key)) {
197
338
  log.info(`Shared sync: new scope ${scope.scopeType}/${scope.scopeId} — syncing files`);
198
339
  await syncScope(scope);
199
340
  }
200
341
  }
201
- activeScopes = [...newScopes];
342
+ activeScopes = [...validatedScopes];
202
343
  },
203
344
  async handleNotification(filePath, eventType) {
204
345
  const parsed = parseScopedPath(filePath);
@@ -206,11 +347,8 @@ function createSharedSyncEngine(config, log) {
206
347
  log.debug(`Shared sync: ignoring notification for unknown path: ${filePath}`);
207
348
  return;
208
349
  }
209
- const dir = scopeDir(parsed.scope);
210
- const localPath = join(dir, parsed.relativePath);
211
- try {
212
- assertContained(dir, localPath);
213
- } catch {
350
+ const localPath = await assertNoSymlinkTraversal(scopeDir(parsed.scope), parsed.relativePath).catch(() => null);
351
+ if (!localPath) {
214
352
  log.warn(`Shared sync: path traversal blocked for ${filePath}`);
215
353
  return;
216
354
  }
@@ -306,6 +444,9 @@ const SYNC_ACTIVATION_KEY = getActivationKey("sync");
306
444
  const SYNC_RELAY_RECONNECT_BASE_MS = 1e3;
307
445
  const SYNC_RELAY_RECONNECT_MAX_MS = 3e4;
308
446
  const SYNC_RELAY_DEBOUNCE_MS = 500;
447
+ const SYNC_RELAY_MAX_MESSAGE_BYTES = 64 * 1024;
448
+ const SYNC_RELAY_HANDSHAKE_TIMEOUT_MS = 1e4;
449
+ const REMOTE_DELETE_SUPPRESSION_MS = 3e4;
309
450
  let client = null;
310
451
  let agentId = null;
311
452
  let syncEngine = null;
@@ -320,7 +461,119 @@ let syncRelayWs = null;
320
461
  let syncRelayReconnectTimer = null;
321
462
  let syncRelayReconnectAttempt = 0;
322
463
  let syncRelayDebounceTimer = null;
464
+ let syncRelayGeneration = 0;
465
+ let syncRelayActive = false;
466
+ let syncOperationTail = Promise.resolve();
323
467
  const syncRelayPendingPaths = /* @__PURE__ */ new Map();
468
+ const remoteDeleteSuppressions = /* @__PURE__ */ new Map();
469
+ function enqueueSyncOperation(operation) {
470
+ const result = syncOperationTail.then(operation, operation);
471
+ syncOperationTail = result.then(() => void 0, () => void 0);
472
+ return result;
473
+ }
474
+ function parseRelayMessage(value) {
475
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
476
+ const candidate = value;
477
+ if (candidate.type === "PING") return { type: "PING" };
478
+ if (candidate.type === "SUBSCRIBE_ACK" && (candidate.status === "ok" || candidate.status === "error") && typeof candidate.agentId === "string" && candidate.agentId.length > 0 && candidate.agentId.length <= 512 && (candidate.message === void 0 || typeof candidate.message === "string" && candidate.message.length <= 1024)) return {
479
+ type: "SUBSCRIBE_ACK",
480
+ status: candidate.status,
481
+ agentId: candidate.agentId,
482
+ ...typeof candidate.message === "string" ? { message: candidate.message } : {}
483
+ };
484
+ if (candidate.type === "FILE_CHANGED" && typeof candidate.agentId === "string" && candidate.agentId.length > 0 && candidate.agentId.length <= 512 && typeof candidate.filePath === "string" && (candidate.eventType === "created" || candidate.eventType === "deleted") && (candidate.etag === void 0 || typeof candidate.etag === "string" && candidate.etag.length <= 256)) try {
485
+ const sharedSegments = candidate.filePath.split("/");
486
+ const isCanonicalSharedPath = sharedSegments[0] === "shared" && sharedSegments.length >= 3 && !sharedSegments.some((segment) => segment === "" || segment === "." || segment === "..");
487
+ const filePath = isCanonicalSharedPath ? candidate.filePath : validatePrivateRelativePath(candidate.filePath);
488
+ if (filePath.length > 1024 || filePath.includes("\0") || filePath.includes("\\") || (filePath === "shared" || filePath.startsWith("shared/")) && !isCanonicalSharedPath) return null;
489
+ return {
490
+ type: "FILE_CHANGED",
491
+ agentId: candidate.agentId,
492
+ filePath,
493
+ eventType: candidate.eventType,
494
+ ...typeof candidate.etag === "string" ? { etag: candidate.etag } : {}
495
+ };
496
+ } catch {
497
+ return null;
498
+ }
499
+ return null;
500
+ }
501
+ function parseSharedScopes(value) {
502
+ if (!Array.isArray(value) || value.length > 1e3) return null;
503
+ const scopes = [];
504
+ for (const item of value) {
505
+ if (typeof item !== "object" || item === null || Array.isArray(item)) return null;
506
+ const candidate = item;
507
+ if (candidate.scopeType !== "org" && candidate.scopeType !== "team" && candidate.scopeType !== "project" || typeof candidate.scopeId !== "string" || candidate.scopeId.length === 0 || candidate.scopeId.length > 512 || candidate.scopeId === "." || candidate.scopeId === ".." || candidate.scopeId.includes("/") || candidate.scopeId.includes("\\") || candidate.scopeId.includes("\0") || typeof candidate.name !== "string" || candidate.name.length > 512) return null;
508
+ scopes.push({
509
+ scopeType: candidate.scopeType,
510
+ scopeId: candidate.scopeId,
511
+ name: candidate.name
512
+ });
513
+ }
514
+ return scopes;
515
+ }
516
+ async function runFullSync(engine, log, label) {
517
+ return enqueueSyncOperation(async () => {
518
+ if (syncEngine !== engine) return null;
519
+ try {
520
+ const result = await engine.fullSync({ quiet: true });
521
+ lastSyncResult = result;
522
+ log.info(`${label} complete: ${String(result.pushed)} pushed, ${String(result.pulled)} pulled`);
523
+ return result;
524
+ } catch (err) {
525
+ log.error(`${label} failed: ${err instanceof Error ? err.message : String(err)}`);
526
+ return null;
527
+ }
528
+ });
529
+ }
530
+ async function handleRealtimeChanges(paths, workspacePath, log) {
531
+ const engine = syncEngine;
532
+ const brake = deleteBrake;
533
+ if (!engine || !brake) return;
534
+ await enqueueSyncOperation(async () => {
535
+ if (syncEngine !== engine || deleteBrake !== brake) return;
536
+ const existing = [];
537
+ const missing = [];
538
+ const now = Date.now();
539
+ for (const [suppressedPath, expiresAt] of remoteDeleteSuppressions) if (expiresAt <= now) remoteDeleteSuppressions.delete(suppressedPath);
540
+ for (const candidate of paths) {
541
+ let path;
542
+ try {
543
+ path = validatePrivateRelativePath(candidate);
544
+ } catch {
545
+ log.warn("Realtime sync ignored an invalid workspace path");
546
+ continue;
547
+ }
548
+ if (existsSync(join(workspacePath, path))) {
549
+ remoteDeleteSuppressions.delete(path);
550
+ existing.push(path);
551
+ } else if ((remoteDeleteSuppressions.get(path) ?? 0) > now) remoteDeleteSuppressions.delete(path);
552
+ else missing.push(path);
553
+ }
554
+ log.debug(`Realtime sync: ${String(existing.length)} upload(s), ${String(missing.length)} delete(s)`);
555
+ if (existing.length > 0) try {
556
+ lastSyncResult = await engine.push(existing, { quiet: true });
557
+ } catch (err) {
558
+ log.error(`Realtime push failed: ${err instanceof Error ? err.message : String(err)}`);
559
+ }
560
+ if (missing.length === 0) return;
561
+ const artifactDeletes = missing.filter((path) => isRecoveryArtifact(path));
562
+ const regularDeletes = missing.filter((path) => !isRecoveryArtifact(path));
563
+ const manifest = await readManifest(workspacePath);
564
+ const manifestSize = Object.keys(manifest.files).length;
565
+ let toDelete = missing;
566
+ if (!brake.check(regularDeletes.length, manifestSize)) {
567
+ log.error(`Sync delete brake tripped: ${String(brake.windowSum() + regularDeletes.length)} deletes in last 60s vs manifest size ${String(manifestSize)} (threshold 30%). Refusing batch — investigate the workspace state.`);
568
+ toDelete = artifactDeletes;
569
+ }
570
+ if (toDelete.length > 0) try {
571
+ lastSyncResult = await engine.pushDeletes(toDelete, { quiet: true });
572
+ } catch (err) {
573
+ log.error(`Realtime delete failed: ${err instanceof Error ? err.message : String(err)}`);
574
+ }
575
+ });
576
+ }
324
577
  const SCHEDULE_INTERVALS_MS = {
325
578
  hourly: 3600 * 1e3,
326
579
  daily: 1440 * 60 * 1e3,
@@ -348,45 +601,30 @@ function setupSchedule(schedule, log) {
348
601
  scheduledInterval = setInterval(() => {
349
602
  if (!syncEngine) return;
350
603
  const engine = syncEngine;
351
- (async () => {
352
- try {
353
- log.info(`Scheduled sync (${schedule}) starting...`);
354
- lastSyncResult = await engine.fullSync({ quiet: true });
355
- log.info(`Scheduled sync complete: ${String(lastSyncResult.pushed)} pushed, ${String(lastSyncResult.pulled)} pulled`);
356
- } catch (err) {
357
- log.error(`Scheduled sync failed: ${err instanceof Error ? err.message : String(err)}`);
358
- }
359
- })();
604
+ log.info(`Scheduled sync (${schedule}) starting...`);
605
+ runFullSync(engine, log, "Scheduled sync");
360
606
  }, intervalMs);
361
607
  scheduledInterval.unref();
362
608
  }
363
609
  function handleDaemonMessage(msg, log) {
364
610
  if (msg.type === "SYNC_NOW" || msg.command === "SYNC_NOW") {
365
611
  log.info("Received SYNC_NOW command — triggering immediate sync...");
366
- if (syncEngine) {
367
- const engine = syncEngine;
368
- (async () => {
369
- try {
370
- lastSyncResult = await engine.fullSync({ quiet: true });
371
- log.info(`SYNC_NOW complete: ${String(lastSyncResult.pushed)} pushed, ${String(lastSyncResult.pulled)} pulled`);
372
- } catch (err) {
373
- log.error(`SYNC_NOW failed: ${err instanceof Error ? err.message : String(err)}`);
374
- }
375
- })();
376
- }
612
+ if (syncEngine) runFullSync(syncEngine, log, "SYNC_NOW");
377
613
  }
378
614
  if (msg.type === "SHARED_SCOPES") {
379
- const scopes = msg.scopes;
615
+ const scopes = parseSharedScopes(msg.scopes);
380
616
  const engine = sharedSyncEngine;
381
- if (scopes && engine) {
617
+ if (!scopes) log.warn("Ignored invalid SHARED_SCOPES payload from daemon");
618
+ else if (engine) {
382
619
  log.info(`Received SHARED_SCOPES update: ${String(scopes.length)} scope(s)`);
383
- (async () => {
620
+ enqueueSyncOperation(async () => {
621
+ if (sharedSyncEngine !== engine) return;
384
622
  try {
385
623
  await engine.updateScopes(scopes);
386
624
  } catch (err) {
387
625
  log.error(`SHARED_SCOPES update failed: ${err instanceof Error ? err.message : String(err)}`);
388
626
  }
389
- })();
627
+ });
390
628
  }
391
629
  }
392
630
  }
@@ -422,6 +660,7 @@ async function processPendingNotifications(log) {
422
660
  for (const [filePath, info] of privateEntries) if (info.eventType === "deleted") toDelete.push(filePath);
423
661
  else toPull.push(filePath);
424
662
  for (const filePath of toDelete) try {
663
+ remoteDeleteSuppressions.set(filePath, Date.now() + REMOTE_DELETE_SUPPRESSION_MS);
425
664
  await engine.removeLocalFile(filePath, { quiet: true });
426
665
  log.debug(`Sync relay: deleted ${filePath}`);
427
666
  } catch (err) {
@@ -436,11 +675,20 @@ async function processPendingNotifications(log) {
436
675
  }
437
676
  }
438
677
  }
439
- async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
678
+ async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log, generation) {
440
679
  try {
441
680
  const { default: WebSocket } = await import("ws");
442
- const ws = new WebSocket(`${relayUrl}?token=${encodeURIComponent(token)}`);
681
+ if (!syncRelayActive || generation !== syncRelayGeneration) return null;
682
+ const ws = new WebSocket(relayUrl, {
683
+ headers: { Authorization: `Bearer ${token}` },
684
+ handshakeTimeout: SYNC_RELAY_HANDSHAKE_TIMEOUT_MS,
685
+ maxPayload: SYNC_RELAY_MAX_MESSAGE_BYTES
686
+ });
443
687
  ws.on("open", () => {
688
+ if (!syncRelayActive || generation !== syncRelayGeneration) {
689
+ ws.close(1e3, "Stale sync relay connection");
690
+ return;
691
+ }
444
692
  log.info("Connected to Sync Relay");
445
693
  syncRelayReconnectAttempt = 0;
446
694
  ws.send(JSON.stringify({
@@ -449,34 +697,47 @@ async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
449
697
  }));
450
698
  });
451
699
  ws.on("message", (data) => {
452
- let message;
700
+ if (!syncRelayActive || generation !== syncRelayGeneration) return;
701
+ const raw = data.toString();
702
+ if (Buffer.byteLength(raw) > SYNC_RELAY_MAX_MESSAGE_BYTES) {
703
+ ws.close(1009, "Sync relay message too large");
704
+ return;
705
+ }
706
+ let value;
453
707
  try {
454
- message = JSON.parse(data.toString());
708
+ value = JSON.parse(raw);
455
709
  } catch {
456
710
  return;
457
711
  }
712
+ const message = parseRelayMessage(value);
713
+ if (!message) return;
458
714
  switch (message.type) {
459
715
  case "SUBSCRIBE_ACK":
716
+ if (message.agentId !== agentIdForSubscribe) return;
460
717
  if (message.status === "ok") {
461
- log.info(`Subscribed to sync notifications for agent ${message.agentId ?? agentIdForSubscribe}`);
718
+ log.info(`Subscribed to sync notifications for agent ${message.agentId}`);
462
719
  const sharedEngine = sharedSyncEngine;
463
- if (sharedEngine) (async () => {
720
+ if (sharedEngine) enqueueSyncOperation(async () => {
721
+ if (sharedSyncEngine !== sharedEngine) return;
464
722
  try {
465
723
  await sharedEngine.fullSync();
466
724
  log.info("Shared sync: reconnect full sync complete");
467
725
  } catch (err) {
468
726
  log.error(`Shared sync: reconnect full sync failed: ${err instanceof Error ? err.message : String(err)}`);
469
727
  }
470
- })();
728
+ });
471
729
  } else log.warn(`Sync relay subscribe failed: ${message.message ?? "unknown"}`);
472
730
  break;
473
731
  case "FILE_CHANGED":
474
- if (message.filePath) syncRelayPendingPaths.set(message.filePath, {
732
+ if (message.agentId !== agentIdForSubscribe) return;
733
+ syncRelayPendingPaths.set(message.filePath, {
475
734
  etag: message.etag,
476
- eventType: message.eventType === "deleted" ? "deleted" : "created"
735
+ eventType: message.eventType
477
736
  });
478
737
  clearSyncRelayDebounce();
479
- syncRelayDebounceTimer = setTimeout(() => void processPendingNotifications(log), SYNC_RELAY_DEBOUNCE_MS);
738
+ syncRelayDebounceTimer = setTimeout(() => {
739
+ enqueueSyncOperation(() => processPendingNotifications(log));
740
+ }, SYNC_RELAY_DEBOUNCE_MS);
480
741
  syncRelayDebounceTimer.unref();
481
742
  break;
482
743
  case "PING":
@@ -487,9 +748,10 @@ async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
487
748
  }
488
749
  });
489
750
  ws.on("close", (code) => {
751
+ if (syncRelayWs === ws) syncRelayWs = null;
752
+ if (!syncRelayActive || generation !== syncRelayGeneration) return;
490
753
  log.info(`Sync Relay disconnected (code=${String(code)})`);
491
- syncRelayWs = null;
492
- scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log);
754
+ scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log, generation);
493
755
  });
494
756
  ws.on("error", (err) => {
495
757
  log.debug(`Sync Relay error: ${err.message}`);
@@ -497,40 +759,43 @@ async function connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log) {
497
759
  return ws;
498
760
  } catch (err) {
499
761
  log.debug(`Failed to connect to Sync Relay: ${err instanceof Error ? err.message : String(err)}`);
500
- scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log);
762
+ if (syncRelayActive && generation === syncRelayGeneration) scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log, generation);
501
763
  return null;
502
764
  }
503
765
  }
504
- function scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log) {
766
+ function scheduleSyncRelayReconnect(relayUrl, token, agentIdForSubscribe, log, generation) {
767
+ if (!syncRelayActive || generation !== syncRelayGeneration) return;
505
768
  clearSyncRelayReconnect();
506
769
  const delay = Math.min(SYNC_RELAY_RECONNECT_BASE_MS * Math.pow(2, syncRelayReconnectAttempt), SYNC_RELAY_RECONNECT_MAX_MS);
507
770
  syncRelayReconnectAttempt++;
508
771
  log.debug(`Reconnecting to Sync Relay in ${String(delay)}ms (attempt ${String(syncRelayReconnectAttempt)})`);
509
772
  syncRelayReconnectTimer = setTimeout(() => {
510
773
  (async () => {
511
- syncRelayWs = await connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log);
774
+ const socket = await connectToSyncRelay(relayUrl, token, agentIdForSubscribe, log, generation);
775
+ if (syncRelayActive && generation === syncRelayGeneration) syncRelayWs = socket;
776
+ else if (socket) socket.close(1e3, "Stale sync relay connection");
512
777
  })();
513
778
  }, delay);
514
779
  syncRelayReconnectTimer.unref();
515
780
  }
516
781
  function disconnectSyncRelay() {
782
+ syncRelayActive = false;
783
+ syncRelayGeneration++;
517
784
  clearSyncRelayReconnect();
518
785
  clearSyncRelayDebounce();
519
786
  syncRelayPendingPaths.clear();
787
+ remoteDeleteSuppressions.clear();
520
788
  if (syncRelayWs) {
521
789
  try {
522
- syncRelayWs.send(JSON.stringify({ type: "UNSUBSCRIBE" }));
790
+ if (agentId) syncRelayWs.send(JSON.stringify({
791
+ type: "UNSUBSCRIBE",
792
+ agentId
793
+ }));
523
794
  syncRelayWs.close(1e3, "Plugin deactivating");
524
795
  } catch {}
525
796
  syncRelayWs = null;
526
797
  }
527
798
  }
528
- function deriveRelayUrl(apiUrl) {
529
- if (apiUrl.includes("dev.alfe.ai")) return "wss://sync.dev.alfe.ai/ws";
530
- if (apiUrl.includes("demo.alfe.ai")) return "wss://sync.demo.alfe.ai/ws";
531
- if (apiUrl.includes("test.alfe.ai")) return "wss://sync.test.alfe.ai/ws";
532
- return "wss://sync.alfe.ai/ws";
533
- }
534
799
  const plugin = {
535
800
  id: "@alfe.ai/openclaw-sync",
536
801
  name: "Alfe Sync Plugin",
@@ -590,8 +855,12 @@ const plugin = {
590
855
  } catch (err) {
591
856
  log.warn(`Initial workspace reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
592
857
  }
593
- syncEngine.pruneIgnored({ quiet: true }).then((pruned) => {
594
- if (pruned.pushed > 0) log.info(`Pruned ${String(pruned.pushed)} ignored file(s) from cloud`);
858
+ const engineForPrune = syncEngine;
859
+ enqueueSyncOperation(async () => {
860
+ if (syncEngine !== engineForPrune) return null;
861
+ return engineForPrune.pruneIgnored({ quiet: true });
862
+ }).then((pruned) => {
863
+ if (pruned && pruned.pushed > 0) log.info(`Pruned ${String(pruned.pushed)} ignored file(s) from cloud`);
595
864
  }).catch((err) => {
596
865
  log.warn(`Ignored-file prune failed: ${err instanceof Error ? err.message : String(err)}`);
597
866
  });
@@ -601,35 +870,7 @@ const plugin = {
601
870
  workspacePath,
602
871
  runtime,
603
872
  debounceMs: 2e3,
604
- onChanges: async (paths) => {
605
- if (!syncEngine) return;
606
- const existing = [];
607
- const missing = [];
608
- for (const p of paths) if (existsSync(join(workspacePath, p))) existing.push(p);
609
- else missing.push(p);
610
- log.debug(`Realtime sync: ${String(existing.length)} upload(s), ${String(missing.length)} delete(s)`);
611
- if (existing.length > 0) try {
612
- lastSyncResult = await syncEngine.push(existing, { quiet: true });
613
- } catch (err) {
614
- log.error(`Realtime push failed: ${err instanceof Error ? err.message : String(err)}`);
615
- }
616
- if (missing.length > 0) {
617
- const artifactDeletes = missing.filter((p) => isRecoveryArtifact(p));
618
- const regularDeletes = missing.filter((p) => !isRecoveryArtifact(p));
619
- const manifest = await readManifest(workspacePath);
620
- const manifestSize = Object.keys(manifest.files).length;
621
- let toDelete = missing;
622
- if (deleteBrake && !deleteBrake.check(regularDeletes.length, manifestSize)) {
623
- log.error(`Sync delete brake tripped: ${String(deleteBrake.windowSum() + regularDeletes.length)} deletes in last 60s vs manifest size ${String(manifestSize)} (threshold 30%). Refusing batch — investigate the workspace state.`);
624
- toDelete = artifactDeletes;
625
- }
626
- if (toDelete.length > 0) try {
627
- lastSyncResult = await syncEngine.pushDeletes(toDelete, { quiet: true });
628
- } catch (err) {
629
- log.error(`Realtime delete failed: ${err instanceof Error ? err.message : String(err)}`);
630
- }
631
- }
632
- }
873
+ onChanges: (paths) => handleRealtimeChanges(paths, workspacePath, log)
633
874
  });
634
875
  log.info("File watcher started for realtime sync");
635
876
  } catch (err) {
@@ -652,11 +893,6 @@ const plugin = {
652
893
  log.warn(`Sync register failed: ${err instanceof Error ? err.message : String(err)}`);
653
894
  }
654
895
  if (registered) {
655
- try {
656
- syncRelayWs = await connectToSyncRelay(pluginConfig.syncRelayUrl ?? deriveRelayUrl(syncCfg.apiUrl), syncCfg.apiKey, registered.agentId, log);
657
- } catch (err) {
658
- log.debug(`Sync Relay connection skipped: ${err instanceof Error ? err.message : String(err)}`);
659
- }
660
896
  if (pluginConfig.sharedSync !== false) try {
661
897
  sharedSyncEngine = createSharedSyncEngine({
662
898
  workspacePath,
@@ -666,12 +902,21 @@ const plugin = {
666
902
  } catch (err) {
667
903
  log.debug(`Shared sync engine skipped: ${err instanceof Error ? err.message : String(err)}`);
668
904
  }
905
+ try {
906
+ const relayUrl = pluginConfig.syncRelayUrl ?? deriveServiceWsUrl(syncCfg.apiUrl, "sync");
907
+ syncRelayActive = true;
908
+ const relayGeneration = ++syncRelayGeneration;
909
+ syncRelayWs = await connectToSyncRelay(relayUrl, syncCfg.apiKey, registered.agentId, log, relayGeneration);
910
+ } catch (err) {
911
+ log.debug(`Sync Relay connection skipped: ${err instanceof Error ? err.message : String(err)}`);
912
+ }
669
913
  }
670
914
  });
671
915
  };
672
916
  const stopSyncService = async () => {
673
917
  clearSchedule();
674
918
  disconnectSyncRelay();
919
+ deleteBrake = null;
675
920
  if (stopWatcher) {
676
921
  try {
677
922
  await stopWatcher();
@@ -681,7 +926,6 @@ const plugin = {
681
926
  }
682
927
  stopWatcher = null;
683
928
  }
684
- deleteBrake = null;
685
929
  if (daemonIpcClient) {
686
930
  try {
687
931
  daemonIpcClient.stop();
@@ -707,10 +951,13 @@ const plugin = {
707
951
  error: "Sync engine not initialized — run `alfe login`"
708
952
  };
709
953
  try {
710
- lastSyncResult = await syncEngine.fullSync({ quiet: true });
711
- return {
954
+ const result = await runFullSync(syncEngine, log, "sync.now");
955
+ return result ? {
712
956
  ok: true,
713
- result: lastSyncResult
957
+ result
958
+ } : {
959
+ ok: false,
960
+ error: "Sync did not complete"
714
961
  };
715
962
  } catch (err) {
716
963
  return {
@@ -724,7 +971,7 @@ const plugin = {
724
971
  ok: true,
725
972
  initialized: !!syncEngine,
726
973
  agentId,
727
- schedule: currentConfig.syncSchedule ?? "daily",
974
+ schedule: currentConfig.syncSchedule ?? "realtime",
728
975
  scope: currentConfig.syncScope ?? [
729
976
  "config",
730
977
  "conversations",
@@ -749,6 +996,7 @@ const plugin = {
749
996
  log.info("Alfe Sync plugin deactivating...");
750
997
  clearSchedule();
751
998
  disconnectSyncRelay();
999
+ deleteBrake = null;
752
1000
  if (stopWatcher) {
753
1001
  try {
754
1002
  await stopWatcher();
@@ -785,22 +1033,28 @@ const plugin = {
785
1033
  };
786
1034
  if (config.syncSchedule) {
787
1035
  if (config.syncSchedule === "realtime" && syncEngine && !stopWatcher) {
1036
+ clearSchedule();
1037
+ const engine = syncEngine;
1038
+ try {
1039
+ const reconciled = await enqueueSyncOperation(async () => {
1040
+ if (syncEngine !== engine) return null;
1041
+ return engine.firstRunReconcile({ quiet: true });
1042
+ });
1043
+ if (reconciled) lastSyncResult = reconciled;
1044
+ } catch (err) {
1045
+ log.warn(`Realtime reconfigure reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
1046
+ }
1047
+ const workspacePath = currentConfig.workspacePath ?? syncEngine.workspacePath;
1048
+ deleteBrake = createDeleteBrake();
788
1049
  stopWatcher = await startWatcher({
789
- workspacePath: currentConfig.workspacePath ?? syncEngine.workspacePath,
1050
+ workspacePath,
790
1051
  runtime: syncEngine.runtime,
791
1052
  debounceMs: 2e3,
792
- onChanges: async (paths) => {
793
- if (!syncEngine) return;
794
- try {
795
- lastSyncResult = await syncEngine.push(paths, { quiet: true });
796
- } catch (err) {
797
- log.error(`Realtime push failed: ${err instanceof Error ? err.message : String(err)}`);
798
- }
799
- }
1053
+ onChanges: (paths) => handleRealtimeChanges(paths, workspacePath, log)
800
1054
  });
801
- clearSchedule();
802
1055
  log.info("Switched to realtime sync");
803
1056
  } else if (config.syncSchedule !== "realtime") {
1057
+ deleteBrake = null;
804
1058
  if (stopWatcher) {
805
1059
  await stopWatcher();
806
1060
  stopWatcher = null;