@dmsdc-ai/aigentry-telepty 0.5.9 → 0.6.1
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/CHANGELOG.md +86 -0
- package/cli.js +404 -30
- package/cross-machine.js +124 -1
- package/daemon-control.js +9 -0
- package/daemon.js +495 -23
- package/install.js +156 -26
- package/package.json +5 -5
- package/src/audit/inject-log.js +234 -0
- package/src/audit/provenance.js +86 -0
- package/src/protocol/http-auth.js +36 -1
- package/src/submit-gate.js +130 -5
- package/src/transport/broker-client.js +498 -0
- package/src/transport/broker-protocol.js +155 -0
- package/src/transport/broker-server.js +531 -0
- package/src/win-resolve-executable.js +6 -1
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
|
|
113
|
+
<string>${escapeXml(command)}</string>
|
|
109
114
|
</array>
|
|
110
115
|
<key>EnvironmentVariables</key>
|
|
111
116
|
<dict>
|
|
112
|
-
|
|
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
|
|
150
|
+
Description=${description}
|
|
137
151
|
After=network.target
|
|
138
152
|
|
|
139
153
|
[Service]
|
|
140
|
-
ExecStart=${systemdExecArg(nodeBin)} ${systemdExecArg(cliJs)}
|
|
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
|
|
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',
|
|
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(
|
|
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(
|
|
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(
|
|
402
|
+
fs.writeFileSync(`/etc/systemd/system/${profile.systemdService}.service`, serviceContent);
|
|
285
403
|
run('systemctl daemon-reload');
|
|
286
|
-
run(
|
|
287
|
-
run(
|
|
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',
|
|
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(
|
|
303
|
-
run(
|
|
304
|
-
assertSystemdServiceLive(
|
|
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,
|
|
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.
|
|
3
|
+
"version": "0.6.1",
|
|
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/daemon-harness-port.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/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.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/daemon-harness-port.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/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.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/daemon-harness-port.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/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.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
|
|
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
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// #47 P4 — delivery provenance wrapper.
|
|
4
|
+
//
|
|
5
|
+
// Component B in the spec (docs/specs/2026-06-09-inject-audit-provenance.md §6; ADR §3/§4).
|
|
6
|
+
// Pure Node only (§17 무의존 — `crypto`, no external deps), no I/O, no daemon state, so the
|
|
7
|
+
// trust decision is unit-testable in isolation.
|
|
8
|
+
//
|
|
9
|
+
// - mintSessionNonce() — per-session random nonce (the shared secret).
|
|
10
|
+
// - resolveOrigin(ctx) — 'trusted-local' | 'untrusted-remote'.
|
|
11
|
+
// - wrapDelivery(payload, {sid,origin,nonce}) — banner + fence around byte-exact payload.
|
|
12
|
+
// - applyProvenance(payload, opts) — capability gate: wrap iff capable && nonce, else RAW.
|
|
13
|
+
//
|
|
14
|
+
// TRUST MODEL (spec §6, ADR §3): this is a nonce-gated, tamper-EVIDENT in-band banner, NOT a
|
|
15
|
+
// signature. Strength = secrecy of the nonce; a body-typed banner without the session nonce is
|
|
16
|
+
// non-authoritative. The authoritative path stays OUT-OF-BAND (token-gated GET /api/injects).
|
|
17
|
+
//
|
|
18
|
+
// §1 경량 WATCHED LINE (ADR §4 A4): the banner is a single nonce STRING-MATCH only. No HMAC, no
|
|
19
|
+
// PKI, no signed envelope an LLM "verifies" — an LLM cannot verify crypto over its own input, so
|
|
20
|
+
// that would be security theater. If this grows toward a crypto protocol, that is the 위헌 line —
|
|
21
|
+
// stop.
|
|
22
|
+
|
|
23
|
+
const crypto = require('crypto');
|
|
24
|
+
|
|
25
|
+
const PROV_VERSION = 1;
|
|
26
|
+
// U+27E6 / U+27E7 — rare in normal prompts, visually distinct, single-token-ish across tokenizers.
|
|
27
|
+
const FENCE_OPEN = '⟦'; // ⟦
|
|
28
|
+
const FENCE_CLOSE = '⟧'; // ⟧
|
|
29
|
+
|
|
30
|
+
// Per-session random nonce. base64url so it survives intact through any plain-text channel and
|
|
31
|
+
// carries no fence/whitespace chars. 18 bytes → 24 url-safe chars (~144 bits).
|
|
32
|
+
function mintSessionNonce() {
|
|
33
|
+
return crypto.randomBytes(18).toString('base64url');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Map a delivery context to a coarse origin label. Explicit label wins; a `remote` signal maps
|
|
37
|
+
// to untrusted-remote; everything else (and any unknown label) is trusted-local.
|
|
38
|
+
function resolveOrigin(ctx = {}) {
|
|
39
|
+
if (ctx.origin === 'trusted-local' || ctx.origin === 'untrusted-remote') return ctx.origin;
|
|
40
|
+
if (ctx.remote === true) return 'untrusted-remote';
|
|
41
|
+
return 'trusted-local';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Render the banner `from=` field, honest about confidence: the verified sid verbatim when the
|
|
45
|
+
// daemon verified it, otherwise `claimed:<x>?` (trailing ? = unverified), or `claimed:?` if blank.
|
|
46
|
+
function formatSender({ verified, claimed } = {}) {
|
|
47
|
+
if (verified) return String(verified);
|
|
48
|
+
if (claimed) return `claimed:${claimed}?`;
|
|
49
|
+
return 'claimed:?';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Wrap a payload in the nonce-gated provenance banner (spec §6 format):
|
|
53
|
+
// ⟦telepty:provenance v=1 from=<sid> origin=<...> nonce=<N>⟧
|
|
54
|
+
// <payload, byte-for-byte>
|
|
55
|
+
// ⟦telepty:end nonce=<N>⟧
|
|
56
|
+
// Requires a nonce — an un-nonced banner would be forgeable by anyone, defeating the gate.
|
|
57
|
+
function wrapDelivery(payload, opts = {}) {
|
|
58
|
+
const { sid, origin, nonce } = opts;
|
|
59
|
+
if (!nonce) throw new Error('wrapDelivery requires a nonce');
|
|
60
|
+
const o = resolveOrigin({ origin });
|
|
61
|
+
const from = sid != null ? sid : 'claimed:?';
|
|
62
|
+
const body = typeof payload === 'string' ? payload : String(payload == null ? '' : payload);
|
|
63
|
+
const header = `${FENCE_OPEN}telepty:provenance v=${PROV_VERSION} from=${from} origin=${o} nonce=${nonce}${FENCE_CLOSE}`;
|
|
64
|
+
const footer = `${FENCE_OPEN}telepty:end nonce=${nonce}${FENCE_CLOSE}`;
|
|
65
|
+
return `${header}\n${body}\n${footer}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Capability gate for the delivery hot path (pure). Sessions that are NOT provenance-capable
|
|
69
|
+
// (the default) — or that have no minted nonce — receive the RAW payload byte-for-byte, so no
|
|
70
|
+
// existing session's delivered bytes change (regression guard, spec §6 rollout). Returns
|
|
71
|
+
// { payload, wrapped }.
|
|
72
|
+
function applyProvenance(payload, opts = {}) {
|
|
73
|
+
const { capable, nonce, verified, claimed, origin } = opts;
|
|
74
|
+
if (!capable || !nonce) return { payload, wrapped: false };
|
|
75
|
+
const sid = formatSender({ verified, claimed });
|
|
76
|
+
return { payload: wrapDelivery(payload, { sid, origin, nonce }), wrapped: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
PROV_VERSION,
|
|
81
|
+
mintSessionNonce,
|
|
82
|
+
resolveOrigin,
|
|
83
|
+
formatSender,
|
|
84
|
+
wrapDelivery,
|
|
85
|
+
applyProvenance
|
|
86
|
+
};
|