@dmsdc-ai/aigentry-telepty 0.5.9 → 0.6.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/install.js CHANGED
@@ -91,9 +91,14 @@ function buildLaunchdPlist(options = {}) {
91
91
  const nodeBin = options.nodeBin || process.execPath;
92
92
  const cliJs = options.cliJs || path.join(__dirname, 'cli.js');
93
93
  const logDir = options.logDir || path.join(os.homedir(), '.telepty', 'logs');
94
+ const command = options.command || 'daemon';
94
95
  const daemonPath = buildDaemonPath(nodeBin, ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin']);
95
96
  const stdoutPath = path.join(logDir, 'launchd.out.log');
96
97
  const stderrPath = path.join(logDir, 'launchd.err.log');
98
+ const envPairs = [['PATH', daemonPath], ...Object.entries(options.extraEnv || {})];
99
+ const envXml = envPairs
100
+ .map(([key, value]) => ` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`)
101
+ .join('\n');
97
102
 
98
103
  return `<?xml version="1.0" encoding="UTF-8"?>
99
104
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -105,12 +110,11 @@ function buildLaunchdPlist(options = {}) {
105
110
  <array>
106
111
  <string>${escapeXml(nodeBin)}</string>
107
112
  <string>${escapeXml(cliJs)}</string>
108
- <string>daemon</string>
113
+ <string>${escapeXml(command)}</string>
109
114
  </array>
110
115
  <key>EnvironmentVariables</key>
111
116
  <dict>
112
- <key>PATH</key>
113
- <string>${escapeXml(daemonPath)}</string>
117
+ ${envXml}
114
118
  </dict>
115
119
  <key>StandardOutPath</key>
116
120
  <string>${escapeXml(stdoutPath)}</string>
@@ -124,6 +128,11 @@ function buildLaunchdPlist(options = {}) {
124
128
  </plist>`;
125
129
  }
126
130
 
131
+ function systemdEnvLine(key, value) {
132
+ const escaped = String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
133
+ return `Environment="${key}=${escaped}"`;
134
+ }
135
+
127
136
  function buildSystemdService(options = {}) {
128
137
  const nodeBin = options.nodeBin || process.execPath;
129
138
  const cliJs = options.cliJs || path.join(__dirname, 'cli.js');
@@ -131,17 +140,22 @@ function buildSystemdService(options = {}) {
131
140
  const userLine = user ? `User=${user}\n` : '';
132
141
  const daemonPath = buildDaemonPath(nodeBin, ['/usr/local/bin', '/usr/bin', '/bin']);
133
142
  const wantedBy = options.wantedBy || 'multi-user.target';
143
+ const command = options.command || 'daemon';
144
+ const description = options.description || 'Telepty Daemon';
145
+ const extraEnvLines = Object.entries(options.extraEnv || {})
146
+ .map(([key, value]) => `${systemdEnvLine(key, value)}\n`)
147
+ .join('');
134
148
 
135
149
  return `[Unit]
136
- Description=Telepty Daemon
150
+ Description=${description}
137
151
  After=network.target
138
152
 
139
153
  [Service]
140
- ExecStart=${systemdExecArg(nodeBin)} ${systemdExecArg(cliJs)} daemon
154
+ ExecStart=${systemdExecArg(nodeBin)} ${systemdExecArg(cliJs)} ${systemdExecArg(command)}
141
155
  Restart=always
142
156
  ${userLine}Environment=PATH=${daemonPath}
143
157
  Environment=NODE_ENV=production
144
-
158
+ ${extraEnvLines}
145
159
  [Install]
146
160
  WantedBy=${wantedBy}`;
147
161
  }
@@ -150,7 +164,8 @@ function buildWindowsAutostartCommand(options = {}) {
150
164
  const nodeBin = options.nodeBin || process.execPath;
151
165
  const cliJs = options.cliJs || path.join(__dirname, 'cli.js');
152
166
  const taskName = options.taskName || 'telepty-daemon';
153
- const taskCommand = `${quoteWindowsArg(nodeBin)} ${quoteWindowsArg(cliJs)} daemon`;
167
+ const command = options.command || 'daemon';
168
+ const taskCommand = `${quoteWindowsArg(nodeBin)} ${quoteWindowsArg(cliJs)} ${command}`;
154
169
 
155
170
  return `schtasks /create /tn ${quoteWindowsArg(taskName)} /sc onlogon /rl LIMITED /f /tr ${quoteWindowsArg(taskCommand)}`;
156
171
  }
@@ -165,6 +180,91 @@ function buildWindowsQueryTaskCommand(options = {}) {
165
180
  return `schtasks /query /tn ${quoteWindowsArg(taskName)} /fo LIST`;
166
181
  }
167
182
 
183
+ // --- Broker-host service variant (telepty #42 broker MVP — spec §6 + §2 H; reuses #41 hardening) ---
184
+ // The broker runs the SAME hardened service definition as the daemon, but executes
185
+ // `<node> <cli.js> broker` (spec §5/§6) and carries the broker host env (TLS + JWT/enroll
186
+ // secrets, spec §6). Selected via `telepty install --broker` or env TELEPTY_BROKER_MODE.
187
+ const BROKER_LAUNCHD_LABEL = 'com.aigentry.telepty-broker';
188
+ const BROKER_SYSTEMD_SERVICE = 'telepty-broker';
189
+ const BROKER_WINDOWS_TASK = 'telepty-broker';
190
+
191
+ // Pass-through env keys for the broker host service (spec §5/§6). Secrets are NEVER
192
+ // hardcoded — they are read from the install-time environment and forwarded into the
193
+ // generated service definition so the always-on broker host loads them on start.
194
+ const BROKER_ENV_KEYS = [
195
+ 'TELEPTY_JWT_SECRET',
196
+ 'TELEPTY_ENROLL_SECRET',
197
+ 'TELEPTY_TLS_CERT',
198
+ 'TELEPTY_TLS_KEY',
199
+ 'TELEPTY_BROKER_ACL',
200
+ 'TELEPTY_ENROLL_MAX_NODES',
201
+ 'PORT',
202
+ ];
203
+
204
+ function collectBrokerServiceEnv(env = process.env) {
205
+ const result = { TELEPTY_BROKER_MODE: '1' };
206
+ for (const key of BROKER_ENV_KEYS) {
207
+ const value = env[key];
208
+ if (value !== undefined && value !== '') {
209
+ result[key] = String(value);
210
+ }
211
+ }
212
+ return result;
213
+ }
214
+
215
+ function buildBrokerLaunchdPlist(options = {}) {
216
+ return buildLaunchdPlist({
217
+ ...options,
218
+ label: options.label || BROKER_LAUNCHD_LABEL,
219
+ command: 'broker',
220
+ extraEnv: { ...collectBrokerServiceEnv(options.env), ...(options.extraEnv || {}) },
221
+ });
222
+ }
223
+
224
+ function buildBrokerSystemdService(options = {}) {
225
+ return buildSystemdService({
226
+ ...options,
227
+ command: 'broker',
228
+ description: options.description || 'Telepty Broker',
229
+ extraEnv: { ...collectBrokerServiceEnv(options.env), ...(options.extraEnv || {}) },
230
+ });
231
+ }
232
+
233
+ function buildBrokerWindowsAutostartCommand(options = {}) {
234
+ return buildWindowsAutostartCommand({
235
+ ...options,
236
+ taskName: options.taskName || BROKER_WINDOWS_TASK,
237
+ command: 'broker',
238
+ });
239
+ }
240
+
241
+ // Resolve the active service profile (daemon vs broker) so main() can install either
242
+ // variant from one code path. The daemon profile reproduces the exact pre-#42 values
243
+ // (no behavior change for existing installs); the broker profile selects distinct
244
+ // label/service/task names, the `broker` command, and the broker host env.
245
+ function resolveServiceProfile(options = {}) {
246
+ if (options.broker) {
247
+ return {
248
+ command: 'broker',
249
+ launchdLabel: BROKER_LAUNCHD_LABEL,
250
+ launchdPlistName: `${BROKER_LAUNCHD_LABEL}.plist`,
251
+ systemdService: BROKER_SYSTEMD_SERVICE,
252
+ windowsTask: BROKER_WINDOWS_TASK,
253
+ description: 'Telepty Broker',
254
+ extraEnv: collectBrokerServiceEnv(options.env),
255
+ };
256
+ }
257
+ return {
258
+ command: 'daemon',
259
+ launchdLabel: 'com.aigentry.telepty',
260
+ launchdPlistName: 'com.aigentry.telepty.plist',
261
+ systemdService: 'telepty',
262
+ windowsTask: 'telepty-daemon',
263
+ description: 'Telepty Daemon',
264
+ extraEnv: {},
265
+ };
266
+ }
267
+
168
268
  function assertLaunchdServiceLive(label = 'com.aigentry.telepty') {
169
269
  let output = '';
170
270
  try {
@@ -224,6 +324,16 @@ async function installSkills() {
224
324
  async function main() {
225
325
  console.log("🚀 Installing @dmsdc-ai/aigentry-telepty...");
226
326
 
327
+ // Broker-host variant (telepty #42): `telepty install --broker` or env TELEPTY_BROKER_MODE
328
+ // installs the SAME hardened service running `telepty broker` instead of `telepty daemon`.
329
+ // Default-OFF — existing daemon installs are entirely unaffected (additive).
330
+ const wantsBroker = process.argv.slice(2).includes('--broker')
331
+ || process.env.TELEPTY_BROKER_MODE === '1';
332
+ const profile = resolveServiceProfile({ broker: wantsBroker });
333
+ if (wantsBroker) {
334
+ console.log(`🛰️ Broker-host mode: installing service '${profile.launchdLabel}' (runs 'telepty broker').`);
335
+ }
336
+
227
337
  // 1. Install globally via npm
228
338
  console.log("📦 Installing package globally...");
229
339
  run("npm install -g @dmsdc-ai/aigentry-telepty");
@@ -243,24 +353,29 @@ async function main() {
243
353
  if (platform === 'win32') {
244
354
  cleanupLocalDaemons();
245
355
  console.log("⚙️ Setting up Windows scheduled task...");
246
- run(buildWindowsAutostartCommand(launchOptions));
247
- run(buildWindowsRunTaskCommand());
248
- assertWindowsTaskRunning();
356
+ run(buildWindowsAutostartCommand({ ...launchOptions, taskName: profile.windowsTask, command: profile.command }));
357
+ run(buildWindowsRunTaskCommand({ taskName: profile.windowsTask }));
358
+ assertWindowsTaskRunning(profile.windowsTask);
249
359
  console.log("✅ Windows scheduled task installed and started.");
250
360
 
251
361
  } else if (platform === 'darwin') {
252
362
  console.log("⚙️ Setting up macOS launchd service...");
253
- const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.aigentry.telepty.plist');
363
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', profile.launchdPlistName);
254
364
  fs.mkdirSync(path.dirname(plistPath), { recursive: true });
255
365
  fs.mkdirSync(launchOptions.logDir, { recursive: true });
256
366
  try { execSync(`launchctl unload ${shellQuote(plistPath)} 2>/dev/null`); } catch(e){}
257
367
  cleanupLocalDaemons();
258
368
 
259
- const plistContent = buildLaunchdPlist(launchOptions);
369
+ const plistContent = buildLaunchdPlist({
370
+ ...launchOptions,
371
+ label: profile.launchdLabel,
372
+ command: profile.command,
373
+ extraEnv: profile.extraEnv,
374
+ });
260
375
 
261
376
  fs.writeFileSync(plistPath, plistContent);
262
377
  run(`launchctl load ${shellQuote(plistPath)}`);
263
- assertLaunchdServiceLive();
378
+ assertLaunchdServiceLive(profile.launchdLabel);
264
379
  console.log("✅ macOS LaunchAgent installed and started.");
265
380
 
266
381
  } else {
@@ -274,34 +389,40 @@ async function main() {
274
389
  if (hasSystemd) {
275
390
  if (process.getuid && process.getuid() === 0) {
276
391
  console.log("⚙️ Setting up systemd service for Linux...");
277
- try { execSync('systemctl stop telepty', { stdio: 'ignore' }); } catch(e) {}
392
+ try { execSync(`systemctl stop ${profile.systemdService}`, { stdio: 'ignore' }); } catch(e) {}
278
393
  cleanupLocalDaemons();
279
394
  const serviceContent = buildSystemdService({
280
395
  ...launchOptions,
281
- user: process.env.SUDO_USER || process.env.USER || 'root'
396
+ user: process.env.SUDO_USER || process.env.USER || 'root',
397
+ command: profile.command,
398
+ description: profile.description,
399
+ extraEnv: profile.extraEnv,
282
400
  });
283
401
 
284
- fs.writeFileSync('/etc/systemd/system/telepty.service', serviceContent);
402
+ fs.writeFileSync(`/etc/systemd/system/${profile.systemdService}.service`, serviceContent);
285
403
  run('systemctl daemon-reload');
286
- run('systemctl enable telepty');
287
- run('systemctl start telepty');
288
- assertSystemdServiceLive();
404
+ run(`systemctl enable ${profile.systemdService}`);
405
+ run(`systemctl start ${profile.systemdService}`);
406
+ assertSystemdServiceLive(profile.systemdService);
289
407
  console.log("✅ Systemd service installed and started.");
290
408
  process.exit(0);
291
409
  }
292
410
 
293
411
  console.log("⚙️ Setting up user systemd service for Linux...");
294
- const userServicePath = path.join(os.homedir(), '.config', 'systemd', 'user', 'telepty.service');
412
+ const userServicePath = path.join(os.homedir(), '.config', 'systemd', 'user', `${profile.systemdService}.service`);
295
413
  fs.mkdirSync(path.dirname(userServicePath), { recursive: true });
296
414
  cleanupLocalDaemons();
297
415
  fs.writeFileSync(userServicePath, buildSystemdService({
298
416
  ...launchOptions,
299
- wantedBy: 'default.target'
417
+ wantedBy: 'default.target',
418
+ command: profile.command,
419
+ description: profile.description,
420
+ extraEnv: profile.extraEnv,
300
421
  }));
301
422
  run('systemctl --user daemon-reload');
302
- run('systemctl --user enable telepty');
303
- run('systemctl --user start telepty');
304
- assertSystemdServiceLive('telepty', { user: true });
423
+ run(`systemctl --user enable ${profile.systemdService}`);
424
+ run(`systemctl --user start ${profile.systemdService}`);
425
+ assertSystemdServiceLive(profile.systemdService, { user: true });
305
426
  console.log("✅ User systemd service installed and started.");
306
427
  process.exit(0);
307
428
  }
@@ -309,9 +430,10 @@ async function main() {
309
430
  // Fallback for Linux without systemd
310
431
  console.log("⚠️ Skipping persistent systemd setup. Starting daemon for this session only...");
311
432
  cleanupLocalDaemons();
312
- const subprocess = spawn(launchOptions.nodeBin, [launchOptions.cliJs, 'daemon'], {
433
+ const subprocess = spawn(launchOptions.nodeBin, [launchOptions.cliJs, profile.command], {
313
434
  detached: true,
314
- stdio: 'ignore'
435
+ stdio: 'ignore',
436
+ env: { ...process.env, ...profile.extraEnv }
315
437
  });
316
438
  subprocess.unref();
317
439
  console.log("✅ Linux daemon started in background for the current session.");
@@ -333,4 +455,12 @@ module.exports = {
333
455
  buildSystemdService,
334
456
  buildWindowsAutostartCommand,
335
457
  resolveDaemonLaunchOptions,
458
+ buildBrokerLaunchdPlist,
459
+ buildBrokerSystemdService,
460
+ buildBrokerWindowsAutostartCommand,
461
+ collectBrokerServiceEnv,
462
+ resolveServiceProfile,
463
+ BROKER_LAUNCHD_LABEL,
464
+ BROKER_SYSTEMD_SERVICE,
465
+ BROKER_WINDOWS_TASK,
336
466
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.5.9",
3
+ "version": "0.6.0",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",
@@ -35,9 +35,9 @@
35
35
  ],
36
36
  "scripts": {
37
37
  "postinstall": "node scripts/postinstall.js",
38
- "test": "node --require ./test-support/setup-env.js --test test/auth.test.js test/http-auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js && git diff --exit-code tests/snippet-protocol/v1/",
39
- "test:watch": "node --require ./test-support/setup-env.js --test --watch test/auth.test.js test/http-auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js",
40
- "test:ci": "node --require ./test-support/setup-env.js --test --test-reporter=spec test/auth.test.js test/http-auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js && git diff --exit-code tests/snippet-protocol/v1/",
38
+ "test": "node --require ./test-support/setup-env.js --test test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
39
+ "test:watch": "node --require ./test-support/setup-env.js --test --watch test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js",
40
+ "test:ci": "node --require ./test-support/setup-env.js --test --test-reporter=spec test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/integration/daemon-launch.test.js test/cli.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
41
41
  "typecheck": "tsc --noEmit",
42
42
  "regen-fixtures": "node scripts/regen-snippet-fixtures.js"
43
43
  },
@@ -59,7 +59,7 @@
59
59
  ],
60
60
  "author": "dmsdc-ai",
61
61
  "license": "ISC",
62
- "description": "Universal terminal session bridge connect any terminal to any terminal, any machine",
62
+ "description": "Universal terminal session bridge \u2014 connect any terminal to any terminal, any machine",
63
63
  "repository": {
64
64
  "type": "git",
65
65
  "url": "git+https://github.com/dmsdc-ai/aigentry-telepty.git"
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+
3
+ // #43 P1 — inject audit spine.
4
+ //
5
+ // Component A in the spec (docs/specs/2026-06-09-inject-audit-provenance.md §3, §5, §8).
6
+ // Three concerns, pure Node only (§17 무의존 — `crypto`/`fs`/`events`, no external deps):
7
+ // 1. buildAuditLine(record) — pure builder for the JSONL schema v1 (one line/delivery).
8
+ // 2. createAuditWriter({...}) — bounded async append, size+age rotation, 0700 dir / 0600
9
+ // file, overflow→drop-oldest+event, NEVER fsync-block delivery.
10
+ // 3. readInjectLog(path, filters) — read/filter/paginate helper backing the P3 GET /api/injects.
11
+ //
12
+ // This is an AUDIT log, not a transactional ledger: no per-line fsync, bounded loss of the last
13
+ // in-flight batch on hard crash is accepted (spec §8). Rotation/prune is best-effort and never
14
+ // blocks the delivery hot path (append() is fire-and-forget; the writer drains on a timer).
15
+
16
+ const crypto = require('crypto');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { EventEmitter } = require('events');
20
+
21
+ const SCHEMA_VERSION = 1;
22
+ const DEFAULT_PREVIEW_BYTES = 200;
23
+ const DEFAULT_QUEUE_MAX = 10000;
24
+ const DEFAULT_FLUSH_MS = 250;
25
+ const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
26
+ const DEFAULT_MAX_FILES = 5;
27
+ const DEFAULT_MAX_AGE_DAYS = 30;
28
+
29
+ // Build one compact JSON line for a single delivery (schema v1, spec §5). Pure: the only
30
+ // inputs are `record` fields; sha256/byte-length/spoof_suspected are derived here so the
31
+ // builder is the single testable source of truth. Redaction default = hash-only: payload
32
+ // content is NEVER written unless `record.preview === true`, and then only truncated.
33
+ function buildAuditLine(record = {}) {
34
+ const payload = typeof record.payload === 'string' ? record.payload : '';
35
+ const claimed = record.claimed_from != null ? record.claimed_from : null;
36
+ const verified = record.verified_sender_sid != null ? record.verified_sender_sid : null;
37
+ const previewOn = record.preview === true;
38
+ const previewBytes = Number.isFinite(record.previewBytes) ? record.previewBytes : DEFAULT_PREVIEW_BYTES;
39
+
40
+ const line = {
41
+ v: SCHEMA_VERSION,
42
+ ts: record.ts || new Date().toISOString(),
43
+ inject_id: record.inject_id != null ? record.inject_id : null,
44
+ kind: record.kind || 'inject',
45
+ source: record.source || record.kind || 'inject',
46
+ claimed_from: claimed,
47
+ verified_sender_sid: verified,
48
+ spoof_suspected: !!(claimed && verified && claimed !== verified),
49
+ to: record.to != null ? record.to : null,
50
+ to_alias: record.to_alias != null ? record.to_alias : null,
51
+ origin: record.origin || 'trusted-local',
52
+ origin_host: record.origin_host != null ? record.origin_host : null,
53
+ ref_path: record.ref_path != null ? record.ref_path : null,
54
+ payload_sha256: crypto.createHash('sha256').update(payload).digest('hex'),
55
+ payload_bytes: Buffer.byteLength(payload),
56
+ payload_preview: previewOn ? truncatePreview(payload, previewBytes) : null,
57
+ delivery_result: record.delivery_result || 'success'
58
+ };
59
+ return JSON.stringify(line);
60
+ }
61
+
62
+ // Truncate a preview to at most `previewBytes` characters — never the full payload. Slicing
63
+ // by character (not raw bytes) avoids splitting a multibyte sequence into invalid UTF-8.
64
+ function truncatePreview(payload, previewBytes) {
65
+ if (payload.length <= previewBytes) return payload;
66
+ return payload.slice(0, previewBytes);
67
+ }
68
+
69
+ // Bounded async writer. append() pushes onto an in-memory FIFO and returns immediately; a
70
+ // timer drains the batch with a single appendFile (spec §8). Overflow drops the OLDEST and
71
+ // emits `audit_overflow` (silent truncation forbidden). Rotation/prune runs inside the drain,
72
+ // off the delivery hot path. Returns an EventEmitter-like object ({ append, flush, close, on }).
73
+ function createAuditWriter(options = {}) {
74
+ const filePath = options.path;
75
+ if (!filePath) throw new Error('createAuditWriter requires a path');
76
+ const queueMax = Number.isFinite(options.queueMax) ? options.queueMax : DEFAULT_QUEUE_MAX;
77
+ const flushMs = Number.isFinite(options.flushMs) ? options.flushMs : DEFAULT_FLUSH_MS;
78
+ const preview = options.preview === true;
79
+ const previewBytes = Number.isFinite(options.previewBytes) ? options.previewBytes : DEFAULT_PREVIEW_BYTES;
80
+ const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : DEFAULT_MAX_BYTES;
81
+ const maxFiles = Number.isFinite(options.maxFiles) ? options.maxFiles : DEFAULT_MAX_FILES;
82
+ const maxAgeDays = Number.isFinite(options.maxAgeDays) ? options.maxAgeDays : DEFAULT_MAX_AGE_DAYS;
83
+
84
+ const emitter = new EventEmitter();
85
+ let queue = [];
86
+ let timer = null;
87
+ let writing = false;
88
+ let closed = false;
89
+ let droppedTotal = 0;
90
+
91
+ ensureDir();
92
+
93
+ function ensureDir() {
94
+ const dir = path.dirname(filePath);
95
+ try {
96
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
97
+ fs.chmodSync(dir, 0o700); // tighten even if the dir pre-existed with a looser mode
98
+ } catch { /* best-effort; surfaced as audit_error at first write if truly broken */ }
99
+ }
100
+
101
+ // Fire-and-forget: NEVER returns a promise to the caller — the delivery path must not await.
102
+ function append(record) {
103
+ if (closed) return;
104
+ if (queue.length >= queueMax) {
105
+ queue.shift(); // drop oldest
106
+ droppedTotal += 1;
107
+ emitter.emit('audit_overflow', { dropped: droppedTotal, queueMax });
108
+ }
109
+ queue.push({
110
+ ...record,
111
+ preview: record.preview != null ? record.preview : preview,
112
+ previewBytes: record.previewBytes != null ? record.previewBytes : previewBytes
113
+ });
114
+ scheduleFlush();
115
+ }
116
+
117
+ function scheduleFlush() {
118
+ if (timer || writing || queue.length === 0) return;
119
+ timer = setTimeout(() => { timer = null; flush().catch(() => {}); }, flushMs);
120
+ if (timer && typeof timer.unref === 'function') timer.unref();
121
+ }
122
+
123
+ async function flush() {
124
+ if (writing || queue.length === 0) return;
125
+ writing = true;
126
+ const batch = queue;
127
+ queue = [];
128
+ try {
129
+ await rotateIfNeeded();
130
+ const data = batch.map((r) => buildAuditLine(r)).join('\n') + '\n';
131
+ await fs.promises.appendFile(filePath, data, { mode: 0o600 });
132
+ // appendFile's mode only applies on create — chmod each flush so a pre-existing file
133
+ // (or umask-loosened create) is forced to 0600 (the file may carry preview content).
134
+ try { await fs.promises.chmod(filePath, 0o600); } catch { /* best-effort */ }
135
+ } catch (err) {
136
+ emitter.emit('audit_error', err);
137
+ } finally {
138
+ writing = false;
139
+ if (queue.length > 0) scheduleFlush();
140
+ }
141
+ }
142
+
143
+ async function rotateIfNeeded() {
144
+ let size = 0;
145
+ try { size = (await fs.promises.stat(filePath)).size; } catch { size = 0; }
146
+ if (size >= maxBytes) {
147
+ // Shift .{maxFiles-1}→.{maxFiles} … .1→.2, dropping the oldest beyond maxFiles, then
148
+ // active→.1. rename overwrites, so the file falling off the top is discarded.
149
+ for (let i = maxFiles - 1; i >= 1; i--) {
150
+ try { await fs.promises.rename(`${filePath}.${i}`, `${filePath}.${i + 1}`); } catch { /* gap */ }
151
+ }
152
+ try { await fs.promises.rename(filePath, `${filePath}.1`); } catch { /* nothing to rotate */ }
153
+ }
154
+ await pruneByAge();
155
+ }
156
+
157
+ async function pruneByAge() {
158
+ if (!(maxAgeDays > 0)) return;
159
+ const cutoff = Date.now() - maxAgeDays * 86400000;
160
+ for (let i = 1; i <= maxFiles; i++) {
161
+ const rotated = `${filePath}.${i}`;
162
+ try {
163
+ const st = await fs.promises.stat(rotated);
164
+ if (st.mtimeMs < cutoff) await fs.promises.unlink(rotated);
165
+ } catch { /* absent or busy — best-effort */ }
166
+ }
167
+ }
168
+
169
+ async function close() {
170
+ closed = true;
171
+ if (timer) { clearTimeout(timer); timer = null; }
172
+ await flush();
173
+ }
174
+
175
+ return {
176
+ append,
177
+ flush,
178
+ close,
179
+ on: emitter.on.bind(emitter),
180
+ off: emitter.off.bind(emitter)
181
+ };
182
+ }
183
+
184
+ function toMs(value) {
185
+ if (value == null) return null;
186
+ if (typeof value === 'number') return value < 1e12 ? value * 1000 : value;
187
+ const s = String(value).trim();
188
+ if (/^\d+$/.test(s)) {
189
+ const n = Number(s);
190
+ return n < 1e12 ? n * 1000 : n;
191
+ }
192
+ const parsed = Date.parse(s);
193
+ return Number.isNaN(parsed) ? null : parsed;
194
+ }
195
+
196
+ // Filter an array of parsed records (spec §7). `from` matches claimed_from OR
197
+ // verified_sender_sid; `to` matches the resolved sid OR the pre-resolution alias.
198
+ function matchInjects(records, filters = {}) {
199
+ let out = records;
200
+ const since = toMs(filters.since);
201
+ const until = toMs(filters.until);
202
+ if (since != null) out = out.filter((r) => { const t = toMs(r.ts); return t != null && t >= since; });
203
+ if (until != null) out = out.filter((r) => { const t = toMs(r.ts); return t != null && t <= until; });
204
+ if (filters.to) out = out.filter((r) => r.to === filters.to || r.to_alias === filters.to);
205
+ if (filters.from) out = out.filter((r) => r.claimed_from === filters.from || r.verified_sender_sid === filters.from);
206
+ if (filters.spoof) out = out.filter((r) => r.spoof_suspected === true);
207
+ return out;
208
+ }
209
+
210
+ // Read the jsonl, filter, return newest-first, paginate by cursor (line offset into the
211
+ // filtered newest-first list). Bounded by `limit` (default 200). Absent file → empty result.
212
+ function readInjectLog(filePath, options = {}) {
213
+ const limit = Math.min(Math.max(Number(options.limit) || 200, 1), 1000);
214
+ const cursor = Number(options.cursor) > 0 ? Number(options.cursor) : 0;
215
+ let raw = '';
216
+ try { raw = fs.readFileSync(filePath, 'utf8'); } catch { return { injects: [], next_cursor: null }; }
217
+
218
+ const records = raw.split('\n').filter(Boolean).map((l) => {
219
+ try { return JSON.parse(l); } catch { return null; }
220
+ }).filter(Boolean);
221
+
222
+ const filtered = matchInjects(records, options);
223
+ filtered.reverse(); // newest first
224
+ const page = filtered.slice(cursor, cursor + limit);
225
+ const next = cursor + limit < filtered.length ? cursor + limit : null;
226
+ return { injects: page, next_cursor: next };
227
+ }
228
+
229
+ module.exports = {
230
+ buildAuditLine,
231
+ createAuditWriter,
232
+ readInjectLog,
233
+ matchInjects
234
+ };
@@ -21,6 +21,38 @@ function createVerifyJwt(JWT_SECRET) {
21
21
  return verifyJwt;
22
22
  }
23
23
 
24
+ function createBrokerAcl(aclTable = {}) {
25
+ return {
26
+ canInject(fromNode, toNode) {
27
+ if (!fromNode || !toNode) return false;
28
+ const allowedTargets = aclTable[fromNode];
29
+ if (Array.isArray(allowedTargets)) return allowedTargets.includes(toNode);
30
+ if (allowedTargets instanceof Set) return allowedTargets.has(toNode);
31
+ return false;
32
+ }
33
+ };
34
+ }
35
+
36
+ function signNodeJwt(secret, claims) {
37
+ if (!secret) throw new Error('JWT secret is required');
38
+ if (!claims || typeof claims !== 'object') throw new Error('JWT claims are required');
39
+
40
+ const headerB64 = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
41
+ const payloadB64 = Buffer.from(JSON.stringify(claims)).toString('base64url');
42
+ const sigB64 = crypto.createHmac('sha256', secret)
43
+ .update(`${headerB64}.${payloadB64}`)
44
+ .digest('base64url');
45
+ return `${headerB64}.${payloadB64}.${sigB64}`;
46
+ }
47
+
48
+ function isRevokedNode(revokedNodes, decodedJwtOrSub) {
49
+ const sub = typeof decodedJwtOrSub === 'string' ? decodedJwtOrSub : decodedJwtOrSub && decodedJwtOrSub.sub;
50
+ if (!sub || !revokedNodes) return false;
51
+ if (Array.isArray(revokedNodes)) return revokedNodes.includes(sub);
52
+ if (revokedNodes instanceof Set) return revokedNodes.has(sub);
53
+ return false;
54
+ }
55
+
24
56
  function createIsAllowedPeer(PEER_ALLOWLIST) {
25
57
  function isAllowedPeer(ip) {
26
58
  if (!ip) return false;
@@ -66,6 +98,9 @@ function createAuthMiddleware(options) {
66
98
 
67
99
  module.exports = {
68
100
  createAuthMiddleware,
101
+ createBrokerAcl,
69
102
  createIsAllowedPeer,
70
- createVerifyJwt
103
+ createVerifyJwt,
104
+ isRevokedNode,
105
+ signNodeJwt
71
106
  };