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
@@ -13,8 +13,8 @@
13
13
  <link rel="preconnect" href="https://fonts.googleapis.com" />
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
15
15
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
16
- <script type="module" crossorigin src="/assets/index-Dc1ljSt3.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-DIMte_k6.css">
16
+ <script type="module" crossorigin src="/assets/index-DJ0AgGIu.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-DIDtPQb-.css">
18
18
  </head>
19
19
  <body>
20
20
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.112",
3
+ "version": "0.1.113",
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",
@@ -79,7 +79,8 @@
79
79
  "rehype-highlight": "^7.0.0",
80
80
  "remark-gfm": "^4.0.1",
81
81
  "tailwind-merge": "^2.6.0",
82
- "ws": "^8.18.0"
82
+ "ws": "^8.18.0",
83
+ "zod": "^3.25.76"
83
84
  },
84
85
  "devDependencies": {
85
86
  "@happy-dom/global-registrator": "^20.14.0",
@@ -35,6 +35,11 @@ import {
35
35
  import { useDarkMode } from '@/hooks/useDarkMode';
36
36
  import { useUnread } from '@/hooks/useUnread';
37
37
  import { ToastStack } from '@/components/Toast';
38
+ import { BackendStatusBanner } from '@/components/BackendStatusBanner';
39
+ import { reportApiFailure, reportApiResponse } from '@/lib/backendHealth';
40
+ import { logger } from '@/lib/debug';
41
+
42
+ const log = logger.forComponent('App');
38
43
 
39
44
  function getSessionFromUrl(): string | undefined {
40
45
  return new URLSearchParams(window.location.search).get('session') || undefined;
@@ -115,6 +120,9 @@ function AppInner() {
115
120
  if (!t) return setAuthorized(false);
116
121
  try {
117
122
  const res = await fetch('/api/config', { headers: { Authorization: `Bearer ${t}` } });
123
+ // This raw probe is the FIRST request the app makes, and in the port-hijack incident it was the
124
+ // one that silently 404'd into an empty shell. Route it through the classifier like the helpers.
125
+ reportApiResponse('/api/config', res);
118
126
  if (res.ok) {
119
127
  wasAuthorized.current = true;
120
128
  localStorage.setItem('shraga:authorized', '1');
@@ -122,7 +130,7 @@ function AppInner() {
122
130
  }
123
131
  const body = await res.json().catch(() => ({}));
124
132
  if (res.status === 401 && (body.error?.includes('audience') || body.error?.includes('aud'))) {
125
- console.warn('[auth] Token audience mismatch — signing out stale session');
133
+ log.warn('token audience mismatch — signing out stale session');
126
134
  logout();
127
135
  return;
128
136
  }
@@ -132,7 +140,9 @@ function AppInner() {
132
140
  }
133
141
  if (wasAuthorized.current) return;
134
142
  setAuthorized(res.status !== 401 && res.status !== 403);
135
- } catch {
143
+ } catch (err) {
144
+ reportApiFailure('/api/config', err);
145
+ log.warn('auth probe could not reach /api/config', err);
136
146
  if (wasAuthorized.current) return;
137
147
  setAuthorized(true);
138
148
  }
@@ -145,7 +155,7 @@ function AppInner() {
145
155
  apiFetch('/api/workspace', getToken)
146
156
  .then((r) => r.json())
147
157
  .then((data: { entries: WorkspaceEntry[] }) => setWorkspaceEntries(data.entries ?? []))
148
- .catch((err) => console.warn('[workspace] refresh failed', err));
158
+ .catch((err) => log.warn('workspace refresh failed', err));
149
159
  }, [token, getToken]);
150
160
 
151
161
  const handleScheduleEvent = useCallback((event: Extract<ServerEvent, { type: `schedule:${string}` }>) => {
@@ -257,15 +267,16 @@ function AppInner() {
257
267
  // Load config / skills / workspace / features
258
268
  useEffect(() => {
259
269
  if (!token) return;
260
- apiFetch('/api/config', getToken).then((r) => r.json()).then(setAgentConfig).catch(() => {});
270
+ apiFetch('/api/config', getToken).then((r) => r.json()).then(setAgentConfig)
271
+ .catch((err) => log.warn('config load failed', err));
261
272
  apiFetch('/api/skills', getToken)
262
273
  .then((r) => r.json())
263
274
  .then((data: { skills: string[]; builtins: string[] }) => setSkills(data.skills))
264
- .catch(() => {});
275
+ .catch((err) => log.warn('skills load failed', err));
265
276
  apiFetch('/api/features', getToken)
266
277
  .then((r) => r.json())
267
278
  .then((f: { push?: boolean }) => setPushEnabled(!!f.push))
268
- .catch(() => {});
279
+ .catch((err) => log.warn('features load failed', err));
269
280
  }, [token, getToken]);
270
281
  useEffect(() => {
271
282
  refreshWorkspace();
@@ -306,7 +317,18 @@ function AppInner() {
306
317
  return <div className="flex h-full items-center justify-center text-muted-foreground text-sm">Loading…</div>;
307
318
  }
308
319
 
309
- if (!user || !token) return <LoginPage mode={mode} needsSetup={needsSetup} onLoginLocal={loginLocal} onRegisterLocal={registerLocal} />;
320
+ // The banner sits ABOVE the login gate too: a hijacked backend also breaks the auth-mode probe, so
321
+ // the user would otherwise stare at a sign-in that can never succeed. It stays silent in the ORDINARY
322
+ // pre-login state, because a healthy /api/auth/mode carries the identity header and clears the store.
323
+ if (!user || !token)
324
+ return (
325
+ <div className="flex h-full flex-col">
326
+ <BackendStatusBanner />
327
+ <div className="flex-1 min-h-0">
328
+ <LoginPage mode={mode} needsSetup={needsSetup} onLoginLocal={loginLocal} onRegisterLocal={registerLocal} />
329
+ </div>
330
+ </div>
331
+ );
310
332
 
311
333
  if (authorized === null) {
312
334
  return <div className="flex h-full items-center justify-center text-muted-foreground text-sm">Verifying access…</div>;
@@ -339,7 +361,9 @@ function AppInner() {
339
361
  }
340
362
 
341
363
  return (
342
- <div className="flex h-full">
364
+ <div className="flex h-full flex-col">
365
+ <BackendStatusBanner />
366
+ <div className="flex flex-1 min-h-0">
343
367
  {/* Sidebar */}
344
368
  {sidebarOpen && (
345
369
  <div className="fixed inset-0 z-50 bg-background sm:relative sm:inset-auto sm:z-auto sm:w-64 sm:shrink-0 sm:border-r flex flex-col">
@@ -521,6 +545,7 @@ function AppInner() {
521
545
  markRead(sid);
522
546
  }}
523
547
  />
548
+ </div>
524
549
  </div>
525
550
  );
526
551
  }
@@ -0,0 +1,62 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { AlertTriangle, PlugZap, RefreshCw, ServerCrash, WifiOff } from 'lucide-react';
3
+ import { cn } from '@/lib/utils';
4
+ import { type BackendFault, type BackendFaultKind, subscribeBackendHealth } from '@/lib/backendHealth';
5
+
6
+ /**
7
+ * Persistent, always-visible banner naming a broken backend, so a user can diagnose a dead page FROM
8
+ * THE UI instead of opening devtools or asking an agent. Zero props on purpose: it reads the shared
9
+ * {@link subscribeBackendHealth} store, which the shared fetch helpers and the AgentSocket feed, so a
10
+ * shell only has to render it once. It clears itself the moment the store reports recovery.
11
+ *
12
+ * Mount it ABOVE the login gate as well as inside the authenticated shell. A hijacked backend breaks
13
+ * the unauthenticated `/api/auth/mode` probe too, so a pre-login user would otherwise face a sign-in
14
+ * that can never succeed with nothing on screen saying why. It stays silent in the ORDINARY pre-login
15
+ * state: a healthy probe carries the identity header, and the classifier ignores 401/403 outright.
16
+ */
17
+
18
+ const STYLE: Record<BackendFaultKind, { icon: typeof AlertTriangle; classes: string }> = {
19
+ 'wrong-backend': { icon: PlugZap, classes: 'bg-red-600 text-white border-red-700' },
20
+ offline: { icon: WifiOff, classes: 'bg-red-600 text-white border-red-700' },
21
+ 'server-error': { icon: ServerCrash, classes: 'bg-amber-500 text-amber-950 border-amber-600' },
22
+ 'ws-down': { icon: AlertTriangle, classes: 'bg-amber-500 text-amber-950 border-amber-600' },
23
+ };
24
+
25
+ export function BackendStatusBanner() {
26
+ const [fault, setFault] = useState<BackendFault | null>(null);
27
+ useEffect(() => subscribeBackendHealth(setFault), []);
28
+
29
+ if (!fault) return null;
30
+ const { icon: Icon, classes } = STYLE[fault.kind];
31
+
32
+ return (
33
+ <div
34
+ role="alert"
35
+ data-testid="backend-status-banner"
36
+ data-fault-kind={fault.kind}
37
+ className={cn('shrink-0 border-b px-3 py-2 text-xs', classes)}
38
+ style={{ paddingTop: 'max(0.5rem, env(safe-area-inset-top))' }}
39
+ >
40
+ <div className="flex items-start gap-2">
41
+ <Icon className="w-4 h-4 mt-0.5 shrink-0" />
42
+ <div className="min-w-0 flex-1">
43
+ <div className="font-semibold">{fault.title}</div>
44
+ <div className="opacity-90 mt-0.5">{fault.hint}</div>
45
+ {(fault.path || fault.status !== undefined) && (
46
+ <div className="opacity-75 mt-0.5 font-mono text-[11px] break-all">
47
+ {fault.path ?? 'websocket'}
48
+ {fault.status !== undefined ? ` → ${fault.status}` : ''}
49
+ </div>
50
+ )}
51
+ </div>
52
+ <button
53
+ onClick={() => window.location.reload()}
54
+ className="shrink-0 inline-flex items-center gap-1 rounded bg-white/20 hover:bg-white/30 px-2 py-1 font-semibold transition-colors"
55
+ >
56
+ <RefreshCw className="w-3 h-3" />
57
+ Retry
58
+ </button>
59
+ </div>
60
+ </div>
61
+ );
62
+ }
@@ -6,6 +6,7 @@ import { Textarea } from './ui/textarea';
6
6
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogBody, DialogFooter } from './ui/dialog';
7
7
  import { useSlots } from '@/lib/slots';
8
8
  import { useEngines, type EngineModel } from '@/hooks/useEngines';
9
+ import { DEFAULT_MODEL } from '../../shared/models';
9
10
  import { ClaudeAccountSection } from './ClaudeAccountSection';
10
11
 
11
12
  interface AgentConfig {
@@ -21,7 +22,7 @@ interface AgentConfig {
21
22
  }
22
23
 
23
24
  const FALLBACK_MODELS: EngineModel[] = [
24
- { value: '', label: 'Default (claude-sonnet-5)' },
25
+ { value: '', label: `Default (${DEFAULT_MODEL})` },
25
26
  { value: 'claude-fable-5-1', label: 'Fable 5.1 — frontier, most capable' },
26
27
  { value: 'claude-fable-5', label: 'Fable 5' },
27
28
  { value: 'claude-opus-5', label: 'Opus 5 — most capable, best for complex/agentic tasks' },
@@ -41,6 +42,11 @@ const PERMISSION_MODES = [
41
42
 
42
43
  interface SessionDirectives { engine?: string; model?: string; turns?: number; thinking?: string }
43
44
 
45
+ /** True if any field of `next` differs from `prev` (value-wise; key order irrelevant). */
46
+ const changedFrom = (prev: AgentConfig, next: AgentConfig) =>
47
+ [...new Set([...Object.keys(prev), ...Object.keys(next)])]
48
+ .some((k) => JSON.stringify((prev as any)[k]) !== JSON.stringify((next as any)[k]));
49
+
44
50
  interface Props {
45
51
  getToken: () => Promise<string | null>;
46
52
  onSaved?: (config: AgentConfig) => void;
@@ -62,15 +68,22 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
62
68
  // Save scope for the runtime knobs (engine/model/turns/thinking): this conversation, or the
63
69
  // global default every channel without its own pin uses — Slack, email, scheduler included.
64
70
  const [applyGlobally, setApplyGlobally] = useState(false);
71
+ const [error, setError] = useState<string | null>(null);
72
+ // Global config is owner-only (PUT /api/config 403s otherwise); a session's own runtime knobs are not.
73
+ const [isOwner, setIsOwner] = useState<boolean>();
74
+ const canGlobal = isOwner === true;
75
+ const canRuntime = canGlobal || !!sessionId;
65
76
 
66
77
  useEffect(() => {
67
78
  if (!open) return;
68
79
  setApplyGlobally(false); // scope is a per-open decision, never a sticky one
80
+ setError(null);
69
81
  getToken().then((token) => {
70
82
  if (!token) return;
71
83
  fetch('/api/config', { headers: { Authorization: `Bearer ${token}` } })
72
84
  .then((r) => r.json())
73
- .then((global: AgentConfig) => {
85
+ .then(({ isOwner: owner, ...global }: AgentConfig & { isOwner?: boolean }) => {
86
+ setIsOwner(!!owner);
74
87
  globalRef.current = global;
75
88
  // For an active session, the runtime knobs reflect the session's own directives
76
89
  // (falling back to the global default when the session hasn't overridden them).
@@ -87,8 +100,17 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
87
100
  });
88
101
  }, [open, getToken, sessionId, sessionDirectives]);
89
102
 
103
+ /** Throws the server's error message on a non-2xx, so save() surfaces it instead of closing. */
104
+ const send = async (url: string, body: unknown, auth: Record<string, string>) => {
105
+ const res = await fetch(url, { method: 'PUT', headers: auth, body: JSON.stringify(body) });
106
+ const data = await res.json().catch((e) => { console.warn('[config] response not JSON', url, res.status, e); return {}; });
107
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
108
+ return data;
109
+ };
110
+
90
111
  const save = async () => {
91
112
  setSaving(true);
113
+ setError(null);
92
114
  const token = await getToken();
93
115
  const auth = { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json' };
94
116
  try {
@@ -99,21 +121,26 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
99
121
  const runtime: SessionDirectives = applyGlobally
100
122
  ? { engine: '', model: '', turns: null, thinking: '' } as any // '' / null = clear the pin
101
123
  : { engine: config.engine, model: config.model, turns: config.maxTurns, thinking: config.thinking };
102
- const r = await fetch(`/api/sessions/${sessionId}/directives`, { method: 'PUT', headers: auth, body: JSON.stringify(runtime) })
103
- .then((res) => res.json()).catch((e) => { console.warn('[config] directives save failed', e); return null; });
124
+ const r = await send(`/api/sessions/${sessionId}/directives`, runtime, auth);
104
125
  if (r?.directives) onDirectivesSaved?.(r.directives);
105
126
  const global: AgentConfig = applyGlobally ? config : {
106
127
  ...config,
107
128
  engine: globalRef.current.engine, model: globalRef.current.model,
108
129
  maxTurns: globalRef.current.maxTurns, thinking: globalRef.current.thinking,
109
130
  };
110
- await fetch('/api/config', { method: 'PUT', headers: auth, body: JSON.stringify(global) });
111
- onSaved?.(global);
131
+ // A session-only save with no global field changed has nothing to persist globally.
132
+ if (changedFrom(globalRef.current, global)) {
133
+ await send('/api/config', global, auth);
134
+ onSaved?.(global);
135
+ }
112
136
  } else {
113
- await fetch('/api/config', { method: 'PUT', headers: auth, body: JSON.stringify(config) });
137
+ await send('/api/config', config, auth);
114
138
  onSaved?.(config);
115
139
  }
116
140
  setOpen(false);
141
+ } catch (e: any) {
142
+ console.warn('[config] save failed', e);
143
+ setError(e.message || 'Save failed');
117
144
  } finally {
118
145
  setSaving(false);
119
146
  }
@@ -171,16 +198,19 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
171
198
  Engine, Model, Max Turns & Thinking apply to{' '}
172
199
  <strong>{applyGlobally ? 'every conversation' : 'this conversation'}</strong>. Other settings are always global defaults.
173
200
  </p>
174
- <label className="flex items-center gap-2 cursor-pointer">
175
- <input type="checkbox" checked={applyGlobally} onChange={(e) => setApplyGlobally(e.target.checked)} />
176
- <span>Apply globally — also the default for Slack, email &amp; scheduled runs</span>
177
- </label>
201
+ {canGlobal && (
202
+ <label className="flex items-center gap-2 cursor-pointer">
203
+ <input type="checkbox" checked={applyGlobally} onChange={(e) => setApplyGlobally(e.target.checked)} />
204
+ <span>Apply globally — also the default for Slack, email &amp; scheduled runs</span>
205
+ </label>
206
+ )}
178
207
  </div>
179
208
  )}
180
209
  {multiEngine && (
181
210
  <div>
182
211
  <label className="text-sm font-medium mb-1.5 block">Engine</label>
183
212
  <select
213
+ disabled={!canRuntime}
184
214
  value={config.engine ?? ''}
185
215
  onChange={(e) => {
186
216
  const engine = e.target.value || undefined;
@@ -200,6 +230,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
200
230
  <div>
201
231
  <label className="text-sm font-medium mb-1.5 block">Model</label>
202
232
  <select
233
+ disabled={!canRuntime}
203
234
  value={config.model ?? ''}
204
235
  onChange={(e) => setConfig((c) => ({ ...c, model: e.target.value || undefined }))}
205
236
  className={selectClass}
@@ -227,6 +258,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
227
258
  <div>
228
259
  <label className="text-sm font-medium mb-1.5 block">Permission Mode</label>
229
260
  <select
261
+ disabled={!canGlobal}
230
262
  value={config.permissionMode ?? 'acceptEdits'}
231
263
  onChange={(e) => setConfig((c) => ({ ...c, permissionMode: e.target.value }))}
232
264
  className={selectClass}
@@ -243,6 +275,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
243
275
  type="number"
244
276
  min={1}
245
277
  max={200}
278
+ disabled={!canRuntime}
246
279
  value={config.maxTurns ?? 50}
247
280
  onChange={(e) => setConfig((c) => ({ ...c, maxTurns: Number(e.target.value) || 50 }))}
248
281
  />
@@ -251,6 +284,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
251
284
  <div>
252
285
  <label className="text-sm font-medium mb-1.5 block">Thinking</label>
253
286
  <select
287
+ disabled={!canRuntime}
254
288
  value={config.thinking ?? ''}
255
289
  onChange={(e) => setConfig((c) => ({ ...c, thinking: (e.target.value || undefined) as AgentConfig['thinking'] }))}
256
290
  className={selectClass}
@@ -265,6 +299,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
265
299
  <div>
266
300
  <label className="text-sm font-medium mb-1.5 block">Effort</label>
267
301
  <select
302
+ disabled={!canGlobal}
268
303
  value={config.effort ?? ''}
269
304
  onChange={(e) => setConfig((c) => ({ ...c, effort: (e.target.value || undefined) as AgentConfig['effort'] }))}
270
305
  className={selectClass}
@@ -281,6 +316,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
281
316
  <label className="text-sm font-medium mb-1.5 block">Allowed Tools</label>
282
317
  <Input
283
318
  placeholder="Read, Edit, Bash, WebSearch, Glob, LS"
319
+ disabled={!canGlobal}
284
320
  value={(config.allowedTools ?? []).join(', ')}
285
321
  onChange={(e) => setConfig((c) => ({
286
322
  ...c,
@@ -298,6 +334,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
298
334
  <button
299
335
  type="button"
300
336
  role="switch"
337
+ disabled={!canGlobal}
301
338
  aria-checked={config.skillDiscovery !== false}
302
339
  onClick={() => setConfig((c) => ({ ...c, skillDiscovery: c.skillDiscovery === false ? true : false }))}
303
340
  className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${config.skillDiscovery !== false ? 'bg-primary' : 'bg-muted'}`}
@@ -310,6 +347,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
310
347
  <label className="text-sm font-medium mb-1.5 block">System Prompt (optional)</label>
311
348
  <Textarea
312
349
  placeholder="Additional instructions appended to Claude's system prompt…"
350
+ disabled={!canGlobal}
313
351
  value={config.systemPrompt ?? ''}
314
352
  onChange={(e) => setConfig((c) => ({ ...c, systemPrompt: e.target.value || undefined }))}
315
353
  rows={4}
@@ -317,10 +355,18 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
317
355
  </div>
318
356
  </DialogBody>
319
357
 
320
- <DialogFooter>
321
- <Button onClick={save} disabled={saving}>
322
- {saving ? 'Saving…' : 'Save Configuration'}
323
- </Button>
358
+ <DialogFooter className="flex-col items-stretch gap-2">
359
+ {error && <p className="text-sm text-destructive">{error}</p>}
360
+ {isOwner === false && (
361
+ <p className="text-xs text-muted-foreground">
362
+ {sessionId ? 'Only an owner can change the global defaults — runtime knobs apply to this conversation.' : 'Read-only — only an owner can change the agent config.'}
363
+ </p>
364
+ )}
365
+ {canRuntime && (
366
+ <Button onClick={save} disabled={saving}>
367
+ {saving ? 'Saving…' : 'Save Configuration'}
368
+ </Button>
369
+ )}
324
370
  </DialogFooter>
325
371
  </DialogContent>
326
372
  </Dialog>
@@ -3,6 +3,10 @@ import { Copy, Check, Clock, GitFork, Layers } from 'lucide-react';
3
3
  import { Button } from '@/components/ui/button';
4
4
  import { ConfigPanel } from '@/components/ConfigPanel';
5
5
  import type { AgentConfig } from '@/lib/workspaceContext';
6
+ // Same constants the server resolves a turn with — a client-local copy drifts and makes the
7
+ // header name a model no turn would actually run (it still said `sonnet-4-6` after the default
8
+ // moved to Sonnet 5).
9
+ import { DEFAULT_MODEL, DEFAULT_CURSOR_MODEL } from '../../shared/models';
6
10
 
7
11
  interface SessionDirectives {
8
12
  model?: string;
@@ -45,7 +49,7 @@ export function deriveRuntimeBadges(input: {
45
49
  // anthropic models). So with no `lastEngine`, the engine stays unknown rather than guessed.
46
50
  const engine = provenance === 'ran' ? input.actualEngine! : provenance === 'pending' ? requestedEngine : undefined;
47
51
  const rawModel =
48
- input.actualModel || input.requestedModel || (requestedEngine === 'cursor' ? 'cursor/composer-2.5' : 'sonnet-4-6');
52
+ input.actualModel || input.requestedModel || (requestedEngine === 'cursor' ? DEFAULT_CURSOR_MODEL : DEFAULT_MODEL);
49
53
 
50
54
  // A mismatch is a fact worth showing, not something to launder away: the last turn ran somewhere
51
55
  // other than where this session currently asks to run — which the ground-truth pill alone cannot
@@ -3,6 +3,7 @@ import { Plus, Trash2, Settings, Lock } from 'lucide-react';
3
3
  import { Button } from './ui/button';
4
4
  import { Input } from './ui/input';
5
5
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogBody, DialogFooter } from './ui/dialog';
6
+ import { useIsOwner } from '@/hooks/useIsOwner';
6
7
 
7
8
  export interface McpServerConfig {
8
9
  command: string;
@@ -23,6 +24,9 @@ export function McpManager({ getToken, trigger }: Props) {
23
24
  const [config, setConfig] = useState<McpConfig>({});
24
25
  const [open, setOpen] = useState(false);
25
26
  const [saving, setSaving] = useState(false);
27
+ const [error, setError] = useState<string | null>(null);
28
+ const isOwner = useIsOwner(getToken, open);
29
+ const canEdit = isOwner === true;
26
30
  const [editingEnv, setEditingEnv] = useState<Set<string>>(new Set());
27
31
  const toggleEditing = (id: string) =>
28
32
  setEditingEnv((s) => { const next = new Set(s); next.has(id) ? next.delete(id) : next.add(id); return next; });
@@ -40,14 +44,19 @@ export function McpManager({ getToken, trigger }: Props) {
40
44
 
41
45
  const save = async () => {
42
46
  setSaving(true);
47
+ setError(null);
43
48
  const token = await getToken();
44
49
  try {
45
- await fetch('/api/mcps', {
50
+ const res = await fetch('/api/mcps', {
46
51
  method: 'PUT',
47
52
  headers: { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json' },
48
53
  body: JSON.stringify(config),
49
54
  });
55
+ if (!res.ok) throw new Error((await res.json().catch((e) => { console.warn('[McpManager] error response not JSON', res.status, e); return {}; })).error || `HTTP ${res.status}`);
50
56
  setOpen(false);
57
+ } catch (e: any) {
58
+ console.error('[McpManager] save failed', e);
59
+ setError(e.message || 'Save failed');
51
60
  } finally {
52
61
  setSaving(false);
53
62
  }
@@ -119,6 +128,10 @@ export function McpManager({ getToken, trigger }: Props) {
119
128
  </DialogHeader>
120
129
 
121
130
  <DialogBody className="space-y-4">
131
+ {isOwner === false && (
132
+ <p className="text-xs text-muted-foreground rounded-md bg-muted/50 px-3 py-2">Read-only — only an owner can change MCP servers.</p>
133
+ )}
134
+ <fieldset disabled={!canEdit} className="space-y-4 min-w-0">
122
135
  {Object.entries(config).map(([name, server]) => {
123
136
  const isReadonly = !!server.readonly;
124
137
  return (
@@ -193,16 +206,20 @@ export function McpManager({ getToken, trigger }: Props) {
193
206
  {Object.keys(config).length === 0 && (
194
207
  <p className="text-sm text-muted-foreground text-center py-4">No MCP servers configured</p>
195
208
  )}
209
+ </fieldset>
210
+ {error && <p className="text-sm text-destructive">{error}</p>}
196
211
  </DialogBody>
197
212
 
198
- <DialogFooter className="flex gap-2">
199
- <Button variant="outline" onClick={addServer} className="gap-2">
200
- <Plus className="w-4 h-4" /> Add Server
201
- </Button>
202
- <Button onClick={save} disabled={saving}>
203
- {saving ? 'Saving…' : 'Save'}
204
- </Button>
205
- </DialogFooter>
213
+ {canEdit && (
214
+ <DialogFooter className="flex gap-2">
215
+ <Button variant="outline" onClick={addServer} className="gap-2">
216
+ <Plus className="w-4 h-4" /> Add Server
217
+ </Button>
218
+ <Button onClick={save} disabled={saving}>
219
+ {saving ? 'Saving…' : 'Save'}
220
+ </Button>
221
+ </DialogFooter>
222
+ )}
206
223
  </DialogContent>
207
224
  </Dialog>
208
225
  );