farming-code 2.2.8 → 2.2.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 (107) hide show
  1. package/README.md +42 -28
  2. package/README.zh_cn.md +42 -28
  3. package/THIRD_PARTY_NOTICES.md +11 -2
  4. package/backend/acp-checkpoint-store.js +198 -0
  5. package/backend/acp-runtime.js +306 -83
  6. package/backend/acp-session-state.js +202 -6
  7. package/backend/acp-transcript.js +112 -0
  8. package/backend/agent-activity.js +6 -157
  9. package/backend/agent-manager.js +1592 -741
  10. package/backend/agent-provider-session.js +17 -242
  11. package/backend/agent-runtime-binding.js +219 -0
  12. package/backend/agent-session-history.js +66 -1
  13. package/backend/auth.js +79 -6
  14. package/backend/codex-models.js +81 -84
  15. package/backend/codex-session-archive.js +54 -0
  16. package/backend/codex-terminal-profile.js +500 -0
  17. package/backend/codex-transcript-sanitizer.js +12 -0
  18. package/backend/codex-transcript.js +230 -8
  19. package/backend/config-manager.js +35 -0
  20. package/backend/control-api.js +192 -17
  21. package/backend/executable-discovery.js +2 -0
  22. package/backend/farming-net-pass.js +285 -0
  23. package/backend/farming-net-registry.js +112 -0
  24. package/backend/farming-net-server.js +298 -0
  25. package/backend/farming-session-store.js +5 -13
  26. package/backend/git-worktree-info.js +181 -0
  27. package/backend/local-session-engine.js +411 -186
  28. package/backend/main-page-session.js +5 -2
  29. package/backend/native-pty-controller-generation.js +106 -0
  30. package/backend/native-pty-host-client.js +275 -7
  31. package/backend/native-pty-host-identity.js +86 -0
  32. package/backend/native-pty-host.js +813 -114
  33. package/backend/native-session-engine.js +100 -28
  34. package/backend/packaged-node-pty.js +22 -2
  35. package/backend/provider-adapters.js +253 -0
  36. package/backend/provider-session-service.js +241 -0
  37. package/backend/runtime-observation.js +81 -0
  38. package/backend/server.js +354 -84
  39. package/backend/session-engine-bridge.js +21 -2
  40. package/backend/session-engine-router.js +1 -1
  41. package/backend/session-engine.js +1 -1
  42. package/backend/session-stream-protocol.js +185 -0
  43. package/backend/storage-layout.js +55 -0
  44. package/backend/terminal-attach-checkpoint.js +74 -0
  45. package/backend/terminal-exit-quiescence.js +39 -0
  46. package/backend/terminal-reducer-flow-control.js +97 -0
  47. package/backend/terminal-screen-state.js +11 -2
  48. package/backend/terminal-screen-worker-pool.js +59 -6
  49. package/backend/terminal-screen-worker-thread.js +97 -57
  50. package/backend/terminal-screen-worker.js +133 -51
  51. package/backend/terminal-state-serialization.js +127 -0
  52. package/backend/terminal-status.js +23 -4
  53. package/backend/usage-monitor.js +176 -17
  54. package/backend/workspace-directory.js +232 -0
  55. package/backend/workspace-file-router.js +182 -76
  56. package/backend/workspace-file-service.js +319 -4
  57. package/backend/workspace-root-registry.js +164 -0
  58. package/dist/assets/App-DWsa7DXG.js +206 -0
  59. package/dist/assets/{FileEditorMarkdownPreview-elKWc8Im.js → FileEditorMarkdownPreview-UlVd0O4D.js} +92 -92
  60. package/dist/assets/FileEditorPane-Dxx4rKOg.js +2 -0
  61. package/dist/assets/IconGlyphs-AU22gPDY.js +1 -0
  62. package/dist/assets/ProjectFilesSection-DE7Nb9Jk.js +12 -0
  63. package/dist/assets/ReviewPage-CeDKDyZh.js +3 -0
  64. package/dist/assets/code-dark-DPiuyeSp.css +1 -0
  65. package/dist/assets/file-icons-Bw2qd5iT.js +1 -0
  66. package/dist/assets/{index-BrbljRqn.js → index-CUFPJoa5.js} +3 -3
  67. package/dist/assets/main-Bqtb5kuz.css +1 -0
  68. package/dist/assets/{monaco.contribution-CyLosfZI.js → monaco.contribution-BXz-lfFB.js} +2 -2
  69. package/dist/assets/{tsMode-DGZTlTj8.js → tsMode-B0oUTcXQ.js} +1 -1
  70. package/dist/assets/workspace-editor-model-BQol4qbA.js +1 -0
  71. package/dist/assets/workspace-editor-monaco-BICJESG0.js +4 -0
  72. package/dist/assets/workspace-editor-monaco-DRJ7IaXk.js +1 -0
  73. package/dist/assets/workspace-view-state-DvYG_9PH.js +7 -0
  74. package/dist/assets/workspace-working-copy-D8-s_Sgh.js +1 -0
  75. package/dist/farming-2/site.webmanifest +2 -0
  76. package/dist/index.html +1 -1
  77. package/frontend/farming-net/app.css +625 -0
  78. package/frontend/farming-net/app.js +268 -0
  79. package/frontend/farming-net/index.html +86 -0
  80. package/frontend/reading-anchor.js +198 -0
  81. package/frontend/session-bridge.js +12 -3
  82. package/frontend/session-modal-bridge.js +5 -12
  83. package/frontend/skins/crt/app.js +1978 -793
  84. package/frontend/skins/crt/index.html +313 -23
  85. package/frontend/skins/crt/styles/billing.css +294 -223
  86. package/frontend/skins/crt/styles/monochrome-green.css +7 -2
  87. package/frontend/terminal-replay.js +372 -0
  88. package/package.json +10 -3
  89. package/shared/browser-protocol.d.ts +5 -0
  90. package/shared/browser-protocol.js +130 -0
  91. package/dist/assets/App-8dYAM6ql.js +0 -124
  92. package/dist/assets/FileEditorPane-RWiFD2cq.js +0 -5
  93. package/dist/assets/IconGlyphs-DfL0EBnj.js +0 -1
  94. package/dist/assets/ProjectFilesSection-Q4PDsWmM.js +0 -12
  95. package/dist/assets/ReviewPage-BaXu1ZdX.js +0 -3
  96. package/dist/assets/code-dark-CDkOQAtK.css +0 -1
  97. package/dist/assets/file-icons-EFUGSSwf.js +0 -1
  98. package/dist/assets/main-D073SnW4.css +0 -1
  99. package/dist/assets/qoder-C9LmmOSf.svg +0 -1
  100. package/dist/assets/qoder-Cf9gl0Y5.svg +0 -1
  101. package/dist/assets/qoder-gHCinseV.svg +0 -1
  102. package/dist/assets/workspace-view-state-CTyDzk2D.js +0 -1
  103. package/dist/assets/zsh-CLpveKlF.svg +0 -1
  104. package/dist/assets/zsh-FxSpMPbz.svg +0 -1
  105. /package/dist/assets/{api-D1lyBYIQ.js → api-D8nyOEbz.js} +0 -0
  106. /package/dist/assets/{core-ZlAPicox.js → core-D0LFJkDt.js} +0 -0
  107. /package/dist/assets/{useWorkspaceMenuKeyboard-CneKAZUJ.js → useWorkspaceMenuKeyboard-Brws6Ar9.js} +0 -0
