farming-code 2.2.22 → 2.2.27

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.
@@ -4,7 +4,10 @@ const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const net = require('net');
6
6
  const path = require('path');
7
- const { nativePtyHostSocketPath } = require('./native-pty-host-path');
7
+ const {
8
+ nativePtyHostPrivateSocketNamePattern,
9
+ nativePtyHostSocketPath,
10
+ } = require('./native-pty-host-path');
8
11
  const {
9
12
  nativePtyHostRuntimeIdentity,
10
13
  nativePtyHostRuntimeIdentityMatches,
@@ -17,6 +20,7 @@ const {
17
20
  const { isTemporaryProviderSessionId } = require('./provider-session-id');
18
21
  const storageLayout = require('./storage-layout');
19
22
  const { deserializeTerminalState } = require('./terminal-state-serialization');
23
+ const { probeUnixSocket } = require('./terminal-runtime-cleanup');
20
24
 
21
25
  const DEFAULT_CONNECT_RETRIES = 300;
22
26
  const DEFAULT_CONNECT_RETRY_MS = 50;
@@ -184,6 +188,7 @@ class NativePtyHostClient extends EventEmitter {
184
188
  this.controllerIdentityReady = null;
185
189
  this.hostRotationTimeoutMs = options.hostRotationTimeoutMs || DEFAULT_HOST_ROTATION_TIMEOUT_MS;
186
190
  this.connectedHostInfo = null;
191
+ this.connectedSocketPath = '';
187
192
  this.runtimeRotationInfo = null;
188
193
  this.socket = null;
189
194
  this.socketGeneration = 0;
@@ -235,7 +240,85 @@ class NativePtyHostClient extends EventEmitter {
235
240
 
236
241
  canConnectWithoutStartingHost() {
237
242
  if (process.platform === 'win32') return true;
238
- return fs.existsSync(this.socketPath);
243
+ return fs.existsSync(this.socketPath) || this.privateSocketCandidates().length > 0;
244
+ }
245
+
246
+ privateSocketCandidates() {
247
+ if (process.platform === 'win32') return [];
248
+ const directory = path.dirname(this.socketPath);
249
+ const pattern = nativePtyHostPrivateSocketNamePattern(this.socketPath);
250
+ try {
251
+ return fs.readdirSync(directory)
252
+ .filter(name => pattern.test(name))
253
+ .map(name => path.join(directory, name));
254
+ } catch (error) {
255
+ if (error?.code === 'ENOENT') return [];
256
+ throw error;
257
+ }
258
+ }
259
+
260
+ connectedPrivateSocketPath(hostInfo = {}) {
261
+ if (process.platform === 'win32') return '';
262
+ if (typeof hostInfo.privateSocketPath === 'string' && hostInfo.privateSocketPath) {
263
+ return hostInfo.privateSocketPath;
264
+ }
265
+ if (!this.connectedSocketPath || this.connectedSocketPath !== this.socketPath) {
266
+ return this.connectedSocketPath;
267
+ }
268
+
269
+ let publicIdentity;
270
+ try {
271
+ const stat = fs.statSync(this.socketPath, { bigint: true });
272
+ publicIdentity = { dev: stat.dev, ino: stat.ino };
273
+ } catch (error) {
274
+ if (error?.code === 'ENOENT') return '';
275
+ throw error;
276
+ }
277
+
278
+ const matching = this.privateSocketCandidates().filter((candidate) => {
279
+ try {
280
+ const stat = fs.statSync(candidate, { bigint: true });
281
+ return stat.dev === publicIdentity.dev && stat.ino === publicIdentity.ino;
282
+ } catch (error) {
283
+ if (error?.code === 'ENOENT') return false;
284
+ throw error;
285
+ }
286
+ });
287
+ return matching.length === 1 ? matching[0] : '';
288
+ }
289
+
290
+ async resolveConnectSocketPath() {
291
+ if (process.platform === 'win32' || fs.existsSync(this.socketPath)) {
292
+ return this.socketPath;
293
+ }
294
+ const active = [];
295
+ for (const candidate of this.privateSocketCandidates()) {
296
+ const probe = await probeUnixSocket(candidate);
297
+ if (probe.active) active.push(candidate);
298
+ }
299
+ if (active.length > 1) {
300
+ const error = new Error('Multiple live native PTY hosts were found for this Farming config');
301
+ error.code = 'FARMING_NATIVE_HOST_AMBIGUOUS';
302
+ error.socketPaths = active;
303
+ throw error;
304
+ }
305
+ return active[0] || this.socketPath;
306
+ }
307
+
308
+ restorePublicSocketPath(connectedPath) {
309
+ if (
310
+ process.platform === 'win32'
311
+ || !connectedPath
312
+ || connectedPath === this.socketPath
313
+ || fs.existsSync(this.socketPath)
314
+ ) {
315
+ return;
316
+ }
317
+ try {
318
+ fs.linkSync(connectedPath, this.socketPath);
319
+ } catch (error) {
320
+ if (!error || error.code !== 'EEXIST') throw error;
321
+ }
239
322
  }
240
323
 
241
324
  spawnHost() {
@@ -288,9 +371,10 @@ class NativePtyHostClient extends EventEmitter {
288
371
  });
289
372
  }
290
373
 
291
- connectOnce() {
374
+ async connectOnce() {
375
+ const connectedPath = await this.resolveConnectSocketPath();
292
376
  return new Promise((resolve, reject) => {
293
- const socket = net.createConnection(this.socketPath);
377
+ const socket = net.createConnection(connectedPath);
294
378
  const onError = (error) => {
295
379
  socket.destroy();
296
380
  reject(error);
@@ -298,6 +382,8 @@ class NativePtyHostClient extends EventEmitter {
298
382
  socket.once('error', onError);
299
383
  socket.once('connect', () => {
300
384
  socket.off('error', onError);
385
+ this.restorePublicSocketPath(connectedPath);
386
+ this.connectedSocketPath = connectedPath;
301
387
  this.attachSocket(socket);
302
388
  resolve();
303
389
  });
@@ -373,6 +459,9 @@ class NativePtyHostClient extends EventEmitter {
373
459
  if (error && error.code === 'FARMING_NATIVE_HOST_RUNTIME_MISMATCH') {
374
460
  throw error;
375
461
  }
462
+ if (error && error.code === 'FARMING_NATIVE_HOST_AMBIGUOUS') {
463
+ throw error;
464
+ }
376
465
  if (this.hostStartError) {
377
466
  lastError = this.hostStartError;
378
467
  }
@@ -427,7 +516,7 @@ class NativePtyHostClient extends EventEmitter {
427
516
  return this.controllerIdentityReady;
428
517
  }
429
518
 
430
- async waitForHostRelease() {
519
+ async waitForHostRelease(privateSocketPath = '') {
431
520
  const deadline = Date.now() + this.hostRotationTimeoutMs;
432
521
  if (process.platform === 'win32') {
433
522
  await delay(Math.min(250, this.hostRotationTimeoutMs));
@@ -437,7 +526,8 @@ class NativePtyHostClient extends EventEmitter {
437
526
  const childReleased = !this.hostChild ||
438
527
  this.hostChild.exitCode !== null ||
439
528
  this.hostChild.signalCode !== null;
440
- if (!fs.existsSync(this.socketPath) && childReleased) return;
529
+ const privateReleased = !privateSocketPath || !fs.existsSync(privateSocketPath);
530
+ if (!fs.existsSync(this.socketPath) && privateReleased && childReleased) return;
441
531
  await delay(50);
442
532
  }
443
533
  const error = new Error('Timed out waiting for the previous native PTY host to stop');
@@ -589,6 +679,7 @@ class NativePtyHostClient extends EventEmitter {
589
679
  throw mismatchError;
590
680
  }
591
681
 
682
+ const privateSocketPath = this.connectedPrivateSocketPath(hostInfo);
592
683
  const socket = this.socket;
593
684
  if (socket) this.suppressedDisconnectSockets.add(socket);
594
685
  let shutdownUncertain = false;
@@ -622,7 +713,7 @@ class NativePtyHostClient extends EventEmitter {
622
713
  }
623
714
 
624
715
  try {
625
- await this.waitForHostRelease();
716
+ await this.waitForHostRelease(privateSocketPath || this.connectedSocketPath);
626
717
  } catch (error) {
627
718
  if (shutdownUncertain) {
628
719
  try {
@@ -674,6 +765,7 @@ class NativePtyHostClient extends EventEmitter {
674
765
  this.socket = null;
675
766
  this.buffer = '';
676
767
  this.connectedHostInfo = null;
768
+ this.connectedSocketPath = '';
677
769
  if (this.disposed) return;
678
770
  const error = new Error('Native pty host disconnected');
679
771
  error.code = 'ECONNRESET';
@@ -17,6 +17,26 @@ function nativePtyHostSocketPath(configDir) {
17
17
  return path.join(os.tmpdir(), `farming-native-pty-${process.getuid ? process.getuid() : 'user'}-${hash}.sock`);
18
18
  }
19
19
 
20
+ function nativePtyHostPrivateSocketHash(socketPath) {
21
+ return crypto.createHash('sha256').update(socketPath).digest('hex').slice(0, 8);
22
+ }
23
+
24
+ function nativePtyHostPrivateSocketPath(socketPath, options = {}) {
25
+ const pid = Number(options.pid || process.pid);
26
+ const nonce = String(options.nonce || crypto.randomBytes(4).toString('hex'));
27
+ return path.join(
28
+ path.dirname(socketPath),
29
+ `.fpty-${pid}-${nativePtyHostPrivateSocketHash(socketPath)}-${nonce}.sock`,
30
+ );
31
+ }
32
+
33
+ function nativePtyHostPrivateSocketNamePattern(socketPath) {
34
+ const hash = nativePtyHostPrivateSocketHash(socketPath);
35
+ return new RegExp(`^\\.fpty-\\d+-${hash}-[a-f0-9]+\\.sock$`);
36
+ }
37
+
20
38
  module.exports = {
39
+ nativePtyHostPrivateSocketNamePattern,
40
+ nativePtyHostPrivateSocketPath,
21
41
  nativePtyHostSocketPath,
22
42
  };
@@ -7,7 +7,10 @@ const os = require('os');
7
7
  const path = require('path');
8
8
 
9
9
  const TerminalScreenWorkerPool = require('./terminal-screen-worker-pool');
10
- const { nativePtyHostSocketPath } = require('./native-pty-host-path');
10
+ const {
11
+ nativePtyHostPrivateSocketPath,
12
+ nativePtyHostSocketPath,
13
+ } = require('./native-pty-host-path');
11
14
  const { terminalInputToPtyString } = require('./input-parts');
12
15
  const { normalizeInteractiveTerminalEnv } = require('./agent-env');
13
16
  const {
@@ -77,6 +80,15 @@ function sanitizeAgentEnv(env) {
77
80
  });
78
81
  }
79
82
 
83
+ function socketIdentity(socketPath) {
84
+ const stat = fs.statSync(socketPath, { bigint: true });
85
+ return { dev: stat.dev, ino: stat.ino };
86
+ }
87
+
88
+ function sameSocketIdentity(left, right) {
89
+ return Boolean(left && right && left.dev === right.dev && left.ino === right.ino);
90
+ }
91
+
80
92
  class NativePtyHost {
81
93
  constructor(options = {}) {
82
94
  this.configDir = options.configDir || process.env.FARMING_CONFIG_DIR || path.join(os.homedir(), '.farming');
@@ -84,6 +96,10 @@ class NativePtyHost {
84
96
  this.ownerPid = Number(options.ownerPid || process.env.FARMING_NATIVE_PTY_HOST_OWNER_PID || 0);
85
97
  this.runtimeIdentity = options.runtimeIdentity || HOST_RUNTIME_IDENTITY;
86
98
  this.exitOnShutdown = options.exitOnShutdown !== false;
99
+ this.boundSocketPath = process.platform === 'win32'
100
+ ? this.socketPath
101
+ : nativePtyHostPrivateSocketPath(this.socketPath);
102
+ this.boundSocketIdentity = null;
87
103
  this.sessions = new Map();
88
104
  this.clients = new Set();
89
105
  this.ownerCheckTimer = null;
@@ -130,13 +146,22 @@ class NativePtyHost {
130
146
  this.server = net.createServer(socket => this.handleConnection(socket));
131
147
  await new Promise((resolve, reject) => {
132
148
  this.server.once('error', reject);
133
- this.server.listen(this.socketPath, () => {
149
+ this.server.listen(this.boundSocketPath, () => {
134
150
  this.server.off('error', reject);
135
151
  resolve();
136
152
  });
137
153
  });
138
154
  if (process.platform !== 'win32') {
139
- fs.chmodSync(this.socketPath, 0o600);
155
+ this.boundSocketIdentity = socketIdentity(this.boundSocketPath);
156
+ fs.chmodSync(this.boundSocketPath, 0o600);
157
+ try {
158
+ fs.linkSync(this.boundSocketPath, this.socketPath);
159
+ } catch (error) {
160
+ await new Promise(resolve => this.server.close(() => resolve()));
161
+ this.server = null;
162
+ this.boundSocketIdentity = null;
163
+ throw error;
164
+ }
140
165
  }
141
166
  console.error(`[${new Date().toISOString()}] Native PTY host listening on ${this.socketPath} ownerPid=${this.ownerPid || ''}`);
142
167
  this.startOwnerWatch();
@@ -173,6 +198,11 @@ class NativePtyHost {
173
198
  } catch (error) {
174
199
  if (!error || error.code !== 'ENOENT') throw error;
175
200
  }
201
+ try {
202
+ fs.unlinkSync(this.boundSocketPath);
203
+ } catch (error) {
204
+ if (!error || error.code !== 'ENOENT') throw error;
205
+ }
176
206
  }
177
207
 
178
208
  handleConnection(socket) {
@@ -317,6 +347,7 @@ class NativePtyHost {
317
347
  return {
318
348
  ok: true,
319
349
  pid: process.pid,
350
+ privateSocketPath: process.platform === 'win32' ? '' : this.boundSocketPath,
320
351
  runtimeIdentity: this.runtimeIdentity,
321
352
  };
322
353
  case 'registerController':
@@ -1476,16 +1507,22 @@ class NativePtyHost {
1476
1507
  }
1477
1508
  }
1478
1509
  await this.screenWorkerPool.dispose();
1479
- if (this.server) {
1480
- await new Promise(resolve => this.server.close(() => resolve()));
1481
- this.server = null;
1482
- }
1483
- if (process.platform !== 'win32') {
1510
+ if (this.server && process.platform !== 'win32') {
1484
1511
  try {
1485
- fs.unlinkSync(this.socketPath);
1512
+ const currentSocketIdentity = socketIdentity(this.socketPath);
1513
+ if (sameSocketIdentity(currentSocketIdentity, this.boundSocketIdentity)) {
1514
+ // Unlink while this listener is still active. A competing host cannot
1515
+ // replace an active socket between the ownership check and unlink.
1516
+ fs.unlinkSync(this.socketPath);
1517
+ }
1486
1518
  } catch (error) {
1487
1519
  if (!error || error.code !== 'ENOENT') throw error;
1488
1520
  }
1521
+ this.boundSocketIdentity = null;
1522
+ }
1523
+ if (this.server) {
1524
+ await new Promise(resolve => this.server.close(() => resolve()));
1525
+ this.server = null;
1489
1526
  }
1490
1527
  }
1491
1528
  }
@@ -136,6 +136,23 @@ function assertGlobalWorkspacePathAllowed(agentManager, userPath, options = {})
136
136
  return { target: requestedTarget, allowedRoots };
137
137
  }
138
138
 
139
+ function assertExactExternalFileReadable(userPath) {
140
+ const requestedTarget = globalUserPathToAbsolute(userPath);
141
+ let target;
142
+ let stat;
143
+ try {
144
+ target = fs.realpathSync(requestedTarget);
145
+ stat = fs.statSync(target);
146
+ fs.accessSync(target, fs.constants.R_OK);
147
+ } catch {
148
+ throw new WorkspaceFileError('external file is not readable', 403);
149
+ }
150
+ if (!stat.isFile()) {
151
+ throw new WorkspaceFileError('external path must be a file', 400);
152
+ }
153
+ return relativeGlobalPath(target);
154
+ }
155
+
139
156
  function globalSyntheticTree(agentManager, userPath = '') {
140
157
  const { target, allowedRoots } = assertGlobalWorkspacePathAllowed(agentManager, userPath, {
141
158
  allowAllowedRootAncestor: true,
@@ -233,11 +250,14 @@ function createWorkspaceFileRouter(agentManager, fileService, options = {}) {
233
250
  router.get('/file', async (req, res) => {
234
251
  try {
235
252
  const rootRef = workspaceRef(req.query);
236
- if (isGlobalWorkspaceFilesAgentId(rootRef)) {
237
- assertGlobalWorkspacePathAllowed(agentManager, req.query.path || '');
253
+ const requestPath = isGlobalWorkspaceFilesAgentId(rootRef) && req.query.exact === '1'
254
+ ? assertExactExternalFileReadable(req.query.path || '')
255
+ : req.query.path || '';
256
+ if (isGlobalWorkspaceFilesAgentId(rootRef) && req.query.exact !== '1') {
257
+ assertGlobalWorkspacePathAllowed(agentManager, requestPath);
238
258
  }
239
259
  const { root, rootId } = resolveRequestRoot(req.query);
240
- const file = await fileService.readFile(root, req.query.path || '', readOptionsForAgent(agentManager, rootRef));
260
+ const file = await fileService.readFile(root, requestPath, readOptionsForAgent(agentManager, rootRef));
241
261
  res.json({ rootId, root, file });
242
262
  } catch (error) {
243
263
  sendWorkspaceFileError(res, error);
@@ -247,11 +267,14 @@ function createWorkspaceFileRouter(agentManager, fileService, options = {}) {
247
267
  router.get('/raw', async (req, res) => {
248
268
  try {
249
269
  const rootRef = workspaceRef(req.query);
250
- if (isGlobalWorkspaceFilesAgentId(rootRef)) {
251
- assertGlobalWorkspacePathAllowed(agentManager, req.query.path || '');
270
+ const requestPath = isGlobalWorkspaceFilesAgentId(rootRef) && req.query.exact === '1'
271
+ ? assertExactExternalFileReadable(req.query.path || '')
272
+ : req.query.path || '';
273
+ if (isGlobalWorkspaceFilesAgentId(rootRef) && req.query.exact !== '1') {
274
+ assertGlobalWorkspacePathAllowed(agentManager, requestPath);
252
275
  }
253
276
  const { root } = resolveRequestRoot(req.query);
254
- const file = await fileService.readPreviewFile(root, req.query.path || '', readOptionsForAgent(agentManager, rootRef));
277
+ const file = await fileService.readPreviewFile(root, requestPath, readOptionsForAgent(agentManager, rootRef));
255
278
  res
256
279
  .status(200)
257
280
  .type(file.preview.mediaType)
@@ -533,6 +556,7 @@ module.exports = {
533
556
  PROJECT_FILES_WORKSPACE_PREFIX,
534
557
  PROJECT_FILES_AGENT_PREFIX: PROJECT_FILES_WORKSPACE_PREFIX,
535
558
  assertGlobalWorkspacePathAllowed,
559
+ assertExactExternalFileReadable,
536
560
  createWorkspaceFileRouter,
537
561
  globalWorkspaceAllowedRoots,
538
562
  isGlobalWorkspaceFilesAgentId,