@zeph-to/cli 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.hasFrameBudget = 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);
@@ -740,6 +744,13 @@ const pendingInjectSnapshots = new Map();
740
744
  * captures so a burst of arrow presses yields frames after the LAST press.
741
745
  */
742
746
  const scheduleInjectSnapshots = (sessionName, send, delays = INJECT_SNAPSHOT_DELAYS_MS) => {
747
+ // These snapshots exist to show the phone what a keystroke did when nobody
748
+ // is mirroring the pane. A live stream already repaints it at the burst
749
+ // cadence, so scheduling them there is two extra blocking captures and two
750
+ // extra sends per keystroke — outside the stream's own send budget, which
751
+ // is exactly the cost the budget exists to bound.
752
+ if (activeStreams.has(sessionName))
753
+ return;
743
754
  for (const t of pendingInjectSnapshots.get(sessionName) ?? [])
744
755
  clearTimeout(t);
745
756
  let lastContent = null;
@@ -758,14 +769,63 @@ const scheduleInjectSnapshots = (sessionName, send, delays = INJECT_SNAPSHOT_DEL
758
769
  pendingInjectSnapshots.set(sessionName, timers);
759
770
  };
760
771
  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.
772
+ // ~2.5 fps idle ceiling. Cadence + diff-gating + the per-second send budget
773
+ // are the ONLY bound on API Gateway WS cost (the $default route has no
774
+ // per-message limit), so keep it modest and let unchanged frames drop.
764
775
  // How far back the live-stream capture reaches — more than the screen-peek
765
776
  // window so the mirror has scrollback to scroll through. SCREEN_PEEK_MAX_BYTES
766
777
  // still caps the actual payload, so very wide panes get top-truncated.
767
778
  const STREAM_CAPTURE_LINES = 200;
768
- const STREAM_INTERVAL_MS = 400;
779
+ exports.STREAM_INTERVAL_MS = 400;
780
+ // Burst cadence: right after a keystroke lands, the pane IS what the user is
781
+ // staring at, so captures tighten for BURST_WINDOW_MS and then fall back to
782
+ // the idle cadence. The window is what keeps this affordable — one keypress
783
+ // costs a bounded number of extra frames instead of raising the steady rate.
784
+ exports.BURST_INTERVAL_MS = 120;
785
+ exports.BURST_WINDOW_MS = 2_500;
786
+ // Cost bound the burst must not break: MAX_CONCURRENT_STREAMS × 8 = 24 msg/s
787
+ // worst case, under half of the API Gateway WS stage throttle (50 rps) that is
788
+ // SHARED across every user, so a bursting host still leaves headroom for push
789
+ // delivery and presence. At BURST_INTERVAL_MS the chain tops out at 8.3 fps,
790
+ // so this budget bites only in the last fraction of a fully bursting second —
791
+ // it is what holds the line if the burst cadence is ever tightened further.
792
+ exports.MAX_FRAMES_PER_SEC = 8;
793
+ /**
794
+ * Delay before the next capture: burst while the last input is still echoing,
795
+ * idle otherwise. Exclusive at the boundary — exactly BURST_WINDOW_MS after
796
+ * the input is already idle, so the window can't stretch. A lastInputAt in the
797
+ * future (wall-clock jump) reads as fresh input rather than as an expired
798
+ * window: bursting is the recoverable side of that.
799
+ */
800
+ const streamCadence = (lastInputAt, now) => lastInputAt !== null && now - lastInputAt < exports.BURST_WINDOW_MS ? exports.BURST_INTERVAL_MS : exports.STREAM_INTERVAL_MS;
801
+ exports.streamCadence = streamCadence;
802
+ /**
803
+ * Claim one send from the budget, rolling the window over when the second has
804
+ * passed. False means this tick must skip WITHOUT marking the frame as sent —
805
+ * the diff-gate would otherwise swallow that content for good.
806
+ */
807
+ /** Is there budget left this second? A peek, so a tick can decline to pay for a
808
+ * blocking capture it could not send anyway — spending the token here instead
809
+ * would burn budget on ticks the diff-gate goes on to skip. */
810
+ const hasFrameBudget = (budget, now) => now - budget.windowStartedAt >= 1_000 || now < budget.windowStartedAt
811
+ ? true
812
+ : budget.sent < exports.MAX_FRAMES_PER_SEC;
813
+ exports.hasFrameBudget = hasFrameBudget;
814
+ const claimFrameSend = (budget, now) => {
815
+ // `now < windowStartedAt` = the clock ran backwards (NTP step, resume).
816
+ // Without the guard the window never rolls again and a spent budget
817
+ // freezes the mirror for the whole regression — same failure streamCadence
818
+ // already defends against, so the two must agree.
819
+ if (now - budget.windowStartedAt >= 1_000 || now < budget.windowStartedAt) {
820
+ budget.windowStartedAt = now;
821
+ budget.sent = 0;
822
+ }
823
+ if (budget.sent >= exports.MAX_FRAMES_PER_SEC)
824
+ return false;
825
+ budget.sent++;
826
+ return true;
827
+ };
828
+ exports.claimFrameSend = claimFrameSend;
769
829
  // Orphan guard for subscribers that can't renew (clients older than the renew
770
830
  // protocol): a phone that dies without sending agent.stream.stop must not leak
771
831
  // an interval forever. Auto-stop after this long; the phone re-subscribes on
@@ -805,29 +865,69 @@ const STREAM_LOG_INTERVAL_MS = 5_000;
805
865
  /** How far a start (and each renew) pushes the deadline out. */
806
866
  const leaseFor = (renewing) => (renewing ? exports.STREAM_LEASE_MS : STREAM_MAX_MS);
807
867
  const activeStreams = new Map();
868
+ /** Inbound key ordering, one per (streamed session, sender device) — see the
869
+ * `agent.command.input` section below. Lives here so stopStream can drop them
870
+ * with the lease they belong to. Keyed `<session>#<deviceId>`, or the bare
871
+ * session name from a relay too old to stamp the sender. */
872
+ const inputSequencers = new Map();
873
+ /** Frames per second for one cadence phase. A phase that got no wall time in
874
+ * the window (a stream that never bursted) reads 0.0, never NaN. */
875
+ const phaseFps = (frames, ms) => (ms > 0 ? (frames / (ms / 1000)).toFixed(1) : '0.0');
808
876
  const maybeLogStreamStats = (sessionName, stats) => {
809
877
  const elapsed = Date.now() - stats.lastLogAt;
810
878
  if (elapsed < STREAM_LOG_INTERVAL_MS)
811
879
  return;
812
880
  const secs = elapsed / 1000;
813
- const fps = (stats.frames - stats.lastLogFrames) / secs;
881
+ const frames = stats.frames - stats.lastLogFrames;
882
+ const fps = frames / secs;
814
883
  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)`);
884
+ // burstMs is charged when a burst tick is armed, so it can overshoot the
885
+ // window by at most one interval — phaseFps clamps the idle remainder.
886
+ const burstMs = stats.burstMs - stats.lastLogBurstMs;
887
+ const burstFrames = stats.framesBurst - stats.lastLogFramesBurst;
888
+ log(`⧉ stream ${sessionName}: ${fps.toFixed(1)} fps, ${kbps.toFixed(1)} KB/s ` +
889
+ `(${stats.frames} sent, ${stats.skipped} skipped incl. ${stats.rateCapped} rate-capped) ` +
890
+ `[burst ${phaseFps(burstFrames, burstMs)} fps · idle ${phaseFps(frames - burstFrames, elapsed - burstMs)} fps]`);
816
891
  stats.lastLogAt = Date.now();
817
892
  stats.lastLogFrames = stats.frames;
818
893
  stats.lastLogBytes = stats.bytes;
894
+ stats.lastLogFramesBurst = stats.framesBurst;
895
+ stats.lastLogBurstMs = stats.burstMs;
896
+ };
897
+ /** A keystroke landed in this pane, so the next captures run at the burst
898
+ * cadence and the echo is visible instead of up to STREAM_INTERVAL_MS late.
899
+ * Called from the shared inject helpers, which is what both the ephemeral
900
+ * (agent.command.input) and the REST (agent.command) paths funnel through. */
901
+ const noteStreamInput = (sessionName) => {
902
+ const entry = activeStreams.get(sessionName);
903
+ if (!entry)
904
+ return;
905
+ entry.lastInputAt = Date.now();
906
+ entry.wake();
819
907
  };
820
908
  const stopStream = (sessionName) => {
821
909
  const entry = activeStreams.get(sessionName);
822
910
  if (!entry)
823
911
  return;
824
- clearInterval(entry.timer);
912
+ clearTimeout(entry.timer);
825
913
  activeStreams.delete(sessionName);
914
+ // The next stream is a new run: a sequencer carrying this one's high-water
915
+ // mark would swallow its first keys if the sender restarts its counter.
916
+ // Every sender that typed into this session holds its own, so drop the
917
+ // whole `<session>#…` family, not just the bare-name key.
918
+ for (const [key, sequencer] of inputSequencers) {
919
+ if (key !== sessionName && !key.startsWith(`${sessionName}#`))
920
+ continue;
921
+ sequencer.reset();
922
+ inputSequencers.delete(key);
923
+ }
826
924
  const { stats } = entry;
827
925
  const secs = Math.max(0.001, (Date.now() - stats.startedAt) / 1000);
828
926
  log(`⧉ stream ${sessionName} stopped: ${stats.frames} frames / ` +
829
927
  `${(stats.bytes / 1024).toFixed(1)} KB over ${secs.toFixed(1)}s ` +
830
- `(${(stats.frames / secs).toFixed(1)} fps avg, ${stats.skipped} diff-skipped)`);
928
+ `(${(stats.frames / secs).toFixed(1)} fps avg, ${stats.skipped} skipped incl. ${stats.rateCapped} rate-capped) ` +
929
+ `[burst ${stats.framesBurst} frames / ${(stats.bytesBurst / 1024).toFixed(1)} KB ` +
930
+ `over ${(stats.burstMs / 1000).toFixed(1)}s = ${phaseFps(stats.framesBurst, stats.burstMs)} fps]`);
831
931
  };
