@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 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
@@ -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;AAkDD;;;;;;;;;;;;;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"}
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;
@@ -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`. */
@@ -1 +1 @@
1
- {"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"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"}
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
- const ALLOWED_KEYS = {
163
- escape: 'Escape',
164
- up: 'Up',
165
- down: 'Down',
166
- left: 'Left',
167
- right: 'Right',
168
- enter: 'Enter',
169
- tab: 'Tab',
170
- backtab: 'BTab',
171
- backspace: 'BSpace',
172
- delete: 'DC',
173
- space: 'Space',
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[k.toLowerCase().trim()];
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 are the ONLY bound on API Gateway
762
- // WS cost (the $default route has no per-message limit), so keep it modest
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
- const STREAM_INTERVAL_MS = 400;
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 fps = (stats.frames - stats.lastLogFrames) / secs;
867
+ const frames = stats.frames - stats.lastLogFrames;
868
+ const fps = frames / secs;
814
869
  const kbps = (stats.bytes - stats.lastLogBytes) / 1024 / secs;
815
- log(`⧉ stream ${sessionName}: ${fps.toFixed(1)} fps, ${kbps.toFixed(1)} KB/s (${stats.frames} sent, ${stats.skipped} diff-skipped)`);
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
- clearInterval(entry.timer);
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} diff-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
- subtype: 'agent.stream.frame',
869
- sessionName,
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 timer = setInterval(() => {
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() >= (activeStreams.get(sessionName)?.expiresAt ?? 0)) {
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 400ms for 5 minutes.
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
- stats.bytes += Buffer.byteLength(JSON.stringify(frame), 'utf-8');
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
- }, STREAM_INTERVAL_MS);
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
- if (!(0, exports.handleStreamControl)(m.data, sendEphemeral)) {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeph-to/cli",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Zeph CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",