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