kojee-mcp 0.5.4 → 0.5.6
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 +24 -5
- package/dist/{chunk-62KH6VNQ.js → chunk-2BDAM3TH.js} +61 -160
- package/dist/{chunk-36L3GCU3.js → chunk-3XDJOHMZ.js} +12 -2
- package/dist/chunk-6SK6ITFE.js +142 -0
- package/dist/{control-token-TYDAL477.js → chunk-GI2CKKBL.js} +13 -2
- package/dist/{resubscribe-SLZNA76S.js → chunk-OT2GILXC.js} +1 -0
- package/dist/{chunk-YVUXQ4Z2.js → chunk-UEGQGXPY.js} +53 -8
- package/dist/{chunk-OSKHA5DS.js → chunk-V5VZPYMZ.js} +2 -2
- package/dist/cli.js +19 -24
- package/dist/control-token-4BUCTYQB.js +13 -0
- package/dist/{doctor-TXWMMSRC.js → doctor-QCQDFLEH.js} +29 -16
- package/dist/{doctor-codex-3A7KYOVX.js → doctor-codex-NZ53ROQA.js} +3 -3
- package/dist/ensure-join-7AEDJMPE.js +96 -0
- package/dist/gateway-client-93P1E0CZ.d.ts +92 -0
- package/dist/{hook-server-NDJSV22J.js → hook-server-37E2LUKJ.js} +6 -0
- package/dist/index.d.ts +18 -15
- package/dist/index.js +7 -4
- package/dist/lib.d.ts +427 -0
- package/dist/lib.js +44 -0
- package/dist/reconnect-scheduler-JSXCJKQP.js +26 -0
- package/dist/resubscribe-G5OGDZJD.js +6 -0
- package/dist/{send-cli-7QJ36YY7.js → send-cli-C2F4WTBN.js} +1 -1
- package/dist/{stop-hook-GO363SMD.js → stop-hook-TRAMQYNE.js} +15 -7
- package/dist/{tail-stream-U436QL2X.js → tail-stream-VUZBYKXS.js} +4 -4
- package/dist/{user-prompt-submit-hook-ARPEO6FF.js → user-prompt-submit-hook-ZD2XKN7U.js} +7 -1
- package/dist/{webhook-config-UKUSI2FE.js → webhook-config-O4WMQ532.js} +1 -1
- package/dist/{webhook-sink-GCLL2S6S.js → webhook-sink-NWGCUDGY.js} +17 -3
- package/dist/{wizard-Z5JA3YPV.js → wizard-OSOAY4GO.js} +4 -4
- package/package.json +11 -2
package/dist/lib.d.ts
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { KeyLike, JWK } from 'jose';
|
|
2
|
+
import { L as LoadedKeyPair, K as KeystoreData, G as GatewayClient } from './gateway-client-93P1E0CZ.js';
|
|
3
|
+
export { P as ProxyConfig, T as ToolCallResult } from './gateway-client-93P1E0CZ.js';
|
|
4
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wire shape of a single SSE event from /api/v2/tandems/stream.
|
|
8
|
+
* Matches A2A spec §6.1 + impl plan B2 SSE event payload.
|
|
9
|
+
*/
|
|
10
|
+
interface TandemEvent {
|
|
11
|
+
type: "message" | "state_change";
|
|
12
|
+
id: string;
|
|
13
|
+
tandem_id: string;
|
|
14
|
+
cursor: number;
|
|
15
|
+
time: string;
|
|
16
|
+
from: {
|
|
17
|
+
member_id: string;
|
|
18
|
+
principal: string;
|
|
19
|
+
agent_id?: string;
|
|
20
|
+
session_id?: string;
|
|
21
|
+
displayname: string;
|
|
22
|
+
};
|
|
23
|
+
kind: "tell" | "ask" | "ack" | "status" | "message" | "system";
|
|
24
|
+
content: {
|
|
25
|
+
body: string;
|
|
26
|
+
format?: string;
|
|
27
|
+
};
|
|
28
|
+
mentions?: Array<{
|
|
29
|
+
type: "principal";
|
|
30
|
+
member_id: string;
|
|
31
|
+
displayname_at_mention: string;
|
|
32
|
+
} | {
|
|
33
|
+
type: "class";
|
|
34
|
+
class: "all" | "humans" | "agents" | "businesses";
|
|
35
|
+
} | {
|
|
36
|
+
type: "unresolved";
|
|
37
|
+
raw: string;
|
|
38
|
+
}>;
|
|
39
|
+
reply_to?: string | null;
|
|
40
|
+
severity?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Orchestrates key enrollment: checks keystore, generates keys if needed,
|
|
45
|
+
* runs register -> confirm flow.
|
|
46
|
+
*/
|
|
47
|
+
declare class AuthModule {
|
|
48
|
+
private readonly token;
|
|
49
|
+
private readonly brokerUrl;
|
|
50
|
+
private readonly keystorePath;
|
|
51
|
+
private privateKey;
|
|
52
|
+
private publicJwk;
|
|
53
|
+
private kid;
|
|
54
|
+
constructor(token: string, brokerUrl: string, keystorePath: string);
|
|
55
|
+
/**
|
|
56
|
+
* Ensure we have an enrolled keypair. Either loads from disk or
|
|
57
|
+
* performs the full enrollment flow.
|
|
58
|
+
*/
|
|
59
|
+
ensureEnrolled(): Promise<LoadedKeyPair>;
|
|
60
|
+
getPrivateKey(): KeyLike;
|
|
61
|
+
getPublicJwk(): JWK;
|
|
62
|
+
getKid(): string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a DPoP proof JWT for authenticating requests to the Kojee gateway.
|
|
67
|
+
*
|
|
68
|
+
* The JWT header must contain:
|
|
69
|
+
* - typ: "dpop+jwt"
|
|
70
|
+
* - alg: "ES256"
|
|
71
|
+
* - jwk: { kid: <bot_key_id> } (server reads kid from the jwk sub-object)
|
|
72
|
+
*
|
|
73
|
+
* The JWT payload must contain:
|
|
74
|
+
* - htm: HTTP method
|
|
75
|
+
* - htu: request URL
|
|
76
|
+
* - iat: issued-at (unix seconds)
|
|
77
|
+
* - jti: unique identifier (UUID) for replay protection
|
|
78
|
+
* - nonce: server-issued DPoP nonce (when available)
|
|
79
|
+
* - ath: base64url(SHA-256(access_token)) — binds proof to the token
|
|
80
|
+
*
|
|
81
|
+
* @param privateKey - ES256 private key for signing
|
|
82
|
+
* @param kid - bot_key_id from enrollment
|
|
83
|
+
* @param method - HTTP method (e.g. "POST")
|
|
84
|
+
* @param url - Full URL of the request endpoint
|
|
85
|
+
* @param nonce - Server-issued DPoP nonce (optional on first request)
|
|
86
|
+
* @param accessToken - Gateway token for ath claim
|
|
87
|
+
* @returns Signed DPoP proof JWT string
|
|
88
|
+
*/
|
|
89
|
+
declare function createDPoPProof(privateKey: KeyLike, kid: string, method: string, url: string, nonce?: string, accessToken?: string): Promise<string>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Canonical keystore path for PAIRED mode (`kojee-mcp pair`):
|
|
93
|
+
* ~/.kojee/keypair.json.
|
|
94
|
+
*
|
|
95
|
+
* The backend keeps exactly ONE DPoP key per gateway token (register_bot_key
|
|
96
|
+
* REPLACES; verification is exact-kid-match) — so every client of the same
|
|
97
|
+
* credential on a box MUST share one keystore or they enroll over each other
|
|
98
|
+
* in a lockout ping-pong. These two functions ARE that contract; out-of-repo
|
|
99
|
+
* consumers (the OpenClaw/Hermes plugins via `kojee-mcp/lib`) reuse them
|
|
100
|
+
* rather than reimplementing the paths.
|
|
101
|
+
*/
|
|
102
|
+
declare function defaultPairedKeystorePath(): string;
|
|
103
|
+
/**
|
|
104
|
+
* Canonical keystore path for TOKEN mode (explicit `--token` / env token):
|
|
105
|
+
* ~/.kojee/keypair-<sha256(token)[:12]>.json — one slot per distinct token.
|
|
106
|
+
*/
|
|
107
|
+
declare function deriveKeystorePath(token: string): string;
|
|
108
|
+
/**
|
|
109
|
+
* Load an existing keypair from disk.
|
|
110
|
+
* Returns null if the file does not exist or the broker URL does not match.
|
|
111
|
+
*/
|
|
112
|
+
declare function loadKeystore(keystorePath?: string, expectedBrokerUrl?: string): Promise<(LoadedKeyPair & {
|
|
113
|
+
data: KeystoreData;
|
|
114
|
+
}) | null>;
|
|
115
|
+
/**
|
|
116
|
+
* Save a keypair to disk with restrictive file permissions.
|
|
117
|
+
*/
|
|
118
|
+
declare function saveKeystore(privateKey: KeyLike, publicJwk: JWK, kid: string, brokerUrl: string, keystorePath?: string): Promise<void>;
|
|
119
|
+
/**
|
|
120
|
+
* Generate a new ES256 key pair and export the public JWK.
|
|
121
|
+
*/
|
|
122
|
+
declare function generateES256KeyPair(): Promise<{
|
|
123
|
+
privateKey: KeyLike;
|
|
124
|
+
publicJwk: JWK;
|
|
125
|
+
}>;
|
|
126
|
+
|
|
127
|
+
interface PairedConfig {
|
|
128
|
+
token: string;
|
|
129
|
+
broker_url: string;
|
|
130
|
+
paired_at: string;
|
|
131
|
+
principal_id?: string;
|
|
132
|
+
agent_id?: string;
|
|
133
|
+
}
|
|
134
|
+
declare function pairedConfigPath(): string;
|
|
135
|
+
declare function loadPairedConfig(filePath?: string): PairedConfig | null;
|
|
136
|
+
|
|
137
|
+
type Runtime = "claude-code" | "codex" | "unknown";
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Per-harness adapter. Today: minimal — only what's needed for the
|
|
141
|
+
* Channels wake-path. Grows as Codex/Cursor/Openclaw adapters land.
|
|
142
|
+
*/
|
|
143
|
+
interface HarnessAdapter {
|
|
144
|
+
readonly runtime: Runtime;
|
|
145
|
+
readonly supportsChannels: boolean;
|
|
146
|
+
/**
|
|
147
|
+
* Format a Tandem event into the {content, meta} pair that becomes
|
|
148
|
+
* a <channel source="..." key=value ...>content</channel> tag in
|
|
149
|
+
* the agent's context.
|
|
150
|
+
*
|
|
151
|
+
* Meta keys must match /^[a-z_][a-z0-9_]*$/ (CC docs requirement);
|
|
152
|
+
* values are free-form strings.
|
|
153
|
+
*
|
|
154
|
+
* Adapters with supportsChannels=false MUST throw if this is called;
|
|
155
|
+
* server.ts gates the call site on supportsChannels.
|
|
156
|
+
*/
|
|
157
|
+
formatTandemEvent(event: TandemEvent): {
|
|
158
|
+
content: string;
|
|
159
|
+
meta: Record<string, string>;
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface QueueEntry {
|
|
164
|
+
event: TandemEvent;
|
|
165
|
+
receivedAt: number;
|
|
166
|
+
deliveredViaChannel: boolean;
|
|
167
|
+
deliveredViaMonitor: boolean;
|
|
168
|
+
deliveredViaHook: boolean;
|
|
169
|
+
}
|
|
170
|
+
interface EventQueueOptions {
|
|
171
|
+
capacity?: number;
|
|
172
|
+
maxAgeMs?: number;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* In-memory queue of Tandem events with per-event delivery-tracking metadata.
|
|
176
|
+
*
|
|
177
|
+
* Dedup rule (locked in spec §0 decision 3): hook-deliverable subset is
|
|
178
|
+
* entries where !deliveredViaChannel && !deliveredViaHook. Channel-delivered
|
|
179
|
+
* events are suppressed from the hook path to avoid double-delivery.
|
|
180
|
+
*
|
|
181
|
+
* Eviction: by maxAgeMs on read of push (default 10min), by capacity FIFO
|
|
182
|
+
* (default 200). Bounds memory under "agent isn't paying attention" cases.
|
|
183
|
+
*/
|
|
184
|
+
declare class EventQueue {
|
|
185
|
+
private entries;
|
|
186
|
+
private readonly capacity;
|
|
187
|
+
private readonly maxAgeMs;
|
|
188
|
+
constructor(opts?: EventQueueOptions);
|
|
189
|
+
push(event: TandemEvent): void;
|
|
190
|
+
markChannelDelivered(eventId: string): void;
|
|
191
|
+
markMonitorDelivered(eventId: string): void;
|
|
192
|
+
takeForHook(): QueueEntry[];
|
|
193
|
+
snapshot(): QueueEntry[];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface EventLog {
|
|
197
|
+
/** The MESSAGES-ONLY log (byte-compatible with the old deployed recipe). */
|
|
198
|
+
path: string;
|
|
199
|
+
/** The SEPARATE status/telemetry sibling, next to `path`. */
|
|
200
|
+
statusPath: string;
|
|
201
|
+
append(event: TandemEvent): Promise<void>;
|
|
202
|
+
/**
|
|
203
|
+
* Append a raw STATUS line (round-2: SPLIT THE STREAMS). Status/heartbeat
|
|
204
|
+
* lines describe stream health, not Tandem messages — and they NEVER touch
|
|
205
|
+
* the messages log. They go to a SEPARATE sibling file (`statusPath`) so the
|
|
206
|
+
* messages log stays byte-compatible with every OLD deployed Monitor (which
|
|
207
|
+
* runs raw `tail -n +1 -F <messages-log>` and wakes on every line it sees).
|
|
208
|
+
* Status lines bypass the per-event-id dedup set (they have no event.id) and
|
|
209
|
+
* are NOT subject to body truncation. Callers pass the field body (e.g.
|
|
210
|
+
* "status=connected", "status=heartbeat", "status=subscribed n=3"); this
|
|
211
|
+
* method prefixes the timestamp + sentinel.
|
|
212
|
+
*/
|
|
213
|
+
appendStatus(fields: string): Promise<void>;
|
|
214
|
+
cleanup(): void;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface WebhookSinkStats {
|
|
218
|
+
/** Events successfully delivered (a 2xx response). */
|
|
219
|
+
delivered: number;
|
|
220
|
+
/**
|
|
221
|
+
* Events whose attempt TIMED OUT: the receiver may well have processed them
|
|
222
|
+
* (a session-spawning receiver answers late), so they are NOT retried and
|
|
223
|
+
* NOT counted as dropped — delivery is unconfirmed, not failed (0.5.6).
|
|
224
|
+
*/
|
|
225
|
+
deliveredUnconfirmed: number;
|
|
226
|
+
/** Events dropped after a permanent failure or exhausted retries. */
|
|
227
|
+
dropped: number;
|
|
228
|
+
/** Events dropped because the bounded queue was full at enqueue time. */
|
|
229
|
+
overflowDropped: number;
|
|
230
|
+
/** Current depth = buffered + (in-flight ? 1 : 0). */
|
|
231
|
+
queueDepth: number;
|
|
232
|
+
/** Last delivery outcome, for doctor/status. */
|
|
233
|
+
lastOutcome: "delivered" | "delivered-unconfirmed" | "dropped" | "none";
|
|
234
|
+
/** Epoch-ms of the last delivery attempt's completion (success or final drop). */
|
|
235
|
+
lastAttemptAt: number | null;
|
|
236
|
+
}
|
|
237
|
+
interface WebhookSink {
|
|
238
|
+
/**
|
|
239
|
+
* Fire-and-forget. Push an event for delivery. SYNCHRONOUS — never blocks,
|
|
240
|
+
* never throws, never awaits the in-flight POST. This is what consumeSse
|
|
241
|
+
* calls AFTER the channel + event-log sinks, so a webhook can't delay a wake.
|
|
242
|
+
*/
|
|
243
|
+
enqueue(event: TandemEvent): void;
|
|
244
|
+
/** Snapshot of delivery counters (for /status + doctor). */
|
|
245
|
+
stats(): WebhookSinkStats;
|
|
246
|
+
/** A log-safe summary that NEVER contains the secret. */
|
|
247
|
+
configSummary(): string;
|
|
248
|
+
/** Resolves when the queue is fully drained (no buffered, none in flight). */
|
|
249
|
+
idle(): Promise<void>;
|
|
250
|
+
/** Stop the drainer and drop any buffered events (best-effort, on shutdown). */
|
|
251
|
+
stop(): Promise<void>;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
interface EventStreamOptions {
|
|
255
|
+
brokerUrl: string;
|
|
256
|
+
token: string;
|
|
257
|
+
gateway: GatewayClient;
|
|
258
|
+
adapter: HarnessAdapter;
|
|
259
|
+
server: Server;
|
|
260
|
+
queue?: EventQueue;
|
|
261
|
+
eventLog?: EventLog;
|
|
262
|
+
/**
|
|
263
|
+
* Optional WEBHOOK SINK — a third, isolated delivery sink. When wired, every
|
|
264
|
+
* normalized TandemEvent is also pushed (fire-and-forget) to a configured
|
|
265
|
+
* HTTP endpoint so a daemonized proxy can wake a runtime via a local receiver.
|
|
266
|
+
* The push is SYNCHRONOUS and runs LAST in the fan-out, AFTER the event-log
|
|
267
|
+
* (Monitor wake) and channel sinks, so a slow/hanging/failing webhook can
|
|
268
|
+
* never delay or break those wake paths (see webhook-sink.ts). Unset ⇒ no
|
|
269
|
+
* webhook delivery, zero behavior change.
|
|
270
|
+
*/
|
|
271
|
+
webhookSink?: WebhookSink;
|
|
272
|
+
/**
|
|
273
|
+
* Resubscribe-on-start hook (P0 audit item #2). Called once after each
|
|
274
|
+
* successful (re)connect with the live `Response`. Implementations touch
|
|
275
|
+
* the agent's memberships so a backend that scopes the stream by
|
|
276
|
+
* session-touch self-heals on every backend restart / scope reset, and
|
|
277
|
+
* write a `status=subscribed n=<count>` line to the event log. Injected so
|
|
278
|
+
* the proxy startup wires it (it owns the tandem_list result + gateway) and
|
|
279
|
+
* the unit tests can assert it fires per-connect without a live backend.
|
|
280
|
+
*
|
|
281
|
+
* CAVEAT (documented in resubscribeMemberships): which tool call actually
|
|
282
|
+
* registers a session for streaming is an UNVERIFIED backend contract — see
|
|
283
|
+
* docs/forward-to-backend. Until the backend defines it, the touch is a
|
|
284
|
+
* best-effort no-op-safe read and the status line is the load-bearing part.
|
|
285
|
+
*/
|
|
286
|
+
onConnected?: () => void | Promise<void>;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Live stream state, surfaced by the hook-server's GET /status so `kojee-mcp
|
|
290
|
+
* doctor` can give an authoritative verdict instead of guessing from file
|
|
291
|
+
* mtimes (audit P2 doctor item). All timestamps are epoch ms, or null when the
|
|
292
|
+
* event hasn't happened yet.
|
|
293
|
+
*/
|
|
294
|
+
interface StreamState {
|
|
295
|
+
/** True between a successful connect and the next disconnect. */
|
|
296
|
+
connected: boolean;
|
|
297
|
+
/** When the current connection opened (null if never connected). */
|
|
298
|
+
connectedSince: number | null;
|
|
299
|
+
/** Last time ANY byte arrived on the stream (null if never). */
|
|
300
|
+
lastEventAt: number | null;
|
|
301
|
+
/** Last time a backend heartbeat arrived (null if never). */
|
|
302
|
+
lastHeartbeatAt: number | null;
|
|
303
|
+
/** Per-room high-water cursors (tandemId → cursor). */
|
|
304
|
+
cursors: Record<string, number>;
|
|
305
|
+
/** How many times the stream has (re)connected. */
|
|
306
|
+
reconnectCount: number;
|
|
307
|
+
/**
|
|
308
|
+
* The LEARNED stale-stream threshold in ms (3× the observed heartbeat
|
|
309
|
+
* interval, ≥90s floor), or null when the watchdog hasn't yet observed ≥2
|
|
310
|
+
* heartbeats. null ⇒ no cadence learned ⇒ the proxy will NOT abort on
|
|
311
|
+
* silence, and `/status` must NOT declare the stream stale (idle ≠ dead).
|
|
312
|
+
*/
|
|
313
|
+
staleAfterMs: number | null;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* A cancel function that also exposes the live stream state for /status and a
|
|
317
|
+
* graceful `reconnect()` (0.5.4 wake continuity): the subscription is a
|
|
318
|
+
* connect-time snapshot of the caller's memberships, so a seat acquired AFTER
|
|
319
|
+
* connect (tandem_join via the proxy) is invisible until the next reconnect.
|
|
320
|
+
* `reconnect()` closes the current connection and lets the existing
|
|
321
|
+
* backoff/jitter machinery re-open it — the new snapshot then includes the
|
|
322
|
+
* just-acquired seats. No-op after cancel.
|
|
323
|
+
*/
|
|
324
|
+
type StreamHandle = (() => void) & {
|
|
325
|
+
getState: () => StreamState;
|
|
326
|
+
reconnect: () => void;
|
|
327
|
+
};
|
|
328
|
+
/**
|
|
329
|
+
* Long-lived SSE subscription to /api/v2/tandems/stream.
|
|
330
|
+
* Returns a cancel function the caller can invoke on shutdown.
|
|
331
|
+
*
|
|
332
|
+
* Reconnect strategy: exponential backoff (1s → 30s) with full jitter.
|
|
333
|
+
* Resume via a PER-ROOM cursor map: ?since=<tandemId>:<cursor>,<tandemId>:<cursor>,…
|
|
334
|
+
* Cursors are per-tandem, so a single global `since` would skip/dupe events
|
|
335
|
+
* across rooms on a multi-room reconnect (H3). We track each room's high-water
|
|
336
|
+
* mark and resume each independently. Against a backend that predates H3 (which
|
|
337
|
+
* 400s the map), the proxy falls back to a bare global `?since=<n>` so it stays
|
|
338
|
+
* compatible with ANY backend version regardless of deploy order — see
|
|
339
|
+
* connectAndConsume.
|
|
340
|
+
*
|
|
341
|
+
* SSE failure never crashes the proxy — connector tool traffic
|
|
342
|
+
* continues uninterrupted.
|
|
343
|
+
*/
|
|
344
|
+
declare function startEventStream(opts: EventStreamOptions): Promise<StreamHandle>;
|
|
345
|
+
/**
|
|
346
|
+
* Sanitize a wire-controlled displayname for terminal-bound output.
|
|
347
|
+
*
|
|
348
|
+
* `sender.display` lands in the line-oriented event log (`from=<name>`) AND in
|
|
349
|
+
* channel content/meta, both of which reach a terminal AND are read back by a
|
|
350
|
+
* Claude Code agent (a prompt-injection channel). Without sanitization an
|
|
351
|
+
* interior `\n` forges an entire fake wake-line (attacker-chosen
|
|
352
|
+
* `tandem=`/`cursor=`/`msg=`) that the Monitor delivers as a separate wake,
|
|
353
|
+
* ANSI escapes reach the terminal, and invisible code points (the TAG block,
|
|
354
|
+
* variation selectors, zero-width/bidi) smuggle hidden bytes into the agent's
|
|
355
|
+
* reading context.
|
|
356
|
+
*
|
|
357
|
+
* Defense-in-depth: we do NOT trust the backend to sanitize terminal-bound
|
|
358
|
+
* output. Same policy as the backend naming funnel (apps/tandems/naming.py
|
|
359
|
+
* `sanitize_display_name`), byte for byte:
|
|
360
|
+
* 1. NFC-normalize first so a decomposed base+combining input folds to its
|
|
361
|
+
* precomposed form (`e` + combining acute → `é`) before filtering.
|
|
362
|
+
* 2. DROP every code point in the strip categories (Cc/Cf/Cs/Co/Cn) or the
|
|
363
|
+
* invisible-Mn set. Allowlist-leaning: letters, marks, numbers,
|
|
364
|
+
* punctuation, symbols, and spaces survive.
|
|
365
|
+
* 3. Cap any CONSECUTIVE combining-mark (Mn) run at MAX_COMBINING_RUN
|
|
366
|
+
* (zalgo defense — legitimate single accents survive).
|
|
367
|
+
* 4. Collapse whitespace runs (incl. LS/PS and exotic Zs spaces) to single
|
|
368
|
+
* ASCII spaces, trim.
|
|
369
|
+
* 5. Cap at MAX_DISPLAYNAME_CHARS without splitting a surrogate pair, then
|
|
370
|
+
* trim any trailing space the cut exposed — so the function is
|
|
371
|
+
* idempotent.
|
|
372
|
+
* Returns "" if nothing survives (all-hostile input) so the caller can fall
|
|
373
|
+
* back to the historical `principal:<prefix>` synthesis.
|
|
374
|
+
*/
|
|
375
|
+
declare function sanitizeDisplayname(name: string): string;
|
|
376
|
+
declare function normalizeBackendEvent(raw: unknown, sseEventType: string): TandemEvent;
|
|
377
|
+
|
|
378
|
+
/** Mutable debounce cursor shared across reconnects (one per proxy). */
|
|
379
|
+
interface ResubscribeDebounceState {
|
|
380
|
+
/** Epoch-ms of the last SUCCESSFUL (non-skipped) resubscribe run. */
|
|
381
|
+
lastRunAt: number;
|
|
382
|
+
}
|
|
383
|
+
interface ResubscribeOptions {
|
|
384
|
+
gateway: GatewayClient;
|
|
385
|
+
eventLog?: EventLog;
|
|
386
|
+
/**
|
|
387
|
+
* The tandem_ids to touch. Provide EITHER `tandemIds` (a fixed snapshot) OR
|
|
388
|
+
* `listTandems` (a fresh fetch per call). ROUND-2 MINOR 6: the proxy should
|
|
389
|
+
* pass `listTandems` so the membership list is RE-LISTED on every reconnect —
|
|
390
|
+
* a tandem joined mid-session is then touched on the next reconnect instead
|
|
391
|
+
* of being frozen at boot. `tandemIds` remains for tests and simple callers.
|
|
392
|
+
* Empty result ⇒ nothing to touch; we still log `status=subscribed n=0`.
|
|
393
|
+
*/
|
|
394
|
+
tandemIds?: string[];
|
|
395
|
+
/** Fresh membership fetch, called once per resubscribe (per reconnect). */
|
|
396
|
+
listTandems?: () => Promise<string[]>;
|
|
397
|
+
/** Per-touch timeout budget (AbortSignal). Default 10s. */
|
|
398
|
+
perCallTimeoutMs?: number;
|
|
399
|
+
/** Bounded touch concurrency. Default 4. */
|
|
400
|
+
concurrency?: number;
|
|
401
|
+
/**
|
|
402
|
+
* Flap-damping window (MINOR E). When `debounceState` is provided and the
|
|
403
|
+
* last successful run was < `debounceMs` ago, this resubscribe is SKIPPED
|
|
404
|
+
* (returns 0, no touches, no status line). Default 30s. With no
|
|
405
|
+
* `debounceState` the debounce is inert (every call runs) — preserving the
|
|
406
|
+
* existing test/simple-caller behavior.
|
|
407
|
+
*/
|
|
408
|
+
debounceMs?: number;
|
|
409
|
+
/** Shared debounce cursor (one per proxy). Omit to disable debouncing. */
|
|
410
|
+
debounceState?: ResubscribeDebounceState;
|
|
411
|
+
/** Injectable clock for tests. Defaults to Date.now. */
|
|
412
|
+
now?: () => number;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Touch each membership (best-effort, in parallel with a bounded pool and a
|
|
416
|
+
* per-call AbortSignal timeout) and write a `status=subscribed n=<count>` line.
|
|
417
|
+
* Returns the number of tandems for which the touch call succeeded. Never
|
|
418
|
+
* throws — a failed/timed-out touch is folded into the status line's `touched=`
|
|
419
|
+
* count and to stderr, but must not break the stream loop that calls it.
|
|
420
|
+
*
|
|
421
|
+
* MINOR 6: this runs CONCURRENTLY with consumeSse (the caller does not await it
|
|
422
|
+
* before consuming the stream), so a hung gateway call can never stall
|
|
423
|
+
* first-event delivery; the per-call timeout caps each touch independently.
|
|
424
|
+
*/
|
|
425
|
+
declare function resubscribeMemberships(opts: ResubscribeOptions): Promise<number>;
|
|
426
|
+
|
|
427
|
+
export { AuthModule, type EventLog, type EventStreamOptions, GatewayClient, type HarnessAdapter, type PairedConfig, type Runtime, type StreamHandle, type StreamState, type TandemEvent, type WebhookSink, createDPoPProof, defaultPairedKeystorePath, deriveKeystorePath, generateES256KeyPair, loadKeystore, loadPairedConfig, normalizeBackendEvent, pairedConfigPath, resubscribeMemberships, sanitizeDisplayname, saveKeystore, startEventStream };
|
package/dist/lib.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resubscribeMemberships
|
|
3
|
+
} from "./chunk-OT2GILXC.js";
|
|
4
|
+
import {
|
|
5
|
+
loadPairedConfig,
|
|
6
|
+
pairedConfigPath
|
|
7
|
+
} from "./chunk-YH27B6SW.js";
|
|
8
|
+
import {
|
|
9
|
+
AuthModule
|
|
10
|
+
} from "./chunk-6SK6ITFE.js";
|
|
11
|
+
import {
|
|
12
|
+
GatewayClient,
|
|
13
|
+
defaultPairedKeystorePath,
|
|
14
|
+
deriveKeystorePath,
|
|
15
|
+
generateES256KeyPair,
|
|
16
|
+
loadKeystore,
|
|
17
|
+
saveKeystore
|
|
18
|
+
} from "./chunk-3XDJOHMZ.js";
|
|
19
|
+
import {
|
|
20
|
+
normalizeBackendEvent,
|
|
21
|
+
sanitizeDisplayname,
|
|
22
|
+
startEventStream
|
|
23
|
+
} from "./chunk-UEGQGXPY.js";
|
|
24
|
+
import {
|
|
25
|
+
createDPoPProof
|
|
26
|
+
} from "./chunk-2MIISF2W.js";
|
|
27
|
+
import "./chunk-LDZXU3DW.js";
|
|
28
|
+
import "./chunk-BLEGIR35.js";
|
|
29
|
+
export {
|
|
30
|
+
AuthModule,
|
|
31
|
+
GatewayClient,
|
|
32
|
+
createDPoPProof,
|
|
33
|
+
defaultPairedKeystorePath,
|
|
34
|
+
deriveKeystorePath,
|
|
35
|
+
generateES256KeyPair,
|
|
36
|
+
loadKeystore,
|
|
37
|
+
loadPairedConfig,
|
|
38
|
+
normalizeBackendEvent,
|
|
39
|
+
pairedConfigPath,
|
|
40
|
+
resubscribeMemberships,
|
|
41
|
+
sanitizeDisplayname,
|
|
42
|
+
saveKeystore,
|
|
43
|
+
startEventStream
|
|
44
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// src/tandem/reconnect-scheduler.ts
|
|
2
|
+
var DEFAULT_DEBOUNCE_MS = 1e3;
|
|
3
|
+
function createJoinReconnectScheduler(opts) {
|
|
4
|
+
const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
5
|
+
let pending = null;
|
|
6
|
+
return {
|
|
7
|
+
requestReconnect() {
|
|
8
|
+
if (pending !== null) return;
|
|
9
|
+
pending = setTimeout(() => {
|
|
10
|
+
pending = null;
|
|
11
|
+
try {
|
|
12
|
+
opts.reconnect();
|
|
13
|
+
} catch (err) {
|
|
14
|
+
console.error(
|
|
15
|
+
"[join-reconnect] reconnect action failed:",
|
|
16
|
+
err?.message ?? String(err)
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}, debounceMs);
|
|
20
|
+
pending.unref?.();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export {
|
|
25
|
+
createJoinReconnectScheduler
|
|
26
|
+
};
|
|
@@ -1,17 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
monitorHeartbeatPath,
|
|
3
|
+
nudgeSentinelPath
|
|
4
|
+
} from "./chunk-2TUAFAIW.js";
|
|
1
5
|
import {
|
|
2
6
|
readHookStdin
|
|
3
7
|
} from "./chunk-LSUB6QMP.js";
|
|
4
8
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
} from "./chunk-BJMASMKX.js";
|
|
9
|
+
controlTokenAuthHeaders
|
|
10
|
+
} from "./chunk-GI2CKKBL.js";
|
|
8
11
|
import {
|
|
9
12
|
buildMonitorNudge
|
|
10
13
|
} from "./chunk-X672ZN7V.js";
|
|
11
14
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from "./chunk-
|
|
15
|
+
deriveDiscoveryKey,
|
|
16
|
+
findClaudeAncestorPid
|
|
17
|
+
} from "./chunk-BJMASMKX.js";
|
|
15
18
|
import {
|
|
16
19
|
readSessionDiscoveryByKey
|
|
17
20
|
} from "./chunk-DO42NPNR.js";
|
|
@@ -74,7 +77,12 @@ async function runStopHook() {
|
|
|
74
77
|
discovery: discovery ? { port: discovery.port, eventLogPath: discovery.eventLogPath } : null,
|
|
75
78
|
pollEvents: async () => {
|
|
76
79
|
const res = await fetch(
|
|
77
|
-
`http://127.0.0.1:${discovery.port}/poll?type=stop&timeout_ms=${STOP_POLL_TIMEOUT_MS}
|
|
80
|
+
`http://127.0.0.1:${discovery.port}/poll?type=stop&timeout_ms=${STOP_POLL_TIMEOUT_MS}`,
|
|
81
|
+
// 0.5.4: GET /poll is gated by the control token. Same-user hook —
|
|
82
|
+
// read the bearer from the path the discovery file advertises. A
|
|
83
|
+
// missing token yields no header and the !res.ok branch degrades to
|
|
84
|
+
// "no events" (fail-open, the Monitor file path is the primary wake).
|
|
85
|
+
{ headers: controlTokenAuthHeaders(discovery.controlTokenPath) }
|
|
78
86
|
);
|
|
79
87
|
if (!res.ok) return { events: [], count: 0 };
|
|
80
88
|
return await res.json();
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createAdaptiveWatchdog
|
|
3
|
-
} from "./chunk-YVUXQ4Z2.js";
|
|
4
|
-
import "./chunk-2MIISF2W.js";
|
|
5
1
|
import {
|
|
6
2
|
STATUS_LINE_PREFIX,
|
|
7
3
|
monitorHeartbeatPath,
|
|
8
4
|
statusLogPath
|
|
9
5
|
} from "./chunk-2TUAFAIW.js";
|
|
6
|
+
import {
|
|
7
|
+
createAdaptiveWatchdog
|
|
8
|
+
} from "./chunk-UEGQGXPY.js";
|
|
9
|
+
import "./chunk-2MIISF2W.js";
|
|
10
10
|
import "./chunk-DO42NPNR.js";
|
|
11
11
|
import "./chunk-BLEGIR35.js";
|
|
12
12
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
readHookStdin
|
|
3
3
|
} from "./chunk-LSUB6QMP.js";
|
|
4
|
+
import {
|
|
5
|
+
controlTokenAuthHeaders
|
|
6
|
+
} from "./chunk-GI2CKKBL.js";
|
|
4
7
|
import {
|
|
5
8
|
deriveDiscoveryKey,
|
|
6
9
|
findClaudeAncestorPid
|
|
@@ -22,7 +25,10 @@ async function runUserPromptSubmitHook() {
|
|
|
22
25
|
}
|
|
23
26
|
let body;
|
|
24
27
|
try {
|
|
25
|
-
const res = await fetch(
|
|
28
|
+
const res = await fetch(
|
|
29
|
+
`http://127.0.0.1:${discovery.port}/poll?type=user-prompt-submit&timeout_ms=0`,
|
|
30
|
+
{ headers: controlTokenAuthHeaders(discovery.controlTokenPath) }
|
|
31
|
+
);
|
|
26
32
|
if (!res.ok) {
|
|
27
33
|
process.stdout.write("{}");
|
|
28
34
|
return;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
WEBHOOK_DEFAULT_SIGNATURE_HEADER,
|
|
3
3
|
WEBHOOK_DEFAULT_SIGNATURE_PREFIX
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-V5VZPYMZ.js";
|
|
5
5
|
|
|
6
6
|
// src/tandem/webhook-sink.ts
|
|
7
7
|
import crypto from "crypto";
|
|
@@ -39,6 +39,7 @@ function createWebhookSink(config, options = {}) {
|
|
|
39
39
|
let draining = false;
|
|
40
40
|
const stats = {
|
|
41
41
|
delivered: 0,
|
|
42
|
+
deliveredUnconfirmed: 0,
|
|
42
43
|
dropped: 0,
|
|
43
44
|
overflowDropped: 0,
|
|
44
45
|
queueDepth: 0,
|
|
@@ -64,7 +65,11 @@ function createWebhookSink(config, options = {}) {
|
|
|
64
65
|
}
|
|
65
66
|
async function attempt(item) {
|
|
66
67
|
const controller = new AbortController();
|
|
67
|
-
|
|
68
|
+
let timedOut = false;
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
timedOut = true;
|
|
71
|
+
controller.abort();
|
|
72
|
+
}, config.timeoutMs);
|
|
68
73
|
try {
|
|
69
74
|
const res = await fetchImpl(config.url, {
|
|
70
75
|
method: "POST",
|
|
@@ -81,7 +86,7 @@ function createWebhookSink(config, options = {}) {
|
|
|
81
86
|
});
|
|
82
87
|
return classifyStatus(res.status);
|
|
83
88
|
} catch {
|
|
84
|
-
return "retry";
|
|
89
|
+
return timedOut ? "timeout" : "retry";
|
|
85
90
|
} finally {
|
|
86
91
|
clearTimeout(timer);
|
|
87
92
|
}
|
|
@@ -96,6 +101,15 @@ function createWebhookSink(config, options = {}) {
|
|
|
96
101
|
stats.lastAttemptAt = Date.now();
|
|
97
102
|
return;
|
|
98
103
|
}
|
|
104
|
+
if (outcome === "timeout") {
|
|
105
|
+
stats.deliveredUnconfirmed += 1;
|
|
106
|
+
stats.lastOutcome = "delivered-unconfirmed";
|
|
107
|
+
stats.lastAttemptAt = Date.now();
|
|
108
|
+
log(
|
|
109
|
+
`delivery=${item.deliveryId} delivered-unconfirmed (receiver slow: no response within ${config.timeoutMs}ms) \u2014 not retrying, a re-POST could duplicate the receiver's side effects`
|
|
110
|
+
);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
99
113
|
if (outcome === "permanent") {
|
|
100
114
|
stats.dropped += 1;
|
|
101
115
|
stats.lastOutcome = "dropped";
|
|
@@ -16,6 +16,10 @@ import {
|
|
|
16
16
|
WIZARD_RUNTIMES,
|
|
17
17
|
isWizardRuntime
|
|
18
18
|
} from "./chunk-LVL25VLO.js";
|
|
19
|
+
import {
|
|
20
|
+
resolveSignatureEmission,
|
|
21
|
+
resolveWebhookConfig
|
|
22
|
+
} from "./chunk-V5VZPYMZ.js";
|
|
19
23
|
import {
|
|
20
24
|
CODEX_LISTEN_CAP_MS,
|
|
21
25
|
buildWebhookReceiverNote
|
|
@@ -23,10 +27,6 @@ import {
|
|
|
23
27
|
import {
|
|
24
28
|
secureFile
|
|
25
29
|
} from "./chunk-BLEGIR35.js";
|
|
26
|
-
import {
|
|
27
|
-
resolveSignatureEmission,
|
|
28
|
-
resolveWebhookConfig
|
|
29
|
-
} from "./chunk-OSKHA5DS.js";
|
|
30
30
|
|
|
31
31
|
// src/wizard/wizard.ts
|
|
32
32
|
import crypto from "crypto";
|