@@ -1,6 +1,8 @@
1
1
  const express = require('express');
2
+ const { runtimeKind } = require('./agent-runtime-binding');
3
+ const { terminalInputReady } = require('./terminal-status');
2
4
 
3
- const DEFAULT_INITIAL_INPUT_DELAY_MS = 2500;
5
+ const DEFAULT_INITIAL_INPUT_TIMEOUT_MS = 30000;
4
6
 
5
7
  function ensureTrailingNewline(value) {
6
8
  const text = String(value || '');
@@ -17,12 +19,103 @@ function normalizeTail(value, fallback = 4000) {
17
19
  return Math.max(0, Math.min(100000, Math.floor(tail)));
18
20
  }
19
21
 
22
+ function terminalReadinessOptions(agent) {
23
+ return {
24
+ command: agent.command,
25
+ cwd: agent.terminalStatus?.cwd || agent.cwd,
26
+ status: agent.status === 'running' ? 'running' : agent.status,
27
+ title: agent.terminalStatus?.title || agent.sessionTitle || '',
28
+ previewText: agent.previewText || '',
29
+ terminalBusy: typeof agent.terminalBusy === 'boolean' ? agent.terminalBusy : null,
30
+ shellLastEvent: agent.terminalStatus?.source === 'shell-status-marker' ? 'finish' : '',
31
+ shellLastExitCode: agent.terminalStatus?.lastExitCode,
32
+ shellCommand: agent.terminalStatus?.runningCommand || '',
33
+ };
34
+ }
35
+
36
+ function waitForTerminalInputReadiness(agentManager, agentId, options = {}) {
37
+ const expectedStartedAt = Number(options.expectedStartedAt);
38
+ const initialRuntimeEpoch = typeof options.expectedRuntimeEpoch === 'string'
39
+ ? options.expectedRuntimeEpoch
40
+ : '';
41
+ const timeoutMs = Number.isFinite(options.timeoutMs)
42
+ ? Math.max(1, options.timeoutMs)
43
+ : DEFAULT_INITIAL_INPUT_TIMEOUT_MS;
44
+
45
+ return new Promise((resolve, reject) => {
46
+ let settled = false;
47
+ let expectedRuntimeEpoch = initialRuntimeEpoch;
48
+ const removeUpdateListener = () => {
49
+ if (typeof agentManager.off === 'function') agentManager.off('update', inspect);
50
+ else if (typeof agentManager.removeListener === 'function') agentManager.removeListener('update', inspect);
51
+ };
52
+ const finish = (error, value) => {
53
+ if (settled) return;
54
+ settled = true;
55
+ clearTimeout(timeout);
56
+ removeUpdateListener();
57
+ if (error) reject(error);
58
+ else resolve(value);
59
+ };
60
+ const fail = (code, message) => {
61
+ const error = new Error(message);
62
+ error.code = code;
63
+ finish(error);
64
+ };
65
+ const inspect = () => {
66
+ const agent = findAgent(agentManager.getState(), agentId);
67
+ if (!agent) {
68
+ fail('agent-removed', 'Agent disappeared before its initial Terminal task was delivered');
69
+ return;
70
+ }
71
+ if (Number(agent.startedAt) !== expectedStartedAt) {
72
+ fail('runtime-replaced', 'Agent runtime was replaced before its initial Terminal task was delivered');
73
+ return;
74
+ }
75
+ if (agent.terminalInputReceived === true) {
76
+ fail('terminal-already-used', 'Terminal received user input before its initial task was delivered');
77
+ return;
78
+ }
79
+ if (agent.status === 'dead' || agent.status === 'stopped') {
80
+ fail('runtime-exited', 'Agent exited before its initial Terminal task was delivered');
81
+ return;
82
+ }
83
+ const runtimeEpoch = typeof agent.runtimeEpoch === 'string' ? agent.runtimeEpoch : '';
84
+ if (!runtimeEpoch) return;
85
+ if (expectedRuntimeEpoch && runtimeEpoch !== expectedRuntimeEpoch) {
86
+ fail('runtime-replaced', 'Agent runtime changed before its initial Terminal task was delivered');
87
+ return;
88
+ }
89
+ expectedRuntimeEpoch = runtimeEpoch;
90
+ if (agent.status !== 'running' || !terminalInputReady(terminalReadinessOptions(agent))) return;
91
+ finish(null, {
92
+ expectedStartedAt,
93
+ expectedRuntimeEpoch,
94
+ });
95
+ };
96
+ const timeout = setTimeout(() => {
97
+ fail('initial-input-timeout', 'Terminal did not become ready for its initial task before the timeout');
98
+ }, timeoutMs);
99
+ timeout.unref?.();
100
+ if (typeof agentManager.on === 'function') agentManager.on('update', inspect);
101
+ Promise.resolve().then(inspect);
102
+ });
103
+ }
104
+
20
105
  function createControlRouter(agentManager, options = {}) {
21
106
  const router = express.Router();
22
107
  const notifyUpdate = typeof options.notifyUpdate === 'function' ? options.notifyUpdate : () => {};
23
- const initialInputDelayMs = Number.isFinite(options.initialInputDelayMs)
24
- ? Math.max(0, options.initialInputDelayMs)
25
- : DEFAULT_INITIAL_INPUT_DELAY_MS;
108
+ const initialInputTimeoutMs = Number.isFinite(options.initialInputTimeoutMs)
109
+ ? Math.max(1, options.initialInputTimeoutMs)
110
+ : DEFAULT_INITIAL_INPUT_TIMEOUT_MS;
111
+
112
+ async function runTerminalMutation(agentId, expectedRuntimeEpoch, operation) {
113
+ const current = findAgent(agentManager.getState(), agentId);
114
+ if (!current || current.runtimeEpoch !== expectedRuntimeEpoch) {
115
+ return { status: 'rejected', reason: 'runtime-epoch-mismatch' };
116
+ }
117
+ return operation({ expectedRuntimeEpoch });
118
+ }
26
119
 
27
120
  router.use(express.json({ limit: '1mb' }));
28
121
 
@@ -62,17 +155,64 @@ function createControlRouter(agentManager, options = {}) {
62
155
  return;
63
156
  }
64
157
 
65
- if (initialInput) {
66
- setTimeout(() => {
67
- if (!findAgent(agentManager.getState(), agentId)) return;
68
- agentManager.sendInput(agentId, ensureTrailingNewline(initialInput)).catch(() => {});
69
- }, initialInputDelayMs);
70
- }
158
+ void (async () => {
159
+ if (!initialInput) {
160
+ notifyUpdate();
161
+ res.status(201).json({ agentId, initialInputDelivered: false });
162
+ return;
163
+ }
71
164
 
72
- notifyUpdate();
73
- res.status(201).json({
74
- agentId,
75
- scheduledInitialInput: Boolean(initialInput),
165
+ const started = findAgent(agentManager.getState(), agentId);
166
+ if (!started) throw Object.assign(new Error('Agent disappeared before initial input delivery'), {
167
+ code: 'agent-removed',
168
+ });
169
+ const structuredRuntime = runtimeKind(started) !== 'terminal';
170
+ if (structuredRuntime) {
171
+ await agentManager.sendComposerMessage(agentId, initialInput);
172
+ notifyUpdate();
173
+ res.status(201).json({ agentId, initialInputDelivered: true, inputMode: 'structured' });
174
+ return;
175
+ }
176
+
177
+ const readiness = await waitForTerminalInputReadiness(agentManager, agentId, {
178
+ expectedStartedAt: started.startedAt,
179
+ expectedRuntimeEpoch: started.runtimeEpoch,
180
+ timeoutMs: initialInputTimeoutMs,
181
+ });
182
+ const result = await runTerminalMutation(
183
+ agentId,
184
+ readiness.expectedRuntimeEpoch,
185
+ async ({ expectedRuntimeEpoch }) => {
186
+ const current = findAgent(agentManager.getState(), agentId);
187
+ if (
188
+ !current ||
189
+ Number(current.startedAt) !== readiness.expectedStartedAt ||
190
+ current.runtimeEpoch !== readiness.expectedRuntimeEpoch ||
191
+ current.terminalInputReceived === true ||
192
+ current.status !== 'running' ||
193
+ !terminalInputReady(terminalReadinessOptions(current))
194
+ ) {
195
+ return { status: 'rejected', reason: 'startup-state-changed' };
196
+ }
197
+ return agentManager.sendInput(agentId, ensureTrailingNewline(initialInput), {
198
+ expectedRuntimeEpoch,
199
+ });
200
+ },
201
+ );
202
+ if (!result || result.status === 'rejected' || result.status === 'input-rejected') {
203
+ const reason = result?.reason || 'initial-input-rejected';
204
+ throw Object.assign(new Error(`Initial Terminal task was not delivered: ${reason}`), { code: reason });
205
+ }
206
+ notifyUpdate();
207
+ res.status(201).json({ agentId, initialInputDelivered: true, inputMode: 'terminal' });
208
+ })().catch((deliveryError) => {
209
+ const status = deliveryError?.code === 'initial-input-timeout' ? 504 : 409;
210
+ res.status(status).json({
211
+ error: deliveryError?.message || 'Initial input delivery failed',
212
+ code: deliveryError?.code || 'initial-input-failed',
213
+ agentId,
214
+ initialInputDelivered: false,
215
+ });
76
216
  });
77
217
  }, {
78
218
  wantsMain: false,
@@ -94,9 +234,25 @@ function createControlRouter(agentManager, options = {}) {
94
234
  return;
95
235
  }
96
236
 
237
+ if (agentManager.agentSupportsTerminalInput?.(agentId) === false) {
238
+ res.status(409).json({ error: 'raw input is only available for Terminal Agents' });
239
+ return;
240
+ }
241
+ const expectedRuntimeEpoch = typeof findAgent(agentManager.getState(), agentId)?.runtimeEpoch === 'string'
242
+ ? findAgent(agentManager.getState(), agentId).runtimeEpoch
243
+ : '';
244
+ if (!expectedRuntimeEpoch) {
245
+ res.status(409).json({ error: 'terminal runtime is not ready' });
246
+ return;
247
+ }
97
248
  const input = typeof req.body.input === 'string' ? req.body.input : '';
98
- await agentManager.sendInput(agentId, input);
99
- notifyUpdate();
249
+ const result = await runTerminalMutation(agentId, expectedRuntimeEpoch, ({ expectedRuntimeEpoch: epoch }) => (
250
+ agentManager.sendInput(agentId, input, { expectedRuntimeEpoch: epoch })
251
+ ));
252
+ if (!result || result.status === 'rejected' || result.status === 'input-rejected') {
253
+ res.status(409).json({ error: result?.reason || 'terminal input rejected' });
254
+ return;
255
+ }
100
256
  res.json({ success: true });
101
257
  });
102
258
 
@@ -108,7 +264,24 @@ function createControlRouter(agentManager, options = {}) {
108
264
  return;
109
265
  }
110
266
 
111
- const result = await agentManager.clearAgentSessionBuffer(agentId);
267
+ if (agentManager.agentSupportsTerminalInput?.(agentId) === false) {
268
+ res.status(409).json({ error: 'clear is only available for Terminal Agents' });
269
+ return;
270
+ }
271
+ const expectedRuntimeEpoch = typeof findAgent(agentManager.getState(), agentId)?.runtimeEpoch === 'string'
272
+ ? findAgent(agentManager.getState(), agentId).runtimeEpoch
273
+ : '';
274
+ if (!expectedRuntimeEpoch) {
275
+ res.status(409).json({ error: 'terminal runtime is not ready' });
276
+ return;
277
+ }
278
+ const result = await runTerminalMutation(agentId, expectedRuntimeEpoch, ({ expectedRuntimeEpoch: epoch }) => (
279
+ agentManager.clearAgentSessionBuffer(agentId, { expectedRuntimeEpoch: epoch })
280
+ ));
281
+ if (!result || result.status === 'rejected' || result.cleared === false) {
282
+ res.status(409).json({ error: result?.reason || result?.error || 'terminal clear rejected' });
283
+ return;
284
+ }
112
285
  notifyUpdate();
113
286
  res.json({ success: Boolean(result && result.cleared), ...result });
114
287
  });
