shraga 0.1.22 → 0.1.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,3 +1,9 @@
1
+ /** Subprotocol marker used to carry a bearer token through a WebSocket handshake: open the socket as
2
+ * `new WebSocket(url, [WS_AUTH_PROTOCOL, token])`. Browsers can't set headers on a WS handshake, and the
3
+ * subprotocol list is the one field they can — used by the sidecar WS proxy (see authenticateWsUpgrade
4
+ * in src/server/boot.ts, which must stay in sync with this value). */
5
+ export const WS_AUTH_PROTOCOL = 'shraga.auth';
6
+
1
7
  export type AskQuestion = {
2
8
  question: string;
3
9
  header: string;
@@ -232,6 +232,20 @@ export async function verifyBearer(token: string): Promise<AuthUser> {
232
232
  return AUTH_PROVIDER === 'firebase' ? verifyToken(token) : verifyLocalToken(token);
233
233
  }
234
234
 
235
+ /** Verify a raw token (api-key `uck_…`, scoped internal token, or provider bearer) → AuthUser, or null.
236
+ * The token-only half of `requireAuth`, for callers with no Express req/res — notably the WebSocket
237
+ * upgrade path, where the token arrives in the handshake instead of an Authorization header. */
238
+ export async function authenticateToken(token: string | undefined | null): Promise<AuthUser | null> {
239
+ if (!token) return null;
240
+ if (token.startsWith('uck_')) {
241
+ const identity = validateApiKey(token);
242
+ return identity ? { uid: identity.uid, email: identity.email, isOwner: isOwnerEmail(identity.email) } : null;
243
+ }
244
+ const internal = verifyInternalToken(token);
245
+ if (internal) return { uid: internal.uid, email: internal.email, isOwner: isOwnerEmail(internal.email) };
246
+ try { return await verifyBearer(token); } catch { return null; }
247
+ }
248
+
235
249
  export async function requireAuth(req: Request, res: Response, next: NextFunction) {
236
250
  const internalToken = req.headers['x-internal-token'] as string | undefined;
237
251
  if (internalToken) {
@@ -11,7 +11,7 @@ process.on('unhandledRejection', (reason) => {
11
11
  if (SUPPRESSED_ERRORS.test(msg)) return;
12
12
  console.error('[server] Unhandled rejection (kept alive):', msg);
13
13
  });
14
- import { createServer } from 'node:http';
14
+ import { createServer, STATUS_CODES } from 'node:http';
15
15
  import { createHmac, timingSafeEqual } from 'node:crypto';
16
16
  import { execSync } from 'node:child_process';
17
17
  import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
@@ -19,7 +19,7 @@ import path from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import express from 'express';
21
21
  import { WebSocketServer, WebSocket } from 'ws';
22
- import { requireAuth, verifyBearer, AUTH_PROVIDER, localLogin, addLocalUser, localUserCount } from './auth.ts';
22
+ import { requireAuth, verifyBearer, authenticateToken, AUTH_PROVIDER, localLogin, addLocalUser, localUserCount } from './auth.ts';
23
23
  import { getMcpConfig, getRawMcpConfig, getResolvedMcpConfig, getGlobalMcpConfig, saveMcpConfig, maskEnvValues, mergeWithOriginal, type McpConfig } from './mcp.ts';
24
24
  import { streamChat, consumeStream, getAgentConfig, saveAgentConfig, getClaudeAuthSource, type AgentConfig, type PermissionHandler, type QuestionHandler, type QuestionAnswers, type AttachmentMeta, type WsEvent } from './claude.ts';
25
25
  import { mountFeatures, registerFeature, resumeFeatureSession, collectFeatureFlags, collectSidecarRoutes } from './features.ts';
@@ -834,34 +834,85 @@ function resolveSidecarPort(urlPath: string): number | null {
834
834
  return prefix ? WS_PROXY_ROUTES[prefix] ?? null : null;
835
835
  }
836
836
 
837
- const sidecarWss = new WebSocketServer({ noServer: true });
837
+ /** Handshake subprotocol marker carrying the caller's token: `[WS_AUTH_PROTOCOL, <token>]`.
838
+ * Keep in sync with `WS_AUTH_PROTOCOL` in src/client/lib/ws.ts (the client half). */
839
+ const WS_AUTH_PROTOCOL = 'shraga.auth';
840
+
841
+ // handleProtocols is explicit so the accepted subprotocol is always the marker — never the token
842
+ // itself (ws's default picks the first offered, and echoing a credential back in a response header
843
+ // would put it in any intermediary's log). Only consulted when the client offered protocols at all,
844
+ // so a header-authenticated non-browser client is unaffected.
845
+ const sidecarWss = new WebSocketServer({ noServer: true, handleProtocols: (protocols) => (protocols.has(WS_AUTH_PROTOCOL) ? WS_AUTH_PROTOCOL : false) });
846
+
847
+ /** Authenticate a WS upgrade. Token comes from the subprotocol list (the only browser-settable
848
+ * handshake field), falling back to a bearer header for non-browser clients (curl/CLI/tests). */
849
+ async function authenticateWsUpgrade(req: import('node:http').IncomingMessage): Promise<import('./auth.ts').AuthUser | null> {
850
+ const protocols = (req.headers['sec-websocket-protocol'] as string | undefined)?.split(',').map((s) => s.trim()) ?? [];
851
+ const token = protocols[0] === WS_AUTH_PROTOCOL && protocols[1]
852
+ ? protocols[1]
853
+ : req.headers.authorization?.replace('Bearer ', '');
854
+ return authenticateToken(token);
855
+ }
856
+
857
+ /** Reject a WS upgrade with a real HTTP response before destroying the socket — same shape as ws's own
858
+ * internal `abortHandshake`. A bare `socket.destroy()` writes ZERO bytes, so the client sees a generic
859
+ * "connection failed" and an expired token is indistinguishable from "server is down".
860
+ *
861
+ * CAVEAT, measured, do not assume this reaches the client today: under Bun (1.3.10, the runtime this
862
+ * server actually runs on) the socket handed to a `node:http` 'upgrade' listener is write-dead — it
863
+ * reports `writable: true` but has no `_handle`, and NOTHING written to it is ever delivered. Probed in
864
+ * an isolated two-runtime rig (plain `createServer` + 'upgrade' listener, raw TCP client): Node 24
865
+ * delivered the 401 for every variant (end / write / paused / unpaused); Bun 1.3.10 delivered zero bytes
866
+ * for all of them, including a direct `_handle.write`. ws's own `abortHandshake` would fail identically —
867
+ * it is the same `socket.end(...)`. So on Bun the observable behaviour is still a bare close.
868
+ * This is kept because it is the correct shape, costs nothing, still closes the socket, and starts
869
+ * working the moment Bun implements it (or the process runs under Node). Until then, do NOT build a
870
+ * client-side "auth error" affordance on top of it — the client cannot see this response. */
871
+ function abortUpgrade(socket: import('node:stream').Duplex, code: number, message?: string) {
872
+ if (socket.destroyed || !socket.writable) return;
873
+ const body = message ?? STATUS_CODES[code] ?? '';
874
+ const headers = ['Connection: close', 'Content-Type: text/plain', `Content-Length: ${Buffer.byteLength(body)}`];
875
+ socket.once('finish', () => socket.destroy());
876
+ // The socket was paused for the auth gap; pause() only stops READS, so it is still writable here.
877
+ try { socket.end(`HTTP/1.1 ${code} ${STATUS_CODES[code] ?? 'Error'}\r\n${headers.join('\r\n')}\r\n\r\n${body}`); }
878
+ catch { socket.destroy(); }
879
+ }
838
880
 
839
881
  function proxySidecarWebSocket(req: import('node:http').IncomingMessage, socket: import('node:stream').Duplex, head: Buffer, port: number) {
840
882
  const targetUrl = `ws://127.0.0.1:${port}${req.url}`;
841
883
  sidecarWss.handleUpgrade(req, socket as any, head, (clientWs) => {
884
+ // The caller paused the socket for the async auth gap; ws has now attached its own 'data' listener
885
+ // (handleUpgrade → setSocket runs before this callback), so it's safe — and necessary — to resume:
886
+ // an EXPLICITLY paused socket does not re-enter flowing mode just because a listener was added, so
887
+ // without this every buffered byte and every subsequent keystroke would sit unread forever.
888
+ socket.resume();
842
889
  const targetWs = new WebSocket(targetUrl);
843
890
  let opened = false;
844
891
 
892
+ // Bound IMMEDIATELY, not inside targetWs 'open': the BROWSER's socket is OPEN the moment
893
+ // handleUpgrade returns, so it reports "connected" and starts sending while we're still dialing the
894
+ // sidecar. Registering the listener on open discarded everything typed in that window — silently.
895
+ // Queue instead, and flush in order once upstream is up.
896
+ const pending: Array<{ data: import('ws').RawData; isBinary: boolean }> = [];
897
+ clientWs.on('message', (data, isBinary) => {
898
+ // App-level liveness probe (Layer 2): the client can't read protocol pongs from JS, so it sends
899
+ // `{type:'ping'}` and expects `{type:'pong'}`. We RELAY it — we must not answer it here. A
900
+ // proxy-local reply only proves THIS hop is alive: if the proxy→sidecar leg is half-open, or the
901
+ // sidecar has already dropped this client from its subscriber set, the browser still gets pongs,
902
+ // keeps `readyState === OPEN`, shows a green "connected" dot, and every keystroke disappears.
903
+ // The probe is only worth anything end-to-end, so the sidecar owns the reply (para-pty answers in
904
+ // its ws message handler); a sidecar that doesn't reply fails the probe, which is the honest
905
+ // outcome — the client then reconnects rather than trusting a dead pipe.
906
+ if (targetWs.readyState === WebSocket.OPEN) targetWs.send(data, { binary: isBinary });
907
+ else if (!opened && pending.length < 256) pending.push({ data, isBinary }); // bounded: never buffer unboundedly
908
+ });
909
+
845
910
  targetWs.on('open', () => {
846
911
  opened = true;
847
- clientWs.on('message', (data, isBinary) => {
848
- // App-level liveness probe (Layer 2): the client can't read protocol pongs from JS and the daemon
849
- // doesn't speak ping, so answer `{type:'ping'}` here without forwarding. Lets the browser detect a
850
- // half-open socket the server-side terminate can't reach (broken path) and force a reconnect.
851
- // Cheap prefilter (small + contains "ping") so we don't JSON-parse every keystroke/paste frame.
852
- if (!isBinary && (data as Buffer).length < 64) {
853
- const s = data.toString();
854
- if (s.includes('"ping"')) {
855
- try {
856
- if (JSON.parse(s).type === 'ping') {
857
- if (clientWs.readyState === WebSocket.OPEN) clientWs.send(JSON.stringify({ type: 'pong' }));
858
- return;
859
- }
860
- } catch { /* not JSON — fall through to relay */ }
861
- }
862
- }
863
- if (targetWs.readyState === WebSocket.OPEN) targetWs.send(data, { binary: isBinary });
864
- });
912
+ for (const m of pending) {
913
+ if (targetWs.readyState === WebSocket.OPEN) targetWs.send(m.data, { binary: m.isBinary });
914
+ }
915
+ pending.length = 0;
865
916
  targetWs.on('message', (data, isBinary) => {
866
917
  if (clientWs.readyState === WebSocket.OPEN) clientWs.send(data, { binary: isBinary });
867
918
  });
@@ -914,9 +965,50 @@ server.on('upgrade', (req, socket, head) => {
914
965
  } else {
915
966
  const port = req.url ? resolveSidecarPort(req.url) : null;
916
967
  if (port) {
917
- proxySidecarWebSocket(req, socket, head, port);
968
+ // A sidecar socket is a LIVE ATTACHED SHELL (para-pty) — read/write on the user's terminals. It
969
+ // must be authenticated BEFORE we bridge it, like the HTTP routes (`requireAuth`) and the `/ws`
970
+ // control socket (its `auth` message). NOTE the limit of this gate: it is IDENTITY-ONLY. It proves
971
+ // the token belongs to a valid user; it does NOT check that the user owns (or may access) the
972
+ // `sessionId` in the URL, so it is weaker than routes that additionally authorize — e.g. the HTTP
973
+ // pty kill route, which checks group membership. Any authenticated user can currently attach to any
974
+ // session id. Unlike those, there's no place to put a bearer header:
975
+ // a browser can't set headers on a WebSocket handshake. The one field it CAN set is the
976
+ // subprotocol list, so the client sends `[WS_AUTH_PROTOCOL, <token>]` (see ptyWsUrl's consumer) and
977
+ // we verify entry 1 with the same token seam requireAuth uses. Works identically over the `.local`
978
+ // HTTPS origin and the cloudflared tunnel — Sec-WebSocket-Protocol is a standard handshake header
979
+ // both pass through, and unlike `?token=` it never lands in an access log or a Referer.
980
+ // Pause for the async gap. Node hands us the raw socket with its own parser detached, so nothing
981
+ // is reading it while we verify; pausing makes that explicit and guarantees bytes the client sends
982
+ // between the handshake and our decision are buffered, not dropped. proxySidecarWebSocket resumes
983
+ // it once ws owns the socket.
984
+ socket.pause();
985
+ // Fail CLOSED on anything: a rejected/thrown/slow auth must destroy the socket, never leave it
986
+ // dangling. Without the catch a throw in authenticateWsUpgrade (token store unavailable, malformed
987
+ // header) produced an unhandled rejection AND an open, unauthenticated, un-proxied socket; without
988
+ // the timeout a hung verifier held the socket and its fd open indefinitely.
989
+ // The catch sits on the auth promise itself rather than on the race, so a rejection that arrives
990
+ // AFTER the timeout already won is visibly logged instead of vanishing into the race's own
991
+ // (already-settled) internal handler.
992
+ const authed = authenticateWsUpgrade(req)
993
+ .catch((err) => { console.error('[ws-proxy] auth threw for upgrade:', (err as Error)?.message ?? err); return null; });
994
+ // Cleared on settle: an uncleared 5s timer pins this socket/req/head closure alive for 5s per
995
+ // upgrade even when auth resolved in milliseconds.
996
+ let authTimer: ReturnType<typeof setTimeout> | undefined;
997
+ const timedOut = new Promise<null>((resolve) => { authTimer = setTimeout(() => resolve(null), WS_AUTH_TIMEOUT_MS); authTimer.unref?.(); });
998
+ void Promise.race([authed, timedOut])
999
+ .then((user) => {
1000
+ if (authTimer) clearTimeout(authTimer);
1001
+ if (socket.destroyed) return;
1002
+ if (!user) {
1003
+ console.warn(`[ws-proxy] rejected unauthenticated upgrade for ${req.url?.split('?')[0]}`);
1004
+ abortUpgrade(socket, 401, 'Unauthorized: missing or invalid token');
1005
+ return;
1006
+ }
1007
+ proxySidecarWebSocket(req, socket, head, port);
1008
+ });
918
1009
  } else {
919
- socket.destroy();
1010
+ // No sidecar registered for this path — a client error, not an auth failure.
1011
+ abortUpgrade(socket, 400, 'Unknown upgrade path');
920
1012
  }
921
1013
  }
922
1014
  });
@@ -927,6 +1019,7 @@ function send(ws: WebSocket, data: object) {
927
1019
 
928
1020
  const DESTRUCTIVE_PERMISSION_TTL = 10 * 60_000;
929
1021
  const WS_PING_INTERVAL = 30_000;
1022
+ const WS_AUTH_TIMEOUT_MS = 5_000; // upper bound on sidecar-upgrade auth; past it the socket is destroyed
930
1023
  const activeConnections = new Map<WebSocket, WsSession>();
931
1024
  const globalPendingPermissions = new Map<string, { resolve: (r: { allow: boolean }) => void; sessionId: string; tool: string; input: unknown; uid: string }>();
932
1025
  function isSessionBusy(sid: string): boolean {