@miraj181/ipingyou 2.0.13 → 2.1.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.
package/src/lib/broker.js CHANGED
@@ -2,16 +2,45 @@ import chalk from 'chalk';
2
2
  import os from 'node:os';
3
3
  import { decrypt, encrypt } from './crypto.js';
4
4
  import { createSpinner, cryptoSpinner, networkSpinner } from './animations.js';
5
+ import { logSessionEvent } from './session-log.js';
6
+
7
+ async function fetchWithLog(action, endpoint, options = {}) {
8
+ const method = options.method || 'GET';
9
+ const startedAt = Date.now();
10
+ logSessionEvent('broker_request', { action, method, endpoint });
11
+ try {
12
+ const res = await fetch(endpoint, options);
13
+ logSessionEvent('broker_response', {
14
+ action,
15
+ method,
16
+ endpoint,
17
+ status: res.status,
18
+ ok: res.ok,
19
+ durationMs: Date.now() - startedAt,
20
+ });
21
+ return res;
22
+ } catch (err) {
23
+ logSessionEvent('broker_error', {
24
+ action,
25
+ method,
26
+ endpoint,
27
+ error: err.message,
28
+ durationMs: Date.now() - startedAt,
29
+ }, 'error');
30
+ throw err;
31
+ }
32
+ }
5
33
 
6
34
  export async function pingBroker(url) {
35
+ const controller = new AbortController();
36
+ const id = setTimeout(() => controller.abort(), 3000);
7
37
  try {
8
- const controller = new AbortController();
9
- const id = setTimeout(() => controller.abort(), 3000);
10
- const res = await fetch(`${url}/health`, { signal: controller.signal });
11
- clearTimeout(id);
38
+ const res = await fetchWithLog('health', `${url}/health`, { signal: controller.signal });
12
39
  return res.ok;
13
40
  } catch {
14
41
  return false;
42
+ } finally {
43
+ clearTimeout(id);
15
44
  }
16
45
  }
17
46
 
