shraga 0.1.112 → 0.1.113

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.
Files changed (57) hide show
  1. package/README.md +2 -0
  2. package/defaults/mcps/README.md +6 -3
  3. package/defaults/skills/mcp-server.md +12 -5
  4. package/defaults/skills/platform.md +1 -1
  5. package/dist/client/assets/index-DIDtPQb-.css +10 -0
  6. package/dist/client/assets/index-DJ0AgGIu.js +1969 -0
  7. package/dist/client/index.html +2 -2
  8. package/package.json +3 -2
  9. package/src/client/App.tsx +33 -8
  10. package/src/client/components/BackendStatusBanner.tsx +62 -0
  11. package/src/client/components/ConfigPanel.tsx +61 -15
  12. package/src/client/components/ConversationHeader.tsx +5 -1
  13. package/src/client/components/McpManager.tsx +26 -9
  14. package/src/client/components/SkillsManager.tsx +48 -27
  15. package/src/client/hooks/useAuth.ts +11 -2
  16. package/src/client/hooks/useIsOwner.ts +24 -0
  17. package/src/client/hooks/useModules.ts +5 -1
  18. package/src/client/lib/api.ts +21 -5
  19. package/src/client/lib/backendHealth.ts +230 -0
  20. package/src/client/lib/debug.ts +48 -0
  21. package/src/client/lib/sessionApi.ts +24 -8
  22. package/src/client/lib/ws.ts +21 -13
  23. package/src/scripts/harden-audit.sh +55 -0
  24. package/src/server/api-key-routes.ts +64 -0
  25. package/src/server/api-keys.ts +181 -43
  26. package/src/server/auth.ts +113 -47
  27. package/src/server/boot.ts +158 -104
  28. package/src/server/claude.ts +98 -3
  29. package/src/server/data-sync.ts +55 -6
  30. package/src/server/directives.ts +2 -4
  31. package/src/server/engine/claude-code.ts +44 -14
  32. package/src/server/engine/types.ts +7 -0
  33. package/src/server/hooks.ts +19 -0
  34. package/src/server/mcp-oauth.ts +24 -5
  35. package/src/server/mcp-server.ts +55 -25
  36. package/src/server/modules/routes.ts +2 -6
  37. package/src/server/notify-owners.ts +5 -17
  38. package/src/server/owners.ts +14 -0
  39. package/src/server/scheduler/builtins.ts +3 -1
  40. package/src/server/scheduler/runner.ts +3 -0
  41. package/src/server/security/audit.ts +498 -0
  42. package/src/server/security/enforce.ts +306 -0
  43. package/src/server/security/escalate.ts +194 -0
  44. package/src/server/security/guard.ts +329 -0
  45. package/src/server/security/owner-only.ts +15 -0
  46. package/src/server/security/owner-routes.ts +43 -0
  47. package/src/server/security/policy.ts +413 -0
  48. package/src/server/security/principal.ts +80 -0
  49. package/src/server/security/revocation.ts +50 -0
  50. package/src/server/security/runtime.ts +174 -0
  51. package/src/server/sessions.ts +35 -0
  52. package/src/server/slack/bot.ts +44 -11
  53. package/src/server/slack/context-cache.ts +40 -7
  54. package/src/server/webhook-lane/feature.ts +17 -6
  55. package/src/shared/models.ts +11 -0
  56. package/dist/client/assets/index-DIMte_k6.css +0 -10
  57. package/dist/client/assets/index-Dc1ljSt3.js +0 -1949
@@ -4,6 +4,7 @@ import { Button } from './ui/button';
4
4
  import { Input } from './ui/input';
5
5
  import { Textarea } from './ui/textarea';
6
6
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
7
+ import { useIsOwner } from '@/hooks/useIsOwner';
7
8
 
8
9
  type DefaultSkillEntry = string | { name: string; capped?: boolean | number };
9
10
 
@@ -43,8 +44,21 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
43
44
  const [dirty, setDirty] = useState(false);
44
45
  const [action, setAction] = useState<SidebarAction>(null);
45
46
  const [actionName, setActionName] = useState('');
47
+ const [error, setError] = useState<string | null>(null);
48
+ const isOwner = useIsOwner(getToken, open);
49
+ const canEdit = isOwner === true;
46
50
 
47
51
  const isSelectedBuiltin = selected ? builtins.includes(selected) : false;
