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.
- package/README.md +2 -0
- package/defaults/mcps/README.md +6 -3
- package/defaults/skills/mcp-server.md +12 -5
- package/defaults/skills/platform.md +1 -1
- package/dist/client/assets/index-DIDtPQb-.css +10 -0
- package/dist/client/assets/index-DJ0AgGIu.js +1969 -0
- package/dist/client/index.html +2 -2
- package/package.json +3 -2
- package/src/client/App.tsx +33 -8
- package/src/client/components/BackendStatusBanner.tsx +62 -0
- package/src/client/components/ConfigPanel.tsx +61 -15
- package/src/client/components/ConversationHeader.tsx +5 -1
- package/src/client/components/McpManager.tsx +26 -9
- package/src/client/components/SkillsManager.tsx +48 -27
- package/src/client/hooks/useAuth.ts +11 -2
- package/src/client/hooks/useIsOwner.ts +24 -0
- package/src/client/hooks/useModules.ts +5 -1
- package/src/client/lib/api.ts +21 -5
- package/src/client/lib/backendHealth.ts +230 -0
- package/src/client/lib/debug.ts +48 -0
- package/src/client/lib/sessionApi.ts +24 -8
- package/src/client/lib/ws.ts +21 -13
- package/src/scripts/harden-audit.sh +55 -0
- package/src/server/api-key-routes.ts +64 -0
- package/src/server/api-keys.ts +181 -43
- package/src/server/auth.ts +113 -47
- package/src/server/boot.ts +158 -104
- package/src/server/claude.ts +98 -3
- package/src/server/data-sync.ts +55 -6
- package/src/server/directives.ts +2 -4
- package/src/server/engine/claude-code.ts +44 -14
- package/src/server/engine/types.ts +7 -0
- package/src/server/hooks.ts +19 -0
- package/src/server/mcp-oauth.ts +24 -5
- package/src/server/mcp-server.ts +55 -25
- package/src/server/modules/routes.ts +2 -6
- package/src/server/notify-owners.ts +5 -17
- package/src/server/owners.ts +14 -0
- package/src/server/scheduler/builtins.ts +3 -1
- package/src/server/scheduler/runner.ts +3 -0
- package/src/server/security/audit.ts +498 -0
- package/src/server/security/enforce.ts +306 -0
- package/src/server/security/escalate.ts +194 -0
- package/src/server/security/guard.ts +329 -0
- package/src/server/security/owner-only.ts +15 -0
- package/src/server/security/owner-routes.ts +43 -0
- package/src/server/security/policy.ts +413 -0
- package/src/server/security/principal.ts +80 -0
- package/src/server/security/revocation.ts +50 -0
- package/src/server/security/runtime.ts +174 -0
- package/src/server/sessions.ts +35 -0
- package/src/server/slack/bot.ts +44 -11
- package/src/server/slack/context-cache.ts +40 -7
- package/src/server/webhook-lane/feature.ts +17 -6
- package/src/shared/models.ts +11 -0
- package/dist/client/assets/index-DIMte_k6.css +0 -10
- 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 ||
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 && !
|
|
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 (!
|
|
263
|
-
readOnly={
|
|
264
|
-
className={`flex-1 resize-none rounded-none border-0 font-mono text-xs focus-visible:ring-0 ${
|
|
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
|
-
|
|
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);
|
package/src/client/lib/api.ts
CHANGED
|
@@ -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>(
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 {
|