@@ -25,7 +54,7 @@ export async function registerWithBroker(brokerUrl, uid, tunnelUrl, password, se
25
54
 
26
55
  spinner.text = 'Registering with broker...';
27
56
 
28
- const res = await fetch(`${brokerUrl}/register`, {
57
+ const res = await fetchWithLog('register', `${brokerUrl}/register`, {
29
58
  method: 'POST',
30
59
  headers: { 'Content-Type': 'application/json' },
31
60
  body: JSON.stringify({
@@ -33,43 +62,134 @@ export async function registerWithBroker(brokerUrl, uid, tunnelUrl, password, se
33
62
  iv: encrypted.iv,
34
63
  ciphertext: encrypted.ciphertext,
35
64
  salt: encrypted.salt,
65
+ approvalRequired: Boolean(serviceConfig.approvalRequired),
66
+ oneTime: Boolean(serviceConfig.oneTimeSharePath),
36
67
  }),
37
68
  });
38
69
 
39
70
  if (!res.ok) {
40
71
  const data = await res.json().catch(() => ({}));
72
+ logSessionEvent('broker_register_failed', { uid, status: res.status, error: data.error || 'unknown' }, 'error');
41
73
  throw new Error(data.error || `HTTP ${res.status}`);
42
74
  }
43
75
 
76
+ const result = await res.json();
44
77
  spinner.succeed(`Registered with broker ${chalk.dim(`(${brokerUrl})`)} ${chalk.green('[E2E encrypted]')}`);
45
- return true;
78
+ logSessionEvent('broker_registered', { uid, broker: brokerUrl });
79
+ // Return the host authentication token — needed for all host-only broker operations
80
+ return { success: true, hostToken: result.hostToken || null };
46
81
  } catch (err) {
47
82
  spinner.fail(`Broker registration failed: ${err.message}`);
48
83
  console.error(chalk.red(` ❌ Error: ${err.message}`));
84
+ logSessionEvent('broker_register_error', { uid, broker: brokerUrl, error: err.message }, 'error');
49
85
  console.log(chalk.yellow(' ⚠️ Remote clients won\'t be able to find you without the broker.'));
50
86
  console.log(chalk.dim(' Share the tunnel URL directly if needed.'));
51
- return false;
87
+ return { success: false, hostToken: null };
52
88
  }
53
89
  }
54
90
 
55
- export async function resolveUID(brokerUrl, uid, password, silent = false) {
91
+ export async function requestHostApproval(brokerUrl, uid, password, details) {
92
+ const encrypted = encrypt(JSON.stringify(details), password);
93
+ const res = await fetchWithLog('approval_request', `${brokerUrl}/approval-request/${uid}`, {
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json' },
96
+ body: JSON.stringify(encrypted),
97
+ });
98
+ if (!res.ok) {
99
+ const data = await res.json().catch(() => ({}));
100
+ logSessionEvent('broker_approval_request_failed', { uid, status: res.status, error: data.error || 'unknown' }, 'error');
101
+ throw new Error(data.error || `HTTP ${res.status}`);
102
+ }
103
+ return res.json();
104
+ }
105
+
106
+ export async function waitForApproval(brokerUrl, uid, requestId, timeoutMs = 300000) {
107
+ const startedAt = Date.now();
108
+ logSessionEvent('approval_wait_started', { uid, requestId, timeoutMs });
109
+ while (Date.now() - startedAt < timeoutMs) {
110
+ const res = await fetchWithLog('approval_status', `${brokerUrl}/approval-status/${uid}/${requestId}`);
111
+ if (res.ok) {
112
+ const data = await res.json();
113
+ if (data.status === 'approved') {
114
+ logSessionEvent('approval_granted', { uid, requestId });
115
+ return true;
116
+ }
117
+ if (data.status === 'denied') {
118
+ logSessionEvent('approval_denied', { uid, requestId });
119
+ return false;
120
+ }
121
+ }
122
+ await new Promise(resolve => setTimeout(resolve, 2000));
123
+ }
124
+ logSessionEvent('approval_timeout', { uid, requestId, timeoutMs }, 'warn');
125
+ throw new Error('Timed out waiting for host approval');
126
+ }
127
+
128
+ export async function fetchApprovalRequests(brokerUrl, uid, hostToken) {
129
+ const res = await fetchWithLog('approval_requests', `${brokerUrl}/approval-requests/${uid}`, {
130
+ headers: hostToken ? { 'x-host-token': hostToken } : {},
131
+ });
132
+ if (!res.ok) {
133
+ const data = await res.json().catch(() => ({}));
134
+ logSessionEvent('broker_approval_fetch_failed', { uid, status: res.status, error: data.error || 'unknown' }, 'error');
135
+ throw new Error(data.error || `HTTP ${res.status}`);
136
+ }
137
+ return res.json();
138
+ }
139
+
140
+ export async function decideApprovalRequest(brokerUrl, uid, requestId, decision, hostToken) {
141
+ const res = await fetchWithLog('approval_decision', `${brokerUrl}/approval-requests/${uid}/${requestId}/${decision}`, {
142
+ method: 'POST',
143
+ headers: hostToken ? { 'x-host-token': hostToken } : {},
144
+ });
145
+ if (!res.ok) {
146
+ const data = await res.json().catch(() => ({}));
147
+ logSessionEvent('broker_approval_decision_failed', { uid, requestId, decision, status: res.status, error: data.error || 'unknown' }, 'error');
148
+ throw new Error(data.error || `HTTP ${res.status}`);
149
+ }
150
+ logSessionEvent('approval_decision_sent', { uid, requestId, decision });
151
+ return res.json();
152
+ }
153
+
154
+ export async function revokeUID(brokerUrl, uid, hostToken) {
155
+ try {
156
+ await fetchWithLog('revoke', `${brokerUrl}/revoke/${uid}`, {
157
+ method: 'DELETE',
158
+ headers: hostToken ? { 'x-host-token': hostToken } : {},
159
+ });
160
+ } catch (err) {
161
+ logSessionEvent('broker_revoke_failed', { uid, broker: brokerUrl, error: err.message }, 'warn');
162
+ }
163
+ }
164
+
165
+ export async function resolveUID(brokerUrl, uid, password, silent = false, requestId = null) {
56
166
  const spinner = !silent ? createSpinner(`Resolving UID ${chalk.cyan(uid)}...`, networkSpinner).start() : null;
57
167
 
58
168
  try {
59
- const res = await fetch(`${brokerUrl}/resolve/${uid}`);
169
+ const suffix = requestId ? `?requestId=${encodeURIComponent(requestId)}` : '';
170
+ const res = await fetchWithLog('resolve', `${brokerUrl}/resolve/${uid}${suffix}`);
60
171
 
61
172
  if (res.status === 404) {
62
173
  if (spinner) spinner.fail('UID not found — the host may not be online or the session expired');
63
174
  else console.error(chalk.red(' ❌ UID not found or expired.'));
175
+ logSessionEvent('broker_resolve_missing', { uid, status: 404 }, 'warn');
64
176
  return null;
65
177
  }
66
178
  if (res.status === 410) {
67
179
  if (spinner) spinner.fail('UID has expired — ask the host for a new session');
68
180
  else console.error(chalk.red(' ❌ UID expired.'));
181
+ logSessionEvent('broker_resolve_expired', { uid, status: 410 }, 'warn');
69
182
  return null;
70
183
  }
184
+ if (res.status === 423) {
185
+ if (spinner) spinner.info('Host approval is required — submitting access request...');
186
+ else console.log(chalk.yellow(' ⏳ Host approval required.'));
187
+ logSessionEvent('broker_resolve_needs_approval', { uid, status: 423 }, 'warn');
188
+ return { needsApproval: true };
189
+ }
71
190
  if (!res.ok) {
72
191
  const data = await res.json().catch(() => ({}));
192
+ logSessionEvent('broker_resolve_failed', { uid, status: res.status, error: data.error || 'unknown' }, 'error');
73
193
  throw new Error(data.error || `HTTP ${res.status}`);
74
194
  }
75
195
 
@@ -77,6 +197,7 @@ export async function resolveUID(brokerUrl, uid, password, silent = false) {
77
197
 
78
198
  if (!data.iv || !data.ciphertext || !data.salt) {
79
199
  if (spinner) spinner.fail('Broker returned invalid response — missing encrypted data or salt');
200
+ logSessionEvent('broker_resolve_invalid', { uid }, 'error');
80
201
  return null;
81
202
  }
82
203
 
@@ -89,6 +210,7 @@ export async function resolveUID(brokerUrl, uid, password, silent = false) {
89
210
  } catch {
90
211
  if (spinner) spinner.fail('Decryption failed — incorrect password or corrupted data');
91
212
  if (!spinner) console.error(chalk.red(' ❌ Error: Could not decrypt tunnel data. Incorrect password.'));
213
+ logSessionEvent('broker_decrypt_failed', { uid }, 'warn');
92
214
  return null;
93
215
  }
94
216
 
@@ -104,18 +226,21 @@ export async function resolveUID(brokerUrl, uid, password, silent = false) {
104
226
 
105
227
  if (!payloadConfig.url.startsWith('https://')) {
106
228
  if (spinner) spinner.fail('Decrypted data is not a valid tunnel URL (incorrect password)');
229
+ logSessionEvent('broker_decrypt_invalid_url', { uid }, 'warn');
107
230
  return null;
108
231
  }
109
232
 
110
233
  if (spinner) spinner.succeed(`Resolved: ${chalk.dim(payloadConfig.url)} ${chalk.green('[decrypted locally]')}`);
234
+ logSessionEvent('broker_resolve_success', { uid, type: payloadConfig.type || 'ssh' });
111
235
  return payloadConfig;
112
236
  } catch (err) {
113
237
  if (spinner) spinner.fail(`Broker lookup failed: ${err.message}`);
238
+ logSessionEvent('broker_resolve_error', { uid, error: err.message }, 'error');
114
239
  return null;
115
240
  }
116
241
  }
117
242
 
118
- export async function pushTelemetry(brokerUrl, uid, password, username) {
243
+ export async function pushTelemetry(brokerUrl, uid, password, username, action = 'connected') {
119
244
  try {
120
245
  let publicIp = 'Unknown';
121
246
  try {
@@ -128,17 +253,18 @@ export async function pushTelemetry(brokerUrl, uid, password, username) {
128
253
  os: `${os.type()} ${os.release()} (${os.arch()})`,
129
254
  cpu: os.cpus()[0]?.model || 'Unknown CPU',
130
255
  ram: `${Math.round(os.totalmem() / 1024 / 1024 / 1024)} GB`,
256
+ action,
131
257
  time: new Date().toLocaleTimeString()
132
258
  };
133
259
 
134
260
  const { iv, ciphertext, salt } = encrypt(JSON.stringify(telemetry), password);
135
261
 
136
- await fetch(`${brokerUrl}/client-info/${uid}`, {
262
+ await fetchWithLog('telemetry', `${brokerUrl}/client-info/${uid}`, {
137
263
  method: 'POST',
138
264
  headers: { 'Content-Type': 'application/json' },
139
265
  body: JSON.stringify({ iv, ciphertext, salt }),
140
266
  });
141
- } catch {
142
- // Telemetry is optional.
267
+ } catch (err) {
268
+ logSessionEvent('telemetry_failed', { uid, error: err.message }, 'warn');
143
269
  }
144
270
  }
@@ -24,6 +24,9 @@ let _revokeUID = null;
24
24
  /** @type {string|null} — Broker URL for revocation */
25
25
  let _brokerUrl = null;
26
26
 
27
+ /** @type {(() => string|null)|null} — Getter for host auth token */
28
+ let _getHostToken = null;
29
+
27
30
  /** @type {Array<() => Promise<void>|void>} — Custom cleanup hooks */
28
31
  const _cleanupHooks = [];
29
32
  let cleanedUp = false;
@@ -56,10 +59,12 @@ export function untrackPID(pid) {
56
59
  * Set UID + broker URL for automatic revocation on shutdown.
57
60
  * @param {string} uid
58
61
  * @param {string} brokerUrl
62
+ * @param {() => string|null} [getHostToken]
59
63
  */
60
- export function setRevokeOnExit(uid, brokerUrl) {
64
+ export function setRevokeOnExit(uid, brokerUrl, getHostToken = null) {
61
65
  _revokeUID = uid;
62
66
  _brokerUrl = brokerUrl;
67
+ _getHostToken = getHostToken;
63
68
  }
64
69
 
65
70
  /**
@@ -110,7 +115,14 @@ export async function cleanupAll() {
110
115
  // Revoke UID from broker
111
116
  if (_revokeUID && _brokerUrl) {
112
117
  try {
113
- const res = await fetch(`${_brokerUrl}/revoke/${_revokeUID}`, { method: 'DELETE' });
118
+ const headers = {};
119
+ const token = _getHostToken ? _getHostToken() : null;
120
+ if (token) headers['x-host-token'] = token;
121
+
122
+ const res = await fetch(`${_brokerUrl}/revoke/${_revokeUID}`, {
123
+ method: 'DELETE',
124
+ headers
125
+ });
114
126
  if (res.ok) {
115
127
  console.log(chalk.dim(` Revoked UID ${_revokeUID} from broker`));
116
128
  }
@@ -164,10 +176,6 @@ export function installShutdownHandlers() {
164
176
  * Get count of tracked PIDs.
165
177
  * @returns {number}
166
178
  */
167
- export function getTrackedCount() {
168
- return trackedPIDs.size;
169
- }
170
-
171
179
  /**
172
180
  * Execute Panic Mode (Self-Destruct)
173
181
  * Wipes all configs, keys, and forcefully kills associated processes.
package/src/lib/config.js CHANGED
@@ -7,10 +7,14 @@ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
7
7
 
8
8
  function ensureConfig() {
9
9
  if (!fs.existsSync(CONFIG_DIR)) {
10
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
10
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
11
+ } else {
12
+ try { fs.chmodSync(CONFIG_DIR, 0o700); } catch { }
11
13
  }
12
14
  if (!fs.existsSync(CONFIG_FILE)) {
13
- fs.writeFileSync(CONFIG_FILE, JSON.stringify({ aliases: {}, settings: {} }, null, 2));
15
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify({ aliases: {}, settings: {} }, null, 2), { mode: 0o600 });
16
+ } else {
17
+ try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { }
14
18
  }
15
19
  }
16
20
 
@@ -26,7 +30,8 @@ export function getConfig() {
26
30
  export function saveConfig(config) {
27
31
  try {
28
32
  ensureConfig();
29
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
33
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
34
+ try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { }
30
35
  } catch (err) {
31
36
  throw new Error(`Could not save config: ${err.message}`);
32
37
  }
@@ -6,6 +6,15 @@ import path from 'node:path';
6
6
  import os from 'node:os';
7
7
  import { buildSshArgs, formatRemoteCd } from './ssh.js';
8
8
 
9
+ class RemoteDirectoryError extends Error {
10
+ constructor(message, remoteDir, stderr = '') {
11
+ super(message);
12
+ this.name = 'RemoteDirectoryError';
13
+ this.remoteDir = remoteDir;
14
+ this.stderr = stderr;
15
+ }
16
+ }
17
+
9
18
  function expandLocalPath(inputPath) {
10
19
  const trimmed = inputPath.trim();
11
20
  if (trimmed === '~') return os.homedir();
@@ -108,12 +117,13 @@ async function listRemoteDirectory(username, hostname, privateKeyPath, remoteDir
108
117
  sshArgs.push(`${username}@${hostname}`, command);
109
118
 
110
119
  const result = await execa('ssh', sshArgs, {
111
- stdio: ['inherit', 'pipe', 'inherit'],
120
+ stdio: ['inherit', 'pipe', 'pipe'],
112
121
  reject: false,
113
122
  });
114
123
 
115
124
  if (result.exitCode !== 0) {
116
- throw new Error('Remote directory listing failed');
125
+ const detail = result.stderr.trim() || `ssh exited with code ${result.exitCode}`;
126
+ throw new RemoteDirectoryError(`Remote directory listing failed: ${detail}`, remoteDir, result.stderr);
117
127
  }
118
128
 
119
129
  const lines = result.stdout.split(/\r?\n/).filter(Boolean);
@@ -136,7 +146,7 @@ async function listRemoteDirectory(username, hostname, privateKeyPath, remoteDir
136
146
  return { pwd, entries };
137
147
  }
138
148
 
139
- export async function promptRemotePath(username, hostname, privateKeyPath, purpose) {
149
+ export async function promptRemotePath(username, hostname, privateKeyPath, purpose, startDir = '~') {
140
150
  const browseLabel = purpose === 'source'
141
151
  ? 'Browse host files interactively'
142
152
  : 'Browse host folders interactively';
@@ -169,13 +179,25 @@ export async function promptRemotePath(username, hostname, privateKeyPath, purpo
169
179
  return remotePath.trim();
170
180
  }
171
181
 
172
- let currentDir = '~';
182
+ let currentDir = startDir || '~';
173
183
  while (true) {
174
184
  let listing;
175
185
  try {
176
186
  listing = await listRemoteDirectory(username, hostname, privateKeyPath, currentDir);
177
187
  } catch (err) {
178
188
  console.log(chalk.yellow(` ⚠️ Could not browse host files: ${err.message}`));
189
+ if (/Operation not permitted/i.test(err.stderr || err.message)) {
190
+ console.log(chalk.dim(' macOS privacy blocked this SSH session from listing that folder.'));
191
+ console.log(chalk.dim(' On the host, allow Full Disk Access for sshd/Remote Login, or choose a non-protected folder.'));
192
+ }
193
+
194
+ const parentDir = currentDir === '/' ? '/' : path.posix.dirname(currentDir);
195
+ if (parentDir && parentDir !== currentDir) {
196
+ console.log(chalk.dim(` Returning to ${parentDir}.`));
197
+ currentDir = parentDir;
198
+ continue;
199
+ }
200
+
179
201
  console.log(chalk.dim(' Falling back to manual host path entry.'));
180
202
  const { remotePath } = await inquirer.prompt([{
181
203
  type: 'input',
@@ -410,37 +410,6 @@ async function installOpenSSH(osInfo) {
410
410
 
411
411
  // ─── Main Dependency Check ───────────────────────────────────
412
412
 
413
- /**
414
- * Install a dependency on the current platform.
415
- * @param {string} pkg — package name (e.g. 'openssh-server', 'cloudflared')
416
- * @param {'debian'|'arch'|'fedora'|'mac'|'windows'} distro
417
- */
418
- export async function installDependency(pkg, distro) {
419
- const spinner = ora(`Installing ${chalk.cyan(pkg)}...`).start();
420
-
421
- const commands = {
422
- debian: `sudo apt-get install -y ${pkg}`,
423
- arch: `sudo pacman -S --noconfirm ${pkg}`,
424
- fedora: `sudo dnf install -y ${pkg}`,
425
- mac: `brew install ${pkg}`,
426
- };
427
-
428
- const cmd = commands[distro];
429
- if (!cmd) {
430
- spinner.fail(`No auto-install command for ${distro}. Please install ${pkg} manually.`);
431
- return false;
432
- }
433
-
434
- try {
435
- await execaCommand(cmd, { stdio: 'inherit' });
436
- spinner.succeed(`${chalk.green(pkg)} installed successfully`);
437
- return true;
438
- } catch (err) {
439
- spinner.fail(`Failed to install ${pkg}: ${err.message}`);
440
- return false;
441
- }
442
- }
443
-
444
413
  /**
445
414
  * Run full dependency check with auto-bootstrap.
446
415
  *
@@ -0,0 +1,93 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { redactSensitive } from './ai/safety.js';
5
+
6
+ const LOG_DIR = path.join(os.homedir(), '.ipingyou', 'logs');
7
+ const LOG_FILE = path.join(LOG_DIR, 'session-events.jsonl');
8
+ const SESSION_LOG_DIR = path.join(os.tmpdir(), 'ipingyou-session-logs');
9
+
10
+ let sessionLogPath = null;
11
+ let sessionLogDisabled = false;
12
+ let cleanupRegistered = false;
13
+
14
+ function sanitize(value) {
15
+ if (typeof value === 'string') return redactSensitive(value);
16
+ if (Array.isArray(value)) return value.map(sanitize);
17
+ if (value && typeof value === 'object') {
18
+ return Object.fromEntries(
19
+ Object.entries(value)
20
+ .filter(([key]) => !/(password|privateKey|tunnelUrl|url|secret|token|apiKey)/i.test(key))
21
+ .map(([key, item]) => [key, sanitize(item)])
22
+ );
23
+ }
24
+ return value;
25
+ }
26
+
27
+ export function initSessionLog(scope = 'session') {
28
+ if (sessionLogPath || sessionLogDisabled) return sessionLogPath;
29
+ try {
30
+ fs.mkdirSync(SESSION_LOG_DIR, { recursive: true, mode: 0o700 });
31
+ sessionLogPath = path.join(
32
+ SESSION_LOG_DIR,
33
+ `ipingyou-${scope}-${Date.now()}-${process.pid}.log`
34
+ );
35
+ fs.writeFileSync(sessionLogPath, '', { mode: 0o600 });
36
+ if (!cleanupRegistered) {
37
+ process.on('exit', () => cleanupSessionLog());
38
+ cleanupRegistered = true;
39
+ }
40
+ logSessionEvent('session_start', { scope, pid: process.pid, node: process.version });
41
+ return sessionLogPath;
42
+ } catch (err) {
43
+ sessionLogDisabled = true;
44
+ console.error(`Session log setup failed: ${err.message}`);
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export function getSessionLogPath() {
50
+ return sessionLogPath;
51
+ }
52
+
53
+ export function logSessionEvent(type, details = {}, level = 'info') {
54
+ if (!sessionLogPath || sessionLogDisabled) return;
55
+ const entry = {
56
+ time: new Date().toISOString(),
57
+ level,
58
+ type,
59
+ details: sanitize(details),
60
+ };
61
+ try {
62
+ fs.appendFileSync(sessionLogPath, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
63
+ } catch (err) {
64
+ sessionLogDisabled = true;
65
+ console.error(`Session log write failed: ${err.message}`);
66
+ }
67
+ }
68
+
69
+ export function cleanupSessionLog() {
70
+ if (!sessionLogPath) return;
71
+ const target = sessionLogPath;
72
+ sessionLogPath = null;
73
+ try {
74
+ if (fs.existsSync(target)) fs.unlinkSync(target);
75
+ } catch (err) {
76
+ console.error(`Session log cleanup failed: ${err.message}`);
77
+ }
78
+ }
79
+
80
+ export function recordEvent(type, details = {}) {
81
+ try {
82
+ fs.mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
83
+ const event = {
84
+ time: new Date().toISOString(),
85
+ type,
86
+ details: sanitize(details),
87
+ };
88
+ fs.appendFileSync(LOG_FILE, `${JSON.stringify(event)}\n`, { mode: 0o600 });
89
+ logSessionEvent(type, details);
90
+ } catch {
91
+ // Session recording is best-effort.
92
+ }
93
+ }