shraga 0.1.112 → 0.1.114

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 (73) hide show
  1. package/README.md +10 -1
  2. package/defaults/mcps/README.md +6 -3
  3. package/defaults/scripts/agent-once.ts +2 -0
  4. package/defaults/security/policy.example.json +27 -0
  5. package/defaults/skills/mcp-server.md +15 -14
  6. package/defaults/skills/platform.md +3 -3
  7. package/defaults/system-prompt.md +1 -1
  8. package/dist/client/assets/index-CFxKc8dH.css +10 -0
  9. package/dist/client/assets/index-DwmlZAEF.js +2009 -0
  10. package/dist/client/index.html +2 -2
  11. package/package.json +3 -2
  12. package/src/client/App.tsx +33 -8
  13. package/src/client/components/BackendStatusBanner.tsx +62 -0
  14. package/src/client/components/ConfigPanel.tsx +83 -15
  15. package/src/client/components/ConversationHeader.tsx +5 -1
  16. package/src/client/components/McpManager.tsx +26 -9
  17. package/src/client/components/Sidebar.tsx +16 -1
  18. package/src/client/components/SkillsManager.tsx +48 -27
  19. package/src/client/components/owner/ApiKeysTab.tsx +87 -0
  20. package/src/client/components/owner/AuditTab.tsx +110 -0
  21. package/src/client/components/owner/BindingsTab.tsx +110 -0
  22. package/src/client/components/owner/BlocklistTab.tsx +96 -0
  23. package/src/client/components/owner/OwnerConsole.tsx +87 -0
  24. package/src/client/components/owner/PrincipalsTab.tsx +55 -0
  25. package/src/client/components/owner/RolesTab.tsx +132 -0
  26. package/src/client/components/owner/shared.tsx +84 -0
  27. package/src/client/hooks/useAuth.ts +11 -2
  28. package/src/client/hooks/useIsOwner.ts +24 -0
  29. package/src/client/hooks/useModules.ts +5 -1
  30. package/src/client/hooks/useOwner.ts +20 -0
  31. package/src/client/lib/api.ts +23 -7
  32. package/src/client/lib/backendHealth.ts +230 -0
  33. package/src/client/lib/debug.ts +48 -0
  34. package/src/client/lib/sessionApi.ts +24 -8
  35. package/src/client/lib/ws.ts +21 -13
  36. package/src/index.ts +10 -2
  37. package/src/scripts/harden-audit.sh +55 -0
  38. package/src/server/agent-config.ts +34 -0
  39. package/src/server/api-key-routes.ts +66 -0
  40. package/src/server/api-keys.ts +181 -43
  41. package/src/server/auth.ts +113 -47
  42. package/src/server/boot.ts +161 -104
  43. package/src/server/claude.ts +102 -26
  44. package/src/server/data-sync.ts +56 -6
  45. package/src/server/directives.ts +2 -4
  46. package/src/server/engine/claude-code.ts +78 -17
  47. package/src/server/engine/types.ts +7 -0
  48. package/src/server/hooks.ts +19 -0
  49. package/src/server/mcp-oauth.ts +24 -5
  50. package/src/server/mcp-server.ts +55 -25
  51. package/src/server/modules/routes.ts +2 -6
  52. package/src/server/notify-owners.ts +5 -17
  53. package/src/server/owners.ts +14 -0
  54. package/src/server/scheduler/builtins.ts +3 -1
  55. package/src/server/scheduler/runner.ts +3 -0
  56. package/src/server/security/audit.ts +505 -0
  57. package/src/server/security/enforce.ts +343 -0
  58. package/src/server/security/escalate.ts +194 -0
  59. package/src/server/security/guard.ts +337 -0
  60. package/src/server/security/owner-only.ts +34 -0
  61. package/src/server/security/owner-routes.ts +285 -0
  62. package/src/server/security/policy.ts +416 -0
  63. package/src/server/security/principal.ts +80 -0
  64. package/src/server/security/revocation.ts +50 -0
  65. package/src/server/security/runtime.ts +226 -0
  66. package/src/server/sessions.ts +38 -0
  67. package/src/server/shraga-config.ts +13 -0
  68. package/src/server/slack/bot.ts +44 -11
  69. package/src/server/slack/context-cache.ts +40 -7
  70. package/src/server/webhook-lane/feature.ts +17 -6
  71. package/src/shared/models.ts +11 -0
  72. package/dist/client/assets/index-DIMte_k6.css +0 -10
  73. 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-DwmlZAEF.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-CFxKc8dH.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.114",
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 {
@@ -18,10 +19,11 @@ interface AgentConfig {
18
19
  skillDiscovery?: boolean;
19
20
  thinking?: 'adaptive' | 'enabled' | 'disabled';
20
21
  effort?: 'low' | 'medium' | 'high' | 'max';
22
+ allowUntrustedReplies?: boolean;
21
23
  }
22
24
 
23
25
  const FALLBACK_MODELS: EngineModel[] = [
24
- { value: '', label: 'Default (claude-sonnet-5)' },
26
+ { value: '', label: `Default (${DEFAULT_MODEL})` },
25
27
  { value: 'claude-fable-5-1', label: 'Fable 5.1 — frontier, most capable' },
26
28
  { value: 'claude-fable-5', label: 'Fable 5' },
27
29
  { value: 'claude-opus-5', label: 'Opus 5 — most capable, best for complex/agentic tasks' },
@@ -41,6 +43,11 @@ const PERMISSION_MODES = [
41
43
 
42
44
  interface SessionDirectives { engine?: string; model?: string; turns?: number; thinking?: string }
43
45
 
46
+ /** True if any field of `next` differs from `prev` (value-wise; key order irrelevant). */
47
+ const changedFrom = (prev: AgentConfig, next: AgentConfig) =>
48
+ [...new Set([...Object.keys(prev), ...Object.keys(next)])]
49
+ .some((k) => JSON.stringify((prev as any)[k]) !== JSON.stringify((next as any)[k]));
50
+
44
51
  interface Props {
45
52
  getToken: () => Promise<string | null>;
46
53
  onSaved?: (config: AgentConfig) => void;
@@ -62,15 +69,22 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
62
69
  // Save scope for the runtime knobs (engine/model/turns/thinking): this conversation, or the
63
70
  // global default every channel without its own pin uses — Slack, email, scheduler included.
64
71
  const [applyGlobally, setApplyGlobally] = useState(false);
72
+ const [error, setError] = useState<string | null>(null);
73
+ // Global config is owner-only (PUT /api/config 403s otherwise); a session's own runtime knobs are not.
74
+ const [isOwner, setIsOwner] = useState<boolean>();
75
+ const canGlobal = isOwner === true;
76
+ const canRuntime = canGlobal || !!sessionId;
65
77
 
66
78
  useEffect(() => {
67
79
  if (!open) return;
68
80
  setApplyGlobally(false); // scope is a per-open decision, never a sticky one
81
+ setError(null);
69
82
  getToken().then((token) => {
70
83
  if (!token) return;
71
84
  fetch('/api/config', { headers: { Authorization: `Bearer ${token}` } })
72
85
  .then((r) => r.json())
73
- .then((global: AgentConfig) => {
86
+ .then(({ isOwner: owner, ...global }: AgentConfig & { isOwner?: boolean }) => {
87
+ setIsOwner(!!owner);
74
88
  globalRef.current = global;
75
89
  // For an active session, the runtime knobs reflect the session's own directives
76
90
  // (falling back to the global default when the session hasn't overridden them).
@@ -87,8 +101,17 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
87
101
  });
88
102
  }, [open, getToken, sessionId, sessionDirectives]);
89
103
 
104
+ /** Throws the server's error message on a non-2xx, so save() surfaces it instead of closing. */
105
+ const send = async (url: string, body: unknown, auth: Record<string, string>) => {
106
+ const res = await fetch(url, { method: 'PUT', headers: auth, body: JSON.stringify(body) });
107
+ const data = await res.json().catch((e) => { console.warn('[config] response not JSON', url, res.status, e); return {}; });
108
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
109
+ return data;
110
+ };
111
+
90
112
  const save = async () => {
91
113
  setSaving(true);
114
+ setError(null);
92
115
  const token = await getToken();
93
116
  const auth = { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json' };
94
117
  try {
@@ -99,21 +122,26 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
99
122
  const runtime: SessionDirectives = applyGlobally
100
123
  ? { engine: '', model: '', turns: null, thinking: '' } as any // '' / null = clear the pin
101
124
  : { 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; });
125
+ const r = await send(`/api/sessions/${sessionId}/directives`, runtime, auth);
104
126
  if (r?.directives) onDirectivesSaved?.(r.directives);
105
127
  const global: AgentConfig = applyGlobally ? config : {
106
128
  ...config,
107
129
  engine: globalRef.current.engine, model: globalRef.current.model,
108
130
  maxTurns: globalRef.current.maxTurns, thinking: globalRef.current.thinking,
109
131
  };
110
- await fetch('/api/config', { method: 'PUT', headers: auth, body: JSON.stringify(global) });
111
- onSaved?.(global);
132
+ // A session-only save with no global field changed has nothing to persist globally.
133
+ if (changedFrom(globalRef.current, global)) {
134
+ await send('/api/config', global, auth);
135
+ onSaved?.(global);
136
+ }
112
137
  } else {
113
- await fetch('/api/config', { method: 'PUT', headers: auth, body: JSON.stringify(config) });
138
+ await send('/api/config', config, auth);
114
139
  onSaved?.(config);
115
140
  }
116
141
  setOpen(false);
142
+ } catch (e: any) {
143
+ console.warn('[config] save failed', e);
144
+ setError(e.message || 'Save failed');
117
145
  } finally {
118
146
  setSaving(false);
119
147
  }
@@ -171,16 +199,19 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
171
199
  Engine, Model, Max Turns & Thinking apply to{' '}
172
200
  <strong>{applyGlobally ? 'every conversation' : 'this conversation'}</strong>. Other settings are always global defaults.
173
201
  </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>
202
+ {canGlobal && (
203
+ <label className="flex items-center gap-2 cursor-pointer">
204
+ <input type="checkbox" checked={applyGlobally} onChange={(e) => setApplyGlobally(e.target.checked)} />
205
+ <span>Apply globally — also the default for Slack, email &amp; scheduled runs</span>
206
+ </label>
207
+ )}
178
208
  </div>
179
209
  )}
180
210
  {multiEngine && (
181
211
  <div>
182
212
  <label className="text-sm font-medium mb-1.5 block">Engine</label>
183
213
  <select
214
+ disabled={!canRuntime}
184
215
  value={config.engine ?? ''}
185
216
  onChange={(e) => {
186
217
  const engine = e.target.value || undefined;
@@ -200,6 +231,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
200
231
  <div>
201
232
  <label className="text-sm font-medium mb-1.5 block">Model</label>
202
233
  <select
234
+ disabled={!canRuntime}
203
235
  value={config.model ?? ''}
204
236
  onChange={(e) => setConfig((c) => ({ ...c, model: e.target.value || undefined }))}
205
237
  className={selectClass}
@@ -227,6 +259,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
227
259
  <div>
228
260
  <label className="text-sm font-medium mb-1.5 block">Permission Mode</label>
229
261
  <select
262
+ disabled={!canGlobal}
230
263
  value={config.permissionMode ?? 'acceptEdits'}
231
264
  onChange={(e) => setConfig((c) => ({ ...c, permissionMode: e.target.value }))}
232
265
  className={selectClass}
@@ -243,6 +276,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
243
276
  type="number"
244
277
  min={1}
245
278
  max={200}
279
+ disabled={!canRuntime}
246
280
  value={config.maxTurns ?? 50}
247
281
  onChange={(e) => setConfig((c) => ({ ...c, maxTurns: Number(e.target.value) || 50 }))}
248
282
  />
@@ -251,6 +285,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
251
285
  <div>
252
286
  <label className="text-sm font-medium mb-1.5 block">Thinking</label>
253
287
  <select
288
+ disabled={!canRuntime}
254
289
  value={config.thinking ?? ''}
255
290
  onChange={(e) => setConfig((c) => ({ ...c, thinking: (e.target.value || undefined) as AgentConfig['thinking'] }))}
256
291
  className={selectClass}
@@ -265,6 +300,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
265
300
  <div>
266
301
  <label className="text-sm font-medium mb-1.5 block">Effort</label>
267
302
  <select
303
+ disabled={!canGlobal}
268
304
  value={config.effort ?? ''}
269
305
  onChange={(e) => setConfig((c) => ({ ...c, effort: (e.target.value || undefined) as AgentConfig['effort'] }))}
270
306
  className={selectClass}
@@ -281,6 +317,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
281
317
  <label className="text-sm font-medium mb-1.5 block">Allowed Tools</label>
282
318
  <Input
283
319
  placeholder="Read, Edit, Bash, WebSearch, Glob, LS"
320
+ disabled={!canGlobal}
284
321
  value={(config.allowedTools ?? []).join(', ')}
285
322
  onChange={(e) => setConfig((c) => ({
286
323
  ...c,
@@ -298,6 +335,7 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
298
335
  <button
299
336
  type="button"
300
337
  role="switch"
338
+ disabled={!canGlobal}
301
339
  aria-checked={config.skillDiscovery !== false}
302
340
  onClick={() => setConfig((c) => ({ ...c, skillDiscovery: c.skillDiscovery === false ? true : false }))}
303
341
  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'}`}
@@ -306,10 +344,32 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
306
344
  </button>
307
345
  </div>
308
346
 
347
+ <div className="flex items-center justify-between">
348
+ <div className="pr-4">
349
+ <label className="text-sm font-medium">Reply to untrusted senders</label>
350
+ <p className="text-xs text-muted-foreground">
351
+ Off: inbound messages from unverified or unknown senders are still processed and escalated to
352
+ owners, but the agent sends them no automatic reply.
353
+ </p>
354
+ </div>
355
+ <button
356
+ type="button"
357
+ role="switch"
358
+ disabled={!canGlobal}
359
+ aria-checked={config.allowUntrustedReplies === true}
360
+ aria-label="Reply to untrusted senders"
361
+ onClick={() => setConfig((c) => ({ ...c, allowUntrustedReplies: c.allowUntrustedReplies === true ? false : true }))}
362
+ className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${config.allowUntrustedReplies === true ? 'bg-primary' : 'bg-muted'}`}
363
+ >
364
+ <span className={`pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform ${config.allowUntrustedReplies === true ? 'translate-x-5' : 'translate-x-0'}`} />
365
+ </button>
366
+ </div>
367
+
309
368
  <div>
310
369
  <label className="text-sm font-medium mb-1.5 block">System Prompt (optional)</label>
311
370
  <Textarea
312
371
  placeholder="Additional instructions appended to Claude's system prompt…"
372
+ disabled={!canGlobal}
313
373
  value={config.systemPrompt ?? ''}
314
374
  onChange={(e) => setConfig((c) => ({ ...c, systemPrompt: e.target.value || undefined }))}
315
375
  rows={4}
@@ -317,10 +377,18 @@ export function ConfigPanel({ getToken, onSaved, trigger, sessionId, sessionDire
317
377
  </div>
318
378
  </DialogBody>
319
379
 
320
- <DialogFooter>
321
- <Button onClick={save} disabled={saving}>
322
- {saving ? 'Saving…' : 'Save Configuration'}
323
- </Button>
380
+ <DialogFooter className="flex-col items-stretch gap-2">
381
+ {error && <p className="text-sm text-destructive">{error}</p>}
382
+ {isOwner === false && (
383
+ <p className="text-xs text-muted-foreground">
384
+ {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.'}
385
+ </p>
386
+ )}
387
+ {canRuntime && (
388
+ <Button onClick={save} disabled={saving}>
389
+ {saving ? 'Saving…' : 'Save Configuration'}
390
+ </Button>
391
+ )}
324
392
  </DialogFooter>
325
393
  </DialogContent>
326
394
  </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
  );
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react';
2
- import { Plus, MessageSquare, Hash, AtSign, MessageCircle } from 'lucide-react';
2
+ import { Plus, MessageSquare, Hash, AtSign, MessageCircle, ShieldCheck } from 'lucide-react';
3
3
  import { Button } from './ui/button';
4
4
  import { ScrollArea } from './ui/scroll-area';
5
5
  import { cn } from '@/lib/utils';
@@ -10,6 +10,9 @@ import type { UnreadSession } from '@/hooks/useUnread';
10
10
  import type { AgentSocket } from '@/lib/ws';
11
11
  import { useSessionList, type SessionRow as Session, type ChatsFilter } from '@/hooks/useSessionList';
12
12
  import { CLIENT_BUILD_VERSION } from '@/lib/build-version';
13
+ import { useOwner } from '@/hooks/useOwner';
14
+ import { api } from '@/lib/api';
15
+ import { OwnerConsole } from './owner/OwnerConsole';
13
16
 
14
17
  interface Props {
15
18
  getToken: () => Promise<string | null>;
@@ -75,6 +78,7 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
75
78
  const [filter, setFilter] = useState<ChatsFilter>(() => (localStorage.getItem(FILTER_KEY) as ChatsFilter) || 'mine');
76
79
  const [unreadOnly, setUnreadOnly] = useState(() => localStorage.getItem(UNREAD_FILTER_KEY) === 'true');
77
80
  const [version, setVersion] = useState<string>('');
81
+ const { isOwner, call: ownerCall } = useOwner(getToken);
78
82
  const activeRef = useRef<HTMLButtonElement | null>(null);
79
83
 
80
84
  function changeFilter(f: ChatsFilter) {
@@ -242,6 +246,17 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
242
246
  </ScrollArea>
243
247
 
244
248
  <div className="px-4 py-2 flex flex-col items-center gap-1">
249
+ {isOwner && (
250
+ <OwnerConsole
251
+ call={ownerCall}
252
+ onOpenSession={async (id) => { await api(`/api/sessions/${encodeURIComponent(id)}/meta`, getToken); onSelect(id); }}
253
+ trigger={
254
+ <button className="flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors" title="Roles, bindings, keys, blocklist, audit">
255
+ <ShieldCheck className="w-3.5 h-3.5" /> Owner Console
256
+ </button>
257
+ }
258
+ />
259
+ )}
245
260
  <MachineStats socket={socket ?? null} getToken={getToken} />
246
261
  {slots.sidebarExtras?.()}
247
262
  {version && (