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
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
17
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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",
|
package/src/client/App.tsx
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
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
|
-
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
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 & 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
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
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' ?
|
|
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
|
-
|
|
199
|
-
<
|
|
200
|
-
<
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
{saving
|
|
204
|
-
|
|
205
|
-
|
|
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
|
);
|