@@ -146,6 +319,8 @@ function createControlRouter(agentManager, options = {}) {
146
319
  }
147
320
 
148
321
  module.exports = {
322
+ DEFAULT_INITIAL_INPUT_TIMEOUT_MS,
149
323
  createControlRouter,
150
324
  ensureTrailingNewline,
325
+ waitForTerminalInputReadiness,
151
326
  };
@@ -4,6 +4,7 @@ const { execFileSync } = require('child_process');
4
4
  const { getUserLaunchAgents } = require('./cli-agents');
5
5
 
6
6
  const DEFAULT_CODEX_APP_BIN = '/Applications/Codex.app/Contents/Resources/codex';
7
+ const DEFAULT_CHATGPT_APP_CODEX_BIN = '/Applications/ChatGPT.app/Contents/Resources/codex';
7
8
  const executableVersionCache = new Map();
8
9
 
9
10
  function getPathDirectories(pathEnv = process.env.PATH || '') {
@@ -90,6 +91,7 @@ function getPreferredExecutableCandidates(agentName, pathEnv = process.env.PATH
90
91
  return [
91
92
  process.env.FARMING_CODEX_BIN || '',
92
93
  DEFAULT_CODEX_APP_BIN,
94
+ DEFAULT_CHATGPT_APP_CODEX_BIN,
93
95
  ...pathCandidates,
94
96
  ].filter(Boolean);
95
97
  }
@@ -0,0 +1,285 @@
1
+ const crypto = require('crypto');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const PASS_QUERY_PARAM = 'farming_net_pass';
6
+ const PASS_SCOPE = 'farming:open';
7
+ const DEFAULT_PASS_TTL_SECONDS = 30;
8
+ const MAX_PASS_TTL_SECONDS = 60;
9
+ const CLOCK_SKEW_SECONDS = 5;
10
+
11
+ function normalizeAudience(value) {
12
+ const audience = String(value || '').trim().toLowerCase();
13
+ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(audience)) {
14
+ throw new Error('Invalid Farming Net audience');
15
+ }
16
+ return audience;
17
+ }
18
+
19
+ function normalizeIssuerId(value) {
20
+ const issuerId = String(value || '').trim();
21
+ if (!/^[A-Za-z0-9_-]{8,80}$/.test(issuerId)) {
22
+ throw new Error('Invalid Farming Net issuer id');
23
+ }
24
+ return issuerId;
25
+ }
26
+
27
+ function normalizeSubject(value) {
28
+ const subject = String(value || 'owner').trim();
29
+ if (!subject || subject.length > 80 || /[\x00-\x1f\x7f]/.test(subject)) {
30
+ throw new Error('Invalid Farming Net subject');
31
+ }
32
+ return subject;
33
+ }
34
+
35
+ function safeTtlSeconds(value) {
36
+ const ttl = Number(value);
37
+ if (!Number.isInteger(ttl)) return DEFAULT_PASS_TTL_SECONDS;
38
+ return Math.min(MAX_PASS_TTL_SECONDS, Math.max(5, ttl));
39
+ }
40
+
41
+ function publicKeyDetails(publicKeyPem) {
42
+ const publicKey = publicKeyPem && typeof publicKeyPem === 'object' && publicKeyPem.type === 'public'
43
+ ? publicKeyPem
44
+ : crypto.createPublicKey(String(publicKeyPem || ''));
45
+ if (publicKey.asymmetricKeyType !== 'ed25519') {
46
+ throw new Error('Farming Net requires an Ed25519 public key');
47
+ }
48
+ const canonicalPem = publicKey.export({ format: 'pem', type: 'spki' }).toString();
49
+ const der = publicKey.export({ format: 'der', type: 'spki' });
50
+ const issuer = `fnet_${crypto.createHash('sha256').update(der).digest('base64url').slice(0, 24)}`;
51
+ return { canonicalPem, issuer, publicKey };
52
+ }
53
+
54
+ function privateKeyDetails(privateKeyPem) {
55
+ const privateKey = crypto.createPrivateKey(String(privateKeyPem || ''));
56
+ if (privateKey.asymmetricKeyType !== 'ed25519') {
57
+ throw new Error('Farming Net requires an Ed25519 private key');
58
+ }
59
+ const canonicalPem = privateKey.export({ format: 'pem', type: 'pkcs8' }).toString();
60
+ const publicDetails = publicKeyDetails(crypto.createPublicKey(privateKey));
61
+ return { canonicalPem, privateKey, publicDetails };
62
+ }
63
+
64
+ function writePrivateFile(filePath, contents) {
65
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
66
+ const tempFile = `${filePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
67
+ fs.writeFileSync(tempFile, contents, { flag: 'wx', mode: 0o600 });
68
+ try {
69
+ fs.renameSync(tempFile, filePath);
70
+ fs.chmodSync(filePath, 0o600);
71
+ } finally {
72
+ try {
73
+ fs.unlinkSync(tempFile);
74
+ } catch {
75
+ // The rename already consumed the temporary file.
76
+ }
77
+ }
78
+ }
79
+
80
+ function loadOrCreateFarmingNetSigningIdentity(options = {}) {
81
+ const privateKeyFile = options.privateKeyFile;
82
+ const publicKeyFile = options.publicKeyFile;
83
+ if (!privateKeyFile || !publicKeyFile) {
84
+ throw new Error('Farming Net signing key paths are required');
85
+ }
86
+
87
+ let privateKeyPem = '';
88
+ try {
89
+ privateKeyPem = fs.readFileSync(privateKeyFile, 'utf8');
90
+ } catch (error) {
91
+ if (!error || error.code !== 'ENOENT') throw error;
92
+ const generated = crypto.generateKeyPairSync('ed25519');
93
+ const generatedPem = generated.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString();
94
+ try {
95
+ fs.mkdirSync(path.dirname(privateKeyFile), { recursive: true });
96
+ fs.writeFileSync(privateKeyFile, generatedPem, { flag: 'wx', mode: 0o600 });
97
+ privateKeyPem = generatedPem;
98
+ } catch (writeError) {
99
+ if (!writeError || writeError.code !== 'EEXIST') throw writeError;
100
+ privateKeyPem = fs.readFileSync(privateKeyFile, 'utf8');
101
+ }
102
+ }
103
+
104
+ const details = privateKeyDetails(privateKeyPem);
105
+ fs.chmodSync(privateKeyFile, 0o600);
106
+ writePrivateFile(publicKeyFile, details.publicDetails.canonicalPem);
107
+ return {
108
+ issuer: details.publicDetails.issuer,
109
+ privateKey: details.privateKey,
110
+ privateKeyFile,
111
+ publicKey: details.publicDetails.publicKey,
112
+ publicKeyFile,
113
+ publicKeyPem: details.publicDetails.canonicalPem,
114
+ };
115
+ }
116
+
117
+ function encodeJsonSegment(value) {
118
+ return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
119
+ }
120
+
121
+ function decodeJsonSegment(segment) {
122
+ if (typeof segment !== 'string' || segment.length < 2 || segment.length > 2048) {
123
+ throw new Error('Invalid Farming Net pass segment');
124
+ }
125
+ if (!/^[A-Za-z0-9_-]+$/.test(segment)) {
126
+ throw new Error('Invalid Farming Net pass encoding');
127
+ }
128
+ const parsed = JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
129
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
130
+ throw new Error('Invalid Farming Net pass JSON');
131
+ }
132
+ return parsed;
133
+ }
134
+
135
+ function createFarmingNetPass(identity, options = {}) {
136
+ if (!identity || !identity.privateKey || !identity.issuer) {
137
+ throw new Error('Farming Net signing identity is required');
138
+ }
139
+ const audience = normalizeAudience(options.audience);
140
+ const subject = normalizeSubject(options.subject);
141
+ const nowSeconds = Math.floor(Number(options.nowMs ?? Date.now()) / 1000);
142
+ if (!Number.isSafeInteger(nowSeconds) || nowSeconds <= 0) {
143
+ throw new Error('Invalid Farming Net pass time');
144
+ }
145
+ const ttlSeconds = safeTtlSeconds(options.ttlSeconds);
146
+ const header = {
147
+ alg: 'EdDSA',
148
+ kid: normalizeIssuerId(identity.issuer),
149
+ typ: 'FarmingNetPass',
150
+ };
151
+ const payload = {
152
+ aud: audience,
153
+ exp: nowSeconds + ttlSeconds,
154
+ iat: nowSeconds,
155
+ iss: header.kid,
156
+ jti: crypto.randomBytes(18).toString('base64url'),
157
+ scope: PASS_SCOPE,
158
+ sub: subject,
159
+ };
160
+ const signingInput = `${encodeJsonSegment(header)}.${encodeJsonSegment(payload)}`;
161
+ const signature = crypto.sign(null, Buffer.from(signingInput, 'ascii'), identity.privateKey);
162
+ return `${signingInput}.${signature.toString('base64url')}`;
163
+ }
164
+
165
+ function normalizeFarmingNetTrust(rawTrust) {
166
+ if (!rawTrust || typeof rawTrust !== 'object' || Array.isArray(rawTrust)) {
167
+ throw new Error('Invalid Farming Net trust document');
168
+ }
169
+ const audience = normalizeAudience(rawTrust.audience);
170
+ const seenIssuers = new Set();
171
+ const issuers = (Array.isArray(rawTrust.issuers) ? rawTrust.issuers : []).map(rawIssuer => {
172
+ if (!rawIssuer || typeof rawIssuer !== 'object' || Array.isArray(rawIssuer)) {
173
+ throw new Error('Invalid Farming Net trusted issuer');
174
+ }
175
+ const id = normalizeIssuerId(rawIssuer.id);
176
+ const details = publicKeyDetails(rawIssuer.publicKey);
177
+ if (details.issuer !== id) {
178
+ throw new Error('Farming Net trusted issuer id does not match its public key');
179
+ }
180
+ if (seenIssuers.has(id)) throw new Error('Duplicate Farming Net trusted issuer');
181
+ seenIssuers.add(id);
182
+ return {
183
+ id,
184
+ name: String(rawIssuer.name || '').trim().slice(0, 80),
185
+ publicKey: details.canonicalPem,
186
+ };
187
+ });
188
+ if (issuers.length === 0 || issuers.length > 16) {
189
+ throw new Error('Farming Net trust requires between 1 and 16 issuers');
190
+ }
191
+ return { version: 1, audience, issuers };
192
+ }
193
+
194
+ function writeFarmingNetTrust(filePath, trust) {
195
+ const normalized = normalizeFarmingNetTrust(trust);
196
+ writePrivateFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`);
197
+ return normalized;
198
+ }
199
+
200
+ function loadFarmingNetTrust(filePath) {
201
+ try {
202
+ return normalizeFarmingNetTrust(JSON.parse(fs.readFileSync(filePath, 'utf8')));
203
+ } catch (error) {
204
+ if (error && error.code === 'ENOENT') return null;
205
+ throw error;
206
+ }
207
+ }
208
+
209
+ class FarmingNetPassVerifier {
210
+ constructor(options = {}) {
211
+ this.trustFile = String(options.trustFile || '');
212
+ this.seenPasses = new Map();
213
+ }
214
+
215
+ verify(pass, options = {}) {
216
+ try {
217
+ if (!this.trustFile) return { valid: false, reason: 'trust_not_configured' };
218
+ const trust = loadFarmingNetTrust(this.trustFile);
219
+ if (!trust) return { valid: false, reason: 'trust_not_configured' };
220
+ if (typeof pass !== 'string' || pass.length < 32 || pass.length > 4096) {
221
+ return { valid: false, reason: 'invalid_pass' };
222
+ }
223
+ const segments = pass.split('.');
224
+ if (segments.length !== 3 || !/^[A-Za-z0-9_-]+$/.test(segments[2])) {
225
+ return { valid: false, reason: 'invalid_pass' };
226
+ }
227
+ const header = decodeJsonSegment(segments[0]);
228
+ const payload = decodeJsonSegment(segments[1]);
229
+ if (header.alg !== 'EdDSA' || header.typ !== 'FarmingNetPass') {
230
+ return { valid: false, reason: 'invalid_header' };
231
+ }
232
+ const issuerId = normalizeIssuerId(header.kid);
233
+ const issuer = trust.issuers.find(item => item.id === issuerId);
234
+ if (!issuer || payload.iss !== issuerId) return { valid: false, reason: 'untrusted_issuer' };
235
+ if (payload.aud !== trust.audience) return { valid: false, reason: 'wrong_audience' };
236
+ if (payload.scope !== PASS_SCOPE) return { valid: false, reason: 'wrong_scope' };
237
+ normalizeSubject(payload.sub);
238
+ if (!Number.isSafeInteger(payload.iat) || !Number.isSafeInteger(payload.exp)) {
239
+ return { valid: false, reason: 'invalid_time' };
240
+ }
241
+ if (payload.exp <= payload.iat || payload.exp - payload.iat > MAX_PASS_TTL_SECONDS) {
242
+ return { valid: false, reason: 'invalid_lifetime' };
243
+ }
244
+ const nowSeconds = Math.floor(Number(options.nowMs ?? Date.now()) / 1000);
245
+ if (!Number.isSafeInteger(nowSeconds) || nowSeconds < payload.iat - CLOCK_SKEW_SECONDS) {
246
+ return { valid: false, reason: 'not_yet_valid' };
247
+ }
248
+ if (nowSeconds >= payload.exp) return { valid: false, reason: 'expired' };
249
+ if (typeof payload.jti !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(payload.jti)) {
250
+ return { valid: false, reason: 'invalid_jti' };
251
+ }
252
+
253
+ const publicKey = publicKeyDetails(issuer.publicKey).publicKey;
254
+ const signature = Buffer.from(segments[2], 'base64url');
255
+ const signingInput = Buffer.from(`${segments[0]}.${segments[1]}`, 'ascii');
256
+ if (!crypto.verify(null, signingInput, publicKey, signature)) {
257
+ return { valid: false, reason: 'invalid_signature' };
258
+ }
259
+
260
+ for (const [key, expiresAt] of this.seenPasses) {
261
+ if (expiresAt <= nowSeconds) this.seenPasses.delete(key);
262
+ }
263
+ const replayKey = `${issuerId}:${payload.jti}`;
264
+ if (this.seenPasses.has(replayKey)) return { valid: false, reason: 'replayed' };
265
+ this.seenPasses.set(replayKey, payload.exp);
266
+ return { valid: true, payload };
267
+ } catch {
268
+ return { valid: false, reason: 'invalid_pass' };
269
+ }
270
+ }
271
+ }
272
+
273
+ module.exports = {
274
+ CLOCK_SKEW_SECONDS,
275
+ DEFAULT_PASS_TTL_SECONDS,
276
+ FarmingNetPassVerifier,
277
+ MAX_PASS_TTL_SECONDS,
278
+ PASS_QUERY_PARAM,
279
+ PASS_SCOPE,
280
+ createFarmingNetPass,
281
+ loadFarmingNetTrust,
282
+ loadOrCreateFarmingNetSigningIdentity,
283
+ normalizeFarmingNetTrust,
284
+ writeFarmingNetTrust,
285
+ };
@@ -0,0 +1,112 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const ENDPOINT_SCOPES = new Set(['this-device', 'intranet', 'remote', 'tunnel']);
5
+
6
+ function boundedText(value, maxLength) {
7
+ return String(value || '').trim().slice(0, maxLength);
8
+ }
9
+
10
+ function normalizeEndpointUrl(value) {
11
+ try {
12
+ const url = new URL(String(value || '').trim());
13
+ if (!['http:', 'https:'].includes(url.protocol)) return '';
14
+ if (url.username || url.password) return '';
15
+ url.search = '';
16
+ url.hash = '';
17
+ return url.toString();
18
+ } catch {
19
+ return '';
20
+ }
21
+ }
22
+
23
+ function normalizeEndpoint(rawEndpoint) {
24
+ if (!rawEndpoint || typeof rawEndpoint !== 'object' || Array.isArray(rawEndpoint)) return null;
25
+ const url = normalizeEndpointUrl(rawEndpoint.url);
26
+ if (!url) return null;
27
+ const scope = ENDPOINT_SCOPES.has(rawEndpoint.scope) ? rawEndpoint.scope : 'remote';
28
+ return {
29
+ label: boundedText(rawEndpoint.label, 40) || 'Open',
30
+ url,
31
+ scope,
32
+ primary: rawEndpoint.primary === true,
33
+ };
34
+ }
35
+
36
+ function normalizeInstance(rawInstance) {
37
+ if (!rawInstance || typeof rawInstance !== 'object' || Array.isArray(rawInstance)) return null;
38
+ const id = boundedText(rawInstance.id, 64).toLowerCase();
39
+ const name = boundedText(rawInstance.name, 80);
40
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(id) || !name) return null;
41
+
42
+ const seenUrls = new Set();
43
+ const endpoints = (Array.isArray(rawInstance.endpoints) ? rawInstance.endpoints : [])
44
+ .map(normalizeEndpoint)
45
+ .filter(Boolean)
46
+ .filter(endpoint => {
47
+ if (seenUrls.has(endpoint.url)) return false;
48
+ seenUrls.add(endpoint.url);
49
+ return true;
50
+ })
51
+ .slice(0, 8);
52
+ if (endpoints.length === 0) return null;
53
+ if (!endpoints.some(endpoint => endpoint.primary)) endpoints[0].primary = true;
54
+
55
+ return {
56
+ id,
57
+ name,
58
+ owner: boundedText(rawInstance.owner, 80),
59
+ description: boundedText(rawInstance.description, 180),
60
+ federated: rawInstance.federated === true,
61
+ platform: boundedText(rawInstance.platform, 40),
62
+ pinned: rawInstance.pinned === true,
63
+ endpoints,
64
+ };
65
+ }
66
+
67
+ function normalizeFarmingNetRegistry(rawRegistry) {
68
+ const source = rawRegistry && typeof rawRegistry === 'object' && !Array.isArray(rawRegistry)
69
+ ? rawRegistry
70
+ : {};
71
+ const seenIds = new Set();
72
+ const instances = (Array.isArray(source.instances) ? source.instances : [])
73
+ .map(normalizeInstance)
74
+ .filter(Boolean)
75
+ .filter(instance => {
76
+ if (seenIds.has(instance.id)) return false;
77
+ seenIds.add(instance.id);
78
+ return true;
79
+ })
80
+ .slice(0, 200);
81
+
82
+ return {
83
+ version: 1,
84
+ title: boundedText(source.title, 80) || 'Farming Net',
85
+ subtitle: boundedText(source.subtitle, 160),
86
+ instances,
87
+ };
88
+ }
89
+
90
+ function writeFarmingNetRegistry(filePath, registry) {
91
+ const normalized = normalizeFarmingNetRegistry(registry);
92
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
93
+ fs.writeFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
94
+ return normalized;
95
+ }
96
+
97
+ function loadFarmingNetRegistry(filePath) {
98
+ try {
99
+ return normalizeFarmingNetRegistry(JSON.parse(fs.readFileSync(filePath, 'utf8')));
100
+ } catch (error) {
101
+ if (error && error.code !== 'ENOENT') throw error;
102
+ return writeFarmingNetRegistry(filePath, { version: 1, title: 'Farming Net', instances: [] });
103
+ }
104
+ }
105
+
106
+ module.exports = {
107
+ ENDPOINT_SCOPES,
108
+ loadFarmingNetRegistry,
109
+ normalizeEndpointUrl,
110
+ normalizeFarmingNetRegistry,
111
+ writeFarmingNetRegistry,
112
+ };