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.
- package/README.md +10 -1
- package/defaults/mcps/README.md +6 -3
- package/defaults/scripts/agent-once.ts +2 -0
- package/defaults/security/policy.example.json +27 -0
- package/defaults/skills/mcp-server.md +15 -14
- package/defaults/skills/platform.md +3 -3
- package/defaults/system-prompt.md +1 -1
- package/dist/client/assets/index-CFxKc8dH.css +10 -0
- package/dist/client/assets/index-DwmlZAEF.js +2009 -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 +83 -15
- package/src/client/components/ConversationHeader.tsx +5 -1
- package/src/client/components/McpManager.tsx +26 -9
- package/src/client/components/Sidebar.tsx +16 -1
- package/src/client/components/SkillsManager.tsx +48 -27
- package/src/client/components/owner/ApiKeysTab.tsx +87 -0
- package/src/client/components/owner/AuditTab.tsx +110 -0
- package/src/client/components/owner/BindingsTab.tsx +110 -0
- package/src/client/components/owner/BlocklistTab.tsx +96 -0
- package/src/client/components/owner/OwnerConsole.tsx +87 -0
- package/src/client/components/owner/PrincipalsTab.tsx +55 -0
- package/src/client/components/owner/RolesTab.tsx +132 -0
- package/src/client/components/owner/shared.tsx +84 -0
- 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/hooks/useOwner.ts +20 -0
- package/src/client/lib/api.ts +23 -7
- 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/index.ts +10 -2
- package/src/scripts/harden-audit.sh +55 -0
- package/src/server/agent-config.ts +34 -0
- package/src/server/api-key-routes.ts +66 -0
- package/src/server/api-keys.ts +181 -43
- package/src/server/auth.ts +113 -47
- package/src/server/boot.ts +161 -104
- package/src/server/claude.ts +102 -26
- package/src/server/data-sync.ts +56 -6
- package/src/server/directives.ts +2 -4
- package/src/server/engine/claude-code.ts +78 -17
- 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 +505 -0
- package/src/server/security/enforce.ts +343 -0
- package/src/server/security/escalate.ts +194 -0
- package/src/server/security/guard.ts +337 -0
- package/src/server/security/owner-only.ts +34 -0
- package/src/server/security/owner-routes.ts +285 -0
- package/src/server/security/policy.ts +416 -0
- package/src/server/security/principal.ts +80 -0
- package/src/server/security/revocation.ts +50 -0
- package/src/server/security/runtime.ts +226 -0
- package/src/server/sessions.ts +38 -0
- package/src/server/shraga-config.ts +13 -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-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.
|
|
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",
|
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 {
|
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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
|
|
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
|
-
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
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 & 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
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
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' ?
|
|
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
|
);
|
|
@@ -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 && (
|