shraga 0.1.23 → 0.1.25

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.23",
3
+ "version": "0.1.25",
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';
@@ -153,11 +153,18 @@ app.use(express.urlencoded({
153
153
  verify: (req, _res, buf) => { (req as any).rawBody = buf; },
154
154
  }));
155
155
 
156
+ // High-frequency poll routes (pty cwd, list refreshes) are quiet on success — they'd otherwise
157
+ // drown real request logs. Errors and everything else still logs unconditionally.
158
+ const QUIET_POLL_RE = /\/(cwd|ptys|sessions|workspace\/(pty-owners|layout))(\?|$)/;
159
+
156
160
  app.use((req, _res, next) => {
157
161
  const start = Date.now();
158
162
  const orig = _res.end.bind(_res);
159
163
  (_res as any).end = (...args: any[]) => {
160
- console.log(`[http] ${req.method} ${req.url} ${_res.statusCode} (${Date.now() - start}ms)`);
164
+ const quiet = req.method === 'GET' && _res.statusCode < 400 && QUIET_POLL_RE.test(req.url);
165
+ if (!quiet) {
166
+ console.log(`[http] ${req.method} ${req.url} → ${_res.statusCode} (${Date.now() - start}ms)`);
167
+ }
161
168
  return orig(...args);
162
169
  };
163
170
  next();
@@ -834,11 +841,58 @@ function resolveSidecarPort(urlPath: string): number | null {
834
841
  return prefix ? WS_PROXY_ROUTES[prefix] ?? null : null;
835
842
  }
836
843
 
837
- const sidecarWss = new WebSocketServer({ noServer: true });
844
+ /** Handshake subprotocol marker carrying the caller's token: `[WS_AUTH_PROTOCOL, <token>]`.
845
+ * Keep in sync with `WS_AUTH_PROTOCOL` in src/client/lib/ws.ts (the client half). */
846
+ const WS_AUTH_PROTOCOL = 'shraga.auth';
847
+
848
+ // handleProtocols is explicit so the accepted subprotocol is always the marker — never the token
849
+ // itself (ws's default picks the first offered, and echoing a credential back in a response header
850
+ // would put it in any intermediary's log). Only consulted when the client offered protocols at all,
851
+ // so a header-authenticated non-browser client is unaffected.
852
+ const sidecarWss = new WebSocketServer({ noServer: true, handleProtocols: (protocols) => (protocols.has(WS_AUTH_PROTOCOL) ? WS_AUTH_PROTOCOL : false) });
853
+
854
+ /** Authenticate a WS upgrade. Token comes from the subprotocol list (the only browser-settable
855
+ * handshake field), falling back to a bearer header for non-browser clients (curl/CLI/tests). */
856
+ async function authenticateWsUpgrade(req: import('node:http').IncomingMessage): Promise<import('./auth.ts').AuthUser | null> {
857
+ const protocols = (req.headers['sec-websocket-protocol'] as string | undefined)?.split(',').map((s) => s.trim()) ?? [];
858
+ const token = protocols[0] === WS_AUTH_PROTOCOL && protocols[1]
859
+ ? protocols[1]
860
+ : req.headers.authorization?.replace('Bearer ', '');
861
+ return authenticateToken(token);
862
+ }
863
+
864
+ /** Reject a WS upgrade with a real HTTP response before destroying the socket — same shape as ws's own
865
+ * internal `abortHandshake`. A bare `socket.destroy()` writes ZERO bytes, so the client sees a generic
866
+ * "connection failed" and an expired token is indistinguishable from "server is down".
867
+ *
868
+ * CAVEAT, measured, do not assume this reaches the client today: under Bun (1.3.10, the runtime this
869
+ * server actually runs on) the socket handed to a `node:http` 'upgrade' listener is write-dead — it
870
+ * reports `writable: true` but has no `_handle`, and NOTHING written to it is ever delivered. Probed in
871
+ * an isolated two-runtime rig (plain `createServer` + 'upgrade' listener, raw TCP client): Node 24
872
+ * delivered the 401 for every variant (end / write / paused / unpaused); Bun 1.3.10 delivered zero bytes
873
+ * for all of them, including a direct `_handle.write`. ws's own `abortHandshake` would fail identically —
874
+ * it is the same `socket.end(...)`. So on Bun the observable behaviour is still a bare close.
875
+ * This is kept because it is the correct shape, costs nothing, still closes the socket, and starts
876
+ * working the moment Bun implements it (or the process runs under Node). Until then, do NOT build a
877
+ * client-side "auth error" affordance on top of it — the client cannot see this response. */
878
+ function abortUpgrade(socket: import('node:stream').Duplex, code: number, message?: string) {
879
+ if (socket.destroyed || !socket.writable) return;
880
+ const body = message ?? STATUS_CODES[code] ?? '';
881
+ const headers = ['Connection: close', 'Content-Type: text/plain', `Content-Length: ${Buffer.byteLength(body)}`];
882
+ socket.once('finish', () => socket.destroy());
883
+ // The socket was paused for the auth gap; pause() only stops READS, so it is still writable here.
884
+ try { socket.end(`HTTP/1.1 ${code} ${STATUS_CODES[code] ?? 'Error'}\r\n${headers.join('\r\n')}\r\n\r\n${body}`); }
885
+ catch { socket.destroy(); }
886
+ }
838
887
 
839
888
  function proxySidecarWebSocket(req: import('node:http').IncomingMessage, socket: import('node:stream').Duplex, head: Buffer, port: number) {
840
889
  const targetUrl = `ws://127.0.0.1:${port}${req.url}`;
841
890
  sidecarWss.handleUpgrade(req, socket as any, head, (clientWs) => {
891
+ // The caller paused the socket for the async auth gap; ws has now attached its own 'data' listener
892
+ // (handleUpgrade → setSocket runs before this callback), so it's safe — and necessary — to resume:
893
+ // an EXPLICITLY paused socket does not re-enter flowing mode just because a listener was added, so
894
+ // without this every buffered byte and every subsequent keystroke would sit unread forever.
895
+ socket.resume();
842
896
  const targetWs = new WebSocket(targetUrl);
843
897
  let opened = false;
844
898
 
@@ -918,9 +972,50 @@ server.on('upgrade', (req, socket, head) => {
918
972
  } else {
919
973
  const port = req.url ? resolveSidecarPort(req.url) : null;
920
974
  if (port) {
921
- proxySidecarWebSocket(req, socket, head, port);
975
+ // A sidecar socket is a LIVE ATTACHED SHELL (para-pty) — read/write on the user's terminals. It
976
+ // must be authenticated BEFORE we bridge it, like the HTTP routes (`requireAuth`) and the `/ws`
977
+ // control socket (its `auth` message). NOTE the limit of this gate: it is IDENTITY-ONLY. It proves
978
+ // the token belongs to a valid user; it does NOT check that the user owns (or may access) the
979
+ // `sessionId` in the URL, so it is weaker than routes that additionally authorize — e.g. the HTTP
980
+ // pty kill route, which checks group membership. Any authenticated user can currently attach to any
981
+ // session id. Unlike those, there's no place to put a bearer header:
982
+ // a browser can't set headers on a WebSocket handshake. The one field it CAN set is the
983
+ // subprotocol list, so the client sends `[WS_AUTH_PROTOCOL, <token>]` (see ptyWsUrl's consumer) and
984
+ // we verify entry 1 with the same token seam requireAuth uses. Works identically over the `.local`
985
+ // HTTPS origin and the cloudflared tunnel — Sec-WebSocket-Protocol is a standard handshake header
986
+ // both pass through, and unlike `?token=` it never lands in an access log or a Referer.
987
+ // Pause for the async gap. Node hands us the raw socket with its own parser detached, so nothing
988
+ // is reading it while we verify; pausing makes that explicit and guarantees bytes the client sends
989
+ // between the handshake and our decision are buffered, not dropped. proxySidecarWebSocket resumes
990
+ // it once ws owns the socket.
991
+ socket.pause();
992
+ // Fail CLOSED on anything: a rejected/thrown/slow auth must destroy the socket, never leave it
993
+ // dangling. Without the catch a throw in authenticateWsUpgrade (token store unavailable, malformed
994
+ // header) produced an unhandled rejection AND an open, unauthenticated, un-proxied socket; without
995
+ // the timeout a hung verifier held the socket and its fd open indefinitely.
996
+ // The catch sits on the auth promise itself rather than on the race, so a rejection that arrives
997
+ // AFTER the timeout already won is visibly logged instead of vanishing into the race's own
998
+ // (already-settled) internal handler.
999
+ const authed = authenticateWsUpgrade(req)
1000
+ .catch((err) => { console.error('[ws-proxy] auth threw for upgrade:', (err as Error)?.message ?? err); return null; });
1001
+ // Cleared on settle: an uncleared 5s timer pins this socket/req/head closure alive for 5s per
1002
+ // upgrade even when auth resolved in milliseconds.
1003
+ let authTimer: ReturnType<typeof setTimeout> | undefined;
1004
+ const timedOut = new Promise<null>((resolve) => { authTimer = setTimeout(() => resolve(null), WS_AUTH_TIMEOUT_MS); authTimer.unref?.(); });
1005
+ void Promise.race([authed, timedOut])
1006
+ .then((user) => {
1007
+ if (authTimer) clearTimeout(authTimer);
1008
+ if (socket.destroyed) return;
1009
+ if (!user) {
1010
+ console.warn(`[ws-proxy] rejected unauthenticated upgrade for ${req.url?.split('?')[0]}`);
1011
+ abortUpgrade(socket, 401, 'Unauthorized: missing or invalid token');
1012
+ return;
1013
+ }
1014
+ proxySidecarWebSocket(req, socket, head, port);
1015
+ });
922
1016
  } else {
923
- socket.destroy();
1017
+ // No sidecar registered for this path — a client error, not an auth failure.
1018
+ abortUpgrade(socket, 400, 'Unknown upgrade path');
924
1019
  }
925
1020
  }
926
1021
  });
@@ -931,6 +1026,7 @@ function send(ws: WebSocket, data: object) {
931
1026
 
932
1027
  const DESTRUCTIVE_PERMISSION_TTL = 10 * 60_000;
933
1028
  const WS_PING_INTERVAL = 30_000;
1029
+ const WS_AUTH_TIMEOUT_MS = 5_000; // upper bound on sidecar-upgrade auth; past it the socket is destroyed
934
1030
  const activeConnections = new Map<WebSocket, WsSession>();
935
1031
  const globalPendingPermissions = new Map<string, { resolve: (r: { allow: boolean }) => void; sessionId: string; tool: string; input: unknown; uid: string }>();
936
1032
  function isSessionBusy(sid: string): boolean {