832
932
  exports.stopStream = stopStream;
833
933
  const stopAllStreams = () => {
@@ -864,11 +964,20 @@ const evictStalestStream = (now) => {
864
964
  (0, exports.stopStream)(victim.name);
865
965
  return victim.name;
866
966
  };
867
- const streamErrorFrame = (sessionName, error) => ({
868
- subtype: 'agent.stream.frame',
869
- sessionName,
870
- error,
871
- });
967
+ const streamErrorFrame = (sessionName, error,
968
+ /** The message being refused, when there is one to echo. */
969
+ echo) => {
970
+ const frame = { subtype: 'agent.stream.frame', sessionName, error };
971
+ // A malformed message may carry no stamp at all, or garbage — omitting the
972
+ // field beats echoing something the sender can't match.
973
+ if (isSeqNumber(echo?.seq))
974
+ frame.seq = echo.seq;
975
+ if (isSeqNumber(echo?.epoch))
976
+ frame.epoch = echo.epoch;
977
+ if (typeof echo?.deviceId === 'string' && echo.deviceId)
978
+ frame.inputDeviceId = echo.deviceId;
979
+ return frame;
980
+ };
872
981
  /**
873
982
  * Build the wire payload for one stream frame. With a subscriber public key
874
983
  * the pane content rides ONLY inside the E2EE envelope; an encrypt failure
@@ -899,6 +1008,14 @@ exports.buildStreamFrame = buildStreamFrame;
899
1008
  * was a stream-control message (so the caller skips the one-shot screen-peek
900
1009
  * path). start is idempotent — a repeat restarts the loop.
901
1010
  */
1011
+ // deep: over the body-length limit on purpose. Everything past the guards is
1012
+ // one capture loop whose state (lastContent, wireSeq, encryptFailures, budget,
1013
+ // and `stats` as the incarnation token) is only correct while it stays private
1014
+ // to a single start. Hoisting it into a factory would turn those five into
1015
+ // parameters and expose the incarnation invariant — the one that keeps an
1016
+ // orphaned chain from capturing forever — to callers that have no reason to
1017
+ // know it exists. Nothing outside needs the internals; deleting this deletes
1018
+ // the live mirror whole.
902
1019
  const handleStreamControl = (req, send) => {
903
1020
  if (req.subtype !== 'agent.stream.start' && req.subtype !== 'agent.stream.stop' && req.subtype !== 'agent.stream.renew') {
904
1021
  return false;
@@ -955,10 +1072,16 @@ const handleStreamControl = (req, send) => {
955
1072
  startedAt: Date.now(),
956
1073
  frames: 0,
957
1074
  bytes: 0,
1075
+ framesBurst: 0,
1076
+ bytesBurst: 0,
1077
+ burstMs: 0,
958
1078
  skipped: 0,
1079
+ rateCapped: 0,
959
1080
  lastLogAt: Date.now(),
960
1081
  lastLogFrames: 0,
961
1082
  lastLogBytes: 0,
1083
+ lastLogFramesBurst: 0,
1084
+ lastLogBurstMs: 0,
962
1085
  };
963
1086
  // E2EE handshake: load-or-create this device's keypair up front. The
964
1087
  // subscriber asked for encryption, so key failure is FAIL-CLOSED: refuse
@@ -979,21 +1102,50 @@ const handleStreamControl = (req, send) => {
979
1102
  }
980
1103
  let wireSeq = 0;
981
1104
  let encryptFailures = 0;
982
- const timer = setInterval(() => {
1105
+ const budget = { windowStartedAt: Date.now(), sent: 0 };
1106
+ /**
1107
+ * One capture. Returns false only when the stream is gone and the chain
1108
+ * must NOT re-arm — every other outcome re-arms at the single call site in
1109
+ * runTick, so no early return can silently freeze the mirror.
1110
+ */
1111
+ const captureTick = (entry) => {
983
1112
  // Lease check rides the capture tick: one deadline field, no second
984
1113
  // timer to leak. (The previous per-start expiry setTimeout was never
985
1114
  // cleared on stop, so a restart left the old one armed to kill the new
986
1115
  // incarnation.) Reaping here is what frees the slot for everyone whose
987
1116
  // stop never arrived.
988
- if (Date.now() >= (activeStreams.get(sessionName)?.expiresAt ?? 0)) {
1117
+ if (Date.now() >= entry.expiresAt) {
989
1118
  (0, exports.stopStream)(sessionName);
990
- return;
1119
+ return false;
1120
+ }
1121
+ // capturePane is a blocking tmux spawn. Asking the budget first means a
1122
+ // second that is already full costs nothing rather than paying for a
1123
+ // capture whose frame could not go out — a peek, not a claim, so a tick
1124
+ // the diff-gate goes on to skip does not spend the token.
1125
+ if (!(0, exports.hasFrameBudget)(budget, Date.now())) {
1126
+ stats.skipped++;
1127
+ stats.rateCapped++;
1128
+ return true;
991
1129
  }
992
1130
  const captured = capturePane(sessionName, true, STREAM_CAPTURE_LINES);
993
1131
  if (!captured || captured.content === lastContent) {
994
1132
  stats.skipped++;
995
- return; // diff-gate
1133
+ return true; // diff-gate
1134
+ }
1135
+ const now = Date.now();
1136
+ // Send budget BEFORE the diff-gate is marked: a frame refused here has
1137
+ // to stay "changed" so a later tick retries it, or this content is
1138
+ // never sent at all — the next tick would diff-skip it as unchanged.
1139
+ if (!(0, exports.claimFrameSend)(budget, now)) {
1140
+ stats.skipped++;
1141
+ stats.rateCapped++;
1142
+ return true;
996
1143
  }
1144
+ // Phase = the gap that PRODUCED this tick (armedDelay), matching how
1145
+ // burstMs is charged in arm() — deciding by wall-clock here instead
1146
+ // made the last burst-armed frame count idle, under-reporting burst
1147
+ // fps by one frame per episode in the R2 numbers.
1148
+ const inBurst = armedDelay === exports.BURST_INTERVAL_MS;
997
1149
  lastContent = captured.content;
998
1150
  // Stamp the sequence in CAPTURE order, synchronously — frame assembly
999
1151
  // is async (encryption) and fire-and-forget, so resolve order is not
@@ -1010,7 +1162,7 @@ const handleStreamControl = (req, send) => {
1010
1162
  // Encrypt failure — frame dropped, diff-gate un-marked so the
1011
1163
  // next tick retries. A key that keeps failing (malformed
1012
1164
  // subscriber key) never recovers: fail closed after a few
1013
- // strikes instead of retrying every 400ms for 5 minutes.
1165
+ // strikes instead of retrying every tick for 5 minutes.
1014
1166
  lastContent = null;
1015
1167
  // Init still in flight (or failed — its own path fail-closes):
1016
1168
  // a not-yet-ready key is not a malformed key, don't strike.
@@ -1029,7 +1181,12 @@ const handleStreamControl = (req, send) => {
1029
1181
  // ~1.4× the plaintext; base fields now count too, so plaintext
1030
1182
  // streams read slightly higher than the pre-E2EE content-only
1031
1183
  // metric) — this feeds the R2 cost instrumentation.
1032
- stats.bytes += Buffer.byteLength(JSON.stringify(frame), 'utf-8');
1184
+ const bytes = Buffer.byteLength(JSON.stringify(frame), 'utf-8');
1185
+ stats.bytes += bytes;
1186
+ if (inBurst) {
1187
+ stats.framesBurst++;
1188
+ stats.bytesBurst += bytes;
1189
+ }
1033
1190
  // seq = capture order; epoch = this stream incarnation, so the
1034
1191
  // receiver's ordering guard resets across daemon-side restarts.
1035
1192
  send({ ...frame, seq, epoch: stats.startedAt });
@@ -1039,7 +1196,46 @@ const handleStreamControl = (req, send) => {
1039
1196
  // from send()/logging — report and let the next tick carry on.
1040
1197
  log(`⧉ stream ${sessionName}: frame send failed (${err instanceof Error ? err.message : err})`);
1041
1198
  });
1042
- }, STREAM_INTERVAL_MS);
1199
+ return true;
1200
+ };
1201
+ /** Arm the next tick. A chain that outlived its incarnation (restart, or a
1202
+ * stop racing this tick) must die here instead of capturing forever into
1203
+ * a stream nobody can cancel — `stats` is the incarnation token. */
1204
+ let armedDelay = exports.STREAM_INTERVAL_MS;
1205
+ const arm = (delay) => {
1206
+ const entry = activeStreams.get(sessionName);
1207
+ if (entry?.stats !== stats)
1208
+ return;
1209
+ if (delay === exports.BURST_INTERVAL_MS)
1210
+ stats.burstMs += delay;
1211
+ armedDelay = delay;
1212
+ const next = setTimeout(runTick, delay);
1213
+ next.unref?.();
1214
+ entry.timer = next;
1215
+ };
1216
+ const runTick = () => {
1217
+ const entry = activeStreams.get(sessionName);
1218
+ if (entry?.stats !== stats)
1219
+ return;
1220
+ if (!captureTick(entry))
1221
+ return;
1222
+ // The cadence is re-read every tick, so input that lands mid-stream
1223
+ // tightens the NEXT gap rather than the current one.
1224
+ arm((0, exports.streamCadence)(entry.lastInputAt, Date.now()));
1225
+ };
1226
+ /** Swap an idle-armed tick for an immediate burst tick (see ActiveStream.wake).
1227
+ * Only when the armed gap is the idle one: replacing an armed burst tick
1228
+ * on every keystroke would push the next capture away indefinitely. */
1229
+ const wake = () => {
1230
+ const entry = activeStreams.get(sessionName);
1231
+ if (entry?.stats !== stats)
1232
+ return;
1233
+ if (armedDelay === exports.BURST_INTERVAL_MS)
1234
+ return;
1235
+ clearTimeout(entry.timer);
1236
+ arm(exports.BURST_INTERVAL_MS);
1237
+ };
1238
+ const timer = setTimeout(runTick, exports.STREAM_INTERVAL_MS);
1043
1239
  timer.unref?.();
1044
1240
  // A renewing subscriber gets the short lease; anything older keeps the
1045
1241
  // 5-minute orphan guard so a version-skewed client isn't cut off mid-view.
@@ -1047,12 +1243,331 @@ const handleStreamControl = (req, send) => {
1047
1243
  activeStreams.set(sessionName, {
1048
1244
  timer,
1049
1245
  stats,
1246
+ lastInputAt: null,
1247
+ inputDecryptFailures: 0,
1050
1248
  expiresAt: Date.now() + leaseFor(renewing),
1051
1249
  renewing,
1250
+ subscriberPublicKey,
1251
+ wake,
1052
1252
  });
1053
1253
  return true;
1054
1254
  };
1055
1255
  exports.handleStreamControl = handleStreamControl;
1256
+ /** Parity with the REST path, which refuses more than MAX_KEYS_PER_COMMAND per
1257
+ * agent.command (zeph apps/server/src/functions/pushes.ts). The lower-latency
1258
+ * door into the same pane must not also be the wider one. */
1259
+ exports.MAX_INPUT_KEYS = 10;
1260
+ /** The REST path caps no body length, so this bound is ours alone: an
1261
+ * unbounded body is one tmux send-keys argv of unbounded size, and what it
1262
+ * lands in is an agent prompt, not a paste buffer. */
1263
+ exports.MAX_INPUT_BODY_CHARS = 4096;
1264
+ // Both fields are relay JSON, so both are attacker-shaped. Number.isFinite
1265
+ // admits 1e21 — which parks the high-water mark past anything a sender can
1266
+ // count back to — and 1.5, which no later integer can ever equal, so every
1267
+ // following key would sit out the hold before being typed out of order.
1268
+ const isSeqNumber = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
1269
+ // A malformed `keys` would throw where it is consumed (resolveKeys iterates
1270
+ // it, tmux args must be strings), and a throw inside the WebSocket message
1271
+ // handler takes the whole daemon down. Type-check before acting.
1272
+ const isKeyList = (v) => Array.isArray(v) && v.length > 0 && v.every((k) => typeof k === 'string');
1273
+ /** Every field of an inbound envelope, all of them relay JSON. A missing or
1274
+ * ill-typed one would reach WebCrypto as a string it cannot Base64-decode;
1275
+ * refusing here keeps the failure a refusal rather than a thrown rejection. */
1276
+ const isInputEnvelope = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
1277
+ && ['ciphertext', 'iv', 'encryptedKey', 'keyIv', 'senderPublicKey']
1278
+ .every((field) => typeof v[field] === 'string');
1279
+ /** The decrypted payload, under the same stance as the outer message: an
1280
+ * unchecked cast that validateInputMessage re-checks field by field. Only the
1281
+ * envelope's authenticity is proven at this point, never its contents —
1282
+ * a subscriber can seal anything, including 400 keys. */
1283
+ const parseSealedInput = (json) => {
1284
+ try {
1285
+ const parsed = JSON.parse(json);
1286
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
1287
+ return null;
1288
+ return parsed;
1289
+ }
1290
+ catch {
1291
+ return null;
1292
+ }
1293
+ };
1294
+ /**
1295
+ * Parse and whitelist an inbound input message. Pure — the lease gate and the
1296
+ * tmux-side guards stay in the caller, so this is testable on its own.
1297
+ */
1298
+ const validateInputMessage = (msg) => {
1299
+ const { sessionName, seq, epoch } = msg;
1300
+ if (typeof sessionName !== 'string' || !sessionName)
1301
+ return { ok: false, reason: 'no sessionName' };
1302
+ // This function reads plaintext `keys`/`body` and nothing else, so it must
1303
+ // never see a message that still carries an envelope: the encrypted path
1304
+ // opens one and hands the decrypted fields back through here with the
1305
+ // envelope stripped. A message arriving with both would otherwise get the
1306
+ // plaintext half injected while its sender believed the sealed half was
1307
+ // what landed. Fail closed instead.
1308
+ if (msg.encrypted !== undefined)
1309
+ return { ok: false, reason: 'envelope reached the plaintext validator' };
1310
+ if (!isSeqNumber(seq) || !isSeqNumber(epoch))
1311
+ return { ok: false, reason: 'missing seq/epoch' };
1312
+ const base = {
1313
+ sessionName,
1314
+ seq,
1315
+ epoch,
1316
+ ...(typeof msg.deviceId === 'string' && msg.deviceId ? { deviceId: msg.deviceId } : {}),
1317
+ };
1318
+ if (msg.keys !== undefined) {
1319
+ if (!isKeyList(msg.keys))
1320
+ return { ok: false, reason: 'malformed keys' };
1321
+ if (msg.keys.length > exports.MAX_INPUT_KEYS)
1322
+ return { ok: false, reason: `too many keys (${msg.keys.length})` };
1323
+ if (msg.body !== undefined)
1324
+ return { ok: false, reason: 'keys and body are mutually exclusive' };
1325
+ const tokens = (0, exports.resolveKeys)(msg.keys);
1326
+ if (!tokens)
1327
+ return { ok: false, reason: `unknown key(s) [${msg.keys.join(' ')}]` };
1328
+ return { ok: true, input: { ...base, tokens, text: null } };
1329
+ }
1330
+ if (typeof msg.body !== 'string' || !msg.body)
1331
+ return { ok: false, reason: 'empty input' };
1332
+ if (msg.body.length > exports.MAX_INPUT_BODY_CHARS)
1333
+ return { ok: false, reason: `body too long (${msg.body.length})` };
1334
+ return { ok: true, input: { ...base, tokens: null, text: msg.body } };
1335
+ };
1336
+ exports.validateInputMessage = validateInputMessage;
1337
+ /**
1338
+ * Type one ordered message into the pane. Unlike the REST key path this
1339
+ * schedules no follow-up snapshot: the message only got this far because a
1340
+ * live stream is already repainting the pane at frame rate.
1341
+ */
1342
+ const deliverInput = (input) => {
1343
+ // The reorder hold can outlive the lease that admitted the message.
1344
+ const injected = activeStreams.has(input.sessionName)
1345
+ && (input.tokens
1346
+ ? tryInjectKeys(input.sessionName, input.tokens, {})
1347
+ : tryInject(input.sessionName, input.text ?? '', {}));
1348
+ // Every refusal reachable from here — dead lease, shell pane, rate limit,
1349
+ // a failed send-keys — used to be silent, which contradicts the contract
1350
+ // above: the sender would keep waiting on a keystroke that never lands
1351
+ // instead of falling back to the REST push path.
1352
+ if (!injected)
1353
+ input.send(streamErrorFrame(input.sessionName, 'input_rejected', input));
1354
+ };
1355
+ /** Ceiling on concurrent sender lanes. The lane key includes a relay-stamped
1356
+ * deviceId, but a relay older than the unconditional stamp lets a sender vary
1357
+ * it per message — without a cap that grows the map for the lease's whole
1358
+ * life. Real senders are one per device; 16 is generous. */
1359
+ exports.MAX_INPUT_LANES = 16;
1360
+ const inputSequencerFor = (key) => {
1361
+ const existing = inputSequencers.get(key);
1362
+ if (existing)
1363
+ return existing;
1364
+ if (inputSequencers.size >= exports.MAX_INPUT_LANES)
1365
+ return null;
1366
+ const created = (0, input_sequencer_js_1.createInputSequencer)(deliverInput, {
1367
+ // Held keys swept out by an epoch change or stream stop are keystrokes
1368
+ // their sender still waits on — refuse them so it can fall back.
1369
+ onDiscard: (input) => input.send(streamErrorFrame(input.sessionName, 'input_rejected', input)),
1370
+ });
1371
+ inputSequencers.set(key, created);
1372
+ return created;
1373
+ };
1374
+ /** Hand a validated injection to its sender's ordering lane. Shared by both
1375
+ * inbound paths — plaintext and decrypted input order against each other. */
1376
+ const enqueueInput = (input, send) => {
1377
+ // One ordering run per sender, not per session: two devices typing into
1378
+ // the same pane each carry their own seq/epoch, and a shared sequencer
1379
+ // would let the higher epoch supersede the other and silence it.
1380
+ const senderKey = input.deviceId ? `${input.sessionName}#${input.deviceId}` : input.sessionName;
1381
+ const sequencer = inputSequencerFor(senderKey);
1382
+ if (!sequencer)
1383
+ return refuseInput(input, send, 'sender lane cap reached');
1384
+ const accepted = sequencer.accept({ ...input, send });
1385
+ if (accepted !== 'ok') {
1386
+ // overflow / superseded / stale — the message was dropped, and the
1387
+ // sender must hear so instead of waiting on a keystroke that never
1388
+ // lands (the sequencer reports swept HELD messages via onDiscard).
1389
+ refuseInput(input, send, accepted);
1390
+ }
1391
+ };
1392
+ /**
1393
+ * Tail of the encrypted-input decrypt chain.
1394
+ *
1395
+ * Encrypted inputs are opened one at a time rather than concurrently. That
1396
+ * bounds the ECDH work one socket can have in flight, and it keeps arrival
1397
+ * order into the sequencer — two keystrokes whose decrypts race would
1398
+ * otherwise reach it in whichever order WebCrypto finished, turning an
1399
+ * in-order pair into a gap and a 500 ms hold.
1400
+ */
1401
+ let inputDecryptChain = Promise.resolve();
1402
+ let inputDecryptDepth = 0;
1403
+ /** Ceiling on queued decrypts. The subscriber-key binding is a string compare
1404
+ * against a public key the relay fanned out to every same-account connection,
1405
+ * so it gates WHO can enqueue an ECDH derive, not how many — a flooding
1406
+ * client would otherwise grow the chain without bound. Mirrors
1407
+ * MAX_PENDING_INPUTS' role on the plaintext side. */
1408
+ exports.MAX_PENDING_DECRYPTS = 32;
1409
+ /** Resolves once every encrypted input accepted so far has been routed. */
1410
+ const pendingInputDecrypts = () => inputDecryptChain;
1411
+ exports.pendingInputDecrypts = pendingInputDecrypts;
1412
+ /**
1413
+ * Open an E2EE input envelope and route what was inside it.
1414
+ *
1415
+ * Every cheap guard runs synchronously, before any crypto: an envelope from
1416
+ * anyone but the subscriber costs a string compare, not an ECDH derive. What
1417
+ * comes out of the decrypt is then re-validated by exactly the checks the
1418
+ * plaintext path runs — the envelope hides the payload from the relay, never
1419
+ * from the whitelist or the caps.
1420
+ */
1421
+ /**
1422
+ * Refuse one input message: log why here, tell the sender only THAT it was
1423
+ * refused. The reason is derived from plaintext the relay must not learn, so it
1424
+ * never rides the frame; a message with no sessionName gets no frame at all,
1425
+ * since the sender would have nothing to match it against.
1426
+ */
1427
+ const refuseInput = (msg, send, reason) => {
1428
+ log(`! input ${msg.sessionName ?? '(no session)'}: ${reason} — drop`);
1429
+ if (typeof msg.sessionName === 'string' && msg.sessionName) {
1430
+ send(streamErrorFrame(msg.sessionName, 'input_rejected', msg));
1431
+ }
1432
+ };
1433
+ const handleEncryptedInput = (msg, send) => {
1434
+ const { sessionName } = msg;
1435
+ const refuse = (reason) => refuseInput(msg, send, reason);
1436
+ if (typeof sessionName !== 'string' || !sessionName)
1437
+ return refuse('no sessionName');
1438
+ const stream = activeStreams.get(sessionName);
1439
+ if (!stream)
1440
+ return refuse('encrypted input with no live stream');
1441
+ // A stream that handshook no subscriber key has no key to bind this
1442
+ // envelope against — and its own frames go out in the clear, so there is
1443
+ // nothing here worth protecting and no way to prove who sent it.
1444
+ const { subscriberPublicKey } = stream;
1445
+ if (!subscriberPublicKey)
1446
+ return refuse('encrypted input on a plaintext stream');
1447
+ if (!isInputEnvelope(msg.encrypted))
1448
+ return refuse('malformed encrypted envelope');
1449
+ // THE binding. Opening an envelope proves only that its sender holds some
1450
+ // private key; it is this comparison that makes it the key the subscriber
1451
+ // handshook with, and so makes ephemeral input exclusive to the device
1452
+ // actually watching the pane. Without it, anyone who learned this host's
1453
+ // public key could type into it.
1454
+ const envelope = msg.encrypted;
1455
+ if (envelope.senderPublicKey !== subscriberPublicKey) {
1456
+ return refuse('envelope not sealed by the stream subscriber');
1457
+ }
1458
+ // The incarnation this message was admitted under. A stream can stop and
1459
+ // restart — with a different subscriber key — while the decrypt is in
1460
+ // flight, and the lease check inside deliverInput only asks whether SOME
1461
+ // stream of this name exists.
1462
+ const incarnation = stream.stats;
1463
+ if (inputDecryptDepth >= exports.MAX_PENDING_DECRYPTS) {
1464
+ return refuse('decrypt queue full');
1465
+ }
1466
+ // The binding compares against a public key the relay has seen, so it says
1467
+ // who MAY enqueue an ECDH derive, not that they can produce an openable
1468
+ // envelope. Sustained garbage would otherwise spend the one shared decrypt
1469
+ // chain on every stream at once. Same fail-closed shape as the outbound
1470
+ // half (STREAM_MAX_ENCRYPT_FAILURES): after a few consecutive failures this
1471
+ // stream takes no more sealed input until it is re-subscribed.
1472
+ if (stream.inputDecryptFailures >= STREAM_MAX_ENCRYPT_FAILURES) {
1473
+ return refuse('too many failed decrypts on this stream');
1474
+ }
1475
+ inputDecryptDepth++;
1476
+ inputDecryptChain = inputDecryptChain.then(async () => {
1477
+ inputDecryptDepth--;
1478
+ let opened;
1479
+ try {
1480
+ opened = await (0, crypto_js_1.decryptEphemeral)(envelope);
1481
+ }
1482
+ catch (err) {
1483
+ // AES-GCM is authenticated, so this is a forged, truncated or
1484
+ // misaddressed envelope — never a partially readable one.
1485
+ const live = activeStreams.get(sessionName);
1486
+ if (live?.stats === incarnation)
1487
+ live.inputDecryptFailures++;
1488
+ return refuse(`decrypt failed (${err instanceof Error ? err.message : err})`);
1489
+ }
1490
+ // A real envelope clears the strikes: the cap is there to stop a flood,
1491
+ // not to retire a stream that saw one corrupted message.
1492
+ const live = activeStreams.get(sessionName);
1493
+ if (live?.stats !== incarnation) {
1494
+ return refuse('stream replaced while decrypting');
1495
+ }
1496
+ live.inputDecryptFailures = 0;
1497
+ const payload = parseSealedInput(opened);
1498
+ if (!payload)
1499
+ return refuse('sealed payload is not an input object');
1500
+ // Replay binding: the ciphertext must vouch for the plaintext stamps.
1501
+ // AES-GCM stops the relay from forging content, but without this check
1502
+ // it could replay a captured envelope under a fresh seq and type a
1503
+ // real keystroke twice — the relay is exactly the party E2EE distrusts.
1504
+ if (payload.seq !== msg.seq || payload.epoch !== msg.epoch || payload.sessionName !== sessionName) {
1505
+ return refuse('sealed stamps do not match the plaintext ones (replay?)');
1506
+ }
1507
+ const checked = (0, exports.validateInputMessage)({
1508
+ sessionName,
1509
+ seq: msg.seq,
1510
+ epoch: msg.epoch,
1511
+ deviceId: msg.deviceId,
1512
+ keys: payload.keys,
1513
+ body: payload.body,
1514
+ });
1515
+ if (!checked.ok)
1516
+ return refuse(checked.reason);
1517
+ enqueueInput(checked.input, send);
1518
+ }).catch((err) => {
1519
+ // Nothing above should throw outside the decrypt, but one broken link
1520
+ // must not stall every keystroke queued behind it.
1521
+ log(`! input ${sessionName}: encrypted routing failed (${err instanceof Error ? err.message : err})`);
1522
+ });
1523
+ };
1524
+ /**
1525
+ * Handle agent.command.input. Returns true when the message was ours to
1526
+ * answer, so the caller stops routing it. Encrypted messages finish
1527
+ * asynchronously — the return value reports routing, not delivery, exactly as
1528
+ * it already did for a message the sequencer holds.
1529
+ */
1530
+ const handleCommandInput = (msg, send) => {
1531
+ if (msg.subtype !== 'agent.command.input')
1532
+ return false;
1533
+ // Addressing gate, as on stream control: the relay fans every ephemeral
1534
+ // message out to all of this user's connections, and two machines can run
1535
+ // the same tmux session name — an unaddressed inject would type into both.
1536
+ if (msg.targetDeviceId !== (0, exports.computeListenerDeviceId)())
1537
+ return false;
1538
+ // An envelope decides the message on its own. Branching here rather than
1539
+ // inside the validator is what keeps the plaintext `keys`/`body` a relay
1540
+ // could staple alongside it out of reach.
1541
+ if (msg.encrypted !== undefined) {
1542
+ handleEncryptedInput(msg, send);
1543
+ return true;
1544
+ }
1545
+ const checked = (0, exports.validateInputMessage)(msg);
1546
+ if (!checked.ok) {
1547
+ refuseInput(msg, send, checked.reason);
1548
+ return true;
1549
+ }
1550
+ const { input } = checked;
1551
+ // Input rides the stream lease: without one, nobody is watching the pane
1552
+ // this would type into, and seq/epoch have no incarnation to order
1553
+ // against. The sender learns in one hop and falls back to a REST push.
1554
+ const stream = activeStreams.get(input.sessionName);
1555
+ if (!stream) {
1556
+ refuseInput(input, send, 'no live stream');
1557
+ return true;
1558
+ }
1559
+ // This stream's outbound half is E2EE for that subscriber, so accepting a
1560
+ // plaintext keystroke would leave the inbound leg the only one in clear —
1561
+ // and nothing about it proves the subscriber sent it. An encrypted stream
1562
+ // takes sealed input (above) or none.
1563
+ if (stream.subscriberPublicKey) {
1564
+ refuseInput(input, send, 'stream is E2EE, plaintext input refused');
1565
+ return true;
1566
+ }
1567
+ enqueueInput(input, send);
1568
+ return true;
1569
+ };
1570
+ exports.handleCommandInput = handleCommandInput;
1056
1571
  /**
1057
1572
  * Inventory pass that also records *why* each `zeph-*` session was
1058
1573
  * skipped. The verbose log uses the rejection notes to explain empty
@@ -1141,6 +1656,13 @@ exports.collectSessions = collectSessions;
1141
1656
  * prefix path route through here so the defense layers can't diverge.
1142
1657
  */
1143
1658
  const passesInjectGuards = (session, deps) => {
1659
+ // Rate bucket first: the pane probe below is a blocking tmux spawnSync,
1660
+ // and the sequencer can flush several held messages back-to-back — an
1661
+ // empty bucket must refuse before paying that probe N times, not after.
1662
+ if (!(deps.rateLimit ?? exports.checkRateLimit)(session)) {
1663
+ log(`! ${session}: rate-limited — drop`);
1664
+ return false;
1665
+ }
1144
1666
  const cmd = (deps.paneCommand ?? exports.paneCurrentCommand)(session);
1145
1667
  if (cmd === null) {
1146
1668
  log(`! ${session}: no such tmux session — drop`);
@@ -1150,10 +1672,6 @@ const passesInjectGuards = (session, deps) => {
1150
1672
  log(`! ${session}: pane is at shell (${cmd}) — refusing (would be RCE)`);
1151
1673
  return false;
1152
1674
  }
1153
- if (!(deps.rateLimit ?? exports.checkRateLimit)(session)) {
1154
- log(`! ${session}: rate-limited — drop`);
1155
- return false;
1156
- }
1157
1675
  return true;
1158
1676
  };
1159
1677
  /**
@@ -1190,6 +1708,7 @@ const tryInject = (session, text, deps) => {
1190
1708
  const preview = text.length > 60 ? text.slice(0, 60) + '…' : text;
1191
1709
  log(`${ok ? '→' : '✗'} ${session}: ${preview}`);
1192
1710
  if (ok) {
1711
+ noteStreamInput(session);
1193
1712
  const cwd = (deps.paneCwd ?? defaultPaneCwd)(session);
1194
1713
  if (cwd)
1195
1714
  (0, exports.writeRemoteMarker)(cwd, text);
@@ -1202,6 +1721,8 @@ const tryInjectKeys = (session, tokens, deps) => {
1202
1721
  if (!passesInjectGuards(session, deps))
1203
1722
  return false;
1204
1723
  const ok = (deps.sendKeys ?? injectNamedKeys)(session, tokens);
1724
+ if (ok)
1725
+ noteStreamInput(session);
1205
1726
  log(`${ok ? '⌨' : '✗'} ${session}: [${tokens.join(' ')}]`);
1206
1727
  return ok;
1207
1728
  };
@@ -1674,7 +2195,11 @@ const streamSession = (wsUrl, apiKey) => {
1674
2195
  // Live mirror (PoC): agent.stream.start/stop drives a
1675
2196
  // continuous, diff-gated frame loop; falls through to the
1676
2197
  // one-shot screen-peek when it isn't a stream-control message.
1677
- if (!(0, exports.handleStreamControl)(m.data, sendEphemeral)) {
2198
+ // agent.command.input types into a streamed pane without the
2199
+ // REST round-trip; it is only accepted while that stream's
2200
+ // lease is live, so it sits behind the same routing chain.
2201
+ if (!(0, exports.handleCommandInput)(m.data, sendEphemeral) &&
2202
+ !(0, exports.handleStreamControl)(m.data, sendEphemeral)) {
1678
2203
  const reply = (0, exports.handleScreenRequest)(m.data);
1679
2204
  if (reply)
1680
2205
  sendEphemeral(reply);