52
+ const readOnly = isSelectedBuiltin || !canEdit;
53
+
54
+ /** True if the mutation succeeded; otherwise surfaces the server's error and returns false. */
55
+ const ok = async (res: Response, what: string) => {
56
+ if (res.ok) { setError(null); return true; }
57
+ const msg = (await res.json().catch((e) => { console.warn(`[SkillsManager] ${what} error response not JSON`, res.status, e); return {}; })).error || `HTTP ${res.status}`;
58
+ console.warn(`[SkillsManager] ${what} failed`, msg);
59
+ setError(`${what} failed: ${msg}`);
60
+ return false;
61
+ };
48
62
 
49
63
  const loadList = async () => {
50
64
  const token = await getToken();
@@ -72,11 +86,11 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
72
86
  };
73
87
 
74
88
  const save = async () => {
75
- if (!selected || isSelectedBuiltin) return;
89
+ if (!selected || readOnly) return;
76
90
  const token = await getToken();
77
91
  if (!token) return;
78
- await apiFetch(`/api/skills/${selected}`, token, { method: 'PUT', body: JSON.stringify({ content }) });
79
- setDirty(false);
92
+ const res = await apiFetch(`/api/skills/${selected}`, token, { method: 'PUT', body: JSON.stringify({ content }) });
93
+ if (await ok(res, 'Save')) setDirty(false);
80
94
  };
81
95
 
82
96
  const submitAction = async () => {
@@ -86,17 +100,18 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
86
100
  if (!token) return;
87
101
 
88
102
  if (action === 'create') {
89
- await apiFetch(`/api/skills/${name}`, token, { method: 'PUT', body: JSON.stringify({ content: '' }) });
103
+ const res = await apiFetch(`/api/skills/${name}`, token, { method: 'PUT', body: JSON.stringify({ content: '' }) });
104
+ if (!await ok(res, 'Create')) return;
90
105
  await loadList();
91
106
  await loadSkill(name);
92
107
  } else if (action === 'duplicate' && selected) {
93
108
  const res = await apiFetch(`/api/skills/${selected}/duplicate`, token, { method: 'POST', body: JSON.stringify({ newName: name }) });
94
- if (!res.ok) return;
109
+ if (!await ok(res, 'Duplicate')) return;
95
110
  await loadList();
96
111
  await loadSkill(name);
97
112
  } else if (action === 'rename' && selected) {
98
113
  const res = await apiFetch(`/api/skills/${selected}/rename`, token, { method: 'POST', body: JSON.stringify({ newName: name }) });
99
- if (!res.ok) return;
114
+ if (!await ok(res, 'Rename')) return;
100
115
  await loadList();
101
116
  await loadSkill(name);
102
117
  }
@@ -109,7 +124,7 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
109
124
  const token = await getToken();
110
125
  if (!token) return;
111
126
  const res = await apiFetch(`/api/skills/${name}`, token, { method: 'DELETE' });
112
- if (!res.ok) return;
127
+ if (!await ok(res, 'Delete')) return;
113
128
  if (selected === name) { setSelected(null); setContent(''); }
114
129
  await loadList();
115
130
  };
@@ -120,8 +135,8 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
120
135
  const next = isDefault(defaults, name)
121
136
  ? defaults.filter((d) => entryName(d) !== name)
122
137
  : [...defaults, name];
123
- await apiFetch('/api/skills-defaults', token, { method: 'PUT', body: JSON.stringify(next) });
124
- setDefaults(next);
138
+ const res = await apiFetch('/api/skills-defaults', token, { method: 'PUT', body: JSON.stringify(next) });
139
+ if (await ok(res, 'Update defaults')) setDefaults(next);
125
140
  };
126
141
 
127
142
 
@@ -163,9 +178,10 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
163
178
  onClick={() => loadSkill(s)}
164
179
  >
165
180
  <button
166
- className={`shrink-0 transition-colors ${isDefault(defaults, s) ? 'text-amber-500' : 'text-muted-foreground/30 hover:text-amber-400'}`}
167
- onClick={(e) => { e.stopPropagation(); toggleDefault(s); }}
168
- title={isDefault(defaults, s) ? 'Remove from defaults' : 'Set as default (always active)'}
181
+ className={`shrink-0 transition-colors ${isDefault(defaults, s) ? 'text-amber-500' : 'text-muted-foreground/30'} ${canEdit && !isDefault(defaults, s) ? 'hover:text-amber-400' : ''}`}
182
+ disabled={!canEdit}
183
+ onClick={(e) => { e.stopPropagation(); if (canEdit) toggleDefault(s); }}
184
+ title={!canEdit ? (isDefault(defaults, s) ? 'Default (only an owner can change)' : 'Only an owner can change defaults') : isDefault(defaults, s) ? 'Remove from defaults' : 'Set as default (always active)'}
169
185
  >
