@envarai/connect 2026.1031.1 → 2026.1107.1

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/README.md CHANGED
@@ -34,6 +34,13 @@ Use `--framework openclaw` for OpenClaw. Restart the existing native gateway whe
34
34
 
35
35
  The default state remains `~/.envarpay/connectors/AGENT_UUID` for compatibility. Connection IDs, credentials, journal format, process locks and original order/task IDs are preserved. Run only one receiver for a journal. Rerun `connect` against the same Agent/profile to update its native MCP command; an active connection is reused without another pairing. Keep the original directory when reinstalling or rolling back.
36
36
 
37
+ Linux receivers require `flock` from util-linux and a filesystem that supports
38
+ file locks. The kernel releases the receiver lock when its process exits, so
39
+ container restarts and reused PIDs cannot strand the connection. When upgrading
40
+ an orphaned legacy PID lock, first stop the old receiver and verify no other
41
+ receiver uses that journal, then move only `receiver.lock` aside once. Preserve
42
+ the SQLite journal, credentials and native binding. Never remove an active lock.
43
+
37
44
  EnvarPay retains seller charging and exposes the old `envarpay connector ...` command as a compatibility entry. EnvarConnect does not depend on EnvarPay. Free tasks need no payment setup. Optional order tools use separate owner spending grants enforced by Envar; a connection alone cannot spend.
38
45
 
39
46
  ## API and development
@@ -35,8 +35,9 @@ export declare class ConnectorJournal {
35
35
  private readonly db;
36
36
  private lock?;
37
37
  constructor(path: string, binding: ConnectorBinding);
38
- /** A stale process can be replaced only after proving that PID no longer exists. */
38
+ /** One receiver per journal; Linux kernel locks survive PID reuse safely. */
39
39
  acquireProcessLock(path: string): void;
40
+ private acquireKernelLock;
40
41
  ingest(delivery: Delivery): void;
41
42
  save(record: LocalWork): void;
42
43
  get(id: string): LocalWork | undefined;
@@ -1,5 +1,6 @@
1
1
  /** Owner-only durable journal. Transport receipts never imply a second execution. */
2
- import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { spawnSync } from 'node:child_process';
3
4
  import { dirname } from 'node:path';
4
5
  import { randomUUID, createHash } from 'node:crypto';
5
6
  import { DatabaseSync } from 'node:sqlite';
@@ -49,10 +50,14 @@ export class ConnectorJournal {
49
50
  }
50
51
  this.db.prepare("INSERT OR IGNORE INTO connector_meta VALUES ('binding',?)").run(JSON.stringify(binding));
51
52
  }
52
- /** A stale process can be replaced only after proving that PID no longer exists. */
53
+ /** One receiver per journal; Linux kernel locks survive PID reuse safely. */
53
54
  acquireProcessLock(path) {
54
55
  if (this.lock)
55
56
  throw new Error('Connector already owns a process lock');
57
+ if (process.platform === 'linux') {
58
+ this.acquireKernelLock(path);
59
+ return;
60
+ }
56
61
  if (existsSync(path)) {
57
62
  privateFile(path);
58
63
  const value = parseStored(readFileSync(path, 'utf8'));
@@ -77,6 +82,55 @@ export class ConnectorJournal {
77
82
  }
78
83
  this.lock = { path, nonce };
79
84
  }
85
+ acquireKernelLock(path) {
86
+ const guard = `${path}.flock`;
87
+ if (existsSync(guard))
88
+ privateFile(guard);
89
+ const fd = openSync(guard, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
90
+ try {
91
+ // The child inherits this open file description. Its exit keeps the lock
92
+ // held by our descriptor; process death releases it even after SIGKILL.
93
+ const result = spawnSync('flock', ['--nonblock', '3'], { stdio: ['ignore', 'ignore', 'ignore', fd] });
94
+ if (result.status === 1)
95
+ throw new Error('Another Connector process is using this journal');
96
+ if (result.error || result.status !== 0)
97
+ throw new Error('Linux Connector locking requires util-linux flock and filesystem lock support');
98
+ if (existsSync(path)) {
99
+ privateFile(path);
100
+ const stored = readFileSync(path, 'utf8');
101
+ const value = parseStored(stored);
102
+ if (value.locking !== 'flock') {
103
+ // Do not steal a legacy PID lock during an upgrade. An operator must
104
+ // confirm an orphaned container lock before moving it aside once.
105
+ if (!Number.isSafeInteger(value.pid) || value.pid < 1)
106
+ throw new Error('Inspect the original Connector lock');
107
+ try {
108
+ process.kill(value.pid, 0);
109
+ throw new Error('Another Connector process is using this journal (legacy PID lock)');
110
+ }
111
+ catch (error) {
112
+ if (error.code !== 'ESRCH')
113
+ throw error;
114
+ }
115
+ }
116
+ unlinkSync(path);
117
+ }
118
+ const nonce = randomUUID();
119
+ // Exclusive creation also excludes a legacy receiver racing this upgrade.
120
+ const metadata = openSync(path, 'wx', 0o600);
121
+ try {
122
+ writeFileSync(metadata, JSON.stringify({ pid: process.pid, nonce, locking: 'flock' }), { flush: true });
123
+ }
124
+ finally {
125
+ closeSync(metadata);
126
+ }
127
+ this.lock = { path, nonce, fd };
128
+ }
129
+ catch (error) {
130
+ closeSync(fd);
131
+ throw error;
132
+ }
133
+ }
80
134
  ingest(delivery) {
81
135
  const work = delivery.work;
82
136
  const incoming = ['work', 'input', 'cancel'].includes(delivery.kind);
@@ -176,12 +230,19 @@ export class ConnectorJournal {
176
230
  sameObservation(record, observation) { return record.lastObservation === digest(observation); }
177
231
  close() {
178
232
  this.db.close();
179
- if (this.lock && existsSync(this.lock.path)) {
180
- privateFile(this.lock.path);
181
- const value = parseStored(readFileSync(this.lock.path, 'utf8'));
182
- if (value.nonce === this.lock.nonce)
183
- unlinkSync(this.lock.path);
233
+ try {
234
+ if (this.lock && existsSync(this.lock.path)) {
235
+ privateFile(this.lock.path);
236
+ const value = parseStored(readFileSync(this.lock.path, 'utf8'));
237
+ if (value.nonce === this.lock.nonce)
238
+ unlinkSync(this.lock.path);
239
+ }
240
+ }
241
+ finally {
242
+ // The guard inode stays in place while the kernel releases its lock.
243
+ if (this.lock?.fd !== undefined)
244
+ closeSync(this.lock.fd);
245
+ this.lock = undefined;
184
246
  }
185
- this.lock = undefined;
186
247
  }
187
248
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@envarai/connect",
3
- "version": "2026.1031.1",
3
+ "version": "2026.1107.1",
4
4
  "description": "Connect your own Agent to Envar: tasks, native adapters and recovery",
5
5
  "type": "module",
6
6
  "license": "MIT",