@relaymessenger/openclaw-plugin 0.3.3 → 0.4.0-staging.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +159 -124
  3. package/contracts/relay-sdk-0.3.0-staging.4.registry.json +58 -0
  4. package/contracts/relay-v1.lock.json +77 -0
  5. package/dist/index.js +2 -2
  6. package/dist/setup-entry.js +1 -2
  7. package/dist/src/accounts.js +63 -34
  8. package/dist/src/channel.js +144 -498
  9. package/dist/src/dispatch.js +257 -0
  10. package/dist/src/full-sync.js +24 -0
  11. package/dist/src/gateway.js +171 -0
  12. package/dist/src/inbound.js +54 -80
  13. package/dist/src/ingress.js +64 -0
  14. package/dist/src/outbound.js +48 -109
  15. package/dist/src/runtime.js +2 -3
  16. package/dist/src/state.js +492 -0
  17. package/dist/src/types.js +1 -3
  18. package/index.ts +1 -2
  19. package/openclaw.plugin.json +15 -18
  20. package/package.json +113 -40
  21. package/setup-entry.ts +0 -2
  22. package/src/accounts.ts +95 -51
  23. package/src/channel.ts +271 -611
  24. package/src/dispatch.ts +324 -0
  25. package/src/full-sync.ts +47 -0
  26. package/src/gateway.ts +216 -0
  27. package/src/inbound.ts +71 -111
  28. package/src/ingress.ts +123 -0
  29. package/src/outbound.ts +70 -142
  30. package/src/runtime.ts +4 -4
  31. package/src/state.ts +609 -0
  32. package/src/types.ts +51 -148
  33. package/dist/src/account-lock.js +0 -91
  34. package/dist/src/client.js +0 -229
  35. package/dist/src/cursor-store.js +0 -136
  36. package/dist/src/inbound-dedupe.js +0 -175
  37. package/dist/src/lifecycle.js +0 -35
  38. package/dist/src/poll-loop.js +0 -125
  39. package/dist/src/responding.js +0 -13
  40. package/dist/src/security.js +0 -26
  41. package/dist/src/state-files.js +0 -167
  42. package/src/account-lock.ts +0 -108
  43. package/src/client.ts +0 -330
  44. package/src/cursor-store.ts +0 -186
  45. package/src/inbound-dedupe.ts +0 -241
  46. package/src/lifecycle.ts +0 -42
  47. package/src/poll-loop.ts +0 -161
  48. package/src/responding.ts +0 -21
  49. package/src/security.ts +0 -36
  50. package/src/state-files.ts +0 -212