170
186
  <Star className={`w-3 h-3 ${isDefault(defaults, s) ? 'fill-current' : ''}`} />
171
187
  </button>
@@ -173,7 +189,7 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
173
189
  {builtins.includes(s) && (
174
190
  <span title="Built-in (read-only)"><Lock className="w-2.5 h-2.5 text-muted-foreground/40 shrink-0" /></span>
175
191
  )}
176
- {!builtins.includes(s) && (
192
+ {!builtins.includes(s) && canEdit && (
177
193
  <button
178
194
  className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-opacity shrink-0"
179
195
  onClick={(e) => { e.stopPropagation(); remove(s); }}
@@ -200,11 +216,14 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
200
216
  <Button size="icon" className="h-7 w-7 shrink-0" onClick={submitAction}><Check className="w-3 h-3" /></Button>
201
217
  <Button size="icon" variant="ghost" className="h-7 w-7 shrink-0" onClick={cancelAction}><X className="w-3 h-3" /></Button>
202
218
  </div>
203
- ) : (
219
+ ) : canEdit ? (
204
220
  <Button variant="outline" size="sm" className="w-full h-7 text-xs" onClick={() => startAction('create')}>
205
221
  <Plus className="w-3 h-3 mr-1" /> New skill
206
222
  </Button>
223
+ ) : (
224
+ <p className="text-[11px] text-muted-foreground text-center">{isOwner === false ? 'Read-only — only an owner can change skills' : ''}</p>
207
225
  )}
226
+ {error && <p className="text-[11px] text-destructive mt-1 break-words">{error}</p>}
208
227
  </div>
209
228
  </div>
210
229
 
@@ -219,7 +238,7 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
219
238
  <span>{selected}</span>
220
239
  </div>
221
240
  <div className="flex items-center gap-1.5">
222
- {isSelectedBuiltin && (
241
+ {readOnly && (
223
242
  <span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-medium flex items-center gap-0.5">
224
243
  <Lock className="w-2.5 h-2.5" /> read-only
225
244
  </span>
@@ -234,14 +253,16 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
234
253
  </span>
235
254
  </>
236
255
  )}
237
- <Button
238
- variant="ghost" size="sm" className="h-6 text-xs px-1.5"
239
- onClick={() => startAction('duplicate')}
240
- title="Duplicate"
241
- >
242
- <Copy className="w-3 h-3" />
243
- </Button>
244
- {!isSelectedBuiltin && (
256
+ {canEdit && (
257
+ <Button
258
+ variant="ghost" size="sm" className="h-6 text-xs px-1.5"
259
+ onClick={() => startAction('duplicate')}
260
+ title="Duplicate"
261
+ >
262
+ <Copy className="w-3 h-3" />
263
+ </Button>
264
+ )}
265
+ {!readOnly && (
245
266
  <Button
246
267
  variant="ghost" size="sm" className="h-6 text-xs px-1.5"
247
268
  onClick={() => startAction('rename')}
@@ -250,7 +271,7 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
250
271
  <Pencil className="w-3 h-3" />
251
272
  </Button>
252
273
  )}
253
- {dirty && !isSelectedBuiltin && (
274
+ {dirty && !readOnly && (
254
275
  <Button size="sm" className="h-6 text-xs" onClick={save}>
255
276
  <Check className="w-3 h-3 mr-1" /> Save
256
277
  </Button>
@@ -259,9 +280,9 @@ export function SkillsManager({ getToken, onSkillsChange, trigger }: Props) {
259
280
  </div>
260
281
  <Textarea
261
282
  value={content}
262
- onChange={(e) => { if (!isSelectedBuiltin) { setContent(e.target.value); setDirty(true); } }}
263
- readOnly={isSelectedBuiltin}
264
- className={`flex-1 resize-none rounded-none border-0 font-mono text-xs focus-visible:ring-0 ${isSelectedBuiltin ? 'opacity-70 cursor-default' : ''}`}
283
+ onChange={(e) => { if (!readOnly) { setContent(e.target.value); setDirty(true); } }}
284
+ readOnly={readOnly}
285
+ className={`flex-1 resize-none rounded-none border-0 font-mono text-xs focus-visible:ring-0 ${readOnly ? 'opacity-70 cursor-default' : ''}`}
265
286
  placeholder="Write the skill instructions here…"
266
287
  />
267
288
  </>
@@ -1,5 +1,9 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { onAuth, signOutUser, hasFirebase } from '@/lib/firebase';
3
+ import { reportApiFailure, reportApiResponse } from '@/lib/backendHealth';
4
+ import { logger } from '@/lib/debug';
5
+
6
+ const log = logger.forComponent('useAuth');
3
7
 
4
8
  export interface AuthUser {
5
9
  uid: string;
@@ -47,13 +51,18 @@ export function useAuth(): AuthState {
47
51
  let m: 'local' | 'firebase' = hasFirebase ? 'firebase' : 'local';
48
52
  try {
49
53
  const r = await fetch('/api/auth/mode');
54
+ // The ONLY unauthenticated request the app makes, so it is the one chance to detect a hijacked
55
+ // backend BEFORE login. Without this a port-hijack just renders the login page forever with no
56
+ // explanation — the sign-in never succeeds and nothing on screen says why.
57
+ reportApiResponse('/api/auth/mode', r);
50
58
  if (r.ok) {
51
59
  const d = await r.json();
52
60
  m = d.provider === 'firebase' ? 'firebase' : 'local';
53
61
  setNeedsSetup(!!d.needsSetup);
54
62
  }
55
- } catch {
56
- /* fall back to firebase-presence heuristic */
63
+ } catch (err) {
64
+ reportApiFailure('/api/auth/mode', err);
65
+ log.warn('auth-mode probe failed — falling back to the firebase-presence heuristic', err);
57
66
  }
58
67
  setMode(m);
59
68
 
@@ -0,0 +1,24 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ /**
4
+ * Whether the signed-in caller is an owner — `isOwner` on `GET /api/config` (derived server-side, never persisted).
5
+ * `undefined` until known. A UI hint only: the server enforces owner-only mutations regardless.
6
+ */
7
+ export function useIsOwner(getToken: () => Promise<string | null>, enabled = true): boolean | undefined {
8
+ const [isOwner, setIsOwner] = useState<boolean>();
9
+
10
+ useEffect(() => {
11
+ if (!enabled) return;
12
+ let alive = true;
13
+ getToken().then((token) => {
14
+ if (!token || !alive) return;
15
+ fetch('/api/config', { headers: { Authorization: `Bearer ${token}` } })
16
+ .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
17
+ .then((c: { isOwner?: boolean }) => { if (alive) setIsOwner(!!c.isOwner); })
18
+ .catch((e) => console.warn('[isOwner] load failed', e));
19
+ });
20
+ return () => { alive = false; };
21
+ }, [enabled, getToken]);
22
+
23
+ return isOwner;
24
+ }
@@ -42,7 +42,11 @@ export function useModules(getToken: () => Promise<string | null>, enabled: bool
42
42
  setLoading(true);
43
43
  setError(null);
44
44
  try {
45
- const data = await api<{ installed: InstalledModule[]; available: AvailableModule[] }>('/api/modules', getToken);
45
+ const data = await api<{ installed: InstalledModule[]; available: AvailableModule[] }>('/api/modules', getToken, {
46
+ // A 404 here is the documented "server predates /api/modules" probe below — a handled outcome,
47
+ // not a backend fault, so it must not raise the health banner.
48
+ expect: [404],
49
+ });
46
50
  setInstalled(data.installed ?? []);
47
51
  setAvailable(data.available ?? []);
48
52
  setUnsupported(false);
@@ -1,10 +1,26 @@
1
+ import { reportApiFailure, reportApiResponse } from '@/lib/backendHealth';
2
+
1
3
  /** Minimal authenticated JSON fetch helper. Throws `Error("<status> <statusText>")` or the server's `error` field. */
2
- export async function api<T>(path: string, getToken: () => Promise<string | null>, init?: RequestInit): Promise<T> {
4
+ export async function api<T>(
5
+ path: string,
6
+ getToken: () => Promise<string | null>,
7
+ /** `expect`: statuses this call site handles as normal — see `apiFetch`. Still throws; it only keeps
8
+ * the backend-health banner from flagging a by-design non-2xx. */
9
+ init?: RequestInit & { expect?: readonly number[] },
10
+ ): Promise<T> {
3
11
  const token = await getToken();
4
- const res = await fetch(path, {
5
- ...init,
6
- headers: { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json', ...init?.headers },
7
- });
12
+ let res: Response;
13
+ try {
14
+ res = await fetch(path, {
15
+ ...init,
16
+ headers: { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json', ...init?.headers },
17
+ });
18
+ } catch (err) {
19
+ // Single choke point: every caller of api() gets backend-fault surfacing with no call-site change.
20
+ reportApiFailure(path, err);
21
+ throw err;
22
+ }
23
+ reportApiResponse(path, res, init?.expect);
8
24
  if (!res.ok) {
9
25
  const body = await res.json().catch(() => ({}));
10
26
  throw new Error(body.error || `${res.status} ${res.statusText}`);
@@ -0,0 +1,230 @@
1
+ /** Backend-health classifier + tiny store.
2
+ *
3
+ * WHY this exists: a broken backend used to be INVISIBLE in the UI. When something other than shraga
4
+ * answered the app's port (e.g. another project's dev server bound `127.0.0.1:3033` while shraga held
5
+ * `*:3033` — macOS routes the loopback name to the MORE SPECIFIC bind, so the proxy fed every request
6
+ * to the wrong process), the foreign server 404'd `/api/config`, `/api/workspace` and refused the `/ws`
7
+ * upgrade. The UI rendered a normal-looking EMPTY shell: no workspace, no terminals, no error. Every
8
+ * clue was in the browser console and nothing reached the screen.
9
+ *
10
+ * The load-bearing signal is {@link SHRAGA_VERSION_HEADER}: the shraga server stamps it on EVERY
11
+ * response including 404/401/5xx (src/server/boot.ts). So a response WITHOUT it did not come from
12
+ * shraga at all — which turns an ambiguous "some 404" into the exact diagnosis "something else is
13
+ * serving this port".
14
+ *
15
+ * Producers report here from the SHARED helpers (`apiFetch`, `api`, `AgentSocket`) so every existing
16
+ * call site benefits with no call-site change. The single consumer is <BackendStatusBanner />.
17
+ */
18
+ import { logger } from '@/lib/debug';
19
+
20
+ const log = logger.forComponent('BackendHealth');
21
+
22
+ /** Identity header stamped by the shraga server on every response. Lowercase: `Headers` is
23
+ * case-insensitive, but keeping it lowercase avoids any doubt about what we're matching. */
24
+ export const SHRAGA_VERSION_HEADER = 'x-shraga-version';
25
+
26
+ export type BackendFaultKind = 'wrong-backend' | 'server-error' | 'offline' | 'ws-down';
27
+
28
+ export interface BackendFault {
29
+ kind: BackendFaultKind;
30
+ /** One-line headline for the banner. */
31
+ title: string;
32
+ /** Actionable next step — what the user should actually go do. */
33
+ hint: string;
34
+ /** The request that exposed the fault, when there is one. */
35
+ path?: string;
36
+ status?: number;
37
+ }
38
+
39
+ /** Fault sources are tracked SEPARATELY: HTTP recovering must not erase a still-broken WebSocket
40
+ * (that is exactly the "terminals stay blank while the page looks fine" half of the incident). */
41
+ type Source = 'http' | 'ws';
42
+ const faults: Partial<Record<Source, BackendFault>> = {};
43
+ const raisedAt: Partial<Record<Source, number>> = {};
44
+
45
+ /** A raised fault does NOT vanish on the very next good response. The app polls continuously (pty cwd,
46
+ * workspace, layout), so a single interleaved success would otherwise erase the banner milliseconds
47
+ * after it appeared — leaving a user staring at a flicker they can neither read nor act on. Recovery
48
+ * must therefore be CONFIRMED: several consecutive good responses AND a minimum time on screen. */
49
+ /** ...and the SAME reasoning governs the WebSocket. An ordinary reconnect blip closes and re-opens in
50
+ * a couple of seconds (backoff is ~1s * 2^attempt + jitter); painting and erasing an amber banner
51
+ * inside that window is the flicker described above, not information. So a close is given
52
+ * `wsGraceMs` to recover before it becomes a fault at all — which still catches the case that
53
+ * motivated the feature, a socket that never comes back (including one wedged in CONNECTING, where
54
+ * counting reconnect ATTEMPTS would never trip) — and a recovered socket serves the same
55
+ * `minDwellMs` before the banner is taken down. */
56
+ const DEFAULT_TIMING = { okStreakToClear: 3, minDwellMs: 5_000, wsGraceMs: 5_000 };
57
+ type Timing = typeof DEFAULT_TIMING;
58
+ let timing: Timing = { ...DEFAULT_TIMING };
59
+ let okStreak = 0;
60
+ /** The single pending ws timer: a delayed RAISE while no ws fault is shown, a delayed CLEAR while one
61
+ * is (`faults.ws` disambiguates, and every path that changes that state cancels it first). */
62
+ let wsTimer: ReturnType<typeof setTimeout> | null = null;
63
+ function cancelWsTimer() {
64
+ if (wsTimer) { clearTimeout(wsTimer); wsTimer = null; }
65
+ }
66
+
67
+ /** Worst-first. `wrong-backend` outranks everything: when the port is hijacked the 404s and the dead
68
+ * socket are SYMPTOMS, and showing either of those instead would send the user down the wrong path. */
69
+ const PRECEDENCE: BackendFaultKind[] = ['wrong-backend', 'offline', 'server-error', 'ws-down'];
70
+
71
+ type Listener = (fault: BackendFault | null) => void;
72
+ const listeners = new Set<Listener>();
73
+
74
+ export function currentFault(): BackendFault | null {
75
+ const present = (Object.values(faults) as BackendFault[]).filter(Boolean);
76
+ if (present.length === 0) return null;
77
+ return present.sort((a, b) => PRECEDENCE.indexOf(a.kind) - PRECEDENCE.indexOf(b.kind))[0];
78
+ }
79
+
80
+ function emit() {
81
+ const f = currentFault();
82
+ listeners.forEach((l) => l(f));
83
+ }
84
+
85
+ export function subscribeBackendHealth(listener: Listener): () => void {
86
+ listeners.add(listener);
87
+ listener(currentFault());
88
+ return () => { listeners.delete(listener); };
89
+ }
90
+
91
+ function set(source: Source, fault: BackendFault) {
92
+ const prev = faults[source];
93
+ faults[source] = fault;
94
+ // Restart the dwell when the fault CHANGES KIND too: a wrong-backend that supersedes an older
95
+ // server-error is a new message and needs its own time on screen, not the predecessor's leftovers.
96
+ if (prev?.kind !== fault.kind) raisedAt[source] = Date.now();
97
+ if (source === 'http') okStreak = 0;
98
+ // Log the transition only, not every repeat — a broken backend is polled continuously.
99
+ if (prev?.kind !== fault.kind || prev?.path !== fault.path || prev?.status !== fault.status) {
100
+ log.warn(`${fault.kind}: ${fault.title}`, { path: fault.path, status: fault.status });
101
+ }
102
+ emit();
103
+ }
104
+
105
+ function clear(source: Source) {
106
+ if (!faults[source]) return;
107
+ log.info(`${source} recovered`);
108
+ delete faults[source];
109
+ delete raisedAt[source];
110
+ emit();
111
+ }
112
+
113
+ /** Describe the origin the app believes it is talking to, for the wrong-backend message. */
114
+ function originLabel(): string {
115
+ try { return window.location.origin; } catch { return 'this origin'; }
116
+ }
117
+
118
+ /**
119
+ * Classify a COMPLETED HTTP response from the shraga API. Call for ok and !ok alike — a wrong backend
120
+ * can answer `200` just as easily as `404`, so the identity header is checked first, unconditionally.
121
+ *
122
+ * `expect` lists statuses this CALL SITE treats as a normal, handled outcome (e.g. the pty-cwd poller,
123
+ * for which 404 means "that pane is gone" and is swallowed by design). An expected status is not a
124
+ * fault: raising a banner for a by-design 404 is the cry-wolf failure this whole module exists to
125
+ * avoid. It is checked AFTER the identity header, so a hijacker answering 404 is still caught.
126
+ */
127
+ export function reportApiResponse(path: string, res: Response, expect?: readonly number[]): void {
128
+ if (!res.headers.has(SHRAGA_VERSION_HEADER)) {
129
+ set('http', {
130
+ kind: 'wrong-backend',
131
+ title: `${originLabel()} is being served by something that is not Shraga.`,
132
+ hint:
133
+ 'Another process is almost certainly bound to the app port and shadowing the real server ' +
134
+ '(a more specific 127.0.0.1 bind wins over Shraga’s wildcard bind). Find it with ' +
135
+ '`lsof -nP -iTCP:<port> -sTCP:LISTEN`, stop it, then retry.',
136
+ path,
137
+ status: res.status,
138
+ });
139
+ return;
140
+ }
141
+ // Genuine shraga auth responses are the ORDINARY login/permission flow — never a banner. Checked
142
+ // after the header so a hijacker that answers 401 is still caught above.
143
+ if (res.status === 401 || res.status === 403) return;
144
+ // Handled by the caller — neither a fault nor evidence of recovery.
145
+ if (expect?.includes(res.status)) return;
146
+
147
+ if (!res.ok) {
148
+ set('http', {
149
+ kind: 'server-error',
150
+ title: `Shraga returned ${res.status} for ${path}.`,
151
+ hint: 'The server is reachable but this request failed. Retry; if it persists, check the server logs.',
152
+ path,
153
+ status: res.status,
154
+ });
155
+ return;
156
+ }
157
+ okStreak++;
158
+ if (faults.http && okStreak >= timing.okStreakToClear && Date.now() - (raisedAt.http ?? 0) >= timing.minDwellMs) {
159
+ clear('http');
160
+ }
161
+ }
162
+
163
+ /** Classify a request that never produced a response: network down, server unreachable, or aborted.
164
+ *
165
+ * `timedOut` says the HELPER's OWN deadline fired (`apiFetch`'s `timeoutMs` controller). It is the
166
+ * only thing that separates a genuine wedged server from a CALLER-initiated `abort()` — both surface
167
+ * as an indistinguishable `AbortError`. A caller abort is ordinary control flow, not a fault: the tab
168
+ * palette re-issues its search on every keystroke and aborts the in-flight one, so raising here
169
+ * painted a red "cannot reach the server" banner for plain typing. Raise NOTHING for it.
170
+ */
171
+ export function reportApiFailure(path: string, err: unknown, opts?: { timedOut?: boolean }): void {
172
+ const aborted = (err as { name?: string } | null)?.name === 'AbortError';
173
+ if (aborted && !opts?.timedOut) return;
174
+ set('http', {
175
+ kind: 'offline',
176
+ title: opts?.timedOut ? `Request to ${path} timed out.` : `Cannot reach the Shraga server.`,
177
+ hint: opts?.timedOut
178
+ ? 'The server accepted the connection but did not answer in time. It may be overloaded or wedged.'
179
+ : 'The network is down, or nothing is listening on the app port. Check your connection and that the server is running.',
180
+ path,
181
+ });
182
+ }
183
+
184
+ /** WebSocket health, reported by {@link AgentSocket}. A dead socket is why terminals and live updates
185
+ * silently stop; without this the panes just stay blank.
186
+ *
187
+ * A close does NOT raise on its own — it starts the grace window (see {@link DEFAULT_TIMING}). Only a
188
+ * socket still down when the window expires is a fault the user can act on. */
189
+ export function reportWsDown(code?: number): void {
190
+ // Already on screen: a re-close during the recovery dwell just keeps the banner up.
191
+ if (faults.ws) { cancelWsTimer(); return; }
192
+ // Grace already running from an earlier close in the same outage — don't restart it, or a socket
193
+ // that flaps every few seconds would push the deadline out forever and never raise.
194
+ if (wsTimer) return;
195
+ const raise = () => {
196
+ wsTimer = null;
197
+ set('ws', {
198
+ kind: 'ws-down',
199
+ title: 'Live connection lost — terminals and streaming updates are offline.',
200
+ hint:
201
+ code === 1006
202
+ ? 'The connection was refused or dropped without a close handshake, which usually means the ' +
203
+ 'WebSocket upgrade never reached Shraga. Reconnecting…'
204
+ : 'Reconnecting…',
205
+ status: code,
206
+ });
207
+ };
208
+ if (timing.wsGraceMs <= 0) raise();
209
+ else wsTimer = setTimeout(raise, timing.wsGraceMs);
210
+ }
211
+
212
+ export function reportWsUp(): void {
213
+ cancelWsTimer(); // a blip that recovered inside the grace window never earned a banner
214
+ if (!faults.ws) return;
215
+ const remaining = timing.minDwellMs - (Date.now() - (raisedAt.ws ?? 0));
216
+ if (remaining <= 0) { clear('ws'); return; }
217
+ wsTimer = setTimeout(() => { wsTimer = null; clear('ws'); }, remaining);
218
+ }
219
+
220
+ /** Test seam — drop all state, and optionally shrink the timings so a test needn't wait seconds. */
221
+ export function __resetBackendHealth(overrides?: Partial<Timing>): void {
222
+ cancelWsTimer();
223
+ timing = { ...DEFAULT_TIMING, ...overrides };
224
+ delete faults.http;
225
+ delete faults.ws;
226
+ delete raisedAt.http;
227
+ delete raisedAt.ws;
228
+ okStreak = 0;
229
+ emit();
230
+ }
@@ -0,0 +1,48 @@
1
+ /** Component-scoped console logger.
2
+ *
3
+ * `debug`/`verbose` are development output and are GATED per component — they print only when the
4
+ * component's name (or `*`) appears in the comma-separated `DEBUG` localStorage key:
5
+ * localStorage.setItem('DEBUG', 'BackendHealth,ws')
6
+ * `info`/`warn`/`error` are operational and ALWAYS print. Use this instead of raw `console.*` so a
7
+ * noisy module can be silenced without deleting the logging that diagnoses a live incident.
8
+ */
9
+
10
+ type LogFn = (...args: unknown[]) => void;
11
+
12
+ export interface ComponentLogger {
13
+ debug: LogFn;
14
+ verbose: LogFn;
15
+ info: LogFn;
16
+ warn: LogFn;
17
+ error: LogFn;
18
+ }
19
+
20
+ /** Read once per call rather than caching: a dev flipping `DEBUG` in devtools expects it to take
21
+ * effect without a reload, and this only runs on a log call that is already about to hit console. */
22
+ function gateAllows(component: string): boolean {
23
+ let raw: string | null = null;
24
+ try {
25
+ raw = localStorage.getItem('DEBUG');
26
+ } catch {
27
+ return false; // storage blocked (private mode / sandboxed iframe) — dev output is not worth throwing over
28
+ }
29
+ if (!raw) return false;
30
+ return raw
31
+ .split(',')
32
+ .map((s) => s.trim())
33
+ .some((s) => s === '*' || s === component);
34
+ }
35
+
36
+ export const logger = {
37
+ forComponent(component: string): ComponentLogger {
38
+ const tag = `[${component}]`;
39
+ const gated = (fn: LogFn): LogFn => (...args) => { if (gateAllows(component)) fn(tag, ...args); };
40
+ return {
41
+ debug: gated((...a) => console.debug(...a)),
42
+ verbose: gated((...a) => console.debug(...a)),
43
+ info: (...a) => console.info(tag, ...a),
44
+ warn: (...a) => console.warn(tag, ...a),
45
+ error: (...a) => console.error(tag, ...a),
46
+ };
47
+ },
48
+ };
@@ -1,3 +1,4 @@
1
+ import { reportApiFailure, reportApiResponse } from '@/lib/backendHealth';
1
2
  import { randomUUID } from '@/lib/utils';
2
3
  import type { ChatMessage, MessageBlock } from '@/hooks/useConversation';
3
4
 
@@ -15,24 +16,39 @@ export class ApiError extends Error {
15
16
  }
16
17
  }
17
18
 
18
- /** Authenticated fetch with bearer token + timeout. Throws `ApiError` (with `.status`) on !ok. */
19
+ /** Authenticated fetch with bearer token + timeout. Throws `ApiError` (with `.status`) on !ok.
20
+ *
21
+ * `expect` lists statuses this call site HANDLES as a normal outcome (it still throws — see `ApiError`
22
+ * — the list only tells the backend-health classifier not to treat them as a fault). Use it wherever a
23
+ * non-2xx is by design, or the shared banner cries wolf on routine traffic. */
19
24
  export async function apiFetch(
20
25
  path: string,
21
26
  getToken: () => Promise<string | null>,
22
- init?: RequestInit & { timeoutMs?: number },
27
+ init?: RequestInit & { timeoutMs?: number; expect?: readonly number[] },
23
28
  ) {
24
29
  const token = await getToken();
25
30
  if (!token) throw new Error('No auth token');
26
31
  const timeout = init?.timeoutMs ?? 15_000;
27
32
  const controller = new AbortController();
28
33
  if (init?.signal) init.signal.addEventListener('abort', () => controller.abort());
29
- const timer = setTimeout(() => controller.abort(), timeout);
34
+ // `timedOut` is the ONLY thing that distinguishes our own deadline from a caller's abort() — both
35
+ // reject with an identical AbortError, and only the former is a real backend fault.
36
+ let timedOut = false;
37
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeout);
30
38
  try {
31
- const res = await fetch(path, {
32
- ...init,
33
- signal: controller.signal,
34
- headers: { Authorization: `Bearer ${token}`, ...init?.headers },
35
- });
39
+ let res: Response;
40
+ try {
41
+ res = await fetch(path, {
42
+ ...init,
43
+ signal: controller.signal,
44
+ headers: { Authorization: `Bearer ${token}`, ...init?.headers },
45
+ });
46
+ } catch (err) {
47
+ // Single choke point: every apiFetch call site gets backend-fault surfacing for free.
48
+ reportApiFailure(path, err, { timedOut });
49
+ throw err;
50
+ }
51
+ reportApiResponse(path, res, init?.expect);
36
52
  if (!res.ok) throw new ApiError(res.status, res.statusText);
37
53
  return res;
38
54
  } finally {