@ornexus/neocortex 4.0.1 → 4.0.2
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/install.ps1 +92 -33
- package/install.sh +15 -1
- package/package.json +3 -3
- package/packages/client/dist/adapters/adapter-registry.js +1 -106
- package/packages/client/dist/adapters/antigravity-adapter.js +2 -77
- package/packages/client/dist/adapters/claude-code-adapter.js +3 -79
- package/packages/client/dist/adapters/codex-adapter.js +2 -80
- package/packages/client/dist/adapters/cursor-adapter.js +4 -115
- package/packages/client/dist/adapters/gemini-adapter.js +2 -71
- package/packages/client/dist/adapters/index.js +1 -21
- package/packages/client/dist/adapters/platform-detector.js +1 -106
- package/packages/client/dist/adapters/target-adapter.js +0 -12
- package/packages/client/dist/adapters/vscode-adapter.js +2 -72
- package/packages/client/dist/agent/refresh-stubs.js +2 -234
- package/packages/client/dist/agent/update-agent-yaml.js +1 -102
- package/packages/client/dist/agent/update-description.js +1 -251
- package/packages/client/dist/cache/crypto-utils.js +1 -76
- package/packages/client/dist/cache/encrypted-cache.js +1 -94
- package/packages/client/dist/cache/in-memory-asset-cache.js +1 -70
- package/packages/client/dist/cache/index.js +1 -13
- package/packages/client/dist/cli.js +2 -163
- package/packages/client/dist/commands/activate.js +8 -390
- package/packages/client/dist/commands/cache-status.js +2 -112
- package/packages/client/dist/commands/invoke.js +28 -490
- package/packages/client/dist/config/resolver-selection.js +1 -278
- package/packages/client/dist/config/secure-config.js +12 -269
- package/packages/client/dist/constants.js +1 -25
- package/packages/client/dist/context/context-collector.js +2 -222
- package/packages/client/dist/context/context-sanitizer.js +1 -145
- package/packages/client/dist/index.js +1 -38
- package/packages/client/dist/license/index.js +1 -5
- package/packages/client/dist/license/license-client.js +1 -257
- package/packages/client/dist/machine/fingerprint.js +2 -160
- package/packages/client/dist/machine/index.js +1 -5
- package/packages/client/dist/resilience/circuit-breaker.js +1 -170
- package/packages/client/dist/resilience/degradation-manager.js +1 -164
- package/packages/client/dist/resilience/freshness-indicator.js +1 -100
- package/packages/client/dist/resilience/index.js +1 -8
- package/packages/client/dist/resilience/recovery-detector.js +1 -74
- package/packages/client/dist/resolvers/asset-resolver.js +0 -13
- package/packages/client/dist/resolvers/local-resolver.js +8 -218
- package/packages/client/dist/resolvers/remote-resolver.js +1 -282
- package/packages/client/dist/telemetry/index.js +1 -5
- package/packages/client/dist/telemetry/offline-queue.js +1 -131
- package/packages/client/dist/tier/index.js +1 -5
- package/packages/client/dist/tier/tier-aware-client.js +1 -260
- package/packages/client/dist/types/index.js +1 -38
- package/targets-stubs/antigravity/gemini.md +1 -1
- package/targets-stubs/antigravity/install-antigravity.sh +49 -3
- package/targets-stubs/antigravity/skill/SKILL.md +23 -4
- package/targets-stubs/claude-code/neocortex.agent.yaml +19 -1
- package/targets-stubs/claude-code/neocortex.md +64 -29
- package/targets-stubs/codex/agents.md +20 -3
- package/targets-stubs/codex/config-mcp.toml +5 -0
- package/targets-stubs/cursor/agent.md +23 -5
- package/targets-stubs/cursor/install-cursor.sh +51 -3
- package/targets-stubs/cursor/mcp.json +7 -0
- package/targets-stubs/gemini-cli/agent.md +37 -6
- package/targets-stubs/gemini-cli/install-gemini.sh +50 -17
- package/targets-stubs/vscode/agent.md +47 -10
- package/targets-stubs/vscode/install-vscode.sh +50 -3
- package/targets-stubs/vscode/mcp.json +8 -0
|
@@ -1,257 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* @license FSL-1.1
|
|
3
|
-
* Copyright (c) 2026 OrNexus AI
|
|
4
|
-
*
|
|
5
|
-
* This file is part of Neocortex CLI, licensed under the
|
|
6
|
-
* Functional Source License, Version 1.1 (FSL-1.1).
|
|
7
|
-
*
|
|
8
|
-
* Change Date: February 20, 2029
|
|
9
|
-
* Change License: MIT
|
|
10
|
-
*
|
|
11
|
-
* See the LICENSE file in the project root for full license text.
|
|
12
|
-
*/
|
|
13
|
-
/**
|
|
14
|
-
* @neocortex/client - License Client
|
|
15
|
-
*
|
|
16
|
-
* Manages license activation, JWT token caching, and proactive refresh.
|
|
17
|
-
* Integrates with the IP Protection Server's license endpoints and
|
|
18
|
-
* uses EncryptedCache for persistent token storage.
|
|
19
|
-
*
|
|
20
|
-
* Story 31.01: REFRESH_THRESHOLD_S fixed from 3600 to 300 (5 min)
|
|
21
|
-
* Story 31.02: Refresh token support (store, use, rotate)
|
|
22
|
-
* Story 31.03: Refresh token persistence in EncryptedCache
|
|
23
|
-
*
|
|
24
|
-
* NEVER throws exceptions - returns null on failure (graceful degradation).
|
|
25
|
-
*/
|
|
26
|
-
import { decodeJwt } from 'jose';
|
|
27
|
-
import { NoOpCache } from '../types/index.js';
|
|
28
|
-
import { getMachineFingerprint } from '../machine/fingerprint.js';
|
|
29
|
-
// ── Constants ────────────────────────────────────────────────────────────
|
|
30
|
-
const CACHE_KEY = 'neocortex:jwt:token';
|
|
31
|
-
const REFRESH_CACHE_KEY = 'neocortex:jwt:refresh_token';
|
|
32
|
-
const REFRESH_THRESHOLD_S = 300; // 5 minutes before expiry
|
|
33
|
-
const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (matches server REFRESH_TOKEN_TTL_DAYS)
|
|
34
|
-
const DEFAULT_CLIENT_VERSION = '0.1.0';
|
|
35
|
-
// ── LicenseClient ────────────────────────────────────────────────────────
|
|
36
|
-
export class LicenseClient {
|
|
37
|
-
serverUrl;
|
|
38
|
-
licenseKey;
|
|
39
|
-
cache;
|
|
40
|
-
clientVersion;
|
|
41
|
-
machineId;
|
|
42
|
-
token = null;
|
|
43
|
-
refreshToken = null;
|
|
44
|
-
constructor(options) {
|
|
45
|
-
this.serverUrl = options.serverUrl.replace(/\/+$/, '');
|
|
46
|
-
this.licenseKey = options.licenseKey;
|
|
47
|
-
this.cache = options.cacheProvider ?? new NoOpCache();
|
|
48
|
-
this.clientVersion = options.clientVersion ?? DEFAULT_CLIENT_VERSION;
|
|
49
|
-
this.machineId = getMachineFingerprint();
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Get a valid JWT token. Checks memory, cache, refresh token, then activation.
|
|
53
|
-
* NEVER throws - returns null on failure.
|
|
54
|
-
*
|
|
55
|
-
* Flow:
|
|
56
|
-
* 1. in-memory JWT valid? -> return
|
|
57
|
-
* 2. in-memory JWT needs-refresh? -> refresh(refresh_token) -> return
|
|
58
|
-
* 3. cached JWT valid? -> return
|
|
59
|
-
* 4. cached JWT needs-refresh? -> refresh(refresh_token) -> return
|
|
60
|
-
* 5. cached refresh_token exists? -> refresh(refresh_token) -> return (Story 31.02)
|
|
61
|
-
* 6. activate() -> return
|
|
62
|
-
*/
|
|
63
|
-
async getToken() {
|
|
64
|
-
try {
|
|
65
|
-
// 1. Check in-memory token
|
|
66
|
-
if (this.token) {
|
|
67
|
-
const validity = this.checkTokenValidity(this.token);
|
|
68
|
-
if (validity === 'valid')
|
|
69
|
-
return this.token;
|
|
70
|
-
if (validity === 'needs-refresh') {
|
|
71
|
-
const refreshed = await this.refresh();
|
|
72
|
-
return refreshed?.token ?? this.token; // fallback to current if refresh fails
|
|
73
|
-
}
|
|
74
|
-
// expired or invalid - clear memory
|
|
75
|
-
this.token = null;
|
|
76
|
-
}
|
|
77
|
-
// 2. Check JWT cache
|
|
78
|
-
const cached = await this.loadFromCache();
|
|
79
|
-
if (cached) {
|
|
80
|
-
this.token = cached;
|
|
81
|
-
const validity = this.checkTokenValidity(cached);
|
|
82
|
-
if (validity === 'valid')
|
|
83
|
-
return cached;
|
|
84
|
-
if (validity === 'needs-refresh') {
|
|
85
|
-
const refreshed = await this.refresh();
|
|
86
|
-
return refreshed?.token ?? cached; // fallback to current if refresh fails
|
|
87
|
-
}
|
|
88
|
-
// expired - clear
|
|
89
|
-
this.token = null;
|
|
90
|
-
}
|
|
91
|
-
// 3. Try refresh with cached refresh_token (Story 31.02/31.03)
|
|
92
|
-
// Lazy load refresh_token from cache if not in memory
|
|
93
|
-
if (!this.refreshToken) {
|
|
94
|
-
this.refreshToken = await this.loadRefreshTokenFromCache();
|
|
95
|
-
}
|
|
96
|
-
if (this.refreshToken) {
|
|
97
|
-
const refreshed = await this.refresh();
|
|
98
|
-
if (refreshed?.token)
|
|
99
|
-
return refreshed.token;
|
|
100
|
-
}
|
|
101
|
-
// 4. Activate (last resort)
|
|
102
|
-
const result = await this.activate();
|
|
103
|
-
return result?.token ?? null;
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* Activate the license by calling POST /api/v1/license/activate.
|
|
111
|
-
* Stores token and refresh_token in memory and cache on success.
|
|
112
|
-
* NEVER throws - returns null on failure.
|
|
113
|
-
*/
|
|
114
|
-
async activate() {
|
|
115
|
-
try {
|
|
116
|
-
const response = await fetch(`${this.serverUrl}/api/v1/license/activate`, {
|
|
117
|
-
method: 'POST',
|
|
118
|
-
headers: { 'Content-Type': 'application/json' },
|
|
119
|
-
body: JSON.stringify({
|
|
120
|
-
license_key: this.licenseKey,
|
|
121
|
-
machine_id: this.machineId,
|
|
122
|
-
client_version: this.clientVersion,
|
|
123
|
-
}),
|
|
124
|
-
});
|
|
125
|
-
if (!response.ok)
|
|
126
|
-
return null;
|
|
127
|
-
const data = (await response.json());
|
|
128
|
-
this.token = data.token;
|
|
129
|
-
this.refreshToken = data.refresh_token ?? null;
|
|
130
|
-
// Persist JWT to cache (fire-and-forget)
|
|
131
|
-
this.cache.set(CACHE_KEY, data.token, data.expires_in * 1000).catch(() => { });
|
|
132
|
-
// Persist refresh token with 7-day TTL (fire-and-forget)
|
|
133
|
-
if (data.refresh_token) {
|
|
134
|
-
this.cache.set(REFRESH_CACHE_KEY, data.refresh_token, REFRESH_TOKEN_TTL_MS).catch(() => { });
|
|
135
|
-
}
|
|
136
|
-
return {
|
|
137
|
-
token: data.token,
|
|
138
|
-
expiresIn: data.expires_in,
|
|
139
|
-
refreshToken: data.refresh_token,
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
catch {
|
|
143
|
-
return null;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Refresh using refresh_token by calling POST /api/v1/license/refresh.
|
|
148
|
-
* Sends refresh_token in body (no Authorization header needed).
|
|
149
|
-
* Handles token rotation: stores the new refresh_token from response.
|
|
150
|
-
* NEVER throws - returns null on failure.
|
|
151
|
-
*
|
|
152
|
-
* Story 31.02: Signature changed from refresh(currentToken) to refresh().
|
|
153
|
-
* The refresh_token in the body is the credential, not the JWT.
|
|
154
|
-
*
|
|
155
|
-
* @param _currentToken - Deprecated. Kept for backward compat but ignored.
|
|
156
|
-
*/
|
|
157
|
-
async refresh(_currentToken) {
|
|
158
|
-
try {
|
|
159
|
-
// Load refresh_token: in-memory first, then cache
|
|
160
|
-
const rt = this.refreshToken ?? await this.loadRefreshTokenFromCache();
|
|
161
|
-
if (!rt) {
|
|
162
|
-
// No refresh token available -- cannot use new flow
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
|
-
const response = await fetch(`${this.serverUrl}/api/v1/license/refresh`, {
|
|
166
|
-
method: 'POST',
|
|
167
|
-
headers: { 'Content-Type': 'application/json' },
|
|
168
|
-
body: JSON.stringify({ refresh_token: rt }),
|
|
169
|
-
});
|
|
170
|
-
if (!response.ok) {
|
|
171
|
-
// Refresh token invalid/expired -- clear it
|
|
172
|
-
this.refreshToken = null;
|
|
173
|
-
this.cache.set(REFRESH_CACHE_KEY, '', 1).catch(() => { }); // expire immediately
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
const data = (await response.json());
|
|
177
|
-
this.token = data.token;
|
|
178
|
-
// Token rotation: server issues new refresh token
|
|
179
|
-
this.refreshToken = data.refresh_token ?? null;
|
|
180
|
-
// Persist JWT to cache (fire-and-forget)
|
|
181
|
-
this.cache.set(CACHE_KEY, data.token, data.expires_in * 1000).catch(() => { });
|
|
182
|
-
// Persist rotated refresh token (fire-and-forget)
|
|
183
|
-
if (data.refresh_token) {
|
|
184
|
-
this.cache.set(REFRESH_CACHE_KEY, data.refresh_token, REFRESH_TOKEN_TTL_MS).catch(() => { });
|
|
185
|
-
}
|
|
186
|
-
return {
|
|
187
|
-
token: data.token,
|
|
188
|
-
expiresIn: data.expires_in,
|
|
189
|
-
refreshToken: data.refresh_token,
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
catch {
|
|
193
|
-
return null;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* Force-refresh the token.
|
|
198
|
-
* Used when tier_changed is detected or a 401 response indicates the token is expired.
|
|
199
|
-
* Falls back to re-activation if refresh fails.
|
|
200
|
-
* NEVER throws - returns new token or null on failure.
|
|
201
|
-
*
|
|
202
|
-
* Story 18.8: Updated to prefer refresh over re-activate (preserves updated tier).
|
|
203
|
-
* Story 31.02: Uses refresh_token flow instead of JWT-based refresh.
|
|
204
|
-
*/
|
|
205
|
-
async forceRefresh() {
|
|
206
|
-
try {
|
|
207
|
-
// 1. Try refresh with refresh_token first
|
|
208
|
-
const refreshResult = await this.refresh();
|
|
209
|
-
if (refreshResult?.token)
|
|
210
|
-
return refreshResult.token;
|
|
211
|
-
// 2. Refresh failed -- clear everything and re-activate
|
|
212
|
-
this.token = null;
|
|
213
|
-
this.refreshToken = null;
|
|
214
|
-
await this.cache.clear().catch(() => { });
|
|
215
|
-
const result = await this.activate();
|
|
216
|
-
return result?.token ?? null;
|
|
217
|
-
}
|
|
218
|
-
catch {
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
// ── Private Methods ─────────────────────────────────────────────────
|
|
223
|
-
checkTokenValidity(token) {
|
|
224
|
-
try {
|
|
225
|
-
const payload = decodeJwt(token);
|
|
226
|
-
if (!payload.exp)
|
|
227
|
-
return 'expired';
|
|
228
|
-
const now = Math.floor(Date.now() / 1000);
|
|
229
|
-
if (payload.exp <= now)
|
|
230
|
-
return 'expired';
|
|
231
|
-
if (payload.exp - now <= REFRESH_THRESHOLD_S)
|
|
232
|
-
return 'needs-refresh';
|
|
233
|
-
return 'valid';
|
|
234
|
-
}
|
|
235
|
-
catch {
|
|
236
|
-
return 'expired';
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
async loadFromCache() {
|
|
240
|
-
try {
|
|
241
|
-
return await this.cache.get(CACHE_KEY);
|
|
242
|
-
}
|
|
243
|
-
catch {
|
|
244
|
-
return null;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
async loadRefreshTokenFromCache() {
|
|
248
|
-
try {
|
|
249
|
-
const rt = await this.cache.get(REFRESH_CACHE_KEY);
|
|
250
|
-
// Guard against empty/expired entries
|
|
251
|
-
return rt && rt.length > 0 ? rt : null;
|
|
252
|
-
}
|
|
253
|
-
catch {
|
|
254
|
-
return null;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
1
|
+
import{decodeJwt as c}from"jose";import{NoOpCache as o}from"../types/index.js";import{getMachineFingerprint as a}from"../machine/fingerprint.js";const i="neocortex:jwt:token",s="neocortex:jwt:refresh_token",l=300,h=10080*60*1e3,f="0.1.0";class T{serverUrl;licenseKey;cache;clientVersion;machineId;token=null;refreshToken=null;constructor(e){this.serverUrl=e.serverUrl.replace(/\/+$/,""),this.licenseKey=e.licenseKey,this.cache=e.cacheProvider??new o,this.clientVersion=e.clientVersion??f,this.machineId=a()}async getToken(){try{if(this.token){const r=this.checkTokenValidity(this.token);if(r==="valid")return this.token;if(r==="needs-refresh")return(await this.refresh())?.token??this.token;this.token=null}const e=await this.loadFromCache();if(e){this.token=e;const r=this.checkTokenValidity(e);if(r==="valid")return e;if(r==="needs-refresh")return(await this.refresh())?.token??e;this.token=null}if(this.refreshToken||(this.refreshToken=await this.loadRefreshTokenFromCache()),this.refreshToken){const r=await this.refresh();if(r?.token)return r.token}return(await this.activate())?.token??null}catch{return null}}async activate(){try{const e=await fetch(`${this.serverUrl}/api/v1/license/activate`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({license_key:this.licenseKey,machine_id:this.machineId,client_version:this.clientVersion})});if(!e.ok)return null;const t=await e.json();return this.token=t.token,this.refreshToken=t.refresh_token??null,this.cache.set(i,t.token,t.expires_in*1e3).catch(()=>{}),t.refresh_token&&this.cache.set(s,t.refresh_token,h).catch(()=>{}),{token:t.token,expiresIn:t.expires_in,refreshToken:t.refresh_token}}catch{return null}}async refresh(e){try{const t=this.refreshToken??await this.loadRefreshTokenFromCache();if(!t)return null;const r=await fetch(`${this.serverUrl}/api/v1/license/refresh`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refresh_token:t})});if(!r.ok)return this.refreshToken=null,this.cache.set(s,"",1).catch(()=>{}),null;const n=await r.json();return this.token=n.token,this.refreshToken=n.refresh_token??null,this.cache.set(i,n.token,n.expires_in*1e3).catch(()=>{}),n.refresh_token&&this.cache.set(s,n.refresh_token,h).catch(()=>{}),{token:n.token,expiresIn:n.expires_in,refreshToken:n.refresh_token}}catch{return null}}async forceRefresh(){try{const e=await this.refresh();return e?.token?e.token:(this.token=null,this.refreshToken=null,await this.cache.clear().catch(()=>{}),(await this.activate())?.token??null)}catch{return null}}checkTokenValidity(e){try{const t=c(e);if(!t.exp)return"expired";const r=Math.floor(Date.now()/1e3);return t.exp<=r?"expired":t.exp-r<=l?"needs-refresh":"valid"}catch{return"expired"}}async loadFromCache(){try{return await this.cache.get(i)}catch{return null}}async loadRefreshTokenFromCache(){try{const e=await this.cache.get(s);return e&&e.length>0?e:null}catch{return null}}}export{T as LicenseClient};
|
|
@@ -1,160 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* Copyright (c) 2026 OrNexus AI
|
|
4
|
-
*
|
|
5
|
-
* This file is part of Neocortex CLI, licensed under the
|
|
6
|
-
* Functional Source License, Version 1.1 (FSL-1.1).
|
|
7
|
-
*
|
|
8
|
-
* Change Date: February 20, 2029
|
|
9
|
-
* Change License: MIT
|
|
10
|
-
*
|
|
11
|
-
* See the LICENSE file in the project root for full license text.
|
|
12
|
-
*/
|
|
13
|
-
/**
|
|
14
|
-
* @neocortex/client - Machine Fingerprint
|
|
15
|
-
*
|
|
16
|
-
* Generates a stable, deterministic machine identifier from hardware/OS
|
|
17
|
-
* attributes using SHA-256. Used for license activation machine tracking.
|
|
18
|
-
*/
|
|
19
|
-
import { createHash } from 'node:crypto';
|
|
20
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
|
|
21
|
-
import { join } from 'node:path';
|
|
22
|
-
import { arch, cpus, homedir, hostname, networkInterfaces, platform } from 'node:os';
|
|
23
|
-
const ALL_ZEROS_MAC = '00:00:00:00:00:00';
|
|
24
|
-
/** Path to the persisted machine-id file */
|
|
25
|
-
const NEOCORTEX_DIR = join(homedir(), '.neocortex');
|
|
26
|
-
const MACHINE_ID_FILE = join(NEOCORTEX_DIR, '.machine-id');
|
|
27
|
-
/**
|
|
28
|
-
* Collect sorted, non-internal MAC addresses from network interfaces.
|
|
29
|
-
* Filters out internal/loopback interfaces and all-zeros MACs.
|
|
30
|
-
*/
|
|
31
|
-
function collectMacAddresses() {
|
|
32
|
-
const interfaces = networkInterfaces();
|
|
33
|
-
const macs = [];
|
|
34
|
-
for (const entries of Object.values(interfaces)) {
|
|
35
|
-
if (!entries)
|
|
36
|
-
continue;
|
|
37
|
-
for (const entry of entries) {
|
|
38
|
-
if (!entry.internal && entry.mac !== ALL_ZEROS_MAC) {
|
|
39
|
-
macs.push(entry.mac);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
// Sort for determinism regardless of NIC enumeration order
|
|
44
|
-
return [...new Set(macs)].sort();
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Generate a hardware-based fingerprint (not persisted).
|
|
48
|
-
* This is the original computation used as fallback and for seeding.
|
|
49
|
-
*
|
|
50
|
-
* @returns 64-character hex string (SHA-256 digest)
|
|
51
|
-
*/
|
|
52
|
-
export function computeHardwareFingerprint() {
|
|
53
|
-
const host = hostname();
|
|
54
|
-
const plat = platform();
|
|
55
|
-
const architecture = arch();
|
|
56
|
-
const cpuList = cpus();
|
|
57
|
-
const cpuModel = cpuList.length > 0 ? cpuList[0].model : '';
|
|
58
|
-
const macs = collectMacAddresses();
|
|
59
|
-
const input = `${host}|${plat}|${architecture}|${cpuModel}|${macs.join(',')}`;
|
|
60
|
-
return createHash('sha256').update(input).digest('hex');
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Set restrictive file permissions (chmod 600) on a file.
|
|
64
|
-
* Fail-open: never throws.
|
|
65
|
-
*/
|
|
66
|
-
function setFilePermissions600(filePath) {
|
|
67
|
-
try {
|
|
68
|
-
if (process.platform !== 'win32') {
|
|
69
|
-
chmodSync(filePath, 0o600);
|
|
70
|
-
}
|
|
71
|
-
// Windows ACL handled by secure-config.ts setSecureFilePermissions() if needed
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
// Fail-open
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Read persisted machine ID from disk.
|
|
79
|
-
* Returns null if file doesn't exist or can't be read.
|
|
80
|
-
*/
|
|
81
|
-
function readPersistedMachineId() {
|
|
82
|
-
try {
|
|
83
|
-
if (!existsSync(MACHINE_ID_FILE))
|
|
84
|
-
return null;
|
|
85
|
-
const raw = readFileSync(MACHINE_ID_FILE, 'utf-8').trim();
|
|
86
|
-
// Validate: must be a 64-char hex string
|
|
87
|
-
if (/^[a-f0-9]{64}$/.test(raw))
|
|
88
|
-
return raw;
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Persist machine ID to disk.
|
|
97
|
-
* Creates ~/.neocortex/ if needed. Sets chmod 600 on the file.
|
|
98
|
-
* Fail-open: never throws.
|
|
99
|
-
*/
|
|
100
|
-
function persistMachineId(machineId) {
|
|
101
|
-
try {
|
|
102
|
-
mkdirSync(NEOCORTEX_DIR, { recursive: true });
|
|
103
|
-
writeFileSync(MACHINE_ID_FILE, machineId + '\n', 'utf-8');
|
|
104
|
-
setFilePermissions600(MACHINE_ID_FILE);
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
// Fail-open: if disk fails, fingerprint still works from hardware
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Check if config.json has a machineId field that can seed .machine-id.
|
|
112
|
-
* This provides backward compatibility for existing installations.
|
|
113
|
-
*/
|
|
114
|
-
function readMachineIdFromConfig() {
|
|
115
|
-
try {
|
|
116
|
-
const configPath = join(NEOCORTEX_DIR, 'config.json');
|
|
117
|
-
if (!existsSync(configPath))
|
|
118
|
-
return null;
|
|
119
|
-
const raw = readFileSync(configPath, 'utf-8');
|
|
120
|
-
const config = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
121
|
-
const machineId = config.machineId;
|
|
122
|
-
if (typeof machineId === 'string' && /^[a-f0-9]{64}$/.test(machineId)) {
|
|
123
|
-
return machineId;
|
|
124
|
-
}
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
catch {
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Generate a stable machine fingerprint as a SHA-256 hex digest.
|
|
133
|
-
*
|
|
134
|
-
* Story P26.01: On first call, generates from hardware and persists to
|
|
135
|
-
* ~/.neocortex/.machine-id. On subsequent calls, reads from disk.
|
|
136
|
-
* This ensures the fingerprint remains stable even if hardware attributes
|
|
137
|
-
* change (e.g., USB NIC added/removed, hostname change).
|
|
138
|
-
*
|
|
139
|
-
* Backward compat: if config.json has machineId, uses that to seed .machine-id.
|
|
140
|
-
*
|
|
141
|
-
* Fail-open: if disk operations fail, falls back to hardware-generated fingerprint.
|
|
142
|
-
*
|
|
143
|
-
* @returns 64-character hex string (SHA-256 digest)
|
|
144
|
-
*/
|
|
145
|
-
export function getMachineFingerprint() {
|
|
146
|
-
// 1. Try reading persisted machine ID from disk
|
|
147
|
-
const persisted = readPersistedMachineId();
|
|
148
|
-
if (persisted)
|
|
149
|
-
return persisted;
|
|
150
|
-
// 2. Check config.json for backward-compatible machineId
|
|
151
|
-
const fromConfig = readMachineIdFromConfig();
|
|
152
|
-
if (fromConfig) {
|
|
153
|
-
persistMachineId(fromConfig);
|
|
154
|
-
return fromConfig;
|
|
155
|
-
}
|
|
156
|
-
// 3. Generate from hardware and persist
|
|
157
|
-
const fingerprint = computeHardwareFingerprint();
|
|
158
|
-
persistMachineId(fingerprint);
|
|
159
|
-
return fingerprint;
|
|
160
|
-
}
|
|
1
|
+
import{createHash as p}from"node:crypto";import{existsSync as s,readFileSync as a,writeFileSync as h,mkdirSync as d,chmodSync as g}from"node:fs";import{join as o}from"node:path";import{arch as y,cpus as F,homedir as I,hostname as M,networkInterfaces as S,platform as w}from"node:os";const $="00:00:00:00:00:00",i=o(I(),".neocortex"),c=o(i,".machine-id");function E(){const t=S(),n=[];for(const e of Object.values(t))if(e)for(const r of e)!r.internal&&r.mac!==$&&n.push(r.mac);return[...new Set(n)].sort()}function x(){const t=M(),n=w(),e=y(),r=F(),u=r.length>0?r[0].model:"",m=E(),l=`${t}|${n}|${e}|${u}|${m.join(",")}`;return p("sha256").update(l).digest("hex")}function C(t){try{process.platform!=="win32"&&g(t,384)}catch{}}function O(){try{if(!s(c))return null;const t=a(c,"utf-8").trim();return/^[a-f0-9]{64}$/.test(t)?t:null}catch{return null}}function f(t){try{d(i,{recursive:!0}),h(c,t+`
|
|
2
|
+
`,"utf-8"),C(c)}catch{}}function _(){try{const t=o(i,"config.json");if(!s(t))return null;const n=a(t,"utf-8"),r=JSON.parse(n.replace(/^\uFEFF/,"")).machineId;return typeof r=="string"&&/^[a-f0-9]{64}$/.test(r)?r:null}catch{return null}}function N(){const t=O();if(t)return t;const n=_();if(n)return f(n),n;const e=x();return f(e),e}export{x as computeHardwareFingerprint,N as getMachineFingerprint};
|
|
@@ -1,170 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* @license FSL-1.1
|
|
3
|
-
* Copyright (c) 2026 OrNexus AI
|
|
4
|
-
*
|
|
5
|
-
* This file is part of Neocortex CLI, licensed under the
|
|
6
|
-
* Functional Source License, Version 1.1 (FSL-1.1).
|
|
7
|
-
*
|
|
8
|
-
* Change Date: February 20, 2029
|
|
9
|
-
* Change License: MIT
|
|
10
|
-
*
|
|
11
|
-
* See the LICENSE file in the project root for full license text.
|
|
12
|
-
*/
|
|
13
|
-
/**
|
|
14
|
-
* @neocortex/client - Persistent Circuit Breaker
|
|
15
|
-
*
|
|
16
|
-
* Circuit breaker with file-based state persistence for the CLI client.
|
|
17
|
-
* Tracks server health using window-based failure counting and persists
|
|
18
|
-
* state between CLI invocations at ~/.neocortex/.circuit-state.
|
|
19
|
-
*
|
|
20
|
-
* States:
|
|
21
|
-
* CLOSED - Normal operation, requests pass through
|
|
22
|
-
* OPEN - Circuit tripped, requests fail immediately (use cache)
|
|
23
|
-
* HALF_OPEN - Testing if server recovered (one probe request allowed)
|
|
24
|
-
*
|
|
25
|
-
* Story 42.9
|
|
26
|
-
*/
|
|
27
|
-
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
28
|
-
import { dirname } from 'node:path';
|
|
29
|
-
import { homedir } from 'node:os';
|
|
30
|
-
import { join } from 'node:path';
|
|
31
|
-
// ── Defaults ──────────────────────────────────────────────────────────────
|
|
32
|
-
const DEFAULT_CONFIG = {
|
|
33
|
-
failureThreshold: 3,
|
|
34
|
-
failureWindowMs: 60_000,
|
|
35
|
-
halfOpenAfterMs: 60_000,
|
|
36
|
-
stateFilePath: join(homedir(), '.neocortex', '.circuit-state'),
|
|
37
|
-
};
|
|
38
|
-
const INITIAL_STATE = {
|
|
39
|
-
state: 'CLOSED',
|
|
40
|
-
failures: [],
|
|
41
|
-
openedAt: null,
|
|
42
|
-
lastProbeAt: null,
|
|
43
|
-
};
|
|
44
|
-
// ── ClientCircuitBreaker ──────────────────────────────────────────────────
|
|
45
|
-
export class ClientCircuitBreaker {
|
|
46
|
-
config;
|
|
47
|
-
internalState;
|
|
48
|
-
constructor(config, initialState) {
|
|
49
|
-
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
50
|
-
this.internalState = initialState ? { ...initialState } : { ...INITIAL_STATE, failures: [] };
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Check if a request can be made to the server.
|
|
54
|
-
* Returns false if circuit is OPEN (fail-fast to cache).
|
|
55
|
-
*/
|
|
56
|
-
canCall() {
|
|
57
|
-
this.pruneExpiredFailures();
|
|
58
|
-
switch (this.internalState.state) {
|
|
59
|
-
case 'CLOSED':
|
|
60
|
-
return true;
|
|
61
|
-
case 'OPEN': {
|
|
62
|
-
const now = Date.now();
|
|
63
|
-
const openedAt = this.internalState.openedAt ?? 0;
|
|
64
|
-
if (now - openedAt >= this.config.halfOpenAfterMs) {
|
|
65
|
-
this.internalState.state = 'HALF_OPEN';
|
|
66
|
-
this.internalState.lastProbeAt = now;
|
|
67
|
-
return true;
|
|
68
|
-
}
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
case 'HALF_OPEN':
|
|
72
|
-
return true;
|
|
73
|
-
default:
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Record a successful server response. Closes the circuit.
|
|
79
|
-
*/
|
|
80
|
-
async recordSuccess() {
|
|
81
|
-
this.internalState.state = 'CLOSED';
|
|
82
|
-
this.internalState.failures = [];
|
|
83
|
-
this.internalState.openedAt = null;
|
|
84
|
-
this.internalState.lastProbeAt = null;
|
|
85
|
-
await this.saveToDisk();
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Record a failed server response. Opens circuit after threshold failures in window.
|
|
89
|
-
*/
|
|
90
|
-
async recordFailure() {
|
|
91
|
-
const now = Date.now();
|
|
92
|
-
this.internalState.failures.push({ timestamp: now });
|
|
93
|
-
this.pruneExpiredFailures();
|
|
94
|
-
if (this.internalState.failures.length >= this.config.failureThreshold) {
|
|
95
|
-
this.internalState.state = 'OPEN';
|
|
96
|
-
this.internalState.openedAt = now;
|
|
97
|
-
}
|
|
98
|
-
// If in HALF_OPEN and probe fails, go back to OPEN
|
|
99
|
-
if (this.internalState.state === 'HALF_OPEN') {
|
|
100
|
-
this.internalState.state = 'OPEN';
|
|
101
|
-
this.internalState.openedAt = now;
|
|
102
|
-
}
|
|
103
|
-
await this.saveToDisk();
|
|
104
|
-
}
|
|
105
|
-
/**
|
|
106
|
-
* Get current circuit breaker state (copy).
|
|
107
|
-
*/
|
|
108
|
-
getState() {
|
|
109
|
-
this.pruneExpiredFailures();
|
|
110
|
-
return {
|
|
111
|
-
state: this.internalState.state,
|
|
112
|
-
failures: [...this.internalState.failures],
|
|
113
|
-
openedAt: this.internalState.openedAt,
|
|
114
|
-
lastProbeAt: this.internalState.lastProbeAt,
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Reset circuit breaker to initial CLOSED state.
|
|
119
|
-
*/
|
|
120
|
-
async reset() {
|
|
121
|
-
this.internalState = { ...INITIAL_STATE, failures: [] };
|
|
122
|
-
await this.saveToDisk();
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* Load circuit breaker state from disk.
|
|
126
|
-
*/
|
|
127
|
-
static async loadFromDisk(path) {
|
|
128
|
-
const filePath = path ?? DEFAULT_CONFIG.stateFilePath;
|
|
129
|
-
try {
|
|
130
|
-
const raw = await readFile(filePath, 'utf8');
|
|
131
|
-
const data = JSON.parse(raw);
|
|
132
|
-
// Validate loaded state
|
|
133
|
-
if (!data.state || !Array.isArray(data.failures)) {
|
|
134
|
-
return new ClientCircuitBreaker({ stateFilePath: filePath });
|
|
135
|
-
}
|
|
136
|
-
return new ClientCircuitBreaker({ stateFilePath: filePath }, data);
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
// File doesn't exist or is corrupted - start fresh
|
|
140
|
-
return new ClientCircuitBreaker({ stateFilePath: filePath });
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
// ── Private ─────────────────────────────────────────────────────────
|
|
144
|
-
/**
|
|
145
|
-
* Remove failures outside the counting window.
|
|
146
|
-
*/
|
|
147
|
-
pruneExpiredFailures() {
|
|
148
|
-
const cutoff = Date.now() - this.config.failureWindowMs;
|
|
149
|
-
this.internalState.failures = this.internalState.failures.filter((f) => f.timestamp > cutoff);
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* Persist state to disk with atomic write.
|
|
153
|
-
*/
|
|
154
|
-
async saveToDisk() {
|
|
155
|
-
try {
|
|
156
|
-
const dir = dirname(this.config.stateFilePath);
|
|
157
|
-
await mkdir(dir, { recursive: true });
|
|
158
|
-
const tmpPath = `${this.config.stateFilePath}.tmp`;
|
|
159
|
-
const data = JSON.stringify(this.internalState, null, 2);
|
|
160
|
-
await writeFile(tmpPath, data, 'utf8');
|
|
161
|
-
// Atomic rename
|
|
162
|
-
const { rename } = await import('node:fs/promises');
|
|
163
|
-
await rename(tmpPath, this.config.stateFilePath);
|
|
164
|
-
}
|
|
165
|
-
catch {
|
|
166
|
-
// Persistence failures are non-critical - circuit breaker
|
|
167
|
-
// still works in-memory for current session
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
1
|
+
import{readFile as l,writeFile as o,mkdir as h}from"node:fs/promises";import{dirname as u}from"node:path";import{homedir as c}from"node:os";import{join as f}from"node:path";const n={failureThreshold:3,failureWindowMs:6e4,halfOpenAfterMs:6e4,stateFilePath:f(c(),".neocortex",".circuit-state")},r={state:"CLOSED",failures:[],openedAt:null,lastProbeAt:null};class i{config;internalState;constructor(t,e){this.config={...n,...t},this.internalState=e?{...e}:{...r,failures:[]}}canCall(){switch(this.pruneExpiredFailures(),this.internalState.state){case"CLOSED":return!0;case"OPEN":{const t=Date.now(),e=this.internalState.openedAt??0;return t-e>=this.config.halfOpenAfterMs?(this.internalState.state="HALF_OPEN",this.internalState.lastProbeAt=t,!0):!1}case"HALF_OPEN":return!0;default:return!0}}async recordSuccess(){this.internalState.state="CLOSED",this.internalState.failures=[],this.internalState.openedAt=null,this.internalState.lastProbeAt=null,await this.saveToDisk()}async recordFailure(){const t=Date.now();this.internalState.failures.push({timestamp:t}),this.pruneExpiredFailures(),this.internalState.failures.length>=this.config.failureThreshold&&(this.internalState.state="OPEN",this.internalState.openedAt=t),this.internalState.state==="HALF_OPEN"&&(this.internalState.state="OPEN",this.internalState.openedAt=t),await this.saveToDisk()}getState(){return this.pruneExpiredFailures(),{state:this.internalState.state,failures:[...this.internalState.failures],openedAt:this.internalState.openedAt,lastProbeAt:this.internalState.lastProbeAt}}async reset(){this.internalState={...r,failures:[]},await this.saveToDisk()}static async loadFromDisk(t){const e=t??n.stateFilePath;try{const s=await l(e,"utf8"),a=JSON.parse(s);return!a.state||!Array.isArray(a.failures)?new i({stateFilePath:e}):new i({stateFilePath:e},a)}catch{return new i({stateFilePath:e})}}pruneExpiredFailures(){const t=Date.now()-this.config.failureWindowMs;this.internalState.failures=this.internalState.failures.filter(e=>e.timestamp>t)}async saveToDisk(){try{const t=u(this.config.stateFilePath);await h(t,{recursive:!0});const e=`${this.config.stateFilePath}.tmp`,s=JSON.stringify(this.internalState,null,2);await o(e,s,"utf8");const{rename:a}=await import("node:fs/promises");await a(e,this.config.stateFilePath)}catch{}}}export{i as ClientCircuitBreaker};
|