@@ -1,175 +0,0 @@
1
- // Relay inbound replay protection. The long-poll cursor acknowledges batches,
2
- // so a crash between dispatch and cursor advance
3
- // replays events. Each (canonical origin, agent, event) is claimed, safely
4
- // preflighted, and durably committed immediately before agent dispatch. That
5
- // gives engine/tool side effects at-most-once semantics:
6
- // an interrupted turn may need the user to resend, but it is never silently
7
- // executed twice.
8
- import { createHash } from "node:crypto";
9
- import { assertRelayStateDocument, emptyRelayStateDocument, openRelayStateDocument, } from "./state-files.js";
10
- // One shared namespace with stable Relay identity baked into each key so local
11
- // account renames cannot reset safety state or partition the row budget.
12
- const RELAY_INBOUND_DEDUPE_SCOPE = "global";
13
- // 30d window: a long outage can replay a deep cursor backlog.
14
- export const RELAY_INBOUND_DEDUPE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
15
- export const RELAY_INBOUND_DEDUPE_STATE_MAX_ENTRIES = 20_000;
16
- export function buildRelayInboundDedupeKey(params) {
17
- const eventId = params.eventId.trim();
18
- if (!eventId) {
19
- return null;
20
- }
21
- // NUL separator: event ids are opaque strings, so a printable separator
22
- // could collide two distinct (account, event) pairs.
23
- return `${new URL(params.baseUrl).origin}\0${params.agentId}\0${eventId}`;
24
- }
25
- export function createRelayInboundDeduper(params) {
26
- const namespace = RELAY_INBOUND_DEDUPE_SCOPE;
27
- return {
28
- claimEvent: async (eventId) => {
29
- const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
30
- if (!key) {
31
- // Fail closed: an event without a durable identity cannot safely
32
- // cross the at-most-once agent/tool side-effect boundary.
33
- return false;
34
- }
35
- return (await params.guard.claim(key, { namespace })).kind === "claimed";
36
- },
37
- commitEvent: async (eventId) => {
38
- const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
39
- if (!key) {
40
- return;
41
- }
42
- await params.guard.commit(key, { namespace });
43
- },
44
- releaseEvent: (eventId) => {
45
- const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
46
- if (key) {
47
- params.guard.release(key, { namespace });
48
- }
49
- },
50
- };
51
- }
52
- function durableAttemptKey(key, namespace) {
53
- return createHash("sha256").update(`${namespace}\0${key}`).digest("hex");
54
- }
55
- /**
56
- * Strict Relay-owned guard used by the channel runtime. Unlike a normal
57
- * message dedupe cache, persistence is not best effort: a failed attempt write
58
- * must stop before agent dispatch or a crash could execute local tools twice.
59
- */
60
- export function createRelayInboundDedupeGuard(params) {
61
- const now = params?.now ?? Date.now;
62
- const ttlMs = params?.ttlMs ?? RELAY_INBOUND_DEDUPE_TTL_MS;
63
- const maxEntries = params?.maxEntries ?? RELAY_INBOUND_DEDUPE_STATE_MAX_ENTRIES;
64
- if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) {
65
- throw new Error("relay inbound dedupe ttlMs must be a positive safe integer");
66
- }
67
- if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
68
- throw new Error("relay inbound dedupe maxEntries must be a positive safe integer");
69
- }
70
- const readNow = () => {
71
- const timestamp = now();
72
- if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
73
- throw new Error("relay inbound dedupe clock returned an invalid timestamp");
74
- }
75
- return timestamp;
76
- };
77
- const isPersistedAttempt = (key, value) => {
78
- if (!/^[a-f0-9]{64}$/u.test(key) || !value || typeof value !== "object")
79
- return false;
80
- const entry = value;
81
- return (typeof entry.attemptedAt === "number" &&
82
- Number.isSafeInteger(entry.attemptedAt) &&
83
- entry.attemptedAt >= 0 &&
84
- typeof entry.expiresAt === "number" &&
85
- Number.isSafeInteger(entry.expiresAt) &&
86
- entry.expiresAt > entry.attemptedAt);
87
- };
88
- const state = params?.store
89
- ? undefined
90
- : openRelayStateDocument({
91
- fileName: "inbound-attempts.json",
92
- ...(params?.env ? { env: params.env } : {}),
93
- });
94
- const store = params?.store ??
95
- {
96
- lookup: async (key) => {
97
- const current = await state.read();
98
- if (current === undefined)
99
- return undefined;
100
- assertRelayStateDocument(current, "inbound attempt", isPersistedAttempt);
101
- const entry = current.entries[key];
102
- if (!entry || entry.expiresAt <= readNow())
103
- return undefined;
104
- return { attemptedAt: entry.attemptedAt };
105
- },
106
- register: async (key, value, opts) => {
107
- await state.updateOr(emptyRelayStateDocument(), (current) => {
108
- assertRelayStateDocument(current, "inbound attempt", isPersistedAttempt);
109
- const timestamp = readNow();
110
- const entryTtlMs = opts?.ttlMs ?? ttlMs;
111
- if (!Number.isSafeInteger(entryTtlMs) || entryTtlMs < 1) {
112
- throw new Error("relay inbound dedupe ttlMs must be a positive safe integer");
113
- }
114
- const expiresAt = value.attemptedAt + entryTtlMs;
115
- if (!Number.isSafeInteger(expiresAt)) {
116
- throw new Error("relay inbound dedupe expiration exceeds safe integer range");
117
- }
118
- const liveEntries = Object.fromEntries(Object.entries(current.entries).filter(([, entry]) => entry.expiresAt > timestamp));
119
- liveEntries[key] = {
120
- attemptedAt: value.attemptedAt,
121
- expiresAt,
122
- };
123
- const ordered = Object.entries(liveEntries).sort(([leftKey, left], [rightKey, right]) => left.attemptedAt - right.attemptedAt || leftKey.localeCompare(rightKey));
124
- const retained = ordered.slice(Math.max(ordered.length - maxEntries, 0));
125
- return {
126
- version: current.version,
127
- entries: Object.fromEntries(retained),
128
- };
129
- });
130
- },
131
- };
132
- const inflight = new Set();
133
- const claiming = new Set();
134
- const withDiskError = async (operation) => {
135
- try {
136
- return await operation();
137
- }
138
- catch (error) {
139
- params?.onDiskError?.(error);
140
- throw error;
141
- }
142
- };
143
- return {
144
- claim: async (key, opts) => {
145
- const storageKey = durableAttemptKey(key, opts.namespace);
146
- if (inflight.has(storageKey) || claiming.has(storageKey)) {
147
- return { kind: "inflight" };
148
- }
149
- claiming.add(storageKey);
150
- try {
151
- if ((await withDiskError(() => store.lookup(storageKey))) !== undefined) {
152
- return { kind: "duplicate" };
153
- }
154
- inflight.add(storageKey);
155
- return { kind: "claimed" };
156
- }
157
- finally {
158
- claiming.delete(storageKey);
159
- }
160
- },
161
- commit: async (key, opts) => {
162
- const storageKey = durableAttemptKey(key, opts.namespace);
163
- try {
164
- await withDiskError(() => store.register(storageKey, { attemptedAt: readNow() }, { ttlMs }));
165
- return true;
166
- }
167
- finally {
168
- inflight.delete(storageKey);
169
- }
170
- },
171
- release: (key, opts) => {
172
- inflight.delete(durableAttemptKey(key, opts.namespace));
173
- },
174
- };
175
- }
@@ -1,35 +0,0 @@
1
- /** Owns exactly one abortable long-poll lifecycle per configured account. */
2
- export function createRelayAccountLifecycleRegistry() {
3
- const controllers = new Map();
4
- return {
5
- acquire(accountId, parentSignal) {
6
- if (controllers.has(accountId)) {
7
- throw new Error(`relay: account "${accountId}" already has an active consumer`);
8
- }
9
- const controller = new AbortController();
10
- controllers.set(accountId, controller);
11
- const signal = AbortSignal.any([parentSignal, controller.signal]);
12
- let released = false;
13
- return {
14
- signal,
15
- release: () => {
16
- if (released) {
17
- return;
18
- }
19
- released = true;
20
- if (controllers.get(accountId) === controller) {
21
- controllers.delete(accountId);
22
- }
23
- },
24
- };
25
- },
26
- stop(accountId) {
27
- const controller = controllers.get(accountId);
28
- if (!controller) {
29
- return false;
30
- }
31
- controller.abort();
32
- return true;
33
- },
34
- };
35
- }
@@ -1,125 +0,0 @@
1
- // startAccount receive engine: abort-aware long poll over GET /v1/events with
2
- // claim -> durable attempt commit -> dispatch per event and the cursor advanced
3
- // only after the batch's commits. The supervisor owns restart/backoff: this loop settles
4
- // (throws) on terminal auth failure and consumer conflicts, and only sleeps
5
- // in-loop for transient errors.
6
- import { RelayApiError, isAbortError } from "./client.js";
7
- const TRANSIENT_BASE_DELAY_MS = 500;
8
- const TRANSIENT_MAX_DELAY_MS = 30_000;
9
- function defaultSleep(ms, signal) {
10
- return new Promise((resolve) => {
11
- if (signal.aborted) {
12
- resolve();
13
- return;
14
- }
15
- const timer = setTimeout(() => {
16
- signal.removeEventListener("abort", onAbort);
17
- resolve();
18
- }, ms);
19
- const onAbort = () => {
20
- clearTimeout(timer);
21
- resolve();
22
- };
23
- signal.addEventListener("abort", onAbort, { once: true });
24
- });
25
- }
26
- export async function runRelayPollLoop(params) {
27
- const sleep = params.sleep ?? defaultSleep;
28
- const random = params.random ?? Math.random;
29
- const log = params.log ?? (() => { });
30
- let transientAttempts = 0;
31
- const transientDelayMs = () => {
32
- const backoff = Math.min(TRANSIENT_MAX_DELAY_MS, TRANSIENT_BASE_DELAY_MS * 2 ** Math.min(transientAttempts, 6));
33
- return backoff + Math.floor(random() * 250);
34
- };
35
- while (!params.abortSignal.aborted) {
36
- let page;
37
- try {
38
- page = await params.client.pollEvents({
39
- cursor: params.cursorStore.current(),
40
- timeoutSeconds: params.timeoutSeconds ?? 30,
41
- ...(params.limit === undefined ? {} : { limit: params.limit }),
42
- signal: params.abortSignal,
43
- });
44
- }
45
- catch (error) {
46
- if (params.abortSignal.aborted || isAbortError(error)) {
47
- return;
48
- }
49
- if (error instanceof RelayApiError && error.kind === "auth") {
50
- // Token revoked: settle so the supervisor applies terminalDisconnect
51
- // (server-channels.ts:718) — an operator has to fix the token.
52
- throw error;
53
- }
54
- if (error instanceof RelayApiError && error.kind === "conflict") {
55
- // Another consumer took the long poll. Settle and let the
56
- // supervisor's backoff arbitrate.
57
- log(`[relay] long poll terminated by another consumer: ${error.message}`);
58
- throw error;
59
- }
60
- transientAttempts += 1;
61
- log(`[relay] transient poll error (attempt ${transientAttempts}): ${String(error)}`);
62
- await sleep(transientDelayMs(), params.abortSignal);
63
- continue;
64
- }
65
- transientAttempts = 0;
66
- if (page.events.length > 0) {
67
- params.onBatch?.(page.events);
68
- }
69
- let batchFailed = false;
70
- for (const event of page.events) {
71
- if (params.abortSignal.aborted) {
72
- batchFailed = true;
73
- break;
74
- }
75
- if (params.shouldProcess && !params.shouldProcess(event)) {
76
- // Bookkeeping events (receipts, reactions, echoes) never dispatch, so
77
- // they are acked by the batch cursor without a dedupe row.
78
- continue;
79
- }
80
- const claimed = await params.deduper.claimEvent(event.event_id);
81
- if (claimed) {
82
- let attempted = false;
83
- const markAttempt = async () => {
84
- if (attempted)
85
- return;
86
- await params.deduper.commitEvent(event.event_id);
87
- attempted = true;
88
- if (params.abortSignal.aborted)
89
- throw new Error("aborted after durable attempt");
90
- };
91
- try {
92
- await params.handleEvent(event, markAttempt);
93
- }
94
- catch (error) {
95
- if (!attempted) {
96
- params.deduper.releaseEvent(event.event_id);
97
- log(`[relay] event ${event.event_id} safe preflight failed, will replay: ${String(error)}`);
98
- batchFailed = true;
99
- break;
100
- }
101
- log(`[relay] event ${event.event_id} dispatch failed after its attempt was committed; ` +
102
- `will not replay possible tool side effects: ${String(error)}`);
103
- }
104
- if (!attempted) {
105
- // Admission/preflight intentionally consumed no side effect; release
106
- // the claim and allow the page cursor to acknowledge it.
107
- params.deduper.releaseEvent(event.event_id);
108
- }
109
- if (params.abortSignal.aborted) {
110
- batchFailed = true;
111
- break;
112
- }
113
- }
114
- }
115
- // The server's next_cursor covers the whole page (cursor N acks <= N), so
116
- // only a batch with durable attempt markers may ack it. A marker-write
117
- // failure acks nothing and committed predecessors absorb the replay.
118
- if (!batchFailed) {
119
- await params.cursorStore.advance(page.nextCursor);
120
- }
121
- else {
122
- await sleep(transientDelayMs(), params.abortSignal);
123
- }
124
- }
125
- }
@@ -1,13 +0,0 @@
1
- /**
2
- * The receipt must commit before OpenClaw can run an agent or tool. A rejected
3
- * receipt leaves the durable attempt marker untouched, so the poll loop can
4
- * replay the event safely instead of hiding the failure after execution.
5
- */
6
- export async function markRespondingBeforeAttempt(params) {
7
- await params.client.setResponding({
8
- conversationId: params.conversationId,
9
- messageId: params.messageId,
10
- label: params.label,
11
- });
12
- await params.markAttempt();
13
- }
@@ -1,26 +0,0 @@
1
- function normalizeSenderId(value) {
2
- const normalized = String(value).trim();
3
- return normalized ? normalized : null;
4
- }
5
- /**
6
- * Build the only identities allowed to start an OpenClaw turn. The Relay
7
- * account owner is pinned from the authenticated agent profile; operators can
8
- * deliberately extend that set with `allowFrom`. No wildcard is accepted.
9
- */
10
- export function resolveRelayAllowedSenderIds(params) {
11
- const allowed = new Set();
12
- const owner = params.profile.owner_user_id?.trim();
13
- if (owner) {
14
- allowed.add(owner);
15
- }
16
- for (const entry of params.allowFrom ?? []) {
17
- const normalized = normalizeSenderId(entry);
18
- if (normalized && normalized !== "*") {
19
- allowed.add(normalized);
20
- }
21
- }
22
- return [...allowed];
23
- }
24
- export function relaySenderIsAllowed(allowedSenderIds, senderId) {
25
- return allowedSenderIds.includes(senderId);
26
- }
@@ -1,167 +0,0 @@
1
- import { jsonStore } from "@openclaw/fs-safe/store";
2
- import { withFileLock } from "@openclaw/fs-safe/file-lock";
3
- import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
4
- import { chmodSync, lstatSync, mkdirSync } from "node:fs";
5
- import { hostname } from "node:os";
6
- import { basename, join } from "node:path";
7
- const RELAY_STATE_DOCUMENT_VERSION = 1;
8
- const RELAY_STATE_LOCK_VERSION = 1;
9
- const RELAY_STATE_LOCK_TIMEOUT_MS = 30_000;
10
- export function emptyRelayStateDocument() {
11
- return { version: RELAY_STATE_DOCUMENT_VERSION, entries: {} };
12
- }
13
- function isRecord(value) {
14
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
15
- }
16
- export function assertRelayStateDocument(value, label, validateEntry) {
17
- if (!isRecord(value) ||
18
- value.version !== RELAY_STATE_DOCUMENT_VERSION ||
19
- !isRecord(value.entries)) {
20
- throw new Error(`relay ${label} state is corrupt`);
21
- }
22
- for (const [key, entry] of Object.entries(value.entries)) {
23
- if (!validateEntry(key, entry)) {
24
- throw new Error(`relay ${label} state is corrupt`);
25
- }
26
- }
27
- }
28
- function isRelayStateLockOwner(value) {
29
- if (!isRecord(value))
30
- return false;
31
- return (value.version === RELAY_STATE_LOCK_VERSION &&
32
- value.kind === "relay-state" &&
33
- Number.isSafeInteger(value.pid) &&
34
- value.pid > 0 &&
35
- typeof value.host === "string" &&
36
- value.host.length > 0 &&
37
- typeof value.createdAt === "string" &&
38
- Number.isFinite(Date.parse(value.createdAt)));
39
- }
40
- function localProcessIsLive(pid) {
41
- try {
42
- process.kill(pid, 0);
43
- return true;
44
- }
45
- catch (error) {
46
- // EPERM proves the process exists but is owned by another user. Unknown
47
- // failures also fail closed; only ESRCH proves this host no longer has it.
48
- return !(error instanceof Error && "code" in error && error.code === "ESRCH");
49
- }
50
- }
51
- /**
52
- * Windows opens the sidecar lock with an openat-style
53
- * `O_CREAT | O_EXCL` beneath a parent handle. While a just-released lock file
54
- * is still delete-pending, that create returns `ACCESS_DENIED` instead of the
55
- * `already exists` fs-safe retries on, so contention escapes the acquire loop
56
- * as a hard `EACCES`. Retry those on Windows only, bounded by the caller's lock
57
- * timeout: a genuine permission failure simply reproduces until the deadline
58
- * and then surfaces unchanged.
59
- */
60
- function isWindowsLockAcquisitionContention(error) {
61
- if (process.platform !== "win32")
62
- return false;
63
- const code = error?.code;
64
- return code === "EACCES" || code === "EPERM";
65
- }
66
- function lockRetryDelayMs(attempt, remainingMs) {
67
- const backoff = Math.min(25 * 2 ** attempt, 250);
68
- return Math.max(1, Math.min(backoff * (0.5 + Math.random() / 2), remainingMs));
69
- }
70
- function canRecoverRelayStateLock(value) {
71
- return (isRelayStateLockOwner(value) &&
72
- value.host === hostname() &&
73
- !localProcessIsLive(value.pid));
74
- }
75
- function ensurePrivateStateDirectory(path) {
76
- mkdirSync(path, { recursive: true, mode: 0o700 });
77
- const stat = lstatSync(path);
78
- if (stat.isSymbolicLink() || !stat.isDirectory()) {
79
- throw new Error(`relay state path is not a private directory: ${path}`);
80
- }
81
- try {
82
- chmodSync(path, 0o700);
83
- }
84
- catch {
85
- // POSIX modes are not fully implemented on Windows. fs-safe's private
86
- // write path still owns the platform-specific file guarantees.
87
- }
88
- }
89
- /**
90
- * Relay owns these files rather than requesting OpenClaw's privileged host
91
- * SQLite. jsonStore gives every mutation a private atomic replacement. An
92
- * fs-safe sidecar lock serializes cross-process mutations and is recovered
93
- * only when its valid Relay owner names this host and its PID is provably dead.
94
- */
95
- export function openRelayStateDocument(params) {
96
- if (!params.fileName || basename(params.fileName) !== params.fileName) {
97
- throw new Error("relay state fileName must be one file name");
98
- }
99
- const lockTimeoutMs = params.lockTimeoutMs ?? RELAY_STATE_LOCK_TIMEOUT_MS;
100
- if (!Number.isSafeInteger(lockTimeoutMs) || lockTimeoutMs < 1) {
101
- throw new Error("relay state lockTimeoutMs must be a positive safe integer");
102
- }
103
- const stateRoot = resolveStateDir(params.env ?? process.env);
104
- const relayRoot = join(stateRoot, "relay");
105
- const relayStateRoot = join(relayRoot, "state");
106
- ensurePrivateStateDirectory(relayRoot);
107
- ensurePrivateStateDirectory(relayStateRoot);
108
- const store = jsonStore({
109
- filePath: join(relayStateRoot, params.fileName),
110
- dirMode: 0o700,
111
- mode: 0o600,
112
- });
113
- const withMutationLock = async (run) => {
114
- const deadline = Date.now() + lockTimeoutMs;
115
- for (let attempt = 0;; attempt += 1) {
116
- // Only acquisition is retried. Once the mutation itself has started it has
117
- // observed state under the lock, so replaying it could double-apply.
118
- let mutationStarted = false;
119
- try {
120
- return await withFileLock(store.filePath, {
121
- managerKey: `relay-state:${store.filePath}`,
122
- staleMs: RELAY_STATE_LOCK_TIMEOUT_MS,
123
- timeoutMs: Math.max(1, deadline - Date.now()),
124
- staleRecovery: "remove-if-unchanged",
125
- retry: {
126
- retries: 300,
127
- minTimeout: 25,
128
- maxTimeout: 250,
129
- randomize: true,
130
- },
131
- payload: () => ({
132
- version: RELAY_STATE_LOCK_VERSION,
133
- kind: "relay-state",
134
- pid: process.pid,
135
- host: hostname(),
136
- createdAt: new Date().toISOString(),
137
- }),
138
- shouldReclaim: ({ payload }) => canRecoverRelayStateLock(payload),
139
- shouldRemoveStaleLock: ({ payload }) => canRecoverRelayStateLock(payload),
140
- }, async () => {
141
- mutationStarted = true;
142
- return await run();
143
- });
144
- }
145
- catch (error) {
146
- const remaining = deadline - Date.now();
147
- if (mutationStarted ||
148
- remaining <= 0 ||
149
- !isWindowsLockAcquisitionContention(error)) {
150
- throw error;
151
- }
152
- await new Promise((resolve) => setTimeout(resolve, lockRetryDelayMs(attempt, remaining)));
153
- }
154
- }
155
- };
156
- return {
157
- filePath: store.filePath,
158
- read: store.read,
159
- readOr: store.readOr,
160
- readRequired: store.readRequired,
161
- write: async (value) => {
162
- await withMutationLock(async () => await store.write(value));
163
- },
164
- update: async (run) => await withMutationLock(async () => await store.update(run)),
165
- updateOr: async (fallback, run) => await withMutationLock(async () => await store.updateOr(fallback, run)),
166
- };
167
- }
@@ -1,108 +0,0 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import {
3
- existsSync,
4
- mkdirSync,
5
- readFileSync,
6
- renameSync,
7
- rmSync,
8
- writeFileSync,
9
- } from "node:fs";
10
- import { homedir } from "node:os";
11
- import { join } from "node:path";
12
-
13
- interface LockOwner {
14
- pid: number;
15
- nonce: string;
16
- account_id: string;
17
- created_at: string;
18
- }
19
-
20
- function processIsLive(pid: number): boolean {
21
- if (!Number.isSafeInteger(pid) || pid <= 0) return false;
22
- try {
23
- process.kill(pid, 0);
24
- return true;
25
- } catch (error: any) {
26
- return error?.code === "EPERM";
27
- }
28
- }
29
-
30
- function readOwner(path: string): LockOwner | undefined {
31
- try {
32
- const value = JSON.parse(readFileSync(path, "utf8")) as Partial<LockOwner>;
33
- if (
34
- Number.isSafeInteger(value.pid) &&
35
- typeof value.nonce === "string" &&
36
- typeof value.account_id === "string" &&
37
- typeof value.created_at === "string"
38
- ) {
39
- return value as LockOwner;
40
- }
41
- } catch {
42
- // Missing/malformed ownership is never deleted in place by a contender.
43
- }
44
- return undefined;
45
- }
46
-
47
- /** Atomic filesystem lease preventing two OpenClaw processes polling one agent. */
48
- export class RelayAccountLock {
49
- private readonly lockPath: string;
50
- private readonly ownerPath: string;
51
- private readonly nonce = randomUUID();
52
- private held = false;
53
-
54
- constructor(
55
- baseUrl: string,
56
- agentId: string,
57
- private readonly accountId: string,
58
- baseDir = join(homedir(), ".openclaw", "relay", "consumer-locks"),
59
- ) {
60
- const key = createHash("sha256").update(`${baseUrl}\0${agentId}`).digest("hex");
61
- this.lockPath = join(baseDir, key);
62
- this.ownerPath = join(this.lockPath, "owner.json");
63
- }
64
-
65
- acquire(): void {
66
- mkdirSync(join(this.lockPath, ".."), { recursive: true, mode: 0o700 });
67
- for (let attempt = 0; attempt < 2; attempt += 1) {
68
- try {
69
- mkdirSync(this.lockPath, { mode: 0o700 });
70
- const owner: LockOwner = {
71
- pid: process.pid,
72
- nonce: this.nonce,
73
- account_id: this.accountId,
74
- created_at: new Date().toISOString(),
75
- };
76
- writeFileSync(this.ownerPath, `${JSON.stringify(owner)}\n`, { mode: 0o600 });
77
- this.held = true;
78
- return;
79
- } catch (error: any) {
80
- if (error?.code !== "EEXIST") throw error;
81
- const owner = readOwner(this.ownerPath);
82
- if (!owner || processIsLive(owner.pid)) {
83
- const claimant = owner
84
- ? `account "${owner.account_id}" (pid ${owner.pid})`
85
- : "an existing process with unreadable ownership";
86
- throw new Error(`relay: this agent already has an active consumer in ${claimant}`);
87
- }
88
- const stalePath = `${this.lockPath}.stale-${Date.now()}-${randomUUID()}`;
89
- try {
90
- renameSync(this.lockPath, stalePath);
91
- rmSync(stalePath, { recursive: true, force: true });
92
- } catch (renameError: any) {
93
- if (renameError?.code !== "ENOENT") throw renameError;
94
- }
95
- }
96
- }
97
- throw new Error("relay: could not acquire the agent consumer lock");
98
- }
99
-
100
- release(): void {
101
- if (!this.held) return;
102
- const owner = readOwner(this.ownerPath);
103
- if (owner?.nonce === this.nonce && existsSync(this.lockPath)) {
104
- rmSync(this.lockPath, { recursive: true, force: true });
105
- }
106
- this.held = false;
107
- }
108
- }