@zeph-to/cli 2.0.0 → 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/dist/crypto.d.ts +16 -0
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +30 -1
- package/dist/input-sequencer.d.ts +66 -0
- package/dist/input-sequencer.d.ts.map +1 -0
- package/dist/input-sequencer.js +127 -0
- package/dist/listener.d.ts +119 -2
- package/dist/listener.d.ts.map +1 -1
- package/dist/listener.js +549 -40
- package/package.json +1 -1
package/dist/crypto.d.ts
CHANGED
|
@@ -127,5 +127,21 @@ export declare const getDevicePublicKey: () => string | null;
|
|
|
127
127
|
* device. Requires initDeviceCrypto() to have completed.
|
|
128
128
|
*/
|
|
129
129
|
export declare const encryptEphemeral: (plaintext: string, recipientPublicKeyRaw: string) => Promise<EncryptedEphemeralPayload>;
|
|
130
|
+
/**
|
|
131
|
+
* Open an ephemeral envelope addressed to this device — the exact inverse of
|
|
132
|
+
* encryptEphemeral, and of the web's `encrypt` from @zeph/crypto, which
|
|
133
|
+
* produces the same five fields.
|
|
134
|
+
*
|
|
135
|
+
* Rejects rather than returning null: AES-GCM is authenticated, so a throw
|
|
136
|
+
* here means the envelope was sealed for another key, tampered with, or is not
|
|
137
|
+
* an envelope at all. Callers must treat every rejection as a refusal — there
|
|
138
|
+
* is no partial result to fall back on. Requires initDeviceCrypto().
|
|
139
|
+
*
|
|
140
|
+
* Note that opening an envelope proves only that its sender holds the private
|
|
141
|
+
* half of `senderPublicKey`; nothing signs that field, so it authenticates the
|
|
142
|
+
* key pairing and not the sender. A caller that needs to know *which* peer
|
|
143
|
+
* sent this must compare `senderPublicKey` against a key it already trusts.
|
|
144
|
+
*/
|
|
145
|
+
export declare const decryptEphemeral: (payload: EncryptedEphemeralPayload) => Promise<string>;
|
|
130
146
|
export {};
|
|
131
147
|
//# sourceMappingURL=crypto.d.ts.map
|
package/dist/crypto.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AA+EH,UAAU,gBAAgB;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;CACf;
|
|
1
|
+
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AA+EH,UAAU,gBAAgB;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;CACf;AAsED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,UAAU,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,CAyB5E,CAAC;AAsCF,wDAAwD;AACxD,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,0DAA0D;IAC1D,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,mFAAmF;AACnF,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAElD;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,GAC3B,SAAS;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,KAClD,eAAe,EAG8C,CAAC;AA2CjE,eAAO,MAAM,UAAU,QAAO,aAAa,GAAG,IAAkD,CAAC;AACjG,eAAO,MAAM,YAAY,QAAO,MAAM,GAAG,IAA4D,CAAC;AAEtG;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,QAAO,IAEhC,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GACpC,OAAO;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EACtD,YAAY,eAAe,EAAE,KAC5B,OAAO,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,YAAY,CAAC;IAC3B,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB,CAkBA,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,qBAAqB,GAChC,SAAS,MAAM,GAAG,MAAM,EACxB,YAAY,eAAe,EAAE,KAC5B,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,YAAY,CAAA;CAAE,CAsBxE,CAAC;AAcF;;;;GAIG;AACH,MAAM,WAAW,yBAA0B,SAAQ,gBAAgB;IACjE,eAAe,EAAE,MAAM,CAAC;CACzB;AAsBD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,QAAO,OAAO,CAAC,MAAM,CAoBjD,CAAC;AAEF,eAAO,MAAM,kBAAkB,QAAO,MAAM,GAAG,IAA+B,CAAC;AAE/E;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAC3B,WAAW,MAAM,EACjB,uBAAuB,MAAM,KAC5B,OAAO,CAAC,yBAAyB,CAKnC,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,gBAAgB,GAC3B,SAAS,yBAAyB,KACjC,OAAO,CAAC,MAAM,CAIhB,CAAC"}
|
package/dist/crypto.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* account keypair are gone.
|
|
30
30
|
*/
|
|
31
31
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
-
exports.encryptEphemeral = exports.getDevicePublicKey = exports.initDeviceCrypto = exports.encryptFileForDevices = exports.encryptPushBodyForDevices = exports.disableCrypto = exports.getPublicKey = exports.getKeyPair = exports.selectRecipients = exports.initCrypto = void 0;
|
|
32
|
+
exports.decryptEphemeral = exports.encryptEphemeral = exports.getDevicePublicKey = exports.initDeviceCrypto = exports.encryptFileForDevices = exports.encryptPushBodyForDevices = exports.disableCrypto = exports.getPublicKey = exports.getKeyPair = exports.selectRecipients = exports.initCrypto = void 0;
|
|
33
33
|
/// <reference lib="dom" />
|
|
34
34
|
const fs_1 = require("fs");
|
|
35
35
|
const os_1 = require("os");
|
|
@@ -91,6 +91,13 @@ const encrypt = async (plaintext, senderPrivateKey, recipientPublicKey) => {
|
|
|
91
91
|
keyIv: toBase64(keyIv.buffer),
|
|
92
92
|
};
|
|
93
93
|
};
|
|
94
|
+
const decrypt = async (payload, recipientPrivateKey, senderPublicKey) => {
|
|
95
|
+
const sharedKey = await deriveAesKey(recipientPrivateKey, senderPublicKey);
|
|
96
|
+
const rawMessageKey = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(fromBase64(payload.keyIv)) }, sharedKey, fromBase64(payload.encryptedKey));
|
|
97
|
+
const messageKey = await crypto.subtle.importKey('raw', rawMessageKey, { name: 'AES-GCM' }, false, ['decrypt']);
|
|
98
|
+
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(fromBase64(payload.iv)) }, messageKey, fromBase64(payload.ciphertext));
|
|
99
|
+
return new TextDecoder().decode(plaintext);
|
|
100
|
+
};
|
|
94
101
|
// ─── Superseded account keystore ───
|
|
95
102
|
//
|
|
96
103
|
// Held the escrowed account keypair. Nothing reads it any more; it is deleted
|
|
@@ -342,3 +349,25 @@ const encryptEphemeral = async (plaintext, recipientPublicKeyRaw) => {
|
|
|
342
349
|
return { ...payload, senderPublicKey: deviceExportedPublicKey };
|
|
343
350
|
};
|
|
344
351
|
exports.encryptEphemeral = encryptEphemeral;
|
|
352
|
+
/**
|
|
353
|
+
* Open an ephemeral envelope addressed to this device — the exact inverse of
|
|
354
|
+
* encryptEphemeral, and of the web's `encrypt` from @zeph/crypto, which
|
|
355
|
+
* produces the same five fields.
|
|
356
|
+
*
|
|
357
|
+
* Rejects rather than returning null: AES-GCM is authenticated, so a throw
|
|
358
|
+
* here means the envelope was sealed for another key, tampered with, or is not
|
|
359
|
+
* an envelope at all. Callers must treat every rejection as a refusal — there
|
|
360
|
+
* is no partial result to fall back on. Requires initDeviceCrypto().
|
|
361
|
+
*
|
|
362
|
+
* Note that opening an envelope proves only that its sender holds the private
|
|
363
|
+
* half of `senderPublicKey`; nothing signs that field, so it authenticates the
|
|
364
|
+
* key pairing and not the sender. A caller that needs to know *which* peer
|
|
365
|
+
* sent this must compare `senderPublicKey` against a key it already trusts.
|
|
366
|
+
*/
|
|
367
|
+
const decryptEphemeral = async (payload) => {
|
|
368
|
+
if (!deviceKeyPair)
|
|
369
|
+
throw new Error('Device crypto not initialized');
|
|
370
|
+
const senderKey = await importPublicKey(payload.senderPublicKey);
|
|
371
|
+
return decrypt(payload, deviceKeyPair.privateKey, senderKey);
|
|
372
|
+
};
|
|
373
|
+
exports.decryptEphemeral = decryptEphemeral;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound ordering guard for keys/text arriving over the ephemeral relay.
|
|
3
|
+
*
|
|
4
|
+
* The outbound stream stamps every frame with `seq` + `epoch` so the viewer
|
|
5
|
+
* can drop what it has already painted (see listener.ts buildStreamFrame's
|
|
6
|
+
* send site); this is the inverse for the inbound direction. A dropped or
|
|
7
|
+
* reordered keystroke is not a cosmetic glitch the way a stale frame is —
|
|
8
|
+
* it lands in a real editor buffer — so the rules differ:
|
|
9
|
+
*
|
|
10
|
+
* - in-order seq delivers immediately (typing latency is the whole point of
|
|
11
|
+
* this path; nothing is buffered on the happy path)
|
|
12
|
+
* - a gap holds later keys for `holdMs`, then delivers what arrived anyway.
|
|
13
|
+
* Approximate order beats dropped keys: the relay is at-most-once, so a
|
|
14
|
+
* never-arriving seq must not wedge everything behind it forever.
|
|
15
|
+
* - a new epoch (sender restart) resets the high-water mark, and the old
|
|
16
|
+
* epoch's held keys are discarded rather than typed into the new run
|
|
17
|
+
* - a seq at or below the high-water mark is a straggler the hold already
|
|
18
|
+
* flushed past — typing it now would put it out of order. The transport is
|
|
19
|
+
* at-most-once, so this is never a transport duplicate; the sender is told
|
|
20
|
+
* (`stale`) so it can decide whether the key still matters.
|
|
21
|
+
*
|
|
22
|
+
* Every outcome that drops a message is reported — as a non-'ok' AcceptResult
|
|
23
|
+
* for the message being accepted, or through `onDiscard` for held messages
|
|
24
|
+
* swept out by an epoch change or reset. The caller's contract ("every
|
|
25
|
+
* refusal reaches the sender") is only as honest as this accounting.
|
|
26
|
+
*/
|
|
27
|
+
/** How long a key waits for the gap in front of it to fill. */
|
|
28
|
+
export declare const INPUT_HOLD_MS = 500;
|
|
29
|
+
/** Ceiling on the reorder buffer. A gap that never fills lets every later key
|
|
30
|
+
* pile up for the whole hold, and the flush then delivers them back to back —
|
|
31
|
+
* one blocking tmux inject each. 32 is far past any real burst at
|
|
32
|
+
* INPUT_HOLD_MS, so this only bites on a stuck or hostile sender. */
|
|
33
|
+
export declare const MAX_PENDING_INPUTS = 32;
|
|
34
|
+
/** Non-'ok' means this message was dropped and the caller owes its sender a
|
|
35
|
+
* refusal: `overflow` (buffer full), `superseded` (older epoch than the
|
|
36
|
+
* current run), `stale` (at or below the high-water mark — the hold already
|
|
37
|
+
* flushed past it). 'ok' covers delivered AND held; held messages that are
|
|
38
|
+
* later swept out surface through `onDiscard` instead. */
|
|
39
|
+
export type AcceptResult = 'ok' | 'overflow' | 'superseded' | 'stale';
|
|
40
|
+
/** Cancels a pending flush. */
|
|
41
|
+
type CancelFlush = () => void;
|
|
42
|
+
/** Injectable timer so tests drive the hold deadline instead of sleeping. */
|
|
43
|
+
export type ScheduleFlush = (fn: () => void, ms: number) => CancelFlush;
|
|
44
|
+
export type SequencedInput = {
|
|
45
|
+
/** Sender-side monotonic counter within `epoch`. */
|
|
46
|
+
seq: number;
|
|
47
|
+
/** Sender incarnation — a restart bumps it and restarts `seq`. */
|
|
48
|
+
epoch: number;
|
|
49
|
+
};
|
|
50
|
+
export type InputSequencerOptions<T> = {
|
|
51
|
+
holdMs?: number;
|
|
52
|
+
schedule?: ScheduleFlush;
|
|
53
|
+
/** Called for each HELD message dropped without delivery — an epoch change
|
|
54
|
+
* or reset() sweeping the buffer. Accept-time drops are reported via the
|
|
55
|
+
* AcceptResult instead, so no message can vanish through both cracks. */
|
|
56
|
+
onDiscard?: (msg: T) => void;
|
|
57
|
+
};
|
|
58
|
+
export type InputSequencer<T extends SequencedInput> = {
|
|
59
|
+
accept: (msg: T) => AcceptResult;
|
|
60
|
+
/** Drop everything held (reported via onDiscard) and forget the epoch —
|
|
61
|
+
* the run is over. */
|
|
62
|
+
reset: () => void;
|
|
63
|
+
};
|
|
64
|
+
export declare const createInputSequencer: <T extends SequencedInput>(deliver: (msg: T) => void, opts?: InputSequencerOptions<T>) => InputSequencer<T>;
|
|
65
|
+
export {};
|
|
66
|
+
//# sourceMappingURL=input-sequencer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"input-sequencer.d.ts","sourceRoot":"","sources":["../src/input-sequencer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,+DAA+D;AAC/D,eAAO,MAAM,aAAa,MAAM,CAAC;AAEjC;;;sEAGsE;AACtE,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;2DAI2D;AAC3D,MAAM,MAAM,YAAY,GAAG,IAAI,GAAG,UAAU,GAAG,YAAY,GAAG,OAAO,CAAC;AAEtE,+BAA+B;AAC/B,KAAK,WAAW,GAAG,MAAM,IAAI,CAAC;AAE9B,6EAA6E;AAC7E,MAAM,MAAM,aAAa,GAAG,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,WAAW,CAAC;AAExE,MAAM,MAAM,cAAc,GAAG;IACzB,oDAAoD;IACpD,GAAG,EAAE,MAAM,CAAC;IACZ,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,qBAAqB,CAAC,CAAC,IAAI;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB;;8EAE0E;IAC1E,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,IAAI,CAAC;CAChC,CAAC;AAEF,MAAM,MAAM,cAAc,CAAC,CAAC,SAAS,cAAc,IAAI;IACnD,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,YAAY,CAAC;IACjC;2BACuB;IACvB,KAAK,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC;AASF,eAAO,MAAM,oBAAoB,GAAI,CAAC,SAAS,cAAc,EACzD,SAAS,CAAC,GAAG,EAAE,CAAC,KAAK,IAAI,EACzB,OAAM,qBAAqB,CAAC,CAAC,CAAM,KACpC,cAAc,CAAC,CAAC,CAmFlB,CAAC"}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Inbound ordering guard for keys/text arriving over the ephemeral relay.
|
|
4
|
+
*
|
|
5
|
+
* The outbound stream stamps every frame with `seq` + `epoch` so the viewer
|
|
6
|
+
* can drop what it has already painted (see listener.ts buildStreamFrame's
|
|
7
|
+
* send site); this is the inverse for the inbound direction. A dropped or
|
|
8
|
+
* reordered keystroke is not a cosmetic glitch the way a stale frame is —
|
|
9
|
+
* it lands in a real editor buffer — so the rules differ:
|
|
10
|
+
*
|
|
11
|
+
* - in-order seq delivers immediately (typing latency is the whole point of
|
|
12
|
+
* this path; nothing is buffered on the happy path)
|
|
13
|
+
* - a gap holds later keys for `holdMs`, then delivers what arrived anyway.
|
|
14
|
+
* Approximate order beats dropped keys: the relay is at-most-once, so a
|
|
15
|
+
* never-arriving seq must not wedge everything behind it forever.
|
|
16
|
+
* - a new epoch (sender restart) resets the high-water mark, and the old
|
|
17
|
+
* epoch's held keys are discarded rather than typed into the new run
|
|
18
|
+
* - a seq at or below the high-water mark is a straggler the hold already
|
|
19
|
+
* flushed past — typing it now would put it out of order. The transport is
|
|
20
|
+
* at-most-once, so this is never a transport duplicate; the sender is told
|
|
21
|
+
* (`stale`) so it can decide whether the key still matters.
|
|
22
|
+
*
|
|
23
|
+
* Every outcome that drops a message is reported — as a non-'ok' AcceptResult
|
|
24
|
+
* for the message being accepted, or through `onDiscard` for held messages
|
|
25
|
+
* swept out by an epoch change or reset. The caller's contract ("every
|
|
26
|
+
* refusal reaches the sender") is only as honest as this accounting.
|
|
27
|
+
*/
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.createInputSequencer = exports.MAX_PENDING_INPUTS = exports.INPUT_HOLD_MS = void 0;
|
|
30
|
+
/** How long a key waits for the gap in front of it to fill. */
|
|
31
|
+
exports.INPUT_HOLD_MS = 500;
|
|
32
|
+
/** Ceiling on the reorder buffer. A gap that never fills lets every later key
|
|
33
|
+
* pile up for the whole hold, and the flush then delivers them back to back —
|
|
34
|
+
* one blocking tmux inject each. 32 is far past any real burst at
|
|
35
|
+
* INPUT_HOLD_MS, so this only bites on a stuck or hostile sender. */
|
|
36
|
+
exports.MAX_PENDING_INPUTS = 32;
|
|
37
|
+
const defaultSchedule = (fn, ms) => {
|
|
38
|
+
const timer = setTimeout(fn, ms);
|
|
39
|
+
// A pending flush must not keep the daemon's event loop alive.
|
|
40
|
+
timer.unref?.();
|
|
41
|
+
return () => clearTimeout(timer);
|
|
42
|
+
};
|
|
43
|
+
const createInputSequencer = (deliver, opts = {}) => {
|
|
44
|
+
const holdMs = opts.holdMs ?? exports.INPUT_HOLD_MS;
|
|
45
|
+
const schedule = opts.schedule ?? defaultSchedule;
|
|
46
|
+
let epoch = null;
|
|
47
|
+
let highWater = 0;
|
|
48
|
+
let cancelFlush = null;
|
|
49
|
+
const pending = new Map();
|
|
50
|
+
const disarm = () => {
|
|
51
|
+
cancelFlush?.();
|
|
52
|
+
cancelFlush = null;
|
|
53
|
+
};
|
|
54
|
+
/** Sweep the hold buffer without delivering — each swept message is a
|
|
55
|
+
* keystroke its sender still waits on, so each is reported. */
|
|
56
|
+
const discardPending = () => {
|
|
57
|
+
const swept = [...pending.values()];
|
|
58
|
+
pending.clear();
|
|
59
|
+
disarm();
|
|
60
|
+
for (const msg of swept)
|
|
61
|
+
opts.onDiscard?.(msg);
|
|
62
|
+
};
|
|
63
|
+
const emit = (msg) => {
|
|
64
|
+
highWater = msg.seq;
|
|
65
|
+
deliver(msg);
|
|
66
|
+
};
|
|
67
|
+
/** Release the unbroken run sitting on top of the high-water mark. */
|
|
68
|
+
const drain = () => {
|
|
69
|
+
for (;;) {
|
|
70
|
+
const next = pending.get(highWater + 1);
|
|
71
|
+
if (!next)
|
|
72
|
+
break;
|
|
73
|
+
pending.delete(next.seq);
|
|
74
|
+
emit(next);
|
|
75
|
+
}
|
|
76
|
+
if (!pending.size)
|
|
77
|
+
disarm();
|
|
78
|
+
};
|
|
79
|
+
/** Hold expired: the missing seq is not coming. Deliver the rest in seq
|
|
80
|
+
* order, which also moves the high-water mark past the hole. */
|
|
81
|
+
const flush = () => {
|
|
82
|
+
cancelFlush = null;
|
|
83
|
+
const held = [...pending.values()].sort((a, b) => a.seq - b.seq);
|
|
84
|
+
pending.clear();
|
|
85
|
+
for (const msg of held)
|
|
86
|
+
emit(msg);
|
|
87
|
+
};
|
|
88
|
+
const accept = (msg) => {
|
|
89
|
+
if (epoch === null || msg.epoch > epoch) {
|
|
90
|
+
// First message of a run — whatever seq it carries is in order by
|
|
91
|
+
// definition, so joining a stream mid-flight costs no hold.
|
|
92
|
+
discardPending();
|
|
93
|
+
epoch = msg.epoch;
|
|
94
|
+
highWater = msg.seq - 1;
|
|
95
|
+
}
|
|
96
|
+
else if (msg.epoch < epoch) {
|
|
97
|
+
return 'superseded';
|
|
98
|
+
}
|
|
99
|
+
if (msg.seq <= highWater || pending.has(msg.seq))
|
|
100
|
+
return 'stale';
|
|
101
|
+
if (msg.seq === highWater + 1) {
|
|
102
|
+
emit(msg);
|
|
103
|
+
drain();
|
|
104
|
+
return 'ok';
|
|
105
|
+
}
|
|
106
|
+
// Drop the NEW message, not a held one: the buffer already holds the
|
|
107
|
+
// keys closest to the gap, and evicting those would reorder what is
|
|
108
|
+
// about to be typed.
|
|
109
|
+
if (pending.size >= exports.MAX_PENDING_INPUTS)
|
|
110
|
+
return 'overflow';
|
|
111
|
+
pending.set(msg.seq, msg);
|
|
112
|
+
// One deadline per gap: it dates from when the hole opened, so a
|
|
113
|
+
// steady stream of later keys can't extend the wait indefinitely.
|
|
114
|
+
if (!cancelFlush)
|
|
115
|
+
cancelFlush = schedule(flush, holdMs);
|
|
116
|
+
return 'ok';
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
accept,
|
|
120
|
+
reset: () => {
|
|
121
|
+
discardPending();
|
|
122
|
+
epoch = null;
|
|
123
|
+
highWater = 0;
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
exports.createInputSequencer = createInputSequencer;
|
package/dist/listener.d.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { type AgentKind, type RegisteredRemoteAgent } from './remote-agents.js';
|
|
24
24
|
import { type AgentState } from './agent-state.js';
|
|
25
25
|
import { type EncryptedEphemeralPayload } from './crypto.js';
|
|
26
|
+
import { type SequencedInput } from './input-sequencer.js';
|
|
26
27
|
export declare const SESSION_REPORT_HEARTBEAT_MS = 30000;
|
|
27
28
|
interface AgentSession {
|
|
28
29
|
name: string;
|
|
@@ -181,6 +182,29 @@ export interface StreamControl {
|
|
|
181
182
|
*/
|
|
182
183
|
renew?: boolean;
|
|
183
184
|
}
|
|
185
|
+
export declare const STREAM_INTERVAL_MS = 400;
|
|
186
|
+
export declare const BURST_INTERVAL_MS = 120;
|
|
187
|
+
export declare const BURST_WINDOW_MS = 2500;
|
|
188
|
+
export declare const MAX_FRAMES_PER_SEC = 8;
|
|
189
|
+
/**
|
|
190
|
+
* Delay before the next capture: burst while the last input is still echoing,
|
|
191
|
+
* idle otherwise. Exclusive at the boundary — exactly BURST_WINDOW_MS after
|
|
192
|
+
* the input is already idle, so the window can't stretch. A lastInputAt in the
|
|
193
|
+
* future (wall-clock jump) reads as fresh input rather than as an expired
|
|
194
|
+
* window: bursting is the recoverable side of that.
|
|
195
|
+
*/
|
|
196
|
+
export declare const streamCadence: (lastInputAt: number | null, now: number) => number;
|
|
197
|
+
/** Rolling one-second send budget for a single stream. */
|
|
198
|
+
export interface FrameBudget {
|
|
199
|
+
windowStartedAt: number;
|
|
200
|
+
sent: number;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Claim one send from the budget, rolling the window over when the second has
|
|
204
|
+
* passed. False means this tick must skip WITHOUT marking the frame as sent —
|
|
205
|
+
* the diff-gate would otherwise swallow that content for good.
|
|
206
|
+
*/
|
|
207
|
+
export declare const claimFrameSend: (budget: FrameBudget, now: number) => boolean;
|
|
184
208
|
export declare const STREAM_LEASE_MS = 15000;
|
|
185
209
|
export declare const MAX_CONCURRENT_STREAMS = 3;
|
|
186
210
|
export declare const stopStream: (sessionName: string) => void;
|
|
@@ -201,11 +225,23 @@ export type StreamFramePayload = {
|
|
|
201
225
|
/** E2EE envelope — replaces `content` on encrypted streams. */
|
|
202
226
|
encrypted?: EncryptedEphemeralPayload;
|
|
203
227
|
};
|
|
204
|
-
/** Wire shape of a live-mirror error frame — same subtype, no pane data.
|
|
228
|
+
/** Wire shape of a live-mirror error frame — same subtype, no pane data.
|
|
229
|
+
* `input_rejected` refuses an ephemeral `agent.command.input`; it rides this
|
|
230
|
+
* frame so a viewer already handling stream errors can fall back to the REST
|
|
231
|
+
* push path without a second error channel. */
|
|
205
232
|
export type StreamErrorFrame = {
|
|
206
233
|
subtype: 'agent.stream.frame';
|
|
207
234
|
sessionName: string;
|
|
208
|
-
error: 'unknown_session' | 'e2ee_unavailable' | 'encrypt_failed' | 'stream_limit' | 'stream_gone';
|
|
235
|
+
error: 'unknown_session' | 'e2ee_unavailable' | 'encrypt_failed' | 'stream_limit' | 'stream_gone' | 'input_rejected';
|
|
236
|
+
/** `input_rejected` only: the refused message's ordering stamp echoed back.
|
|
237
|
+
* A sender with several keystrokes in flight can't tell which one was
|
|
238
|
+
* refused without it. Absent when the message carried no usable stamp. */
|
|
239
|
+
seq?: number;
|
|
240
|
+
epoch?: number;
|
|
241
|
+
/** `input_rejected` only: the refused sender's deviceId, echoed so a second
|
|
242
|
+
* device whose (seq, epoch) happens to collide doesn't claim the refusal
|
|
243
|
+
* and fire a REST resend for an input that was never refused. */
|
|
244
|
+
inputDeviceId?: string;
|
|
209
245
|
};
|
|
210
246
|
/**
|
|
211
247
|
* Per-listener concurrency guard, as a pure predicate so the boundary is
|
|
@@ -228,6 +264,87 @@ export declare const buildStreamFrame: (captured: {
|
|
|
228
264
|
* path). start is idempotent — a repeat restarts the loop.
|
|
229
265
|
*/
|
|
230
266
|
export declare const handleStreamControl: (req: StreamControl, send: (data: Record<string, unknown>) => void) => boolean;
|
|
267
|
+
/** Wire shape of one inbound injection. Every field is optional, and none of
|
|
268
|
+
* them is proven: this shape is an unchecked cast over relay JSON, so
|
|
269
|
+
* validateInputMessage re-checks the types it acts on rather than trusting
|
|
270
|
+
* the declaration. */
|
|
271
|
+
export interface AgentCommandInput {
|
|
272
|
+
subtype?: string;
|
|
273
|
+
targetDeviceId?: string;
|
|
274
|
+
sessionName?: string;
|
|
275
|
+
/** Which device's seq/epoch run this is. Current relays stamp it from the
|
|
276
|
+
* sending connection unconditionally; relays older than that overwrite
|
|
277
|
+
* only a missing value, so a sender there can claim another device's id.
|
|
278
|
+
* Treat it as an ordering namespace, never as authentication — the worst
|
|
279
|
+
* a forged id buys is a different reorder lane for its own keystrokes. */
|
|
280
|
+
deviceId?: string;
|
|
281
|
+
/** Named keys, ALLOWED_KEYS-validated. Mutually exclusive with `body`. */
|
|
282
|
+
keys?: string[];
|
|
283
|
+
/** Literal text; the injector appends the Enter, as on the REST path. */
|
|
284
|
+
body?: string;
|
|
285
|
+
/** Sender's monotonic counter, and the incarnation it counts within. */
|
|
286
|
+
seq?: number;
|
|
287
|
+
epoch?: number;
|
|
288
|
+
/** E2EE envelope sealing `{ keys | body }` plus a copy of the
|
|
289
|
+
* `sessionName`/`seq`/`epoch` stamp as JSON — the only way in on an
|
|
290
|
+
* encrypted stream, where `keys`/`body` above are refused. Present means
|
|
291
|
+
* the plaintext pair is ignored entirely: on a message the relay can
|
|
292
|
+
* append to, only the ciphertext decides what gets typed, and the sealed
|
|
293
|
+
* stamp is what proves the plaintext one was not re-written for a replay. */
|
|
294
|
+
encrypted?: unknown;
|
|
295
|
+
}
|
|
296
|
+
/** Parity with the REST path, which refuses more than MAX_KEYS_PER_COMMAND per
|
|
297
|
+
* agent.command (zeph apps/server/src/functions/pushes.ts). The lower-latency
|
|
298
|
+
* door into the same pane must not also be the wider one. */
|
|
299
|
+
export declare const MAX_INPUT_KEYS = 10;
|
|
300
|
+
/** The REST path caps no body length, so this bound is ours alone: an
|
|
301
|
+
* unbounded body is one tmux send-keys argv of unbounded size, and what it
|
|
302
|
+
* lands in is an agent prompt, not a paste buffer. */
|
|
303
|
+
export declare const MAX_INPUT_BODY_CHARS = 4096;
|
|
304
|
+
/** The relay socket a message arrived on, and the only way back to its sender. */
|
|
305
|
+
type SendEphemeral = (data: Record<string, unknown>) => void;
|
|
306
|
+
/** One validated injection. */
|
|
307
|
+
type ValidatedInput = SequencedInput & {
|
|
308
|
+
sessionName: string;
|
|
309
|
+
/** Sender's device, when the relay stamped one — rides along so a refusal
|
|
310
|
+
* frame can name whose input was refused (inputDeviceId). */
|
|
311
|
+
deviceId?: string;
|
|
312
|
+
/** Exactly one of these is set. */
|
|
313
|
+
tokens: string[] | null;
|
|
314
|
+
text: string | null;
|
|
315
|
+
};
|
|
316
|
+
type InputCheck = {
|
|
317
|
+
ok: true;
|
|
318
|
+
input: ValidatedInput;
|
|
319
|
+
} | {
|
|
320
|
+
ok: false;
|
|
321
|
+
reason: string;
|
|
322
|
+
};
|
|
323
|
+
/**
|
|
324
|
+
* Parse and whitelist an inbound input message. Pure — the lease gate and the
|
|
325
|
+
* tmux-side guards stay in the caller, so this is testable on its own.
|
|
326
|
+
*/
|
|
327
|
+
export declare const validateInputMessage: (msg: AgentCommandInput) => InputCheck;
|
|
328
|
+
/** Ceiling on concurrent sender lanes. The lane key includes a relay-stamped
|
|
329
|
+
* deviceId, but a relay older than the unconditional stamp lets a sender vary
|
|
330
|
+
* it per message — without a cap that grows the map for the lease's whole
|
|
331
|
+
* life. Real senders are one per device; 16 is generous. */
|
|
332
|
+
export declare const MAX_INPUT_LANES = 16;
|
|
333
|
+
/** Ceiling on queued decrypts. The subscriber-key binding is a string compare
|
|
334
|
+
* against a public key the relay fanned out to every same-account connection,
|
|
335
|
+
* so it gates WHO can enqueue an ECDH derive, not how many — a flooding
|
|
336
|
+
* client would otherwise grow the chain without bound. Mirrors
|
|
337
|
+
* MAX_PENDING_INPUTS' role on the plaintext side. */
|
|
338
|
+
export declare const MAX_PENDING_DECRYPTS = 32;
|
|
339
|
+
/** Resolves once every encrypted input accepted so far has been routed. */
|
|
340
|
+
export declare const pendingInputDecrypts: () => Promise<void>;
|
|
341
|
+
/**
|
|
342
|
+
* Handle agent.command.input. Returns true when the message was ours to
|
|
343
|
+
* answer, so the caller stops routing it. Encrypted messages finish
|
|
344
|
+
* asynchronously — the return value reports routing, not delivery, exactly as
|
|
345
|
+
* it already did for a message the sequencer holds.
|
|
346
|
+
*/
|
|
347
|
+
export declare const handleCommandInput: (msg: AgentCommandInput, send: SendEphemeral) => boolean;
|
|
231
348
|
export interface CollectResult {
|
|
232
349
|
sessions: AgentSession[];
|
|
233
350
|
/** Diagnostic notes per rejected session — surfaced under `--verbose`. */
|
package/dist/listener.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAoBH,OAAO,EAA2B,KAAK,SAAS,EAAE,KAAK,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AACzG,OAAO,EAAiD,KAAK,UAAU,EAA4C,MAAM,kBAAkB,CAAC;AAE5I,OAAO,
|
|
1
|
+
{"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"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"}
|
package/dist/listener.js
CHANGED
|
@@ -25,7 +25,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
25
25
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
26
26
|
};
|
|
27
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
-
exports.handleListener = exports.resolveWsUrl = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.setAttachmentContext = exports.writeRemoteMarker = exports.collectSessions = exports.collectSessionsVerbose = exports.handleStreamControl = exports.buildStreamFrame = exports.isStreamCapReached = exports.stopAllStreams = exports.stopStream = exports.MAX_CONCURRENT_STREAMS = exports.STREAM_LEASE_MS = exports.scheduleInjectSnapshots = exports.handleScreenRequest = exports.collectWatchHits = exports.resetPatternWatches = exports.setPatternWatches = exports.deriveSessionState = exports.resetSessionStates = exports.detectRemoteAgent = exports.parseSessionName = exports.invalidateTmuxSocketCache = exports.resolveKeys = exports.paneCurrentCommand = exports.checkRateLimit = exports.AUTH_FAILURE_CODES = exports.sessionsReportDue = exports.sessionsFingerprint = exports.SESSION_REPORT_HEARTBEAT_MS = void 0;
|
|
28
|
+
exports.handleListener = exports.resolveWsUrl = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.setAttachmentContext = exports.writeRemoteMarker = exports.collectSessions = exports.collectSessionsVerbose = exports.handleCommandInput = exports.pendingInputDecrypts = exports.MAX_PENDING_DECRYPTS = exports.MAX_INPUT_LANES = exports.validateInputMessage = exports.MAX_INPUT_BODY_CHARS = exports.MAX_INPUT_KEYS = exports.handleStreamControl = exports.buildStreamFrame = exports.isStreamCapReached = exports.stopAllStreams = exports.stopStream = exports.MAX_CONCURRENT_STREAMS = exports.STREAM_LEASE_MS = exports.claimFrameSend = exports.streamCadence = exports.MAX_FRAMES_PER_SEC = exports.BURST_WINDOW_MS = exports.BURST_INTERVAL_MS = exports.STREAM_INTERVAL_MS = exports.scheduleInjectSnapshots = exports.handleScreenRequest = exports.collectWatchHits = exports.resetPatternWatches = exports.setPatternWatches = exports.deriveSessionState = exports.resetSessionStates = exports.detectRemoteAgent = exports.parseSessionName = exports.invalidateTmuxSocketCache = exports.resolveKeys = exports.paneCurrentCommand = exports.checkRateLimit = exports.AUTH_FAILURE_CODES = exports.sessionsReportDue = exports.sessionsFingerprint = exports.SESSION_REPORT_HEARTBEAT_MS = void 0;
|
|
29
29
|
const child_process_1 = require("child_process");
|
|
30
30
|
const crypto_1 = require("crypto");
|
|
31
31
|
const fs_1 = require("fs");
|
|
@@ -39,6 +39,7 @@ const remote_agents_js_1 = require("./remote-agents.js");
|
|
|
39
39
|
const agent_state_js_1 = require("./agent-state.js");
|
|
40
40
|
const agent_rules_fetch_js_1 = require("./agent-rules-fetch.js");
|
|
41
41
|
const crypto_js_1 = require("./crypto.js");
|
|
42
|
+
const input_sequencer_js_1 = require("./input-sequencer.js");
|
|
42
43
|
const PING_INTERVAL_MS = 25_000;
|
|
43
44
|
const PONG_TIMEOUT_MS = 10_000;
|
|
44
45
|
const RECONNECT_BASE_MS = 1_000;
|
|
@@ -159,19 +160,22 @@ const injectKeys = (session, text) => {
|
|
|
159
160
|
// Maps a lowercase wire name to the exact tmux key token. Whitelist-ONLY:
|
|
160
161
|
// anything outside this map is refused so a compromised sender can't smuggle
|
|
161
162
|
// `C-c`, `M-x`, or a shell command through send-keys' key-name syntax.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
163
|
+
// A Map, not a plain object: object indexing leaks the prototype chain, so
|
|
164
|
+
// `keys: ['constructor']` resolved to a function that spawnSync happily
|
|
165
|
+
// stringified into the pane. Map lookups know only what was put in.
|
|
166
|
+
const ALLOWED_KEYS = new Map([
|
|
167
|
+
['escape', 'Escape'],
|
|
168
|
+
['up', 'Up'],
|
|
169
|
+
['down', 'Down'],
|
|
170
|
+
['left', 'Left'],
|
|
171
|
+
['right', 'Right'],
|
|
172
|
+
['enter', 'Enter'],
|
|
173
|
+
['tab', 'Tab'],
|
|
174
|
+
['backtab', 'BTab'],
|
|
175
|
+
['backspace', 'BSpace'],
|
|
176
|
+
['delete', 'DC'],
|
|
177
|
+
['space', 'Space'],
|
|
178
|
+
]);
|
|
175
179
|
/**
|
|
176
180
|
* Translate a phone-supplied key list to tmux tokens. Returns null if ANY
|
|
177
181
|
* key is unknown — a partial injection (some keys land, one is dropped)
|
|
@@ -181,7 +185,7 @@ const ALLOWED_KEYS = {
|
|
|
181
185
|
const resolveKeys = (keys) => {
|
|
182
186
|
const out = [];
|
|
183
187
|
for (const k of keys) {
|
|
184
|
-
const token = ALLOWED_KEYS
|
|
188
|
+
const token = ALLOWED_KEYS.get(k.toLowerCase().trim());
|
|
185
189
|
if (!token)
|
|
186
190
|
return null;
|
|
187
191
|
out.push(token);
|
|
@@ -758,14 +762,56 @@ const scheduleInjectSnapshots = (sessionName, send, delays = INJECT_SNAPSHOT_DEL
|
|
|
758
762
|
pendingInjectSnapshots.set(sessionName, timers);
|
|
759
763
|
};
|
|
760
764
|
exports.scheduleInjectSnapshots = scheduleInjectSnapshots;
|
|
761
|
-
// ~2.5 fps ceiling. Cadence + diff-gating
|
|
762
|
-
// WS cost (the $default route has no
|
|
763
|
-
// and let unchanged frames drop.
|
|
765
|
+
// ~2.5 fps idle ceiling. Cadence + diff-gating + the per-second send budget
|
|
766
|
+
// are the ONLY bound on API Gateway WS cost (the $default route has no
|
|
767
|
+
// per-message limit), so keep it modest and let unchanged frames drop.
|
|
764
768
|
// How far back the live-stream capture reaches — more than the screen-peek
|
|
765
769
|
// window so the mirror has scrollback to scroll through. SCREEN_PEEK_MAX_BYTES
|
|
766
770
|
// still caps the actual payload, so very wide panes get top-truncated.
|
|
767
771
|
const STREAM_CAPTURE_LINES = 200;
|
|
768
|
-
|
|
772
|
+
exports.STREAM_INTERVAL_MS = 400;
|
|
773
|
+
// Burst cadence: right after a keystroke lands, the pane IS what the user is
|
|
774
|
+
// staring at, so captures tighten for BURST_WINDOW_MS and then fall back to
|
|
775
|
+
// the idle cadence. The window is what keeps this affordable — one keypress
|
|
776
|
+
// costs a bounded number of extra frames instead of raising the steady rate.
|
|
777
|
+
exports.BURST_INTERVAL_MS = 120;
|
|
778
|
+
exports.BURST_WINDOW_MS = 2_500;
|
|
779
|
+
// Cost bound the burst must not break: MAX_CONCURRENT_STREAMS × 8 = 24 msg/s
|
|
780
|
+
// worst case, under half of the API Gateway WS stage throttle (50 rps) that is
|
|
781
|
+
// SHARED across every user, so a bursting host still leaves headroom for push
|
|
782
|
+
// delivery and presence. At BURST_INTERVAL_MS the chain tops out at 8.3 fps,
|
|
783
|
+
// so this budget bites only in the last fraction of a fully bursting second —
|
|
784
|
+
// it is what holds the line if the burst cadence is ever tightened further.
|
|
785
|
+
exports.MAX_FRAMES_PER_SEC = 8;
|
|
786
|
+
/**
|
|
787
|
+
* Delay before the next capture: burst while the last input is still echoing,
|
|
788
|
+
* idle otherwise. Exclusive at the boundary — exactly BURST_WINDOW_MS after
|
|
789
|
+
* the input is already idle, so the window can't stretch. A lastInputAt in the
|
|
790
|
+
* future (wall-clock jump) reads as fresh input rather than as an expired
|
|
791
|
+
* window: bursting is the recoverable side of that.
|
|
792
|
+
*/
|
|
793
|
+
const streamCadence = (lastInputAt, now) => lastInputAt !== null && now - lastInputAt < exports.BURST_WINDOW_MS ? exports.BURST_INTERVAL_MS : exports.STREAM_INTERVAL_MS;
|
|
794
|
+
exports.streamCadence = streamCadence;
|
|
795
|
+
/**
|
|
796
|
+
* Claim one send from the budget, rolling the window over when the second has
|
|
797
|
+
* passed. False means this tick must skip WITHOUT marking the frame as sent —
|
|
798
|
+
* the diff-gate would otherwise swallow that content for good.
|
|
799
|
+
*/
|
|
800
|
+
const claimFrameSend = (budget, now) => {
|
|
801
|
+
// `now < windowStartedAt` = the clock ran backwards (NTP step, resume).
|
|
802
|
+
// Without the guard the window never rolls again and a spent budget
|
|
803
|
+
// freezes the mirror for the whole regression — same failure streamCadence
|
|
804
|
+
// already defends against, so the two must agree.
|
|
805
|
+
if (now - budget.windowStartedAt >= 1_000 || now < budget.windowStartedAt) {
|
|
806
|
+
budget.windowStartedAt = now;
|
|
807
|
+
budget.sent = 0;
|
|
808
|
+
}
|
|
809
|
+
if (budget.sent >= exports.MAX_FRAMES_PER_SEC)
|
|
810
|
+
return false;
|
|
811
|
+
budget.sent++;
|
|
812
|
+
return true;
|
|
813
|
+
};
|
|
814
|
+
exports.claimFrameSend = claimFrameSend;
|
|
769
815
|
// Orphan guard for subscribers that can't renew (clients older than the renew
|
|
770
816
|
// protocol): a phone that dies without sending agent.stream.stop must not leak
|
|
771
817
|
// an interval forever. Auto-stop after this long; the phone re-subscribes on
|
|
@@ -805,29 +851,69 @@ const STREAM_LOG_INTERVAL_MS = 5_000;
|
|
|
805
851
|
/** How far a start (and each renew) pushes the deadline out. */
|
|
806
852
|
const leaseFor = (renewing) => (renewing ? exports.STREAM_LEASE_MS : STREAM_MAX_MS);
|
|
807
853
|
const activeStreams = new Map();
|
|
854
|
+
/** Inbound key ordering, one per (streamed session, sender device) — see the
|
|
855
|
+
* `agent.command.input` section below. Lives here so stopStream can drop them
|
|
856
|
+
* with the lease they belong to. Keyed `<session>#<deviceId>`, or the bare
|
|
857
|
+
* session name from a relay too old to stamp the sender. */
|
|
858
|
+
const inputSequencers = new Map();
|
|
859
|
+
/** Frames per second for one cadence phase. A phase that got no wall time in
|
|
860
|
+
* the window (a stream that never bursted) reads 0.0, never NaN. */
|
|
861
|
+
const phaseFps = (frames, ms) => (ms > 0 ? (frames / (ms / 1000)).toFixed(1) : '0.0');
|
|
808
862
|
const maybeLogStreamStats = (sessionName, stats) => {
|
|
809
863
|
const elapsed = Date.now() - stats.lastLogAt;
|
|
810
864
|
if (elapsed < STREAM_LOG_INTERVAL_MS)
|
|
811
865
|
return;
|
|
812
866
|
const secs = elapsed / 1000;
|
|
813
|
-
const
|
|
867
|
+
const frames = stats.frames - stats.lastLogFrames;
|
|
868
|
+
const fps = frames / secs;
|
|
814
869
|
const kbps = (stats.bytes - stats.lastLogBytes) / 1024 / secs;
|
|
815
|
-
|
|
870
|
+
// burstMs is charged when a burst tick is armed, so it can overshoot the
|
|
871
|
+
// window by at most one interval — phaseFps clamps the idle remainder.
|
|
872
|
+
const burstMs = stats.burstMs - stats.lastLogBurstMs;
|
|
873
|
+
const burstFrames = stats.framesBurst - stats.lastLogFramesBurst;
|
|
874
|
+
log(`⧉ stream ${sessionName}: ${fps.toFixed(1)} fps, ${kbps.toFixed(1)} KB/s ` +
|
|
875
|
+
`(${stats.frames} sent, ${stats.skipped} skipped incl. ${stats.rateCapped} rate-capped) ` +
|
|
876
|
+
`[burst ${phaseFps(burstFrames, burstMs)} fps · idle ${phaseFps(frames - burstFrames, elapsed - burstMs)} fps]`);
|
|
816
877
|
stats.lastLogAt = Date.now();
|
|
817
878
|
stats.lastLogFrames = stats.frames;
|
|
818
879
|
stats.lastLogBytes = stats.bytes;
|
|
880
|
+
stats.lastLogFramesBurst = stats.framesBurst;
|
|
881
|
+
stats.lastLogBurstMs = stats.burstMs;
|
|
882
|
+
};
|
|
883
|
+
/** A keystroke landed in this pane, so the next captures run at the burst
|
|
884
|
+
* cadence and the echo is visible instead of up to STREAM_INTERVAL_MS late.
|
|
885
|
+
* Called from the shared inject helpers, which is what both the ephemeral
|
|
886
|
+
* (agent.command.input) and the REST (agent.command) paths funnel through. */
|
|
887
|
+
const noteStreamInput = (sessionName) => {
|
|
888
|
+
const entry = activeStreams.get(sessionName);
|
|
889
|
+
if (!entry)
|
|
890
|
+
return;
|
|
891
|
+
entry.lastInputAt = Date.now();
|
|
892
|
+
entry.wake();
|
|
819
893
|
};
|
|
820
894
|
const stopStream = (sessionName) => {
|
|
821
895
|
const entry = activeStreams.get(sessionName);
|
|
822
896
|
if (!entry)
|
|
823
897
|
return;
|
|
824
|
-
|
|
898
|
+
clearTimeout(entry.timer);
|
|
825
899
|
activeStreams.delete(sessionName);
|
|
900
|
+
// The next stream is a new run: a sequencer carrying this one's high-water
|
|
901
|
+
// mark would swallow its first keys if the sender restarts its counter.
|
|
902
|
+
// Every sender that typed into this session holds its own, so drop the
|
|
903
|
+
// whole `<session>#…` family, not just the bare-name key.
|
|
904
|
+
for (const [key, sequencer] of inputSequencers) {
|
|
905
|
+
if (key !== sessionName && !key.startsWith(`${sessionName}#`))
|
|
906
|
+
continue;
|
|
907
|
+
sequencer.reset();
|
|
908
|
+
inputSequencers.delete(key);
|
|
909
|
+
}
|
|
826
910
|
const { stats } = entry;
|
|
827
911
|
const secs = Math.max(0.001, (Date.now() - stats.startedAt) / 1000);
|
|
828
912
|
log(`⧉ stream ${sessionName} stopped: ${stats.frames} frames / ` +
|
|
829
913
|
`${(stats.bytes / 1024).toFixed(1)} KB over ${secs.toFixed(1)}s ` +
|
|
830
|
-
`(${(stats.frames / secs).toFixed(1)} fps avg, ${stats.skipped}
|
|
914
|
+
`(${(stats.frames / secs).toFixed(1)} fps avg, ${stats.skipped} skipped incl. ${stats.rateCapped} rate-capped) ` +
|
|
915
|
+
`[burst ${stats.framesBurst} frames / ${(stats.bytesBurst / 1024).toFixed(1)} KB ` +
|
|
916
|
+
`over ${(stats.burstMs / 1000).toFixed(1)}s = ${phaseFps(stats.framesBurst, stats.burstMs)} fps]`);
|
|
831
917
|
};
|
|
832
918
|
exports.stopStream = stopStream;
|
|
833
919
|
const stopAllStreams = () => {
|
|
@@ -864,11 +950,20 @@ const evictStalestStream = (now) => {
|
|
|
864
950
|
(0, exports.stopStream)(victim.name);
|
|
865
951
|
return victim.name;
|
|
866
952
|
};
|
|
867
|
-
const streamErrorFrame = (sessionName, error
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
error
|
|
871
|
-
|
|
953
|
+
const streamErrorFrame = (sessionName, error,
|
|
954
|
+
/** The message being refused, when there is one to echo. */
|
|
955
|
+
echo) => {
|
|
956
|
+
const frame = { subtype: 'agent.stream.frame', sessionName, error };
|
|
957
|
+
// A malformed message may carry no stamp at all, or garbage — omitting the
|
|
958
|
+
// field beats echoing something the sender can't match.
|
|
959
|
+
if (isSeqNumber(echo?.seq))
|
|
960
|
+
frame.seq = echo.seq;
|
|
961
|
+
if (isSeqNumber(echo?.epoch))
|
|
962
|
+
frame.epoch = echo.epoch;
|
|
963
|
+
if (typeof echo?.deviceId === 'string' && echo.deviceId)
|
|
964
|
+
frame.inputDeviceId = echo.deviceId;
|
|
965
|
+
return frame;
|
|
966
|
+
};
|
|
872
967
|
/**
|
|
873
968
|
* Build the wire payload for one stream frame. With a subscriber public key
|
|
874
969
|
* the pane content rides ONLY inside the E2EE envelope; an encrypt failure
|
|
@@ -899,6 +994,14 @@ exports.buildStreamFrame = buildStreamFrame;
|
|
|
899
994
|
* was a stream-control message (so the caller skips the one-shot screen-peek
|
|
900
995
|
* path). start is idempotent — a repeat restarts the loop.
|
|
901
996
|
*/
|
|
997
|
+
// deep: over the body-length limit on purpose. Everything past the guards is
|
|
998
|
+
// one capture loop whose state (lastContent, wireSeq, encryptFailures, budget,
|
|
999
|
+
// and `stats` as the incarnation token) is only correct while it stays private
|
|
1000
|
+
// to a single start. Hoisting it into a factory would turn those five into
|
|
1001
|
+
// parameters and expose the incarnation invariant — the one that keeps an
|
|
1002
|
+
// orphaned chain from capturing forever — to callers that have no reason to
|
|
1003
|
+
// know it exists. Nothing outside needs the internals; deleting this deletes
|
|
1004
|
+
// the live mirror whole.
|
|
902
1005
|
const handleStreamControl = (req, send) => {
|
|
903
1006
|
if (req.subtype !== 'agent.stream.start' && req.subtype !== 'agent.stream.stop' && req.subtype !== 'agent.stream.renew') {
|
|
904
1007
|
return false;
|
|
@@ -955,10 +1058,16 @@ const handleStreamControl = (req, send) => {
|
|
|
955
1058
|
startedAt: Date.now(),
|
|
956
1059
|
frames: 0,
|
|
957
1060
|
bytes: 0,
|
|
1061
|
+
framesBurst: 0,
|
|
1062
|
+
bytesBurst: 0,
|
|
1063
|
+
burstMs: 0,
|
|
958
1064
|
skipped: 0,
|
|
1065
|
+
rateCapped: 0,
|
|
959
1066
|
lastLogAt: Date.now(),
|
|
960
1067
|
lastLogFrames: 0,
|
|
961
1068
|
lastLogBytes: 0,
|
|
1069
|
+
lastLogFramesBurst: 0,
|
|
1070
|
+
lastLogBurstMs: 0,
|
|
962
1071
|
};
|
|
963
1072
|
// E2EE handshake: load-or-create this device's keypair up front. The
|
|
964
1073
|
// subscriber asked for encryption, so key failure is FAIL-CLOSED: refuse
|
|
@@ -979,21 +1088,41 @@ const handleStreamControl = (req, send) => {
|
|
|
979
1088
|
}
|
|
980
1089
|
let wireSeq = 0;
|
|
981
1090
|
let encryptFailures = 0;
|
|
982
|
-
const
|
|
1091
|
+
const budget = { windowStartedAt: Date.now(), sent: 0 };
|
|
1092
|
+
/**
|
|
1093
|
+
* One capture. Returns false only when the stream is gone and the chain
|
|
1094
|
+
* must NOT re-arm — every other outcome re-arms at the single call site in
|
|
1095
|
+
* runTick, so no early return can silently freeze the mirror.
|
|
1096
|
+
*/
|
|
1097
|
+
const captureTick = (entry) => {
|
|
983
1098
|
// Lease check rides the capture tick: one deadline field, no second
|
|
984
1099
|
// timer to leak. (The previous per-start expiry setTimeout was never
|
|
985
1100
|
// cleared on stop, so a restart left the old one armed to kill the new
|
|
986
1101
|
// incarnation.) Reaping here is what frees the slot for everyone whose
|
|
987
1102
|
// stop never arrived.
|
|
988
|
-
if (Date.now() >=
|
|
1103
|
+
if (Date.now() >= entry.expiresAt) {
|
|
989
1104
|
(0, exports.stopStream)(sessionName);
|
|
990
|
-
return;
|
|
1105
|
+
return false;
|
|
991
1106
|
}
|
|
992
1107
|
const captured = capturePane(sessionName, true, STREAM_CAPTURE_LINES);
|
|
993
1108
|
if (!captured || captured.content === lastContent) {
|
|
994
1109
|
stats.skipped++;
|
|
995
|
-
return; // diff-gate
|
|
1110
|
+
return true; // diff-gate
|
|
996
1111
|
}
|
|
1112
|
+
const now = Date.now();
|
|
1113
|
+
// Send budget BEFORE the diff-gate is marked: a frame refused here has
|
|
1114
|
+
// to stay "changed" so a later tick retries it, or this content is
|
|
1115
|
+
// never sent at all — the next tick would diff-skip it as unchanged.
|
|
1116
|
+
if (!(0, exports.claimFrameSend)(budget, now)) {
|
|
1117
|
+
stats.skipped++;
|
|
1118
|
+
stats.rateCapped++;
|
|
1119
|
+
return true;
|
|
1120
|
+
}
|
|
1121
|
+
// Phase = the gap that PRODUCED this tick (armedDelay), matching how
|
|
1122
|
+
// burstMs is charged in arm() — deciding by wall-clock here instead
|
|
1123
|
+
// made the last burst-armed frame count idle, under-reporting burst
|
|
1124
|
+
// fps by one frame per episode in the R2 numbers.
|
|
1125
|
+
const inBurst = armedDelay === exports.BURST_INTERVAL_MS;
|
|
997
1126
|
lastContent = captured.content;
|
|
998
1127
|
// Stamp the sequence in CAPTURE order, synchronously — frame assembly
|
|
999
1128
|
// is async (encryption) and fire-and-forget, so resolve order is not
|
|
@@ -1010,7 +1139,7 @@ const handleStreamControl = (req, send) => {
|
|
|
1010
1139
|
// Encrypt failure — frame dropped, diff-gate un-marked so the
|
|
1011
1140
|
// next tick retries. A key that keeps failing (malformed
|
|
1012
1141
|
// subscriber key) never recovers: fail closed after a few
|
|
1013
|
-
// strikes instead of retrying every
|
|
1142
|
+
// strikes instead of retrying every tick for 5 minutes.
|
|
1014
1143
|
lastContent = null;
|
|
1015
1144
|
// Init still in flight (or failed — its own path fail-closes):
|
|
1016
1145
|
// a not-yet-ready key is not a malformed key, don't strike.
|
|
@@ -1029,7 +1158,12 @@ const handleStreamControl = (req, send) => {
|
|
|
1029
1158
|
// ~1.4× the plaintext; base fields now count too, so plaintext
|
|
1030
1159
|
// streams read slightly higher than the pre-E2EE content-only
|
|
1031
1160
|
// metric) — this feeds the R2 cost instrumentation.
|
|
1032
|
-
|
|
1161
|
+
const bytes = Buffer.byteLength(JSON.stringify(frame), 'utf-8');
|
|
1162
|
+
stats.bytes += bytes;
|
|
1163
|
+
if (inBurst) {
|
|
1164
|
+
stats.framesBurst++;
|
|
1165
|
+
stats.bytesBurst += bytes;
|
|
1166
|
+
}
|
|
1033
1167
|
// seq = capture order; epoch = this stream incarnation, so the
|
|
1034
1168
|
// receiver's ordering guard resets across daemon-side restarts.
|
|
1035
1169
|
send({ ...frame, seq, epoch: stats.startedAt });
|
|
@@ -1039,7 +1173,46 @@ const handleStreamControl = (req, send) => {
|
|
|
1039
1173
|
// from send()/logging — report and let the next tick carry on.
|
|
1040
1174
|
log(`⧉ stream ${sessionName}: frame send failed (${err instanceof Error ? err.message : err})`);
|
|
1041
1175
|
});
|
|
1042
|
-
|
|
1176
|
+
return true;
|
|
1177
|
+
};
|
|
1178
|
+
/** Arm the next tick. A chain that outlived its incarnation (restart, or a
|
|
1179
|
+
* stop racing this tick) must die here instead of capturing forever into
|
|
1180
|
+
* a stream nobody can cancel — `stats` is the incarnation token. */
|
|
1181
|
+
let armedDelay = exports.STREAM_INTERVAL_MS;
|
|
1182
|
+
const arm = (delay) => {
|
|
1183
|
+
const entry = activeStreams.get(sessionName);
|
|
1184
|
+
if (entry?.stats !== stats)
|
|
1185
|
+
return;
|
|
1186
|
+
if (delay === exports.BURST_INTERVAL_MS)
|
|
1187
|
+
stats.burstMs += delay;
|
|
1188
|
+
armedDelay = delay;
|
|
1189
|
+
const next = setTimeout(runTick, delay);
|
|
1190
|
+
next.unref?.();
|
|
1191
|
+
entry.timer = next;
|
|
1192
|
+
};
|
|
1193
|
+
const runTick = () => {
|
|
1194
|
+
const entry = activeStreams.get(sessionName);
|
|
1195
|
+
if (entry?.stats !== stats)
|
|
1196
|
+
return;
|
|
1197
|
+
if (!captureTick(entry))
|
|
1198
|
+
return;
|
|
1199
|
+
// The cadence is re-read every tick, so input that lands mid-stream
|
|
1200
|
+
// tightens the NEXT gap rather than the current one.
|
|
1201
|
+
arm((0, exports.streamCadence)(entry.lastInputAt, Date.now()));
|
|
1202
|
+
};
|
|
1203
|
+
/** Swap an idle-armed tick for an immediate burst tick (see ActiveStream.wake).
|
|
1204
|
+
* Only when the armed gap is the idle one: replacing an armed burst tick
|
|
1205
|
+
* on every keystroke would push the next capture away indefinitely. */
|
|
1206
|
+
const wake = () => {
|
|
1207
|
+
const entry = activeStreams.get(sessionName);
|
|
1208
|
+
if (entry?.stats !== stats)
|
|
1209
|
+
return;
|
|
1210
|
+
if (armedDelay === exports.BURST_INTERVAL_MS)
|
|
1211
|
+
return;
|
|
1212
|
+
clearTimeout(entry.timer);
|
|
1213
|
+
arm(exports.BURST_INTERVAL_MS);
|
|
1214
|
+
};
|
|
1215
|
+
const timer = setTimeout(runTick, exports.STREAM_INTERVAL_MS);
|
|
1043
1216
|
timer.unref?.();
|
|
1044
1217
|
// A renewing subscriber gets the short lease; anything older keeps the
|
|
1045
1218
|
// 5-minute orphan guard so a version-skewed client isn't cut off mid-view.
|
|
@@ -1047,12 +1220,338 @@ const handleStreamControl = (req, send) => {
|
|
|
1047
1220
|
activeStreams.set(sessionName, {
|
|
1048
1221
|
timer,
|
|
1049
1222
|
stats,
|
|
1223
|
+
lastInputAt: null,
|
|
1224
|
+
inputDecryptFailures: 0,
|
|
1050
1225
|
expiresAt: Date.now() + leaseFor(renewing),
|
|
1051
1226
|
renewing,
|
|
1227
|
+
subscriberPublicKey,
|
|
1228
|
+
wake,
|
|
1052
1229
|
});
|
|
1053
1230
|
return true;
|
|
1054
1231
|
};
|
|
1055
1232
|
exports.handleStreamControl = handleStreamControl;
|
|
1233
|
+
/** Parity with the REST path, which refuses more than MAX_KEYS_PER_COMMAND per
|
|
1234
|
+
* agent.command (zeph apps/server/src/functions/pushes.ts). The lower-latency
|
|
1235
|
+
* door into the same pane must not also be the wider one. */
|
|
1236
|
+
exports.MAX_INPUT_KEYS = 10;
|
|
1237
|
+
/** The REST path caps no body length, so this bound is ours alone: an
|
|
1238
|
+
* unbounded body is one tmux send-keys argv of unbounded size, and what it
|
|
1239
|
+
* lands in is an agent prompt, not a paste buffer. */
|
|
1240
|
+
exports.MAX_INPUT_BODY_CHARS = 4096;
|
|
1241
|
+
// Both fields are relay JSON, so both are attacker-shaped. Number.isFinite
|
|
1242
|
+
// admits 1e21 — which parks the high-water mark past anything a sender can
|
|
1243
|
+
// count back to — and 1.5, which no later integer can ever equal, so every
|
|
1244
|
+
// following key would sit out the hold before being typed out of order.
|
|
1245
|
+
const isSeqNumber = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
|
1246
|
+
// A malformed `keys` would throw where it is consumed (resolveKeys iterates
|
|
1247
|
+
// it, tmux args must be strings), and a throw inside the WebSocket message
|
|
1248
|
+
// handler takes the whole daemon down. Type-check before acting.
|
|
1249
|
+
const isKeyList = (v) => Array.isArray(v) && v.length > 0 && v.every((k) => typeof k === 'string');
|
|
1250
|
+
/** Every field of an inbound envelope, all of them relay JSON. A missing or
|
|
1251
|
+
* ill-typed one would reach WebCrypto as a string it cannot Base64-decode;
|
|
1252
|
+
* refusing here keeps the failure a refusal rather than a thrown rejection. */
|
|
1253
|
+
const isInputEnvelope = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
1254
|
+
&& ['ciphertext', 'iv', 'encryptedKey', 'keyIv', 'senderPublicKey']
|
|
1255
|
+
.every((field) => typeof v[field] === 'string');
|
|
1256
|
+
/** The decrypted payload, under the same stance as the outer message: an
|
|
1257
|
+
* unchecked cast that validateInputMessage re-checks field by field. Only the
|
|
1258
|
+
* envelope's authenticity is proven at this point, never its contents —
|
|
1259
|
+
* a subscriber can seal anything, including 400 keys. */
|
|
1260
|
+
const parseSealedInput = (json) => {
|
|
1261
|
+
try {
|
|
1262
|
+
const parsed = JSON.parse(json);
|
|
1263
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
1264
|
+
return null;
|
|
1265
|
+
return parsed;
|
|
1266
|
+
}
|
|
1267
|
+
catch {
|
|
1268
|
+
return null;
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
/**
|
|
1272
|
+
* Parse and whitelist an inbound input message. Pure — the lease gate and the
|
|
1273
|
+
* tmux-side guards stay in the caller, so this is testable on its own.
|
|
1274
|
+
*/
|
|
1275
|
+
const validateInputMessage = (msg) => {
|
|
1276
|
+
const { sessionName, seq, epoch } = msg;
|
|
1277
|
+
if (typeof sessionName !== 'string' || !sessionName)
|
|
1278
|
+
return { ok: false, reason: 'no sessionName' };
|
|
1279
|
+
// This function reads plaintext `keys`/`body` and nothing else, so it must
|
|
1280
|
+
// never see a message that still carries an envelope: the encrypted path
|
|
1281
|
+
// opens one and hands the decrypted fields back through here with the
|
|
1282
|
+
// envelope stripped. A message arriving with both would otherwise get the
|
|
1283
|
+
// plaintext half injected while its sender believed the sealed half was
|
|
1284
|
+
// what landed. Fail closed instead.
|
|
1285
|
+
if (msg.encrypted !== undefined)
|
|
1286
|
+
return { ok: false, reason: 'envelope reached the plaintext validator' };
|
|
1287
|
+
if (!isSeqNumber(seq) || !isSeqNumber(epoch))
|
|
1288
|
+
return { ok: false, reason: 'missing seq/epoch' };
|
|
1289
|
+
const base = {
|
|
1290
|
+
sessionName,
|
|
1291
|
+
seq,
|
|
1292
|
+
epoch,
|
|
1293
|
+
...(typeof msg.deviceId === 'string' && msg.deviceId ? { deviceId: msg.deviceId } : {}),
|
|
1294
|
+
};
|
|
1295
|
+
if (msg.keys !== undefined) {
|
|
1296
|
+
if (!isKeyList(msg.keys))
|
|
1297
|
+
return { ok: false, reason: 'malformed keys' };
|
|
1298
|
+
if (msg.keys.length > exports.MAX_INPUT_KEYS)
|
|
1299
|
+
return { ok: false, reason: `too many keys (${msg.keys.length})` };
|
|
1300
|
+
if (msg.body !== undefined)
|
|
1301
|
+
return { ok: false, reason: 'keys and body are mutually exclusive' };
|
|
1302
|
+
const tokens = (0, exports.resolveKeys)(msg.keys);
|
|
1303
|
+
if (!tokens)
|
|
1304
|
+
return { ok: false, reason: `unknown key(s) [${msg.keys.join(' ')}]` };
|
|
1305
|
+
return { ok: true, input: { ...base, tokens, text: null } };
|
|
1306
|
+
}
|
|
1307
|
+
if (typeof msg.body !== 'string' || !msg.body)
|
|
1308
|
+
return { ok: false, reason: 'empty input' };
|
|
1309
|
+
if (msg.body.length > exports.MAX_INPUT_BODY_CHARS)
|
|
1310
|
+
return { ok: false, reason: `body too long (${msg.body.length})` };
|
|
1311
|
+
return { ok: true, input: { ...base, tokens: null, text: msg.body } };
|
|
1312
|
+
};
|
|
1313
|
+
exports.validateInputMessage = validateInputMessage;
|
|
1314
|
+
/**
|
|
1315
|
+
* Type one ordered message into the pane. Unlike the REST key path this
|
|
1316
|
+
* schedules no follow-up snapshot: the message only got this far because a
|
|
1317
|
+
* live stream is already repainting the pane at frame rate.
|
|
1318
|
+
*/
|
|
1319
|
+
const deliverInput = (input) => {
|
|
1320
|
+
// The reorder hold can outlive the lease that admitted the message.
|
|
1321
|
+
const injected = activeStreams.has(input.sessionName)
|
|
1322
|
+
&& (input.tokens
|
|
1323
|
+
? tryInjectKeys(input.sessionName, input.tokens, {})
|
|
1324
|
+
: tryInject(input.sessionName, input.text ?? '', {}));
|
|
1325
|
+
// Every refusal reachable from here — dead lease, shell pane, rate limit,
|
|
1326
|
+
// a failed send-keys — used to be silent, which contradicts the contract
|
|
1327
|
+
// above: the sender would keep waiting on a keystroke that never lands
|
|
1328
|
+
// instead of falling back to the REST push path.
|
|
1329
|
+
if (!injected)
|
|
1330
|
+
input.send(streamErrorFrame(input.sessionName, 'input_rejected', input));
|
|
1331
|
+
};
|
|
1332
|
+
/** Ceiling on concurrent sender lanes. The lane key includes a relay-stamped
|
|
1333
|
+
* deviceId, but a relay older than the unconditional stamp lets a sender vary
|
|
1334
|
+
* it per message — without a cap that grows the map for the lease's whole
|
|
1335
|
+
* life. Real senders are one per device; 16 is generous. */
|
|
1336
|
+
exports.MAX_INPUT_LANES = 16;
|
|
1337
|
+
const inputSequencerFor = (key) => {
|
|
1338
|
+
const existing = inputSequencers.get(key);
|
|
1339
|
+
if (existing)
|
|
1340
|
+
return existing;
|
|
1341
|
+
if (inputSequencers.size >= exports.MAX_INPUT_LANES)
|
|
1342
|
+
return null;
|
|
1343
|
+
const created = (0, input_sequencer_js_1.createInputSequencer)(deliverInput, {
|
|
1344
|
+
// Held keys swept out by an epoch change or stream stop are keystrokes
|
|
1345
|
+
// their sender still waits on — refuse them so it can fall back.
|
|
1346
|
+
onDiscard: (input) => input.send(streamErrorFrame(input.sessionName, 'input_rejected', input)),
|
|
1347
|
+
});
|
|
1348
|
+
inputSequencers.set(key, created);
|
|
1349
|
+
return created;
|
|
1350
|
+
};
|
|
1351
|
+
/** Hand a validated injection to its sender's ordering lane. Shared by both
|
|
1352
|
+
* inbound paths — plaintext and decrypted input order against each other. */
|
|
1353
|
+
const enqueueInput = (input, send) => {
|
|
1354
|
+
// One ordering run per sender, not per session: two devices typing into
|
|
1355
|
+
// the same pane each carry their own seq/epoch, and a shared sequencer
|
|
1356
|
+
// would let the higher epoch supersede the other and silence it.
|
|
1357
|
+
const senderKey = input.deviceId ? `${input.sessionName}#${input.deviceId}` : input.sessionName;
|
|
1358
|
+
const sequencer = inputSequencerFor(senderKey);
|
|
1359
|
+
if (!sequencer) {
|
|
1360
|
+
log(`! input ${input.sessionName}: sender lane cap reached — drop`);
|
|
1361
|
+
send(streamErrorFrame(input.sessionName, 'input_rejected', input));
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
const accepted = sequencer.accept({ ...input, send });
|
|
1365
|
+
if (accepted !== 'ok') {
|
|
1366
|
+
// overflow / superseded / stale — the message was dropped, and the
|
|
1367
|
+
// sender must hear so instead of waiting on a keystroke that never
|
|
1368
|
+
// lands (the sequencer reports swept HELD messages via onDiscard).
|
|
1369
|
+
log(`! input ${input.sessionName}: ${accepted} — drop`);
|
|
1370
|
+
send(streamErrorFrame(input.sessionName, 'input_rejected', input));
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
/**
|
|
1374
|
+
* Tail of the encrypted-input decrypt chain.
|
|
1375
|
+
*
|
|
1376
|
+
* Encrypted inputs are opened one at a time rather than concurrently. That
|
|
1377
|
+
* bounds the ECDH work one socket can have in flight, and it keeps arrival
|
|
1378
|
+
* order into the sequencer — two keystrokes whose decrypts race would
|
|
1379
|
+
* otherwise reach it in whichever order WebCrypto finished, turning an
|
|
1380
|
+
* in-order pair into a gap and a 500 ms hold.
|
|
1381
|
+
*/
|
|
1382
|
+
let inputDecryptChain = Promise.resolve();
|
|
1383
|
+
let inputDecryptDepth = 0;
|
|
1384
|
+
/** Ceiling on queued decrypts. The subscriber-key binding is a string compare
|
|
1385
|
+
* against a public key the relay fanned out to every same-account connection,
|
|
1386
|
+
* so it gates WHO can enqueue an ECDH derive, not how many — a flooding
|
|
1387
|
+
* client would otherwise grow the chain without bound. Mirrors
|
|
1388
|
+
* MAX_PENDING_INPUTS' role on the plaintext side. */
|
|
1389
|
+
exports.MAX_PENDING_DECRYPTS = 32;
|
|
1390
|
+
/** Resolves once every encrypted input accepted so far has been routed. */
|
|
1391
|
+
const pendingInputDecrypts = () => inputDecryptChain;
|
|
1392
|
+
exports.pendingInputDecrypts = pendingInputDecrypts;
|
|
1393
|
+
/**
|
|
1394
|
+
* Open an E2EE input envelope and route what was inside it.
|
|
1395
|
+
*
|
|
1396
|
+
* Every cheap guard runs synchronously, before any crypto: an envelope from
|
|
1397
|
+
* anyone but the subscriber costs a string compare, not an ECDH derive. What
|
|
1398
|
+
* comes out of the decrypt is then re-validated by exactly the checks the
|
|
1399
|
+
* plaintext path runs — the envelope hides the payload from the relay, never
|
|
1400
|
+
* from the whitelist or the caps.
|
|
1401
|
+
*/
|
|
1402
|
+
const handleEncryptedInput = (msg, send) => {
|
|
1403
|
+
const { sessionName } = msg;
|
|
1404
|
+
const refuse = (reason) => {
|
|
1405
|
+
log(`! input ${sessionName ?? '(no session)'}: ${reason} — drop`);
|
|
1406
|
+
// No sessionName means the sender can't match the error to anything.
|
|
1407
|
+
// The reason never rides along: it is derived from plaintext the relay
|
|
1408
|
+
// must not learn, so it stays in this host's log.
|
|
1409
|
+
if (typeof sessionName === 'string' && sessionName) {
|
|
1410
|
+
send(streamErrorFrame(sessionName, 'input_rejected', msg));
|
|
1411
|
+
}
|
|
1412
|
+
};
|
|
1413
|
+
if (typeof sessionName !== 'string' || !sessionName)
|
|
1414
|
+
return refuse('no sessionName');
|
|
1415
|
+
const stream = activeStreams.get(sessionName);
|
|
1416
|
+
if (!stream)
|
|
1417
|
+
return refuse('encrypted input with no live stream');
|
|
1418
|
+
// A stream that handshook no subscriber key has no key to bind this
|
|
1419
|
+
// envelope against — and its own frames go out in the clear, so there is
|
|
1420
|
+
// nothing here worth protecting and no way to prove who sent it.
|
|
1421
|
+
const { subscriberPublicKey } = stream;
|
|
1422
|
+
if (!subscriberPublicKey)
|
|
1423
|
+
return refuse('encrypted input on a plaintext stream');
|
|
1424
|
+
if (!isInputEnvelope(msg.encrypted))
|
|
1425
|
+
return refuse('malformed encrypted envelope');
|
|
1426
|
+
// THE binding. Opening an envelope proves only that its sender holds some
|
|
1427
|
+
// private key; it is this comparison that makes it the key the subscriber
|
|
1428
|
+
// handshook with, and so makes ephemeral input exclusive to the device
|
|
1429
|
+
// actually watching the pane. Without it, anyone who learned this host's
|
|
1430
|
+
// public key could type into it.
|
|
1431
|
+
const envelope = msg.encrypted;
|
|
1432
|
+
if (envelope.senderPublicKey !== subscriberPublicKey) {
|
|
1433
|
+
return refuse('envelope not sealed by the stream subscriber');
|
|
1434
|
+
}
|
|
1435
|
+
// The incarnation this message was admitted under. A stream can stop and
|
|
1436
|
+
// restart — with a different subscriber key — while the decrypt is in
|
|
1437
|
+
// flight, and the lease check inside deliverInput only asks whether SOME
|
|
1438
|
+
// stream of this name exists.
|
|
1439
|
+
const incarnation = stream.stats;
|
|
1440
|
+
if (inputDecryptDepth >= exports.MAX_PENDING_DECRYPTS) {
|
|
1441
|
+
return refuse('decrypt queue full');
|
|
1442
|
+
}
|
|
1443
|
+
// The binding compares against a public key the relay has seen, so it says
|
|
1444
|
+
// who MAY enqueue an ECDH derive, not that they can produce an openable
|
|
1445
|
+
// envelope. Sustained garbage would otherwise spend the one shared decrypt
|
|
1446
|
+
// chain on every stream at once. Same fail-closed shape as the outbound
|
|
1447
|
+
// half (STREAM_MAX_ENCRYPT_FAILURES): after a few consecutive failures this
|
|
1448
|
+
// stream takes no more sealed input until it is re-subscribed.
|
|
1449
|
+
if (stream.inputDecryptFailures >= STREAM_MAX_ENCRYPT_FAILURES) {
|
|
1450
|
+
return refuse('too many failed decrypts on this stream');
|
|
1451
|
+
}
|
|
1452
|
+
inputDecryptDepth++;
|
|
1453
|
+
inputDecryptChain = inputDecryptChain.then(async () => {
|
|
1454
|
+
inputDecryptDepth--;
|
|
1455
|
+
let opened;
|
|
1456
|
+
try {
|
|
1457
|
+
opened = await (0, crypto_js_1.decryptEphemeral)(envelope);
|
|
1458
|
+
}
|
|
1459
|
+
catch (err) {
|
|
1460
|
+
// AES-GCM is authenticated, so this is a forged, truncated or
|
|
1461
|
+
// misaddressed envelope — never a partially readable one.
|
|
1462
|
+
const live = activeStreams.get(sessionName);
|
|
1463
|
+
if (live?.stats === incarnation)
|
|
1464
|
+
live.inputDecryptFailures++;
|
|
1465
|
+
return refuse(`decrypt failed (${err instanceof Error ? err.message : err})`);
|
|
1466
|
+
}
|
|
1467
|
+
// A real envelope clears the strikes: the cap is there to stop a flood,
|
|
1468
|
+
// not to retire a stream that saw one corrupted message.
|
|
1469
|
+
const live = activeStreams.get(sessionName);
|
|
1470
|
+
if (live?.stats === incarnation)
|
|
1471
|
+
live.inputDecryptFailures = 0;
|
|
1472
|
+
if (activeStreams.get(sessionName)?.stats !== incarnation) {
|
|
1473
|
+
return refuse('stream replaced while decrypting');
|
|
1474
|
+
}
|
|
1475
|
+
const payload = parseSealedInput(opened);
|
|
1476
|
+
if (!payload)
|
|
1477
|
+
return refuse('sealed payload is not an input object');
|
|
1478
|
+
// Replay binding: the ciphertext must vouch for the plaintext stamps.
|
|
1479
|
+
// AES-GCM stops the relay from forging content, but without this check
|
|
1480
|
+
// it could replay a captured envelope under a fresh seq and type a
|
|
1481
|
+
// real keystroke twice — the relay is exactly the party E2EE distrusts.
|
|
1482
|
+
if (payload.seq !== msg.seq || payload.epoch !== msg.epoch || payload.sessionName !== sessionName) {
|
|
1483
|
+
return refuse('sealed stamps do not match the plaintext ones (replay?)');
|
|
1484
|
+
}
|
|
1485
|
+
const checked = (0, exports.validateInputMessage)({
|
|
1486
|
+
sessionName,
|
|
1487
|
+
seq: msg.seq,
|
|
1488
|
+
epoch: msg.epoch,
|
|
1489
|
+
deviceId: msg.deviceId,
|
|
1490
|
+
keys: payload.keys,
|
|
1491
|
+
body: payload.body,
|
|
1492
|
+
});
|
|
1493
|
+
if (!checked.ok)
|
|
1494
|
+
return refuse(checked.reason);
|
|
1495
|
+
enqueueInput(checked.input, send);
|
|
1496
|
+
}).catch((err) => {
|
|
1497
|
+
// Nothing above should throw outside the decrypt, but one broken link
|
|
1498
|
+
// must not stall every keystroke queued behind it.
|
|
1499
|
+
log(`! input ${sessionName}: encrypted routing failed (${err instanceof Error ? err.message : err})`);
|
|
1500
|
+
});
|
|
1501
|
+
};
|
|
1502
|
+
/**
|
|
1503
|
+
* Handle agent.command.input. Returns true when the message was ours to
|
|
1504
|
+
* answer, so the caller stops routing it. Encrypted messages finish
|
|
1505
|
+
* asynchronously — the return value reports routing, not delivery, exactly as
|
|
1506
|
+
* it already did for a message the sequencer holds.
|
|
1507
|
+
*/
|
|
1508
|
+
const handleCommandInput = (msg, send) => {
|
|
1509
|
+
if (msg.subtype !== 'agent.command.input')
|
|
1510
|
+
return false;
|
|
1511
|
+
// Addressing gate, as on stream control: the relay fans every ephemeral
|
|
1512
|
+
// message out to all of this user's connections, and two machines can run
|
|
1513
|
+
// the same tmux session name — an unaddressed inject would type into both.
|
|
1514
|
+
if (msg.targetDeviceId !== (0, exports.computeListenerDeviceId)())
|
|
1515
|
+
return false;
|
|
1516
|
+
// An envelope decides the message on its own. Branching here rather than
|
|
1517
|
+
// inside the validator is what keeps the plaintext `keys`/`body` a relay
|
|
1518
|
+
// could staple alongside it out of reach.
|
|
1519
|
+
if (msg.encrypted !== undefined) {
|
|
1520
|
+
handleEncryptedInput(msg, send);
|
|
1521
|
+
return true;
|
|
1522
|
+
}
|
|
1523
|
+
const checked = (0, exports.validateInputMessage)(msg);
|
|
1524
|
+
if (!checked.ok) {
|
|
1525
|
+
log(`! input ${msg.sessionName ?? '(no session)'}: ${checked.reason} — drop`);
|
|
1526
|
+
// No sessionName means the sender can't match the error to anything.
|
|
1527
|
+
if (typeof msg.sessionName === 'string' && msg.sessionName) {
|
|
1528
|
+
send(streamErrorFrame(msg.sessionName, 'input_rejected', msg));
|
|
1529
|
+
}
|
|
1530
|
+
return true;
|
|
1531
|
+
}
|
|
1532
|
+
const { input } = checked;
|
|
1533
|
+
// Input rides the stream lease: without one, nobody is watching the pane
|
|
1534
|
+
// this would type into, and seq/epoch have no incarnation to order
|
|
1535
|
+
// against. The sender learns in one hop and falls back to a REST push.
|
|
1536
|
+
const stream = activeStreams.get(input.sessionName);
|
|
1537
|
+
if (!stream) {
|
|
1538
|
+
log(`! input ${input.sessionName}: no live stream — drop`);
|
|
1539
|
+
send(streamErrorFrame(input.sessionName, 'input_rejected', input));
|
|
1540
|
+
return true;
|
|
1541
|
+
}
|
|
1542
|
+
// This stream's outbound half is E2EE for that subscriber, so accepting a
|
|
1543
|
+
// plaintext keystroke would leave the inbound leg the only one in clear —
|
|
1544
|
+
// and nothing about it proves the subscriber sent it. An encrypted stream
|
|
1545
|
+
// takes sealed input (above) or none.
|
|
1546
|
+
if (stream.subscriberPublicKey) {
|
|
1547
|
+
log(`! input ${input.sessionName}: stream is E2EE, plaintext input refused — drop`);
|
|
1548
|
+
send(streamErrorFrame(input.sessionName, 'input_rejected', input));
|
|
1549
|
+
return true;
|
|
1550
|
+
}
|
|
1551
|
+
enqueueInput(input, send);
|
|
1552
|
+
return true;
|
|
1553
|
+
};
|
|
1554
|
+
exports.handleCommandInput = handleCommandInput;
|
|
1056
1555
|
/**
|
|
1057
1556
|
* Inventory pass that also records *why* each `zeph-*` session was
|
|
1058
1557
|
* skipped. The verbose log uses the rejection notes to explain empty
|
|
@@ -1141,6 +1640,13 @@ exports.collectSessions = collectSessions;
|
|
|
1141
1640
|
* prefix path route through here so the defense layers can't diverge.
|
|
1142
1641
|
*/
|
|
1143
1642
|
const passesInjectGuards = (session, deps) => {
|
|
1643
|
+
// Rate bucket first: the pane probe below is a blocking tmux spawnSync,
|
|
1644
|
+
// and the sequencer can flush several held messages back-to-back — an
|
|
1645
|
+
// empty bucket must refuse before paying that probe N times, not after.
|
|
1646
|
+
if (!(deps.rateLimit ?? exports.checkRateLimit)(session)) {
|
|
1647
|
+
log(`! ${session}: rate-limited — drop`);
|
|
1648
|
+
return false;
|
|
1649
|
+
}
|
|
1144
1650
|
const cmd = (deps.paneCommand ?? exports.paneCurrentCommand)(session);
|
|
1145
1651
|
if (cmd === null) {
|
|
1146
1652
|
log(`! ${session}: no such tmux session — drop`);
|
|
@@ -1150,10 +1656,6 @@ const passesInjectGuards = (session, deps) => {
|
|
|
1150
1656
|
log(`! ${session}: pane is at shell (${cmd}) — refusing (would be RCE)`);
|
|
1151
1657
|
return false;
|
|
1152
1658
|
}
|
|
1153
|
-
if (!(deps.rateLimit ?? exports.checkRateLimit)(session)) {
|
|
1154
|
-
log(`! ${session}: rate-limited — drop`);
|
|
1155
|
-
return false;
|
|
1156
|
-
}
|
|
1157
1659
|
return true;
|
|
1158
1660
|
};
|
|
1159
1661
|
/**
|
|
@@ -1190,6 +1692,7 @@ const tryInject = (session, text, deps) => {
|
|
|
1190
1692
|
const preview = text.length > 60 ? text.slice(0, 60) + '…' : text;
|
|
1191
1693
|
log(`${ok ? '→' : '✗'} ${session}: ${preview}`);
|
|
1192
1694
|
if (ok) {
|
|
1695
|
+
noteStreamInput(session);
|
|
1193
1696
|
const cwd = (deps.paneCwd ?? defaultPaneCwd)(session);
|
|
1194
1697
|
if (cwd)
|
|
1195
1698
|
(0, exports.writeRemoteMarker)(cwd, text);
|
|
@@ -1202,6 +1705,8 @@ const tryInjectKeys = (session, tokens, deps) => {
|
|
|
1202
1705
|
if (!passesInjectGuards(session, deps))
|
|
1203
1706
|
return false;
|
|
1204
1707
|
const ok = (deps.sendKeys ?? injectNamedKeys)(session, tokens);
|
|
1708
|
+
if (ok)
|
|
1709
|
+
noteStreamInput(session);
|
|
1205
1710
|
log(`${ok ? '⌨' : '✗'} ${session}: [${tokens.join(' ')}]`);
|
|
1206
1711
|
return ok;
|
|
1207
1712
|
};
|
|
@@ -1674,7 +2179,11 @@ const streamSession = (wsUrl, apiKey) => {
|
|
|
1674
2179
|
// Live mirror (PoC): agent.stream.start/stop drives a
|
|
1675
2180
|
// continuous, diff-gated frame loop; falls through to the
|
|
1676
2181
|
// one-shot screen-peek when it isn't a stream-control message.
|
|
1677
|
-
|
|
2182
|
+
// agent.command.input types into a streamed pane without the
|
|
2183
|
+
// REST round-trip; it is only accepted while that stream's
|
|
2184
|
+
// lease is live, so it sits behind the same routing chain.
|
|
2185
|
+
if (!(0, exports.handleCommandInput)(m.data, sendEphemeral) &&
|
|
2186
|
+
!(0, exports.handleStreamControl)(m.data, sendEphemeral)) {
|
|
1678
2187
|
const reply = (0, exports.handleScreenRequest)(m.data);
|
|
1679
2188
|
if (reply)
|
|
1680
2189
|
sendEphemeral(reply);
|