@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/agent-rules-fetch.d.ts.map +1 -1
- package/dist/agent-rules-fetch.js +7 -2
- package/dist/crypto.d.ts +15 -3
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +53 -5
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.d.ts.map +1 -0
- package/dist/input-sequencer.js +126 -0
- package/dist/listener.d.ts +123 -2
- package/dist/listener.d.ts.map +1 -1
- package/dist/listener.js +565 -40
- package/package.json +1 -1
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
163
|
+
// A Map, not a plain object: object indexing leaks the prototype chain, so
|
|
164
|
+
// `keys: ['constructor']` resolved to a function that spawnSync happily
|
|
165
|
+
// stringified into the pane. Map lookups know only what was put in.
|
|
166
|
+
const ALLOWED_KEYS = new Map([
|
|
167
|
+
['escape', 'Escape'],
|
|
168
|
+
['up', 'Up'],
|
|
169
|
+
['down', 'Down'],
|
|
170
|
+
['left', 'Left'],
|
|
171
|
+
['right', 'Right'],
|
|
172
|
+
['enter', 'Enter'],
|
|
173
|
+
['tab', 'Tab'],
|
|
174
|
+
['backtab', 'BTab'],
|
|
175
|
+
['backspace', 'BSpace'],
|
|
176
|
+
['delete', 'DC'],
|
|
177
|
+
['space', 'Space'],
|
|
178
|
+
]);
|
|
175
179
|
/**
|
|
176
180
|
* Translate a phone-supplied key list to tmux tokens. Returns null if ANY
|
|
177
181
|
* key is unknown — a partial injection (some keys land, one is dropped)
|
|
@@ -181,7 +185,7 @@ const ALLOWED_KEYS = {
|
|
|
181
185
|
const resolveKeys = (keys) => {
|
|
182
186
|
const out = [];
|
|
183
187
|
for (const k of keys) {
|
|
184
|
-
const token = ALLOWED_KEYS
|
|
188
|
+
const token = ALLOWED_KEYS.get(k.toLowerCase().trim());
|
|
185
189
|
if (!token)
|
|
186
190
|
return null;
|
|
187
191
|
out.push(token);
|
|
@@ -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
|
|
762
|
-
// WS cost (the $default route has no
|
|
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
|
-
|
|
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
|
|
881
|
+
const frames = stats.frames - stats.lastLogFrames;
|
|
882
|
+
const fps = frames / secs;
|
|
814
883
|
const kbps = (stats.bytes - stats.lastLogBytes) / 1024 / secs;
|
|
815
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
869
|
-
|
|
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
|
|
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() >=
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|