feat(multiuser): phase 4, event fan-out + stream scoping

Scopes real-time streams and the init snapshot so a multi-user client only
receives what it owns. No-op in single-user mode (identity-less clients).

- WS terminal (ws-routes): owner gate after the session lookup. A non-admin may
  only attach to their own session (close 4003); the global auth hook already
  ran on the upgrade and decorated req.authUser, so an unauthenticated upgrade
  never reaches the handler.
- SSE (sse-stream-manager): per-client identity stored at addClient; broadcast()
  and the terminal-batch flush both enforce a routing hint via canDeliver().
  WebServer.broadcast auto-derives the hint (deriveSseHint): session-scoped event
  families resolve the owner from the payload's session id (fail closed when the
  owner can't be resolved), machine-level families (docker/tunnel/update/system/
  cron) + host-plan telemetry are admin-only, everything else stays global. Raw
  terminal bytes resolve the owner once and are withheld from non-owners.
- getLightState is filtered per connection AFTER the shared cache (sessions,
  respawnStatus, subagents, workflowRuns by owner; scheduledRuns + planUsage
  admin-only); applied to both the SSE init snapshot and GET /api/status.
- file-routes: getKnownSessionWorkingDir + getSessionAttachmentHistory (the
  preview/thumbnail/history helpers that bypass findSessionOrFail) now owner-check
  the session, closing a cross-user file-read path.
- GET /api/search: harvestSources is owner-scoped.

Deferred to a follow-up (documented in docs/multi-user-plan.md): away-digest +
subagent/workflow REST list scoping, push-subscription identity + routing,
per-user screenshot subdirs. The live-event versions of these are already routed
by the SSE hint; only the on-demand REST aggregates remain global for admins-only
follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 04:17:17 +02:00
parent 453605a58f
commit 2a06f7a5a8
7 changed files with 200 additions and 25 deletions
+12
View File
@@ -35,6 +35,7 @@ import type { SessionPort } from '../ports/session-port.js';
import { MAX_INPUT_LENGTH } from '../../config/terminal-limits.js';
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
import { WsConnectionRegistry } from '../ws-connection-registry.js';
import { canAccessOwned, getAuthUser } from '../route-helpers.js';
/** Micro-batch interval for terminal output (ms). Short enough for low latency,
* long enough to group Ink's rapid cursor-up redraw sequences into single frames. */
@@ -93,6 +94,17 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
return;
}
// Multi-user owner gate: writing to this socket injects keystrokes into the
// agent, so a non-admin may only attach to their OWN session. The global auth
// hook already ran on the upgrade request and decorated req.authUser (an
// unauthenticated upgrade never reaches here — the hook 401s the handshake).
// findSessionOrFail throws an HTTP-shaped error, so the check is inlined here
// as a 4003 close. No-op in single-user mode (canAccessOwned returns true).
if (!canAccessOwned(getAuthUser(req), session.owner)) {
socket.close(4003, 'Forbidden');
return;
}
// Structured transport logging — surfaces WS open/close/timeout churn so the
// tunnel-flap behavior (COD-134) is observable in the server logs. Fastify is
// configured logger:false, so we log via console (→ journald under systemd).